diff --git a/.circleci/config.yml b/.circleci/config.yml index 42853134e..717b7d807 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -3,6 +3,28 @@ version: 2.1 orbs: aws-cli: circleci/aws-cli@2.0.2 +parameters: + # Run the gameplay tests of *every* example, even on a branch. Set it when + # triggering a pipeline (CircleCI UI or API) to check the whole repository + # without waiting for the change to land on `main`. + run-all-gameplay-tests: + type: boolean + default: false + # Number of parallel containers used to run the gameplay tests of every + # example. Raise it as more examples get gameplay tests. + all-gameplay-tests-parallelism: + type: integer + default: 4 + # Same, for a branch (which usually only changes one or two games). + changed-gameplay-tests-parallelism: + type: integer + default: 1 + # Branch of the GDevelop repository whose latest build is used to run the + # gameplay tests. + gdevelop-branch: + type: string + default: master + jobs: install: docker: @@ -50,6 +72,115 @@ jobs: - aws-cli/setup - run: npm run deploy -- --cf-zoneid $CLOUDFLARE_ZONE_ID --cf-token $CLOUDFLARE_TOKEN + # Run the gameplay tests of the example games with a real GDevelop: the + # latest Linux build published on S3 by GDevelop's own CI is downloaded and + # used to open each game in CLI mode. + gameplay-tests: + parameters: + scope: + description: > + "all" tests every game of the repository that has gameplay tests. + "changed" only tests the games modified compared to `main`. + type: enum + enum: ['all', 'changed'] + default: 'all' + parallelism: + description: Number of containers the games are split across. + type: integer + default: 1 + resource-class: + description: > + The games are rendered in software (no GPU on CI), which is what + makes them slow: give them enough CPU. + type: string + default: large + parallelism: << parameters.parallelism >> + resource_class: << parameters.resource-class >> + docker: + # Same image as the job of the GDevelop repository that runs its + # portable Linux build headlessly. + - image: cimg/node:24.13.0 + working_directory: ~/GDevelop-examples + + steps: + - checkout + + - run: + name: Install the runtime dependencies of GDevelop + # Even in CLI mode, Electron needs these libraries and a display to + # start up on a headless machine. + command: | + sudo apt-get update + sudo apt-get install -y \ + libnss3 \ + libasound2t64 \ + libgbm1 \ + libgtk-3-0 \ + libxss1 \ + libatk-bridge2.0-0 \ + libdrm2 \ + libxkbcommon0 \ + xvfb + + - run: + name: Install the dependencies of the scripts + # Only the runtime dependencies are needed to run the games. + command: npm ci --omit=dev + + - run: + name: List the games to test + command: | + if [ "<< parameters.scope >>" = "changed" ]; then + node scripts/run-gameplay-tests.js --list \ + --only-changed --base-ref=origin/main > /tmp/games.txt + else + node scripts/run-gameplay-tests.js --list > /tmp/games.txt + fi + echo "Games with gameplay tests to run:" + cat /tmp/games.txt + + - run: + name: Split the games across the parallel containers + # Balanced using the durations recorded by `store_test_results` on + # the previous runs (falls back to an even split the first time). + command: | + if [ -s /tmp/games.txt ]; then + circleci tests split --split-by=timings --timings-type=filename \ + < /tmp/games.txt > /tmp/games-for-this-container.txt + else + : > /tmp/games-for-this-container.txt + fi + echo "Games for this container:" + cat /tmp/games-for-this-container.txt + + - run: + name: Run the gameplay tests + # A single game can legitimately take a few minutes: the games are + # rendered in software on CI. + no_output_timeout: 30m + # The failure is reported by the last step of the job, so that the + # results and artifacts of a failing run are uploaded first (they + # are exactly what is needed to understand the failure). + command: | + set +e + node scripts/run-gameplay-tests.js \ + --projects-file=/tmp/games-for-this-container.txt \ + --gdevelop-branch=<< pipeline.parameters.gdevelop-branch >> \ + --work-dir=/tmp/gdevelop-portable \ + --artifacts-dir=/tmp/gameplay-tests-artifacts \ + --junit-path=/tmp/gameplay-tests-results/results.xml + echo $? > /tmp/gameplay-tests-exit-code + + - store_test_results: + path: /tmp/gameplay-tests-results + - store_artifacts: + path: /tmp/gameplay-tests-artifacts + destination: gameplay-tests + + - run: + name: Report whether the gameplay tests passed + command: exit "$(cat /tmp/gameplay-tests-exit-code)" + workflows: tests: jobs: @@ -67,3 +198,29 @@ workflows: filters: branches: only: main + + # On `main` (or when explicitly asked for): check that every gameplay test + # of the repository still passes. + all-gameplay-tests: + when: + or: + - equal: [main, << pipeline.git.branch >>] + - << pipeline.parameters.run-all-gameplay-tests >> + jobs: + - gameplay-tests: + name: gameplay-tests-all + scope: 'all' + parallelism: << pipeline.parameters.all-gameplay-tests-parallelism >> + + # On a branch / Pull Request: only check the games it modifies. + changed-gameplay-tests: + when: + and: + - not: + equal: [main, << pipeline.git.branch >>] + - not: << pipeline.parameters.run-all-gameplay-tests >> + jobs: + - gameplay-tests: + name: gameplay-tests-changed + scope: 'changed' + parallelism: << pipeline.parameters.changed-gameplay-tests-parallelism >> diff --git a/.gitignore b/.gitignore index 5ce736c74..77c461d1d 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,7 @@ .DS_Store /dist *.autosave + +# Artefacts written next to a game by the gameplay test runner. +gameplay-test-results.json +gameplay-test-screenshots/ diff --git a/GAMEPLAY_TESTS_FEEDBACK-starters.md b/GAMEPLAY_TESTS_FEEDBACK-starters.md new file mode 100644 index 000000000..d3a78612f --- /dev/null +++ b/GAMEPLAY_TESTS_FEEDBACK-starters.md @@ -0,0 +1,995 @@ +# Gameplay tests feedback — the `starting-*` games + +Adding one or two gameplay tests to each starter game of the repository, +and reporting what the harness made easy, hard or impossible. This document +grows as the batches progress. + +## Coverage + +| Starter | Tests | +| --- | --- | +| `starting-platformer` | Jumping with Space · Collecting the coins by running into them | +| `starting-platformer-pixel` | Jumping with Space · Collecting the coins by running into them | +| `starting-3D-platformer` | Jumping with Space · Collecting a coin by walking into it | +| `starting-3d-driving` | Accelerating drives the car forward · Running a traffic cone over knocks it away | +| `starting-3d-tank` | Firing a shell with F · Blowing a target away with a shell | +| `starting-first-person-shooter` | Walking and strafing with WASD · Shooting a target | +| `starting-top-down` | Moving in the four directions · Walls block the player | +| `starting-top-down-pixel` | Moving in the four directions · Walls block the player | +| `starting-flappy-bird` | Flapping with Space · Touching a hazard restarts the run | +| `starting-clicker` | Clicking earns money · Buying the passive upgrade | +| `starting-shootemup` | The ship fires on its own · Enemies take several hits to be destroyed | +| `starting-endless-runner` | Running and jumping · Touching a hazard restarts the run | +| `starting-twin-stick-shooter` | Aiming and firing with the mouse · Enemies take several hits to be destroyed | +| `starting-vampire-survivor` | The player shoots the nearest enemy on its own · Being touched by an enemy ends the run | +| `starting-2d-driving` | Driving and steering · Running into a bush pushes it away | +| `starting-physics` | The ball falls and rests on the ground · Dragging the ball with the mouse | +| `starting-physics-pixel` | The ball falls and rests on the ground · Dragging the ball with the mouse | +| `starting-2d-car-racing` | Driving and steering · Running into a bush pushes it away | +| `starting-beatemup` | Attacking hits the enemy · The player cannot walk while attacking | +| `starting-point-and-click` | Clicking sends the player there · The player walks around obstacles | +| `starting-point-and-click-pixel` | Clicking sends the player there · The player walks around obstacles | +| `starting-2d-platformer-shooter` | Shooting in the direction the player faces · Shooting a target destroys it | +| `starting-quiz` | Only the right answer moves on · Answering every question finishes the quiz | +| `starting-draggable-tiles` | Dragging a piece onto a free cell · Dropping a piece on a taken cell sends it back | +| `starting-tile-placement` | Picking a tile type · Placing a tile on the board | +| `starting-card-game` | Drawing a card from the deck · Putting a card back in the deck | +| `starting-rts-unit-selection` | Selecting a unit and ordering it to move · Selecting every unit at once | +| `starting-top-down-rpg` | Talking to an NPC · Saying yes in the dialog | +| `starting-first-person` | Walking and strafing with WASD · Jumping with Space | +| `starting-first-person-horror` | Walking and strafing with WASD · The monster comes after the player | +| `starting-first-person-shooter-horror` | Walking and strafing with WASD · Shooting leaves an impact | +| `starting-3d-shootemup` | The ship fires on its own · Enemies take several hits to be destroyed | +| `starting-3d-twin-stick-shooter` | Aiming and firing with the mouse · Enemies take several hits to be destroyed | +| `starting-3d-vampire-survivor` | The player shoots the nearest enemy on its own · Being touched by an enemy ends the run | +| `starting-3d-car-racing` | Accelerating drives the car forward · Driving over the finish line counts a lap | +| `starting-3d-endless-runner` | Running and jumping · Touching a hazard restarts the run | +| `starting-3d-draggable-tiles` | Dragging a piece onto a free cell · Dropping a piece on a taken cell sends it back | +| `starting-3d-tile-placement` | Picking a tile type · Placing a tile on the board | +| `starting-3d-rts-unit-selection` | Selecting a unit and ordering it to move · Selecting every unit at once | + +Every test listed here passes, and each was run several times in a row to +check for flakiness. They are also run on CI against the latest Linux build +of GDevelop published on S3 (see `scripts/run-gameplay-tests.js`). + +--- + +## What was tested, and why + +The pattern chosen everywhere is **one control test + one consequence +test**: the first proves that an input actually drives the player object, +the second proves that driving it into the world produces the thing the +game is about (a coin disappears, a cone flies, a target is blown away). +Both halves are needed: a control test alone would keep passing if +collisions broke, and a consequence test alone would keep passing if it +were reached by luck. + +Each test also pins down its **starting state before acting**, so that what +it asserts afterwards can only come from the input: the control tests step a +stretch of frames with no key pressed and assert the player does not drift +(without it, "the player is higher up" would be satisfied by a game with +inverted gravity), the pickup tests assert that nothing is collected while +standing still, and the shooting tests assert that the weapon is *not* +already pointing at the target before it is aimed. + +- **`starting-platformer`** — Jump (the platformer behavior's `IsOnFloor` / + height gained / landing back at the same height and x), and coin pickup. + The coin test deliberately uses the level's geometry: the player starts on + a raised platform, has to run off its edge, fall to the ground below and + run back over the row of coins. That exercises running, falling and + collision-based pickup in one scenario, and the "falling next to the coins + does not collect them" assertion pins the pickup on the contact. +- **`starting-3D-platformer`** — Jump (`PhysicsCharacter3D.IsJumping` + + height), and walking into a coin. Collection is a "within 50 units" + distance check, so the walk test asserts the coin survives 12 frames of + standing 140 units away and then disappears once the player walks into it. +- **`starting-3d-driving`** — There are no events at all in this game: it is + pure `PhysicsCar3D` behaviour, so the tests target the behaviour itself. + Accelerating: engine revs up, the car travels **along its heading** (not + sideways) and does not turn while no steering key is held. Cones: the car + is lined up 120px from the first cone on the road and driven into it. +- **`starting-3d-tank`** — Firing: exactly one shell, leaving from the end of + the cannon (>50px from the tank centre), flying along + `Angle + TopRotation`, and — keeping `f` held for 30 frames — **not** + turning into a machine gun (the firing event is a "trigger once", which is + worth locking down). Target: the tank is parked 340px from a target with + the target 15° off the turret axis, the turret is turned onto it with + `a`/`d`, and the shell's explosion has to move the target. +- **`starting-first-person-shooter`** — Movement: `w` walks along the facing + direction and `d` strafes *sideways* without turning the player (the + camera-relative WASD scheme is the distinctive thing to protect here). + Shooting: aim down onto a target with mouse deltas, fire, then assert both + that the impact effect lands on the target and that the target is knocked + over. +- **`starting-first-person-horror`** — Movement: the same WASD scheme (the + Player is set up identically to the FPS one), so the movement test is the + same test. Monster: the whole game is "something is chasing you", so the + monster is brought within reach and then the player is left completely + alone — anything that closes the gap is the monster deciding to come. +- **`starting-first-person-shooter-horror`** — Movement: same again. + Shooting: clicking has to leave a mark on the world. The player is not + moved or aimed at all, it simply shoots straight ahead into the level, and + the test checks an impact effect appeared where there was none, in front + of the player. +- **`starting-3d-shootemup`** — Same two tests as the 2D shoot'em up, which + is exactly the point: the game is a top-down shooter that happens to be + drawn in 3D, so the ship firing on its own, the bullets flying right and + the arrow keys moving the ship are the same contract, and an enemy put in + the line of fire has to lose its three points of health and be destroyed. +- **`starting-3d-twin-stick-shooter`** — Aiming: the player turns toward the + mouse and fires while the button is held, and the bullets fly where it + aims. Enemies: same "several hits then destroyed" check, with the enemy + placed in the line of fire. Both aims are straight up or straight down + because of the 3D camera (see below). +- **`starting-3d-vampire-survivor`** — Auto-fire: the player shoots the + nearest enemy in range without any input, which is the whole premise of the + genre, so the test puts one enemy in range and checks nothing was fired + before that. Death: an enemy reaching the player has to end the run. +- **`starting-3d-car-racing`** — Driving: the accelerator revs the engine and + drives the car along its heading, and it stays put otherwise. Lap: what + makes it a *race* rather than a driving game, so the car is lined up a + short run-up before the finish line and driven over it — the lap counter + has to go up and the next checkpoint has to become the first of the new + lap. +- **`starting-3d-endless-runner`** — Running and jumping: the player runs on + its own and Space is the only control, so the test checks it moves right + with nothing pressed and that a jump reaches the height its behavior is + configured for. Hazard: touching one has to end the run and restart the + scene. +- **`starting-3d-draggable-tiles`** — Dragging a piece to a free cell and + seeing it snap onto the 64×64 grid, and dropping one on a cell that is + already taken and seeing it go back where it came from. The second is the + rule that makes the board a board rather than a pile of movable models. +- **`starting-3d-tile-placement`** — Picking a tile type in the toolbar shows + what is about to be placed and picking it again stops placing (the toolbar + is a toggle, which is easy to break); then placing that tile on the board, + checking it lands on the cell the indicator was showing and that clicking + the same cell again does not stack a second one. A click on the board + *before* picking a type is included as the control that places nothing. +- **`starting-3d-rts-unit-selection`** — A drag box selects one unit and a + click sends it walking while the others stay put; then a box over all the + units sends the whole group. Same two tests as the 2D version, but the + selection box had to be drawn in screen coordinates (see below). + +--- + +## Missing in the harness + +### 1. No way to raise the 30 s wall-clock timeout (this was the biggest constraint) + +`timeoutMs`/`maxFrames` exist in the run payload but a `gd::Test` only +stores `name`/`type`/`description`/`source`, so a test cannot ask for more +time. On the machine used here (headless Linux, xvfb, software WebGL) the +budget that actually fits in 30 s was: + +| Starter | Frames that fit in 30 s | ms per stepped frame | +| --- | --- | --- | +| `starting-platformer` (2D) | ~330 | ~85 | +| `starting-3d-driving` | ~140 | ~215 | +| `starting-3D-platformer` | ~125 | ~230 | +| `starting-3d-tank` | ~105 | ~280 | +| `starting-first-person-shooter` | ~100 | ~300 | + +That is **1.6 to 2 seconds of simulated gameplay for a 3D starter** — far +from the "under ~15 seconds of simulated gameplay" the guide suggests, and +it shaped every 3D test written here. Concretely I had to drop the jump test +from the FPS starter to keep the movement test inside the budget, park +vehicles next to what they are supposed to hit instead of driving there, and +avoid `resetSceneAndProbeControls` in 3D entirely (see below). + +Two things would fix this, and the first is cheap: + +- Expose `timeoutMs` (and `maxFrames`) as fields of a test, next to + `description`. A test that legitimately needs 60 s of wall clock should be + able to say so. +- **The wall clock is dominated by rendering, not by the game logic.** The + profiler in the very same runs reports `avgStepMs` of **1.0–1.5 ms** for + `starting-platformer` and **3.4–6.3 ms** for the 3D starters, while those + runs advance at 85–310 ms of wall clock per stepped frame. Stepping is + therefore **1–2 %** of the time; the rest is the render performed while + `_maybeYield` waits on `requestAnimationFrame` (each animation frame costs + hundreds of milliseconds with software WebGL). A headless CLI run does not + need a render per simulated frame — a "render at most every N ms of wall + clock" cap, or a `renderEveryFrame: false` run option, would make these + tests roughly an order of magnitude faster without changing anything a + test observes. Screenshots would just need a forced render before capture. + Note that `result.performance.avgStepMs` being tiny while a test times out + is itself confusing: the timeout message could mention how much of the + budget went to rendering/yielding. + +An extra cost of the ceiling being this low: a passing test is not +necessarily a *safe* one, and this is not hypothetical — it turned a CI run +red. `starting-3d-driving`'s cone test passed locally at 27.6 s and timed out +at 30.1 s on CI, and the two next-slowest tests (26.1 s and 26.0 s) were one +bad container away from the same fate. Nothing had warned about any of them: +the local runs said PASSED. + +The distribution is the problem. Across the 78 tests of that CI run the +median is about 4 s, but the slowest ten are all 3D scenes between 17 s and +30 s, and the *same* test can take 19.8 s in one game and 26.0 s in another +that only differs by scene weight. So the useful signal is not the absolute +duration, it is the fraction of the budget used. Two cheap things would have +caught all of this before the merge: + +- report the wall-clock time against the limit in the run output (`24.8s / + 30s`), so a test at 80 % of its budget is visible without doing arithmetic; +- optionally fail — or at least warn loudly — when a test finishes within, + say, 20 % of the ceiling, the same way a test suite warns about slow tests. + +**Shortening the tests is not enough on its own**, and the next CI run proved +it. A different test timed out — `starting-3d-endless-runner`'s hazard test, +which had passed at **14.0 s** in the previous run — and the numbers show it +was not the test's fault and not a globally slow machine either: it stepped +*68 frames in 30.3 s* where it had stepped *73 frames in 14.0 s*, while the +run's overall median (7.2 s → 7.5 s) and total (723 s → 699 s) barely moved. +The per-frame render cost simply doubled for that game, in that run, on the +same `large` resource class. + +That is not something a test author can size for: with a 30 s ceiling and 3D +frames at 200–450 ms, a 2× swing puts *any* test above ~15 s at risk, and +15 s is roughly the floor for a 3D test that does anything at all. So the CI +runner now **re-runs a game once when its run failed only with wall-clock +timeouts**, and never when an assertion failed — a timeout says something +about the machine, a failed assertion says something about the game. Both +paths are verified: a forced timeout retried and went green with a warning, +and a deliberately failing assertion failed immediately with no retry. + +This is a workaround for the missing `timeoutMs` field, not a substitute for +it. A test that could declare the budget it needs would not need any of this. + +Alongside that, I went back over every test above 20 s and shortened it (the +worst is now 18 s locally). Three techniques did all the work, and they are +worth recommending in the guide because none of them weakens a test: +**stop measuring as soon as the thing has happened** — `stepUntil(() => +displacement > 30)` instead of `stepFrames(15)` then checking, which turned +the tank's target test from 26.1 s to 18.0 s and the FPS shooting test from +20 s to 12.3 s; **shorten the run-up rather than the assertion** — the +driving test now lines the car up 90px from the cone instead of 120px; +and **lower a threshold to match a shorter window instead of keeping the +window** — the "walking" checks measure 15 frames rather than 22, with the +bar dropped from 30 to 15 units, still five times the measured standing +drift of under 3. + +#### Follow-up: the render cap landed, and it fixes 2D but not 3D + +Master commit `4cc37b4` adds `FAST_RUN_RENDER_INTERVAL_MS = 250` — in an +unpaced run the game renders at most once every 250 ms instead of once per +stepped frame — and reports each test's time against its budget +(`95 frames, 4.0s / 30s budget`), which is the other thing asked for above. +I built it locally and re-ran four games against the previous commit on the +same machine, same dev build, same tests: + +| Game | Test | Before | After | Speed-up | +| --- | --- | --- | --- | --- | +| `starting-platformer` | Jumping with Space | 97 ms/frame | 34 ms/frame | **2.9×** | +| `starting-platformer` | Collecting the coins | 93 ms/frame | 43 ms/frame | **2.2×** | +| `starting-3D-platformer` | Collecting a coin | 468 ms/frame | 350 ms/frame | 1.3× | +| `starting-3d-driving` | Accelerating | 462 ms/frame | 367 ms/frame | 1.3× | +| `starting-3d-driving` | Running a cone over | 438 ms/frame | 371 ms/frame | 1.2× | +| `starting-first-person` | Walking and strafing | 384 ms/frame | 328 ms/frame | 1.2× | +| `starting-first-person` | Jumping with Space | 407 ms/frame | 350 ms/frame | 1.2× | + +2D is transformed — the platformer's coin test went from 22.3 s to 10.2 s. 3D +barely moved *on that machine*, and the profiler says why: on +`starting-3d-driving` the average step is **4.25 ms** while a frame costs +**371 ms** of wall clock, so **367 ms per frame is still not stepping**. A +frame is still being rendered essentially every time. + +The reason is that the cap bounds the *interval* between renders, and a single +3D render on that machine already costs more than that interval. Once a render +takes ~350 ms, `now - lastRender >= 250` is true again the instant it +finishes, so no render is ever skipped and the cap does nothing. + +**Correction, from real CI hardware.** The build published to S3 now has the +cap, so the same 76 tests can be compared before and after on CircleCI, at +identical frame counts. There, 3D *does* benefit — my sandbox simply renders +more slowly than a CI container, which put it on the wrong side of the +threshold: + +| | Speed-up on CI | +| --- | --- | +| Whole suite (76 tests, 661 s → 390 s) | **1.7×** | +| Best 2D cases (`starting-endless-runner`, `starting-clicker`, …) | **3–7.5×** | +| 3D and first-person games | **1.2–2.9×**, typically ~1.6× | +| A few very short tests | 0.7–0.8× (slightly slower) | + +So the change is a clear win, and the analysis above still holds — it just +describes a threshold rather than a wall. The benefit fades as a render +approaches 250 ms and disappears once it exceeds it, which is exactly the +heavy-3D end where the budget is tightest: on CI, `starting-3d-tank`'s and +`starting-first-person-shooter`'s tests are still 250–300 ms per frame and +gained the least (1.2–1.5×). The handful that got *slower* are short tests +that hardly rendered anyway, where the per-frame `setTimeout` yield is now the +cost. + +Two ways to make it work for 3D, in increasing order of effect: + +- **Bound the duty cycle instead of the interval.** After a render that took + `R` ms, wait until roughly `4 R` ms of stepping have elapsed before the next + one. That caps rendering at a fixed *share* of the run (here ~20 %) whatever + a render costs, instead of assuming it costs less than 250 ms. On the 3D + numbers above that alone would be worth about 4×. +- **Do not render at all in a CLI run**, except the forced render before a + screenshot. Nothing a test observes comes from the renderer, and at 4–15 ms + of stepping per frame the 3D starters would run 20–70× faster than today — + which would retire the whole 30 s problem rather than easing it. + +### 2. `getRelativePosition` / `lookTowardWithMouseDelta` measure from the object centre, not from the camera + +This makes the FPS aiming helpers unusable on `starting-first-person-shooter`, +and the failure is silent — it reports success while aiming at nothing: + +- The game's camera is at `Player.Z + Player.Depth` (the top of the capsule, + z = 80), while `getRelativePosition` uses the player's **centre** + (z = 40). The targets sit at z ≈ 44, so the harness computes + `pitchDiff ≈ 0.37°`, decides the aim is already correct, and + `lookTowardWithMouseDelta` returns `{aimed: true, pitchDiff: 0.37}` after + stepping **zero frames**. Firing then sends the ray straight over the + targets into the wall behind them (verified: the impact effect landed at + z = 80, y = 0 — 173 units past the target). The real angle needed was + 3.3° **down**. +- Even with the right eye height it would not work, because the harness + reads the pitch from `getRotationX()` while this game's + `FirstPersonPointerMapper` pitches the player with `SetRotationY` (its own + source even carries a `// TODO It's probably a bad idea to rotate the + object around Y` comment). The harness would therefore measure a pitch + that never moves, hit `maxUnresponsivePitchFrames`, and *undo* the vertical + aim it had applied — ending up looking straight ahead again. + +I ended up not using `lookTowardWithMouseDelta` at all and writing a small +proportional controller on `player.rotationY` with an explicit eye height of +`player.z + player.depth`. That works, but it required reading the +extension's `LookFromObjectEyes` events to find out where the camera is — +exactly the kind of digging the helper is meant to remove. + +Suggestions: +- Let `getRelativePosition` take an eye/muzzle offset, e.g. + `getRelativePosition('Player', target, { fromZ: player.z + player.depth })`, + or aim from the **actual camera** of the object's layer when one exists. +- Expose the layer camera in a JSON-safe way — `getCameraState(layerName)` + returning `{x, y, z, rotationX, rotationY, angle}`. Today the only route is + `getRuntimeLayer(...)` and raw GDJS, and the docs explicitly discourage it. +- Derive the pitch from whichever rotation the game actually drives (or + report both `rotationX` and `rotationY` deltas in the aim result) instead + of assuming `rotationX`. + +### 3. No way to aim a turret that is independent from the object + +In `starting-3d-tank` the aiming direction is `Angle() + TopRotation()` +(a property of the `CombinedTank` custom object), not the object's angle. +`getRelativePosition().yawDiff` is therefore off by the whole turret +rotation and cannot be used. I recomputed the bearing by hand with +`Math.atan2` on the centres. A `yawDiff` that could be measured against an +arbitrary heading — `getRelativePosition(name, target, { heading: tank.angle ++ tank.state.TopRotation })` — would cover every turret/weapon/tower game. + +### 4. `resetSceneAndProbeControls` is unusable in 3D under the current time budget + +It is the recommended mandatory first step, but each probe restarts the +scene, and one scene load costs ~1.4 s of wall clock in these 3D starters +(measured). Probing four keys means six loads — baseline, four keys, plus +the final reset — so ~8 s of loading *plus* 5 × 40 = 200 stepped frames, +which is more than an entire test's budget on its own. In +`starting-3D-platformer` I +replaced it by reading `PhysicsCharacter3D.ForwardAngle` from the behaviour +state, which is exact and free; that only works because the character +behaviour happens to expose the heading. Ideas: a probe mode that does not +restart between keys (probe, release, wait for the object to settle, probe +the next), or a `probeFrames` default lowered for 3D. + +### 5. Small gaps met along the way + +- **No "was this object just created / destroyed" signal.** Several + assertions ("a shell was fired", "an impact effect appeared") are written + as before/after counts of `getObjects(...)`, which is fragile when the + object is short-lived (the tank shell explodes ~22 frames after the shot, + the FPS impact particle is a `ParticleEmitter3D`). An `eventLog` entry for + object creation/deletion, or `harness.watchCreations('Bullet')` returning + the ids created during a window, would express this directly. +- **No access to the sound that was played.** Coin pickup, gunshots and + explosions all `PlaySound`; being able to assert "the pickup sound played" + would be a very cheap, very direct check of "the mechanic fired" in games + where the visible consequence is subtle. +- **Screenshots go to disk only.** `takeScreenshot` writes files next to the + project; from a CLI batch it would help to have their base64 in the result + JSON, or at least a note in the CLI output that + `gameplay-test-screenshots/` was written (it must not be committed). + +### 6. No way to read or set an object variable + +Object variables drive a lot of game state, and the harness only half +exposes them: + +- **Reading** works, but by hand: `snapshot.variables` is the raw + `getNetworkSyncData()` array, so every read is a + `variables.find(one => one.name === 'Level').value` with a null check. A + `getObjectVariable(idOrName, variableName)` (or a plain + `snapshot.variableValues` map next to the array) would remove that + boilerplate from every test that touches game state. +- **Writing** is not possible at all: `setSceneVariable` and + `setGlobalVariable` exist, there is no `setObjectVariable`. In + `starting-clicker` the price of the upgrade lives in an object variable of + the button, so the only way to reach "the player can afford the upgrade" + was to actually click the clicker **20 times** (80 stepped frames). With a + `setObjectVariable` the test could have arranged the interesting state + directly, as `goToScene(..., {skipCreatingInstances: true})` + `spawn` + allows for everything else. This is the "jump into the middle of the game" + story, but for object driven state. + +### 7. No access to an object's custom points + +`starting-2d-car-racing` decides whether a checkpoint counts by comparing the +direction of the checkpoint arrow — given by its custom point +`CheckpointArrow.PointX("TravelDirection")` — with the angle to the car. A +test cannot read that: the snapshot exposes `x`, `centerX`, `width`... but +nothing about the object's points, and there is no `getObjectPoint(id, +name)`. Without it there is no way to know which side of a checkpoint the car +must approach from, so **the lap and checkpoint logic of that starter is not +covered** (only its driving is). Points are used by a lot of games to mark +muzzles, spawn positions and directions — `snapshot.points` (a name to +`{x, y}` map) would unlock all of them. + +### 8. The Flippable capability is not in the snapshot + +A side view character's facing direction is core state — in +`starting-2d-platformer-shooter` the events literally branch on +`FlippedX` to decide which way the bullet goes. A test cannot read it: +`snapshot.state.FlippedX` throws with `Available: AnimationFrameCount, +Sprite`. `animation`, `opacity` and `text` are all promoted to snapshot +fields, and `flippedX` / `flippedY` belong next to them. (The error message +listing the available names is genuinely great — it is what made this +diagnosable in one run.) + +### 9. No way to ask what is at a position + +`starting-tile-placement` only lets a tile be built on some cells: the +events refuse the click when the placement indicator collides with the +tilemap or with an already placed tile. A test cannot ask the same question — +there is no `getObjectsAt(x, y)`, no collision query, and tilemap contents +are not exposed at all — so the test has to **click candidate cells until one +is accepted** and only then start asserting. It works (and the search is +honest setup, clearly separated from the checks), but a +`getObjectsAt(x, y, objectNames?)` would replace the scan with a statement of +intent, and would help any game built on a grid, an inventory or a board. + +### 10. Selection (and anything held by a free condition) is invisible + +In `starting-rts-unit-selection`, whether a unit is selected lives in +`RTSUnitSelection::IsSelected`, a **free** condition of an extension — not an +object condition — so it appears nowhere in the object snapshot. The +"Selected" visual is an object *effect*, and whether an effect is enabled is +not exposed either. The tests here work around it by checking selection +through its consequence (the selected units accept a move order and the +others do not), which is arguably a better test — but it only exists because +ordering a move is an immediate, observable consequence. A selection with no +such consequence would simply not be testable. + +Two things would close this: evaluating an extension's **free** conditions +that take an object list (they are as much "state of this object" as the +object conditions are), and putting the enabled effects of an object in the +snapshot (`effects: { Selected: true }`). + +### 11. The parts of a custom object report their position *inside* the object + +`snapshot.children` is what makes a custom object testable — in +`starting-top-down-rpg` it is the only way to reach the "Yes" button of the +`TwoChoicesDialogBox`. But the coordinates of the children are **local to the +parent**, while the documentation of `centerX`/`centerY` says they are scene +coordinates ("Use centerX/centerY (never x + width/2)"). It shows in the +numbers: the dialog sits at `x: 320, y: 416` in the scene and its +`TextBorder` part reports `x: 0, y: 0`. Clicking a child at its reported +`centerX`/`centerY` therefore clicks the wrong place, silently — the test +just observes that nothing happened. The children also report the parent's +*internal* layer (`""`), not the layer the parent is on (`"Dialog Layer"`), +so the layer has to be taken from the parent too. + +The working conversion is: + +```javascript +const x = parent.x + child.x + child.width / 2; +const y = parent.y + child.y + child.height / 2; +harness.setMousePosition(x, y, parent.layer); +``` + +Either make the children's `centerX`/`centerY` scene coordinates like every +other snapshot (preferred — that is what the field is documented to be), or +say clearly in the guide that children are in the parent's space and give +this conversion. + +**Fixed** in master commit `4cc37b4`, the preferred way: children's positions +are converted to the parent's coordinate space and they now report the +parent's layer, so `setMousePosition(child.centerX, child.centerY, +child.layer)` works and the conversion above is gone from the test. + +Worth noting how it surfaced, because it will happen again as the harness +improves: the fix *broke* the test that had worked around the old behaviour. +The workaround added the parent's origin to a child position that was now +already in scene coordinates, so the click landed off the button and the +assertion reported `2 NPCs left of 2` — a failure that says nothing about +what changed. Nothing was wrong with either the engine or the test on its +own. It is an argument for the harness treating the shape of a snapshot as an +API with a version, or at least for these behaviour changes being called out +in the release notes the examples repository pins against, since a test suite +in a separate repository cannot see them coming. + +### 12. Custom objects hide the state a test wants + +`ScoreCounter`, `PanelSpriteButton`, `PanelSpriteContinuousBar`, +`CombinedTank`... are events based custom objects, and their useful state is +spread over three different places: the object's own conditions/expressions +(`state.Score`), their properties (`state.PropertyX`), and plain object +variables (`variables`). Nothing in a project tells a test author which one +holds what. `console.log(Object.keys(snapshot.state))` on a first run is the +only practical way to find out — worth mentioning explicitly in the guide, +next to the (excellent) "reading an unknown state throws with the list of +available names" behaviour. + +### 13. `setMousePosition` is wrong on a layer drawn by a 3D camera + +`setMousePosition(sceneX, sceneY, layer)` converts scene coordinates to +screen coordinates the 2D way, so on a scene rendered through a 3D +perspective camera the cursor does not end up where the test asked. In +`starting-3d-twin-stick-shooter` (a top-down game whose camera sits above and +behind the player) the error is large and one-sided: + +| Aim asked for, relative to the player | Angle the player should turn to | Angle it turned to | +| --- | --- | --- | +| 300 right | 0° | **-29°** | +| 300 up | -90° | -90° | +| 300 right and 300 up | -45° | **-58°** | +| 300 left | 180° | **-151°** | + +The vertical axis is exact — a point straight above the player projects to a +point still straight above it, whatever the camera's tilt — and everything +else is off by up to 30°. The failure is silent: the mouse *is* placed +somewhere, the game aims at it perfectly, and only an assertion on the +resulting angle reveals that it is not the direction the test meant. + +The cause is in the engine, and the two halves of the round trip disagree by +design. `setMousePosition` calls `layer.convertInverseCoords`, whose own +comment says *"This method doesn't handle 3D rotations"*; the game reads the +cursor back through `layer.convertCoords`, which says *"This method handles +3D rotations"* and delegates to `renderer.transformTo3DWorld` when the camera +is rotated in 3D. So on a 3D layer the harness places the cursor with the flat +2D transform and the game interprets it with the perspective one. Making +`setMousePosition` invert `convertCoords` (rather than duplicate the 2D +transform) would fix every 3D game at once. + +**A workaround that works today**, and which the `starting-3d-draggable-tiles` +tests use: `getRuntimeLayer(name)` hands over the real `gdjs.RuntimeLayer`, so +a test can call the *correct* conversion itself and search for the screen +position that maps to the scene point it wants — start at the middle of the +screen, and step toward the target along the conversion's own slope +(estimated with two extra samples). It converges in a handful of iterations, +costs no frame, and round-tripped exactly in that game: a piece grabbed at its +centre, dragged two cells over and dropped snapped precisely onto the cell it +was aimed at. So the fix is cheap — the right transform is already reachable, +`setMousePosition` just isn't using it. + +In `starting-3d-twin-stick-shooter` I had already worked around it the poorer +way, by only ever aiming straight up or straight down. That is enough to test +"the player aims where the mouse points" but rules out checking a diagonal +aim, or keeping a crosshair on a moving target. + +--- + +## What was complicated or surprising + +### The CLI runner needs `GDEVELOP` set, or it fails with a misleading message + +`GAMEPLAY_TESTS_STARTERS_SETUP.sh run ` defaults `GDEVELOP` to +`$HOME/GDevelop`. On this machine `$HOME` is `/root` while the checkout is +`/home/user/GDevelop`, so the `cd` inside `run_gameplay_tests` failed, the +electron command never ran, and the script reported +`ERROR: no results file was written - check the dev server is running`, +which sends you off to debug a dev server that was perfectly fine. Passing +`GDEVELOP=... bash ... run ...` fixes it. The script could `set -e` around +the `cd`, or check that `$GDEVELOP/newIDE/electron-app` exists up front. + +### The setup script's step order breaks a fresh install + +Step 3 (`npm install` in `newIDE/app`) runs before step 4 (pre-downloading +the piskel/jfxr/yarn editor zips with proxy-aware curl), but `npm install`'s +own postinstall runs `import-zipped-external-editors`, which uses a +non-proxy-aware downloader: + +``` +🌐 Outdated/non-existing piskel-editor, downloading it ... +❌ Can't download piskel-editor.zip (Error: Client network socket disconnected + before secure TLS connection was established) +npm error command failed +``` + +The whole setup aborts. Moving the curl pre-download (step 4) **before** the +`npm install` of step 3 makes it work first time — the comment in the script +("so the import step finds them up-to-date and skips") already describes +that intent, the steps are just in the wrong order. + +### `Jolt is not defined` — intermittent, breaks any Physics3D game + +Two runs out of the ~30 runs of the 3D starters made here failed instantly +with: + +``` +ERROR (0 frames, 13ms) +ReferenceError: Jolt is not defined + at new b (.../Physics3DRuntimeBehavior.js:1:418) + at b.getSharedData ... + at u.loadFromScene (.../runtimescene.js) + at g._loadNewScene (.../scenestack.js) + at f.startGameLoop ... +``` + +The first scene is created before the asynchronously-loaded Jolt library is +available, even though `loadAllAssets` awaits +`getAllAsynchronouslyLoadingLibraryPromise()`. It is a race — the same +project run again immediately afterwards succeeds. Useful detail: it only +ever hit the **first test of a batch**; in the run above, the second test of +the same batch passed normally right after, so the library had finished +loading by then. *(Reported as already known and being fixed separately; the +tests here do not work around it.)* One data point that made it easy to +reproduce: `starting-first-person` hit it **every time**, on whichever of its +two tests ran first — its first test was red for that reason alone, and both +tests passed whenever they were not the first to run. + +**Fixed — verified.** GDevelop master commit `ba74a65` ("Wait for the game to +be fully booted before running a gameplay test") adds +`RuntimeGame.isStartingUp()` and makes the runner wait on it before starting a +test, so a run request that arrives mid-boot no longer creates scenes before +the asynchronously loaded libraries are ready. I built that commit locally and +ran `starting-first-person` four times in a row: **8/8 tests passed**, against +a build without the fix that failed the first test on every single run. Nothing +in the tests had to change. Worth keeping the reproducer in mind for any future +regression: a Physics3D game whose *first* test is the one that boots the game +is the case that breaks, and it is invisible in any game whose first test +happens to run second. + +### The result status can be misleading when a test's own step budget is too small + +A test whose input is broken but whose `stepUntil` has a generous +`maxFrames` reports `timeout` (wall clock) rather than `failed`, and the +result then says nothing about which assertion did not hold. I checked this +by deliberately breaking the inputs of the two `starting-platformer` tests: +the jump test failed cleanly with +`Assertion failed: The player leaves the floor when jumping.`, while the +coin test only timed out. Sizing every `stepUntil` to roughly twice what the +working case needs turns those into clean failures — worth stating as a rule +in the guide, since the natural instinct is to leave `maxFrames` generous. + +### Thresholds should come from the behavior, not from a measurement + +The first version of the platformer jump test asserted `jumpHeight > 150`, +a number read off a run. It is both weaker and less portable than it looks: +`starting-platformer-pixel` is the same game with `jumpSpeed` 360 instead of +717, so the constant broke immediately. Reading the configuration out of the +behavior state instead: + +```javascript +const { JumpSpeed, Gravity } = player.behaviors.PlatformerObject.state; +const expectedHeight = (JumpSpeed * JumpSpeed) / (2 * Gravity); +harness.assert(jumpHeight > 0.8 * expectedHeight, '...'); +``` + +turns "it moved a bit" into "it moved as far as it is configured to", and +the same test file then works unchanged on both variants. The same trick +works for `TopDownMovement` (`Acceleration` / `MaxSpeed` give the distance a +key press should cover). **This is probably the single most useful thing to +put in the guide**: the state exposes the configuration, not just the +current values, so tests rarely need magic numbers. + +### The same game, two variants, two different behaviours + +The `-pixel` starters are the same games with different art — and different +behavior settings. `starting-top-down-pixel` has `rotateObject: false` where +`starting-top-down` has it `true`, so an assertion on the *object's* angle +passes on one and fails on the other. Asserting on the behavior's own +movement angle (`behaviors.TopDownMovement.state.Angle`) works on both, and +is closer to what the test means anyway ("the player moves in the direction +of the key"). General rule confirmed: prefer behavior state over object +properties, even when the object property looks like the obvious signal. + +### Chained measurements are polluted by the game's own physics + +Measuring the four directions of `starting-top-down` in a row inside one +scene failed: `SeparateFromObjects` pushes the player away from the plants it +bumps into, so the second and third direction started from a nudged position +with a sideways velocity. Restarting the scene before each measurement +(`goToScene` costs about 3 frames in 2D) makes each one independent and +deterministic. In 3D the same reset costs ~1.4 s of wall clock, so the same +pattern is not affordable there — one more consequence of the timeout issue +above. + +### Prefer what the level already does over arranging it + +For `starting-endless-runner` the first version of the "hazard ends the run" +test spawned a hazard on the player's path. It failed, and the reason is +worth recording: the player auto-runs, and the level's **own** first hazard +is closer than anything a test can usefully place, so the run always ended +before reaching the spawned one. Dropping the `spawn` entirely made the test +both simpler and stronger — it now checks that the game's own level kills +the player. The debugging that got there was a `console.log` of the player +and hazard positions every ten frames, which is genuinely the most effective +tool in the harness. + +The same run also showed a trap in how "did we reach it" is expressed: +comparing the player's **centre** to the hazard's **centre** never becomes +true, because the collision (and the scene restart) happens when the +bounding boxes touch, well before the centres meet. Assertions about +reaching something should be written on the distance between the objects, +not on their coordinates crossing. + +### `stepUntil` conditions make silent false greens very easy to write + +This one cost a real bug in a committed-looking test, and it is a trap +anybody writing "wait until it settles" will fall into. The condition given +to `stepUntil` is evaluated **without stepping a frame first**, so a +condition that reads the same value twice, or compares against a variable it +updates itself, is true immediately: + +```javascript +// Silently passes without stepping a single frame: +let restingY = getBall().centerY; +const settled = await harness.stepUntil(() => { + const current = getBall().centerY; + const isStill = Math.abs(current - restingY) < 0.5; + restingY = current; // updated by the condition itself + return isStill; +}, { maxFrames: 400 }); +``` + +The test still *passed*, and it was only noticed because fixing it changed +the frame count. The working form keeps the state in `onFrame` (which does +run after each stepped frame) and keeps the condition pure: + +```javascript +let previousY = getBall().centerY; +let stillFrames = 0; +const settled = await harness.stepUntil(() => stillFrames >= 20, { + maxFrames: 400, + onFrame: () => { + const currentY = getBall().centerY; + if (Math.abs(currentY - previousY) < 0.5) stillFrames++; + else stillFrames = 0; + previousY = currentY; + }, +}); +``` + +Two suggestions: say explicitly in the guide that **the condition must be +pure and the state must live in `onFrame`**, and — since "wait until this +object stops moving" is needed by every physics game — add a +`stepUntilStable(objectName, { frames, tolerance, maxFrames })` to the +harness. `stepUntil` already has `stuckDetection`, which is the same idea +pointed at a different purpose. + +### Hand-rolled overlap checks are not worth it + +Checking "the player did not walk through the obstacle" by comparing +bounding boxes from `width`/`height` failed on a run where the player passed +96px away from a 64x64 obstacle: sprite dimensions include their transparent +margins, so the hand-made overlap box was far bigger than what the game +considers a collision. What worked, and says more, was asserting the +**shape of the path** instead: the start and the destination were put at the +same height, so walking straight would keep the player on that line, and +going around shows up as a measurable detour off it. + +The general lesson: when the question is "did it go around / did it get +there", assert on the trajectory or on the behavior's own state, not on a +geometry test reconstructed from the snapshot. `has2dLineOfSight` exists but +answers a different question (is the straight line blocked *now*). + +### Short lived objects cannot be measured over a window + +The natural way to check "which way did the bullet go" is to note its +position, step a few frames, and look again. In +`starting-2d-platformer-shooter` that failed: bullets are deleted the moment +they touch a target, and the one fired to the left died after ~8 frames — +before the measurement window closed. Worse, the helper returned `null` both +when *nothing was fired* and when *the bullet was already gone*, so the +failure message said "Pressing X fires a bullet after turning around", which +is the opposite of what happened. It took a frame by frame +`console.log` of the object count to see the bullet had been there all along. + +Two lessons. For the test author: prefer an **instantaneous** signal over a +delta measured across a window — here the bullets have `RotateBullet` +enabled, so `bullet.angle` (0 or 180) says the direction immediately and the +test became both shorter and more precise. And never let one `null` mean two +different things. For the harness: this is the same missing piece as the +object creation signal above — "what was created during this window, and +where did it go" is not answerable today. + +### Arrange by moving the *other* object, not the physics character + +`starting-first-person-horror` needs the monster and the player near each +other, and the obvious way to arrange that is `setObjectPosition` on the +player — it is the object the test is about. That went badly: the player is a +Physics3D character, and dropping it at a spot the test picked from +coordinates alone put it inside or above unknown terrain, after which the +physics engine threw it around. The player moved 378, then 48, then 944 units +in three consecutive runs *while no key was pressed*, which of course +destroys any "did the monster close the gap" measurement. + +Moving the **monster** instead fixed it, and moving it *along the line +between the two* rather than to an arbitrary offset kept it on ground both of +them can stand on. The general rule that came out of this: when a test needs +two objects near each other, reposition the one whose exact physics state the +test does not depend on, and place it relative to the other one rather than +at absolute coordinates. The test then also gets a free sanity check — +`playerMoved < 20` asserts the player really was left alone, so a repeat of +this failure mode shows up as an explicit failure instead of a wrong number. + +A harness-side fix would help here: there is no way to ask "is this position +free / on the ground", and no way to place an object in a way the physics +world accepts (`setObjectPosition` teleports the render position and lets the +simulation catch up). A `placeObjectNear(objectId, otherId, distance)` that +does the right thing for physics bodies would make this class of arrangement +one line and remove the trial and error. + +### A "warm up" input that itself triggers game logic ruins before/after counts + +The FPS tests use a first click to take pointer lock before doing anything +meaningful, so that habit was carried into +`starting-first-person-shooter-horror`'s shooting test. It made the test fail +in a confusing way: the count of impact particles went from 1 to 1. The dummy +click *was itself a shot*, it created a particle, and that particle expired +during the frames the test stepped before the real click — so the "before" +count was not 0, the "after" count was not 2, and the assertion +`after > before` was simply false while the game was working perfectly. + +Removing the dummy click entirely was the fix — shooting only reads the mouse +button, pointer lock is irrelevant to it — and the test got stronger as a +result: it can now assert `particlesBefore === 0`, which pins down that the +one particle observed at the end is unambiguously the one the test's own +click created. Two things generalise: a warm-up input is only safe if it is +genuinely inert for the thing being measured (check what it triggers before +adding one), and any before/after count over short-lived objects should +assert the "before" value, not just the direction of the change. This is the +third test in this batch of work where short-lived objects made a +straightforward count unreliable — see also "Short lived objects cannot be +measured over a window" above. + +### A drag box is a screen rectangle, and in 3D that is not a map rectangle + +`starting-3d-rts-unit-selection` selects units by dragging a box over them. +The 2D version of the same game is tested by computing the box from the units' +positions on the map, and porting that verbatim selected five of the six +units: the sixth sat inside the rectangle on the map and outside the +quadrilateral that rectangle becomes on screen once a 3D camera looks at the +ground at an angle. + +The failure is a bad one to debug, because everything about it looks right — +the box is drawn, five units light up, and the assertion just reports a count. +It took logging each unit's travelled distance to see that one had moved +exactly 0 rather than "not far enough", which is what pointed at selection +rather than at pathfinding. + +The fix is to think in the coordinates the player actually works in: the box +the player drags is a rectangle *of the screen*. Converting each unit's +position to screen coordinates first (with the search described in item 13) +and taking the bounding box there selected all six. Worth a line in the guide: +anything the player draws or points at is screen-space, and on a 3D layer that +is a genuinely different space from the scene, not just a scaled one. + +### A death that slows time down costs six times its `Wait` in frames + +Several starters end a run the same way: `ChangeTimeScale 0.15`, `Wait 0.15`, +then restart the scene. A `Wait` counts in *scene* time, so at a time scale of +0.15 that 0.15 second takes a full second of real time — about 60 stepped +frames, not the 9 the number suggests. In `starting-3d-vampire-survivor` I +sized the window from the `Wait` value and the test failed with the player +still 62px from its starting point, which reads like "the death was not +detected" rather than "the window was too short by a factor of six". + +Two things would help. The `Wait` and the time scale are both visible to the +engine, so a `stepUntil` that times out could say how much *scene* time +elapsed next to the frame count — a test author would immediately see the +scene ran 0.09 s while they were expecting 0.6 s. And it is worth calling out +in the guide, because "slow time down, wait, restart" is a very common +starter pattern and every test that checks a game-over has to step through +it. + +### Smaller surprises + +- `getObjects('X')[0].behaviors.Y.state` throwing on an unknown name with + the list of available names is genuinely great. `Object.keys(state)` also + works on the proxy, which made a single exploration run enough to learn + every state name of a game — worth documenting explicitly. +- Object snapshots have `centerZ`, and it is the coordinate that matters in + every 3D check here; the guide's warning to prefer `centerX/centerY` over + `x + width/2` should mention `centerZ` in the same breath. +- `getNearby(...)` sorts by distance, but ties are common in symmetric + starter levels (`starting-3D-platformer` has two coins at exactly 393 + units). Tests that pick `[0]` need to stay correct for either of them. +- In `starting-3d-driving` the car's `EngineSpeed` idles at 1000, not 0, so + "the engine is spinning" is not a proof that the accelerator works; the + test compares against the idle value it measured rather than against zero. +- Clicking an events based button works exactly as expected with + `setMousePosition(x, y, layerName)` + press / step / release / step. Using + the snapshot's `centerX`/`centerY` (not `x`/`y`) matters: several of these + objects have a non centered origin. +- A "restart the scene" mechanic (`starting-flappy-bird` restarts the run + when the bird touches a hazard) has no direct signal in the harness: + `getSceneName()` is unchanged and the `sceneReset` entry of the event log + is not readable from a test script. Observing that the player object is + back at its spawn position works, but a `getSceneRestartCount()` (or + exposing the event log to the script) would say what the test means. +- `spawn(name, x, y)` places the object's **origin** at `x, y`, so an object + whose origin is not its centre lands offset. Spawning then correcting with + `setObjectPosition(id, x + (wantedCenterX - snapshot.centerX), ...)` works, + but a `spawn(..., { centered: true })` (or simply returning a snapshot that + can be fed back) would remove a very repetitive three lines. Note also that + the snapshot returned by `spawn` is a *value*: it does not follow the + object, so re-reading it after a correction requires a `getObjects().find()`. +- Games that end a run by restarting the scene (`starting-flappy-bird`, + `starting-endless-runner`, `starting-vampire-survivor`) are all tested the + same way here: move the player away from its spawn point, then wait for it + to be back there. It works, but three different games needed the same + workaround for the missing "the scene restarted" signal. +- Picking "the object under the player" from a group of same-named instances + needs care: `starting-physics` has six `Ground` instances, four of which are + small angled ramps, and the obvious "the highest one near the ball" picked a + ramp 45px above the actual floor. Choosing the widest instance (the floor) + was both simpler and right. A game-agnostic "what is this object resting + on" would need engine support; picking by a distinctive property is the + practical answer. +- `behaviors.X.act` (whether a behavior is activated) turned out to be the + cleanest way to test a mechanic in `starting-beatemup`, where attacking + deactivates the movement behavior so the player is rooted during the + animation. It deserves a mention in the guide: it is not obvious that the + snapshot answers "is this behavior currently switched off". +- `snapshot.animation` makes animation driven games easy to test: in a + beat'em up the whole state machine *is* the animation name + ("Idle" / "AttackBuildUp" / "AttackStrike" / "Hurt"), so the test reads + exactly like the events do. +- The Pathfinding behavior exposes `PathFound` and `DestinationReached`, + which replaced a distance threshold that was making a test fail for the + wrong reason (the player was still walking the last few pixels). Another + case of "the behavior state says what the test means". +- Structure and array scene variables read back exactly as expected: + `getSceneVariable('QuestionList').children` is an array of entries that + each carry their `name`, `type` and `value`, which made the + `starting-quiz` tests read the game's own data (the questions and which + answer is the right one) instead of hardcoding it. That test would have + been meaningless written any other way. +- The drag recipe in the guide (move to the centre, press, move in small + increments, release) works exactly as written for the `Draggable` + behavior — `starting-draggable-tiles` passed first try with it. +- Games with a grid make for the sharpest assertions in this whole batch: + `dropped.x % 64 === 0` and "it went back to the cell it came from" are + exact, with no tolerance to tune. When a game states a rule that precisely, + the test should assert the rule and not an approximate position. +- When arranging a drop position, the size of the dragged object matters + more than it looks: `starting-card-game` uses 140x190 cards, so dropping + one on the placement area *nearest* the deck still left it overlapping the + deck, and the game put it straight back — the test then reported "the drawn + card is not on the table", which is true but misleading. Choosing the + placement area furthest from the deck fixed it. Snapshots carry `width` and + `height`: a test that positions things should use them. +- Checking a dialog through its **layer** visibility + (`getRuntimeLayer('Dialog Layer').isVisible()`), exactly as the guide + recommends, is what made `starting-top-down-rpg` easy: the game shows and + hides a whole layer, and the test reads like the events do. +- `setObjectPosition` on a physics body works exactly as documented, + including for the `PhysicsCar3D` bodies — repositioning the car and the + tank a short run-up away from their target is what made those two tests + fit in the time budget at all. + +--- + +## Suspected runtime bugs + +1. **`Jolt is not defined` race at first scene load** (above) — a real + runtime/boot ordering bug, not a test-harness one. **Now fixed** by master + commit `ba74a65`, and verified here over four consecutive runs of + `starting-first-person`, the game that reproduced it every time. +2. **`FirstPersonPointerMapper` pitches the player with `SetRotationY`.** + The extension's own events carry `// TODO It's probably a bad idea to + rotate the object around Y`. Whatever the right answer is, the harness's + `pitchDiff` (which reads `getRotationX()`) and this extension disagree, + so FPS aim helpers silently do nothing on this starter and every game + built from it. +3. Not a bug, but worth knowing: in `starting-first-person-shooter` the + "first click to focus" that engages the pointer lock **also fires the + gun** (the shooting event only excludes the cursor being over the controls + toggle). Tests must count impact effects from *after* that click, not + from the start of the scene. diff --git a/README.md b/README.md index 4db10b0c5..6c4270493 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,53 @@ If you know how to create _Pull Requests_, you can also clone this repository an To add a game to the homepage the game have to be listed in the `scripts/generate-database.js` file. +### Gameplay tests + +A game can contain _gameplay tests_: scripts that play the game like a player +would (pressing keys, stepping frames) and check what happens. They are stored +in the game's `.json` file, in a top level `tests` array, and are written and +run from GDevelop itself. + +The CI runs them with the latest Linux build of GDevelop, downloaded from the +same S3 bucket GDevelop's own CI publishes to: + +- on `main`, every game that has gameplay tests is tested; +- on a branch or Pull Request, only the games it modifies are tested. + +To run them yourself (needs `xvfb` and the runtime libraries of Electron on +Linux): + +```bash +npm install + +# Every game that has gameplay tests. +node scripts/run-gameplay-tests.js + +# Only the games changed compared to `main`. +node scripts/run-gameplay-tests.js --only-changed + +# One game in particular. +node scripts/run-gameplay-tests.js --projects=examples/starting-platformer/starting-platformer.json + +# Just list what would be tested. +node scripts/run-gameplay-tests.js --list +``` + +The other options are documented at the top of the script: choosing the +GDevelop branch or version to test with, sharding the games across several +machines, where the results and failure screenshots are written... + +A gameplay test gets a fixed 30 seconds of wall clock from GDevelop, which a +test cannot ask to raise, and how long a frame takes to render varies a lot +from one CI container to the next. So a game whose run failed **only** with +wall-clock timeouts is run a second time, and the second run is the one that +counts. A failed assertion is never retried — that is a real result. + +The tests of every game can also be run on a branch, without waiting for it to +land on `main`, by triggering a CircleCI pipeline with the +`run-all-gameplay-tests` parameter set to `true`. The number of parallel +containers used is the `all-gameplay-tests-parallelism` pipeline parameter. + ## License All examples provided on this repository are MIT licensed, unless specified otherwise. diff --git a/examples/starting-2d-car-racing/starting-2d-car-racing.json b/examples/starting-2d-car-racing/starting-2d-car-racing.json index 5eed2f21c..df7e0e479 100644 --- a/examples/starting-2d-car-racing/starting-2d-car-racing.json +++ b/examples/starting-2d-car-racing/starting-2d-car-racing.json @@ -1963,6 +1963,176 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Driving and steering", + "type": "gameplay", + "description": "The pedal drives the car forward along its heading, and the steering only turns it once it is moving.", + "source": [ + "// The core of the game: the pedal pushes the car along its heading, and the", + "// steering only bites while the car is actually moving (the torque applied", + "// by the events is proportional to the speed).", + "await harness.goToScene('GameScene');", + "harness.watch('PlayerCar');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "await harness.stepFrames(20);", + "const start = getCar();", + "const headingRadians = (start.angle * Math.PI) / 180;", + "", + "// Nothing pressed: the car stays put.", + "await harness.stepFrames(30);", + "const idle = getCar();", + "const idleDistance = Math.hypot(", + " idle.centerX - start.centerX,", + " idle.centerY - start.centerY", + ");", + "harness.assert(", + " idleDistance < 10,", + " 'The car stays put while no key is pressed (it drifted ' +", + " idleDistance.toFixed(1) + 'px).'", + ");", + "", + "// Steering a car that is not moving must do nothing.", + "const angleBeforeSteering = idle.angle;", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(30);", + "harness.setKeyPressed('Right', false);", + "const steeredWhileStopped = getCar();", + "console.log(", + " 'angleWhileStopped=' + Math.round(angleBeforeSteering) +", + " ' -> ' + Math.round(steeredWhileStopped.angle)", + ");", + "harness.assert(", + " Math.abs(steeredWhileStopped.angle - angleBeforeSteering) < 5,", + " 'Steering does nothing while the car is stopped (its angle went from ' +", + " Math.round(angleBeforeSteering) + ' to ' + Math.round(steeredWhileStopped.angle) + ').'", + ");", + "", + "// Accelerate.", + "const beforeDriving = getCar();", + "harness.setKeyPressed('Up', true);", + "await harness.stepFrames(60);", + "harness.setKeyPressed('Up', false);", + "const afterDriving = getCar();", + "", + "const travelX = afterDriving.centerX - beforeDriving.centerX;", + "const travelY = afterDriving.centerY - beforeDriving.centerY;", + "const travelled = Math.hypot(travelX, travelY);", + "const forward =", + " travelX * Math.cos(headingRadians) + travelY * Math.sin(headingRadians);", + "console.log(", + " 'travelled=' + Math.round(travelled) + ' forward=' + Math.round(forward)", + ");", + "harness.assert(", + " forward > 100,", + " 'Holding the pedal drives the car forward (it drove ' +", + " Math.round(forward) + 'px along its heading).'", + ");", + "harness.assert(", + " forward > 0.9 * travelled,", + " 'The car drives along its heading rather than sideways.'", + ");", + "", + "// Now that it rolls, steering turns it.", + "const angleBeforeTurning = getCar().angle;", + "harness.setKeyPressed('Up', true);", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(60);", + "harness.releaseAllInputs();", + "const turned = getCar();", + "console.log(", + " 'angleWhileMoving=' + Math.round(angleBeforeTurning) + ' -> ' + Math.round(turned.angle)", + ");", + "harness.assert(", + " Math.abs(turned.angle - angleBeforeTurning) > 15,", + " 'Steering turns the car once it is moving (its angle went from ' +", + " Math.round(angleBeforeTurning) + ' to ' + Math.round(turned.angle) + ').'", + ");" + ] + }, + { + "name": "Running into a bush pushes it away", + "type": "gameplay", + "description": "A bush standing on the road stays put until the car reaches it, and is then pushed out of the way.", + "source": [ + "// The bushes are physics objects: driving into one must push it out of the", + "// way rather than the car going through it.", + "await harness.goToScene('GameScene');", + "harness.watch('BushObstacle');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "await harness.stepFrames(20);", + "const car = getCar();", + "", + "// Arrange: put a bush on the road ahead of the car. Running it over is", + "// still up to the game.", + "const headingRadians = (car.angle * Math.PI) / 180;", + "const aheadX = car.centerX + Math.cos(headingRadians) * 320;", + "const aheadY = car.centerY + Math.sin(headingRadians) * 320;", + "const spawned = harness.spawn('BushObstacle', aheadX, aheadY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (aheadX - spawned.centerX),", + " spawned.y + (aheadY - spawned.centerY)", + ");", + "await harness.stepFrames(10);", + "", + "const bushBefore = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + "harness.assert(!!bushBefore, 'A bush is standing on the road ahead of the car.');", + "", + "// It must stay put while nothing touches it.", + "await harness.stepFrames(30);", + "const bushIdle = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + "harness.assert(", + " Math.hypot(", + " bushIdle.centerX - bushBefore.centerX,", + " bushIdle.centerY - bushBefore.centerY", + " ) < 5,", + " 'The bush stays where it is while the car has not reached it.'", + ");", + "", + "// Drive into it.", + "harness.setKeyPressed('Up', true);", + "const reached = await harness.stepUntil(", + " () => {", + " const bush = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + " if (!bush) return true;", + " return (", + " Math.hypot(", + " bush.centerX - bushBefore.centerX,", + " bush.centerY - bushBefore.centerY", + " ) > 30", + " );", + " },", + " { maxFrames: 200 }", + ");", + "harness.releaseAllInputs();", + "await harness.stepFrames(20);", + "", + "const bushAfter = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + "harness.assert(!!bushAfter, 'The bush is still in the scene after the impact.');", + "const pushed = Math.hypot(", + " bushAfter.centerX - bushBefore.centerX,", + " bushAfter.centerY - bushBefore.centerY", + ");", + "console.log('bushPushedBy=' + Math.round(pushed) + ' reached=' + reached);", + "harness.assert(", + " pushed > 30,", + " 'Driving into the bush pushes it out of the way (it moved ' +", + " Math.round(pushed) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-2d-driving/starting-2d-driving.json b/examples/starting-2d-driving/starting-2d-driving.json index b25df2317..f3971dfaf 100644 --- a/examples/starting-2d-driving/starting-2d-driving.json +++ b/examples/starting-2d-driving/starting-2d-driving.json @@ -1409,6 +1409,176 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Driving and steering", + "type": "gameplay", + "description": "The pedal drives the car forward along its heading, and the steering only turns it once it is moving.", + "source": [ + "// The core of the game: the pedal pushes the car along its heading, and the", + "// steering only bites while the car is actually moving (the torque applied", + "// by the events is proportional to the speed).", + "await harness.goToScene('GameScene');", + "harness.watch('PlayerCar');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "await harness.stepFrames(20);", + "const start = getCar();", + "const headingRadians = (start.angle * Math.PI) / 180;", + "", + "// Nothing pressed: the car stays put.", + "await harness.stepFrames(30);", + "const idle = getCar();", + "const idleDistance = Math.hypot(", + " idle.centerX - start.centerX,", + " idle.centerY - start.centerY", + ");", + "harness.assert(", + " idleDistance < 10,", + " 'The car stays put while no key is pressed (it drifted ' +", + " idleDistance.toFixed(1) + 'px).'", + ");", + "", + "// Steering a car that is not moving must do nothing.", + "const angleBeforeSteering = idle.angle;", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(30);", + "harness.setKeyPressed('Right', false);", + "const steeredWhileStopped = getCar();", + "console.log(", + " 'angleWhileStopped=' + Math.round(angleBeforeSteering) +", + " ' -> ' + Math.round(steeredWhileStopped.angle)", + ");", + "harness.assert(", + " Math.abs(steeredWhileStopped.angle - angleBeforeSteering) < 5,", + " 'Steering does nothing while the car is stopped (its angle went from ' +", + " Math.round(angleBeforeSteering) + ' to ' + Math.round(steeredWhileStopped.angle) + ').'", + ");", + "", + "// Accelerate.", + "const beforeDriving = getCar();", + "harness.setKeyPressed('Up', true);", + "await harness.stepFrames(60);", + "harness.setKeyPressed('Up', false);", + "const afterDriving = getCar();", + "", + "const travelX = afterDriving.centerX - beforeDriving.centerX;", + "const travelY = afterDriving.centerY - beforeDriving.centerY;", + "const travelled = Math.hypot(travelX, travelY);", + "const forward =", + " travelX * Math.cos(headingRadians) + travelY * Math.sin(headingRadians);", + "console.log(", + " 'travelled=' + Math.round(travelled) + ' forward=' + Math.round(forward)", + ");", + "harness.assert(", + " forward > 100,", + " 'Holding the pedal drives the car forward (it drove ' +", + " Math.round(forward) + 'px along its heading).'", + ");", + "harness.assert(", + " forward > 0.9 * travelled,", + " 'The car drives along its heading rather than sideways.'", + ");", + "", + "// Now that it rolls, steering turns it.", + "const angleBeforeTurning = getCar().angle;", + "harness.setKeyPressed('Up', true);", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(60);", + "harness.releaseAllInputs();", + "const turned = getCar();", + "console.log(", + " 'angleWhileMoving=' + Math.round(angleBeforeTurning) + ' -> ' + Math.round(turned.angle)", + ");", + "harness.assert(", + " Math.abs(turned.angle - angleBeforeTurning) > 15,", + " 'Steering turns the car once it is moving (its angle went from ' +", + " Math.round(angleBeforeTurning) + ' to ' + Math.round(turned.angle) + ').'", + ");" + ] + }, + { + "name": "Running into a bush pushes it away", + "type": "gameplay", + "description": "A bush standing on the road stays put until the car reaches it, and is then pushed out of the way.", + "source": [ + "// The bushes are physics objects: driving into one must push it out of the", + "// way rather than the car going through it.", + "await harness.goToScene('GameScene');", + "harness.watch('BushObstacle');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "await harness.stepFrames(20);", + "const car = getCar();", + "", + "// Arrange: put a bush on the road ahead of the car. Running it over is", + "// still up to the game.", + "const headingRadians = (car.angle * Math.PI) / 180;", + "const aheadX = car.centerX + Math.cos(headingRadians) * 320;", + "const aheadY = car.centerY + Math.sin(headingRadians) * 320;", + "const spawned = harness.spawn('BushObstacle', aheadX, aheadY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (aheadX - spawned.centerX),", + " spawned.y + (aheadY - spawned.centerY)", + ");", + "await harness.stepFrames(10);", + "", + "const bushBefore = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + "harness.assert(!!bushBefore, 'A bush is standing on the road ahead of the car.');", + "", + "// It must stay put while nothing touches it.", + "await harness.stepFrames(30);", + "const bushIdle = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + "harness.assert(", + " Math.hypot(", + " bushIdle.centerX - bushBefore.centerX,", + " bushIdle.centerY - bushBefore.centerY", + " ) < 5,", + " 'The bush stays where it is while the car has not reached it.'", + ");", + "", + "// Drive into it.", + "harness.setKeyPressed('Up', true);", + "const reached = await harness.stepUntil(", + " () => {", + " const bush = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + " if (!bush) return true;", + " return (", + " Math.hypot(", + " bush.centerX - bushBefore.centerX,", + " bush.centerY - bushBefore.centerY", + " ) > 30", + " );", + " },", + " { maxFrames: 200 }", + ");", + "harness.releaseAllInputs();", + "await harness.stepFrames(20);", + "", + "const bushAfter = harness", + " .getObjects('BushObstacle')", + " .find((one) => one.id === spawned.id);", + "harness.assert(!!bushAfter, 'The bush is still in the scene after the impact.');", + "const pushed = Math.hypot(", + " bushAfter.centerX - bushBefore.centerX,", + " bushAfter.centerY - bushBefore.centerY", + ");", + "console.log('bushPushedBy=' + Math.round(pushed) + ' reached=' + reached);", + "harness.assert(", + " pushed > 30,", + " 'Driving into the bush pushes it out of the way (it moved ' +", + " Math.round(pushed) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-2d-platformer-shooter/starting-2d-platformer-shooter.json b/examples/starting-2d-platformer-shooter/starting-2d-platformer-shooter.json index fd0c322b3..b78806183 100644 --- a/examples/starting-2d-platformer-shooter/starting-2d-platformer-shooter.json +++ b/examples/starting-2d-platformer-shooter/starting-2d-platformer-shooter.json @@ -1493,6 +1493,147 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Shooting in the direction the player faces", + "type": "gameplay", + "description": "Pressing X fires a bullet to the right, and to the left once the player turned around by walking left.", + "source": [ + "// Shooting fires a bullet in the direction the player is facing, which is", + "// decided by the way it last walked.", + "await harness.goToScene('Game Scene');", + "harness.watch('Bullet');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isOnFloor = () =>", + " getPlayer().behaviors.PlatformerObject.state.IsOnFloor === true;", + "const normalizeAngle = (angle) => (((angle % 360) + 540) % 360) - 180;", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player lands on the ground.');", + "const startingPoint = getPlayer();", + "harness.assert(", + " harness.getObjects('Bullet').length === 0,", + " 'No bullet is in the air before shooting.'", + ");", + "", + "/**", + " * Shoot once and return the bullet that came out. The field is emptied", + " * first, so the bullet that is found afterwards is unambiguously the new", + " * one (bullets are short lived here: they are destroyed as soon as they hit", + " * a target, so they cannot be tracked over a long window).", + " */", + "const shootOnce = async () => {", + " const emptied = await harness.stepUntil(", + " () => harness.getObjects('Bullet').length === 0,", + " { maxFrames: 180 }", + " );", + " harness.assert(emptied, 'The bullets of the previous shot are gone.');", + " harness.setKeyPressed('x', true);", + " await harness.stepFrames(2);", + " harness.setKeyPressed('x', false);", + " return harness.getObjects('Bullet')[0] || null;", + "};", + "", + "// Facing right by default. The bullets are rotated to their direction, so", + "// their angle says where they are going.", + "const rightBullet = await shootOnce();", + "harness.assert(!!rightBullet, 'Pressing X fires a bullet.');", + "console.log('bulletAngleFacingRight=' + Math.round(rightBullet.angle));", + "harness.assert(", + " Math.abs(normalizeAngle(rightBullet.angle)) < 15,", + " 'The bullet is fired to the right while the player faces right (it is angled at ' +", + " Math.round(rightBullet.angle) + ' degrees).'", + ");", + "harness.assert(", + " rightBullet.centerX > startingPoint.centerX - 5,", + " 'The bullet comes out of the player.'", + ");", + "", + "// Walk left to turn around.", + "harness.setKeyPressed('Left', true);", + "await harness.stepFrames(25);", + "harness.setKeyPressed('Left', false);", + "await harness.stepFrames(5);", + "const turned = getPlayer();", + "console.log(", + " 'walkedLeftBy=' + Math.round(startingPoint.centerX - turned.centerX)", + ");", + "harness.assert(", + " turned.centerX < startingPoint.centerX - 20,", + " 'The player walked left, which is what turns it around (it moved ' +", + " Math.round(turned.centerX - startingPoint.centerX) + 'px).'", + ");", + "", + "const leftBullet = await shootOnce();", + "harness.assert(!!leftBullet, 'Pressing X fires a bullet after turning around.');", + "console.log('bulletAngleFacingLeft=' + Math.round(leftBullet.angle));", + "harness.assert(", + " Math.abs(normalizeAngle(leftBullet.angle - 180)) < 15,", + " 'The bullet is fired to the left once the player faces left (it is angled at ' +", + " Math.round(leftBullet.angle) + ' degrees).'", + ");" + ] + }, + { + "name": "Shooting a target destroys it", + "type": "gameplay", + "description": "A target standing in the line of fire is destroyed once it is shot, and not before.", + "source": [ + "// Shooting a target must destroy it.", + "await harness.goToScene('Game Scene');", + "harness.watch('Target');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isOnFloor = () =>", + " getPlayer().behaviors.PlatformerObject.state.IsOnFloor === true;", + "", + "await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "const targets = harness.getObjects('Target');", + "harness.assert(targets.length > 0, 'There are targets to shoot at.');", + "", + "// Arrange: put one of the targets in the line of fire, to the right of the", + "// player. Destroying it is still up to the game.", + "const player = getPlayer();", + "const target = targets[0];", + "harness.setObjectPosition(", + " target.id,", + " target.x + (player.centerX + 320 - target.centerX),", + " target.y + (player.centerY - target.centerY)", + ");", + "await harness.stepFrames(3);", + "const targetsBefore = harness.getObjects('Target').length;", + "harness.assert(", + " harness.getObjects('Target').some((one) => one.id === target.id),", + " 'The target is standing in the line of fire.'", + ");", + "", + "// Doing nothing must not destroy it.", + "await harness.stepFrames(25);", + "harness.assert(", + " harness.getObjects('Target').some((one) => one.id === target.id),", + " 'The target is not destroyed while nothing is shot at it.'", + ");", + "", + "// Shoot it.", + "harness.setKeyPressed('x', true);", + "const destroyed = await harness.stepUntil(", + " () => !harness.getObjects('Target').some((one) => one.id === target.id),", + " { maxFrames: 200 }", + ");", + "harness.releaseAllInputs();", + "", + "console.log(", + " 'targetsBefore=' + targetsBefore + ' targetsLeft=' + harness.getObjects('Target').length", + ");", + "harness.assert(", + " destroyed,", + " 'Shooting the target destroys it (' +", + " harness.getObjects('Target').length + ' target(s) left of ' + targetsBefore + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3D-platformer/starting-3D-platformer.json b/examples/starting-3D-platformer/starting-3D-platformer.json index 475147d38..e440d06cd 100644 --- a/examples/starting-3D-platformer/starting-3D-platformer.json +++ b/examples/starting-3D-platformer/starting-3D-platformer.json @@ -2621,6 +2621,153 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Jumping with Space", + "type": "gameplay", + "description": "Pressing Space makes the 3D character jump off the ground, and gravity brings it back down.", + "source": [ + "// Space must make the physics character jump off the ground", + "// (PhysicsCharacter3D, mapped to Space by Platformer3DKeyboardMapper).", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const characterState = () => getPlayer().behaviors.PhysicsCharacter3D.state;", + "", + "await harness.stepFrames(5);", + "harness.assert(characterState().IsOnFloor === true, 'The player stands on the ground.');", + "const groundZ = getPlayer().z;", + "", + "// Doing nothing must leave the player on the ground.", + "await harness.stepFrames(10);", + "harness.assert(", + " Math.abs(getPlayer().z - groundZ) < 1 && characterState().IsOnFloor === true,", + " 'The player stays on the ground while no key is pressed.'", + ");", + "", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('Space', false);", + "", + "let highestZ = groundZ;", + "let sawJumping = false;", + "let leftTheFloor = false;", + "await harness.stepFrames(30, {", + " onFrame: () => {", + " const state = characterState();", + " highestZ = Math.max(highestZ, getPlayer().z);", + " if (state.IsJumping === true) sawJumping = true;", + " if (state.IsOnFloor === false) leftTheFloor = true;", + " },", + "});", + "const jumpHeight = highestZ - groundZ;", + "console.log('jumpHeight=' + Math.round(jumpHeight));", + "", + "harness.assert(sawJumping, 'The character reports it is jumping after pressing Space.');", + "harness.assert(leftTheFloor, 'The player leaves the floor when jumping.');", + "harness.assert(", + " jumpHeight > 60,", + " 'The player rises well above the ground when jumping (rose by ' +", + " Math.round(jumpHeight) +", + " ' units).'", + ");", + "", + "// ...and gravity brings it back down.", + "const landedBack = await harness.stepUntil(", + " () => characterState().IsOnFloor === true,", + " { maxFrames: 40 }", + ");", + "harness.assert(landedBack, 'The player falls back onto the ground.');", + "harness.assert(", + " Math.abs(getPlayer().z - groundZ) < 2,", + " 'The player lands back at the height it jumped from.'", + ");" + ] + }, + { + "name": "Collecting a coin by walking into it", + "type": "gameplay", + "description": "The player walks forward into a coin lying on the ground: the coin is picked up.", + "source": [ + "// Walking into a coin must collect it: the events delete a Coin as soon as", + "// it is within 50 units of the Player.", + "await harness.goToScene('Game Scene');", + "harness.watch('Coin');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const coinIds = () => harness.getObjects('Coin').map(coin => coin.id);", + "", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "harness.assert(", + " player.behaviors.PhysicsCharacter3D.state.IsOnFloor === true,", + " 'The player stands on the ground.'", + ");", + "", + "// \"w\" walks the character toward the direction it faces.", + "const forwardAngle = player.behaviors.PhysicsCharacter3D.state.ForwardAngle;", + "const forwardRadians = (forwardAngle * Math.PI) / 180;", + "const forward = { x: Math.cos(forwardRadians), y: Math.sin(forwardRadians) };", + "", + "// Arrange: stand the player back from a coin lying on the ground, so that", + "// the coin is straight ahead. Reaching it is still up to the game.", + "const groundCoins = harness", + " .getNearby('Coin', 'Player', 5000)", + " .filter(coin => Math.abs(coin.centerZ - player.centerZ) < 60);", + "harness.assert(groundCoins.length > 0, 'There are coins lying on the ground.');", + "const targetCoin = groundCoins[0];", + "const approachDistance = 140;", + "harness.setObjectPosition(", + " player.id,", + " targetCoin.centerX - forward.x * approachDistance,", + " targetCoin.centerY - forward.y * approachDistance,", + " player.z", + ");", + "", + "// Standing away from the coin must not collect anything: this is what makes", + "// the check below a test of the player walking into it.", + "await harness.stepFrames(12);", + "const coinsBefore = coinIds().length;", + "harness.assert(", + " coinIds().includes(targetCoin.id),", + " 'The coin to walk into is still there once the player is in place.'", + ");", + "", + "// Walk forward into the coin.", + "harness.setKeyPressed('w', true);", + "const collected = await harness.stepUntil(", + " () => !coinIds().includes(targetCoin.id),", + " { maxFrames: 70 }", + ");", + "harness.releaseAllInputs();", + "", + "const rel = harness.getRelativePosition('Player', {", + " x: targetCoin.centerX,", + " y: targetCoin.centerY,", + " z: targetCoin.centerZ,", + "});", + "console.log(", + " 'coinsBefore=' +", + " coinsBefore +", + " ' coinsLeft=' +", + " coinIds().length +", + " ' distanceToCoin=' +", + " (rel ? Math.round(rel.distance) : 'n/a')", + ");", + "harness.assert(", + " collected,", + " 'Walking forward into the coin collects it (the player ended up ' +", + " (rel ? Math.round(rel.distance) : '?') +", + " ' units from it).'", + ");", + "harness.assert(", + " coinIds().length < coinsBefore,", + " 'The number of coins left in the level went down.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-car-racing/starting-3d-car-racing.json b/examples/starting-3d-car-racing/starting-3d-car-racing.json index ca9f97cd9..79432b85b 100644 --- a/examples/starting-3d-car-racing/starting-3d-car-racing.json +++ b/examples/starting-3d-car-racing/starting-3d-car-racing.json @@ -2050,6 +2050,173 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Accelerating drives the car forward", + "type": "gameplay", + "description": "Holding the accelerator revs the engine and drives the car forward along its heading; it stays put when nothing is pressed.", + "source": [ + "// The core of the game: the accelerator drives the car forward along its", + "// heading (\"Up\", per PhysicsCar3DKeyboardMapper).", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerCar');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "", + "// Let the car settle on the road.", + "await harness.stepFrames(10);", + "const start = getCar();", + "harness.assert(", + " start.behaviors.PhysicsCar3D.state.IsOnFloor === true,", + " 'The car rests on the road.'", + ");", + "const headingRadians = (start.angle * Math.PI) / 180;", + "", + "// Without any input the car does not drive away by itself.", + "await harness.stepFrames(8);", + "const idle = getCar();", + "const idleDistance = Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY);", + "harness.assert(", + " idleDistance < 10,", + " 'The car stays put while no key is pressed (drifted ' + idleDistance.toFixed(1) + 'px).'", + ");", + "", + "// Accelerate.", + "harness.setKeyPressed('Up', true);", + "let maxEngineSpeed = 0;", + "await harness.stepFrames(38, {", + " onFrame: () => {", + " maxEngineSpeed = Math.max(", + " maxEngineSpeed,", + " getCar().behaviors.PhysicsCar3D.state.EngineSpeed", + " );", + " },", + "});", + "harness.releaseAllInputs();", + "const after = getCar();", + "", + "const travelX = after.centerX - idle.centerX;", + "const travelY = after.centerY - idle.centerY;", + "const travelled = Math.hypot(travelX, travelY);", + "const forwardDistance =", + " travelX * Math.cos(headingRadians) + travelY * Math.sin(headingRadians);", + "console.log(", + " 'travelled=' + Math.round(travelled) +", + " ' forward=' + Math.round(forwardDistance) +", + " ' maxEngineSpeed=' + Math.round(maxEngineSpeed) +", + " ' gear=' + after.behaviors.PhysicsCar3D.state.CurrentGear +", + " ' angleDelta=' + Math.round(after.angle - start.angle)", + ");", + "", + "harness.assert(", + " maxEngineSpeed > start.behaviors.PhysicsCar3D.state.EngineSpeed,", + " 'The engine revs up while accelerating (reached ' + Math.round(maxEngineSpeed) + ').'", + ");", + "harness.assert(", + " forwardDistance > 65,", + " 'Holding the accelerator drives the car forward (drove ' +", + " Math.round(forwardDistance) + 'px along its heading).'", + ");", + "harness.assert(", + " forwardDistance > 0.9 * travelled,", + " 'The car drives along its heading rather than sideways.'", + ");", + "harness.assert(", + " Math.abs(after.angle - start.angle) < 15,", + " 'The car keeps going straight while no steering key is pressed (turned by ' +", + " Math.round(after.angle - start.angle) + ' degrees).'", + ");" + ] + }, + { + "name": "Driving over the finish line counts a lap", + "type": "gameplay", + "description": "The car is lined up before the finish line and driven over it: the lap counter goes up and the next checkpoint is the first of the new lap.", + "source": [ + "// What makes it a race: driving past the finish line counts a lap and puts", + "// the car back on the first checkpoint.", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerCar');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "/** Read one of the object variables the game keeps the race progress in. */", + "const readProgress = () => {", + " const variables = getCar().variables;", + " const read = (name) => {", + " const variable = variables.find((one) => one.name === name);", + " return variable ? Number(variable.value) : null;", + " };", + " return { lap: read('LapNumber'), checkpoint: read('LastCheckPoint') };", + "};", + "", + "await harness.stepFrames(10);", + "const finishLine = harness.getObjects('CheckpointArrow').find((arrow) => {", + " const variable = arrow.variables.find((one) => one.name === 'FinishLine');", + " return variable && variable.value === true;", + "});", + "harness.assert(!!finishLine, 'The track has a finish line.');", + "", + "const atStart = readProgress();", + "console.log('atStart=' + JSON.stringify(atStart));", + "harness.assert(atStart.lap === 0, 'No lap has been run yet.');", + "", + "// Arrange: line the car up a short run-up before the finish line, on the", + "// track it starts on and pointing the way it already points. Driving over", + "// the line, and what that counts for, is still up to the game.", + "const car = getCar();", + "const RUN_UP = 100;", + "harness.assert(", + " car.centerX < finishLine.centerX,", + " 'The car starts before the finish line.'", + ");", + "harness.setObjectPosition(", + " car.id,", + " car.x + (finishLine.centerX - RUN_UP - car.centerX),", + " car.y + (finishLine.centerY - car.centerY),", + " car.z", + ");", + "await harness.stepFrames(8);", + "", + "// Being next to the finish line is not enough: it has to be driven over.", + "const beforeDriving = readProgress();", + "harness.assert(", + " beforeDriving.lap === 0,", + " 'Standing just before the finish line does not count a lap.'", + ");", + "", + "harness.setKeyPressed('Up', true);", + "const lapped = await harness.stepUntil(() => readProgress().lap >= 1, {", + " maxFrames: 55,", + "});", + "harness.releaseAllInputs();", + "", + "const finalCar = getCar();", + "const finalProgress = readProgress();", + "console.log(", + " 'final=' + JSON.stringify(finalProgress) +", + " ' carX=' + Math.round(finalCar.centerX) +", + " ' finishLineX=' + Math.round(finishLine.centerX)", + ");", + "", + "harness.assert(", + " finalCar.centerX > finishLine.centerX,", + " 'The car drove over the finish line (it is at x=' +", + " Math.round(finalCar.centerX) + ', the line is at x=' +", + " Math.round(finishLine.centerX) + ').'", + ");", + "harness.assert(", + " lapped && finalProgress.lap === 1,", + " 'Driving over the finish line counts a lap (the lap counter is at ' +", + " finalProgress.lap + ').'", + ");", + "harness.assert(", + " finalProgress.checkpoint === 1,", + " 'The next checkpoint to reach is the first one of the new lap (it is ' +", + " finalProgress.checkpoint + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-draggable-tiles/starting-3d-draggable-tiles.json b/examples/starting-3d-draggable-tiles/starting-3d-draggable-tiles.json index 007eb52d7..718108e19 100644 --- a/examples/starting-3d-draggable-tiles/starting-3d-draggable-tiles.json +++ b/examples/starting-3d-draggable-tiles/starting-3d-draggable-tiles.json @@ -1218,6 +1218,254 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Dragging a piece onto a free cell", + "type": "gameplay", + "description": "A piece dragged with the mouse follows it and is snapped onto the board grid where it is dropped.", + "source": [ + "// The core of the game: a piece can be dragged to a free cell, and is", + "// snapped onto the 64x64 grid of the board when dropped.", + "await harness.goToScene('Game Scene');", + "harness.watch('Tree');", + "", + "const GRID = 64;", + "const getPieceById = (name, id) =>", + " harness.getObjects(name).find((one) => one.id === id);", + "", + "/**", + " * Put the mouse cursor on a point of the board.", + " *", + " * `setMousePosition` cannot be used here: the board is drawn by a 3D camera,", + " * and the scene-to-screen conversion it uses ignores 3D rotations, so the", + " * cursor lands somewhere else than asked. The screen-to-scene conversion of", + " * the layer does handle them (it is the one the game itself reads the cursor", + " * with), so the screen position is searched for instead: start at the middle", + " * of the screen and step toward the wanted point, following the conversion's", + " * own slope. It converges in a handful of iterations and costs no frame.", + " */", + "const layer = harness.getRuntimeLayer('');", + "const sceneAt = (screenX, screenY) => {", + " const point = layer.convertCoords(screenX, screenY, 0, [0, 0]);", + " return { x: point[0], y: point[1] };", + "};", + "const pointMouseAt = (sceneX, sceneY) => {", + " let screenX = harness.getGameResolutionWidth() / 2;", + " let screenY = harness.getGameResolutionHeight() / 2;", + " for (let iteration = 0; iteration < 15; iteration++) {", + " const here = sceneAt(screenX, screenY);", + " const errorX = sceneX - here.x;", + " const errorY = sceneY - here.y;", + " if (Math.hypot(errorX, errorY) < 0.5) break;", + " const step = 4;", + " const right = sceneAt(screenX + step, screenY);", + " const down = sceneAt(screenX, screenY + step);", + " const a = (right.x - here.x) / step;", + " const b = (down.x - here.x) / step;", + " const c = (right.y - here.y) / step;", + " const d = (down.y - here.y) / step;", + " const determinant = a * d - b * c;", + " if (!determinant) break;", + " screenX += (d * errorX - b * errorY) / determinant;", + " screenY += (-c * errorX + a * errorY) / determinant;", + " }", + " harness.setMousePositionScreen(screenX, screenY);", + "};", + "", + "await harness.stepFrames(4);", + "const piece = harness.getObjects('Tree')[0];", + "harness.assert(!!piece, 'There is a piece to drag on the board.');", + "const startX = piece.x;", + "const startY = piece.y;", + "", + "/** All the cells taken by a piece, as \"x,y\" keys. */", + "const occupiedCells = () => {", + " const cells = new Set();", + " for (const name of ['Unit', 'Tower', 'Tree']) {", + " for (const one of harness.getObjects(name)) cells.add(one.x + ',' + one.y);", + " }", + " return cells;", + "};", + "const taken = occupiedCells();", + "// A free cell, a couple of cells away from the piece.", + "let targetX = null;", + "let targetY = null;", + "for (let dx = 1; dx <= 3 && targetX === null; dx++) {", + " for (let dy = 1; dy <= 3 && targetX === null; dy++) {", + " const candidateX = startX + dx * GRID;", + " const candidateY = startY + dy * GRID;", + " if (!taken.has(candidateX + ',' + candidateY)) {", + " targetX = candidateX;", + " targetY = candidateY;", + " }", + " }", + "}", + "harness.assert(targetX !== null, 'There is a free cell to drag the piece to.');", + "console.log('from=' + startX + ',' + startY + ' to=' + targetX + ',' + targetY);", + "", + "/** Drag a piece by its centre to a position, in a few moves. */", + "const dragTo = async (name, id, destinationX, destinationY) => {", + " const dragged = getPieceById(name, id);", + " const grabX = dragged.centerX;", + " const grabY = dragged.centerY;", + " // Where the cursor must end up for the piece's origin to land there.", + " const dropX = destinationX + (grabX - dragged.x);", + " const dropY = destinationY + (grabY - dragged.y);", + " pointMouseAt(grabX, grabY);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " const STEPS = 6;", + " for (let step = 1; step <= STEPS; step++) {", + " pointMouseAt(", + " grabX + ((dropX - grabX) * step) / STEPS,", + " grabY + ((dropY - grabY) * step) / STEPS", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(6);", + "};", + "", + "await dragTo('Tree', piece.id, targetX, targetY);", + "", + "const dropped = getPieceById('Tree', piece.id);", + "console.log('droppedAt=' + Math.round(dropped.x) + ',' + Math.round(dropped.y));", + "harness.assert(", + " dropped.x !== startX || dropped.y !== startY,", + " 'The piece was moved by the drag.'", + ");", + "harness.assert(", + " (dropped.x - startX) % GRID === 0 && (dropped.y - startY) % GRID === 0,", + " 'The piece is snapped onto the grid it started on (it is at ' +", + " Math.round(dropped.x) + ',' + Math.round(dropped.y) + ').'", + ");", + "harness.assert(", + " dropped.x === targetX && dropped.y === targetY,", + " 'The piece is dropped on the cell it was dragged to (it is at ' +", + " Math.round(dropped.x) + ',' + Math.round(dropped.y) +", + " ', expected ' + targetX + ',' + targetY + ').'", + ");" + ] + }, + { + "name": "Dropping a piece on a taken cell sends it back", + "type": "gameplay", + "description": "A piece dropped on a cell that already holds another piece returns to the cell it came from.", + "source": [ + "// A piece dropped on a cell that is already taken must go back where it", + "// came from.", + "await harness.goToScene('Game Scene');", + "harness.watch('Tree');", + "", + "const getPieceById = (name, id) =>", + " harness.getObjects(name).find((one) => one.id === id);", + "", + "/**", + " * Put the mouse cursor on a point of the board.", + " *", + " * `setMousePosition` cannot be used here: the board is drawn by a 3D camera,", + " * and the scene-to-screen conversion it uses ignores 3D rotations, so the", + " * cursor lands somewhere else than asked. The screen-to-scene conversion of", + " * the layer does handle them (it is the one the game itself reads the cursor", + " * with), so the screen position is searched for instead: start at the middle", + " * of the screen and step toward the wanted point, following the conversion's", + " * own slope. It converges in a handful of iterations and costs no frame.", + " */", + "const layer = harness.getRuntimeLayer('');", + "const sceneAt = (screenX, screenY) => {", + " const point = layer.convertCoords(screenX, screenY, 0, [0, 0]);", + " return { x: point[0], y: point[1] };", + "};", + "const pointMouseAt = (sceneX, sceneY) => {", + " let screenX = harness.getGameResolutionWidth() / 2;", + " let screenY = harness.getGameResolutionHeight() / 2;", + " for (let iteration = 0; iteration < 15; iteration++) {", + " const here = sceneAt(screenX, screenY);", + " const errorX = sceneX - here.x;", + " const errorY = sceneY - here.y;", + " if (Math.hypot(errorX, errorY) < 0.5) break;", + " const step = 4;", + " const right = sceneAt(screenX + step, screenY);", + " const down = sceneAt(screenX, screenY + step);", + " const a = (right.x - here.x) / step;", + " const b = (down.x - here.x) / step;", + " const c = (right.y - here.y) / step;", + " const d = (down.y - here.y) / step;", + " const determinant = a * d - b * c;", + " if (!determinant) break;", + " screenX += (d * errorX - b * errorY) / determinant;", + " screenY += (-c * errorX + a * errorY) / determinant;", + " }", + " harness.setMousePositionScreen(screenX, screenY);", + "};", + "", + "await harness.stepFrames(4);", + "const piece = harness.getObjects('Tree')[0];", + "harness.assert(!!piece, 'There is a piece to drag on the board.');", + "const startX = piece.x;", + "const startY = piece.y;", + "", + "// The nearest other piece: its cell is taken.", + "const occupant = harness", + " .getNearby('Unit', 'Tree', 5000)", + " .find((one) => one.x !== startX || one.y !== startY);", + "harness.assert(!!occupant, 'There is another piece on the board.');", + "console.log(", + " 'from=' + startX + ',' + startY +", + " ' onto=' + Math.round(occupant.x) + ',' + Math.round(occupant.y)", + ");", + "", + "/** Drag a piece by its centre to a position, in a few moves. */", + "const dragTo = async (name, id, destinationX, destinationY) => {", + " const dragged = getPieceById(name, id);", + " const grabX = dragged.centerX;", + " const grabY = dragged.centerY;", + " // Where the cursor must end up for the piece's origin to land there.", + " const dropX = destinationX + (grabX - dragged.x);", + " const dropY = destinationY + (grabY - dragged.y);", + " pointMouseAt(grabX, grabY);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " const STEPS = 6;", + " for (let step = 1; step <= STEPS; step++) {", + " pointMouseAt(", + " grabX + ((dropX - grabX) * step) / STEPS,", + " grabY + ((dropY - grabY) * step) / STEPS", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(6);", + "};", + "", + "await dragTo('Tree', piece.id, occupant.x, occupant.y);", + "", + "const dropped = getPieceById('Tree', piece.id);", + "const occupantAfter = harness", + " .getObjects('Unit')", + " .find((one) => one.id === occupant.id);", + "console.log(", + " 'droppedAt=' + Math.round(dropped.x) + ',' + Math.round(dropped.y) +", + " ' occupantAt=' + Math.round(occupantAfter.x) + ',' + Math.round(occupantAfter.y)", + ");", + "", + "harness.assert(", + " dropped.x === startX && dropped.y === startY,", + " 'The piece goes back to the cell it came from (it is at ' +", + " Math.round(dropped.x) + ',' + Math.round(dropped.y) +", + " ', it started at ' + startX + ',' + startY + ').'", + ");", + "harness.assert(", + " occupantAfter.x === occupant.x && occupantAfter.y === occupant.y,", + " 'The piece that was already there did not move.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-driving/starting-3d-driving.json b/examples/starting-3d-driving/starting-3d-driving.json index 2127383ad..e6707f9d2 100644 --- a/examples/starting-3d-driving/starting-3d-driving.json +++ b/examples/starting-3d-driving/starting-3d-driving.json @@ -1450,6 +1450,173 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Accelerating drives the car forward", + "type": "gameplay", + "description": "Holding the accelerator revs the engine and drives the car forward along its heading; it stays put when nothing is pressed.", + "source": [ + "// The core of the game: the accelerator drives the car forward along its", + "// heading (\"Up\", per PhysicsCar3DKeyboardMapper).", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerCar');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "", + "// Let the car settle on the road.", + "await harness.stepFrames(8);", + "const start = getCar();", + "harness.assert(", + " start.behaviors.PhysicsCar3D.state.IsOnFloor === true,", + " 'The car rests on the road.'", + ");", + "const headingRadians = (start.angle * Math.PI) / 180;", + "", + "// Without any input the car does not drive away by itself.", + "await harness.stepFrames(8);", + "const idle = getCar();", + "const idleDistance = Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY);", + "harness.assert(", + " idleDistance < 10,", + " 'The car stays put while no key is pressed (drifted ' + idleDistance.toFixed(1) + 'px).'", + ");", + "", + "// Accelerate.", + "harness.setKeyPressed('Up', true);", + "let maxEngineSpeed = 0;", + "await harness.stepFrames(32, {", + " onFrame: () => {", + " maxEngineSpeed = Math.max(", + " maxEngineSpeed,", + " getCar().behaviors.PhysicsCar3D.state.EngineSpeed", + " );", + " },", + "});", + "harness.releaseAllInputs();", + "const after = getCar();", + "", + "const travelX = after.centerX - idle.centerX;", + "const travelY = after.centerY - idle.centerY;", + "const travelled = Math.hypot(travelX, travelY);", + "const forwardDistance =", + " travelX * Math.cos(headingRadians) + travelY * Math.sin(headingRadians);", + "console.log(", + " 'travelled=' + Math.round(travelled) +", + " ' forward=' + Math.round(forwardDistance) +", + " ' maxEngineSpeed=' + Math.round(maxEngineSpeed) +", + " ' gear=' + after.behaviors.PhysicsCar3D.state.CurrentGear +", + " ' angleDelta=' + Math.round(after.angle - start.angle)", + ");", + "", + "harness.assert(", + " maxEngineSpeed > start.behaviors.PhysicsCar3D.state.EngineSpeed,", + " 'The engine revs up while accelerating (reached ' + Math.round(maxEngineSpeed) + ').'", + ");", + "harness.assert(", + " forwardDistance > 30,", + " 'Holding the accelerator drives the car forward (drove ' +", + " Math.round(forwardDistance) + 'px along its heading).'", + ");", + "harness.assert(", + " forwardDistance > 0.9 * travelled,", + " 'The car drives along its heading rather than sideways.'", + ");", + "harness.assert(", + " Math.abs(after.angle - start.angle) < 15,", + " 'The car keeps going straight while no steering key is pressed (turned by ' +", + " Math.round(after.angle - start.angle) + ' degrees).'", + ");" + ] + }, + { + "name": "Running a traffic cone over knocks it away", + "type": "gameplay", + "description": "The car is driven into a traffic cone standing on the road: the cone is knocked out of the way.", + "source": [ + "// Driving into a traffic cone must send it flying: the cones are dynamic", + "// physics bodies the car collides with.", + "await harness.goToScene('Game Scene');", + "harness.watch('TrafficCone');", + "", + "const getCar = () => harness.getObjects('PlayerCar')[0];", + "await harness.stepFrames(6);", + "const car = getCar();", + "", + "// The nearest cone standing on the road ahead of the car.", + "const conesAhead = harness", + " .getObjects('TrafficCone')", + " .filter(", + " cone =>", + " cone.centerX > car.centerX && Math.abs(cone.centerZ - car.centerZ) < 60", + " )", + " .sort((a, b) => a.centerX - b.centerX);", + "harness.assert(", + " conesAhead.length > 0,", + " 'There are traffic cones standing on the road ahead.'", + ");", + "const targetCone = conesAhead[0];", + "", + "// Arrange: line the car up a short run-up away from that cone. This scene is", + "// the slowest of all the starters to render (about a third of a second of", + "// wall clock per stepped frame), so the run-up is kept as short as the car", + "// needs to get going. Knocking the cone over is still up to the game.", + "const runUp = 90;", + "harness.setObjectPosition(", + " car.id,", + " car.x + (targetCone.centerX - runUp - car.centerX),", + " car.y + (targetCone.centerY - car.centerY),", + " car.z", + ");", + "await harness.stepFrames(6);", + "", + "const coneBefore = harness", + " .getObjects('TrafficCone')", + " .find(cone => cone.id === targetCone.id);", + "harness.assert(!!coneBefore, 'The cone to hit is still standing on the road.');", + "", + "/** How far the cone has been pushed from where it was standing. */", + "const coneDisplacement = () => {", + " const cone = harness", + " .getObjects('TrafficCone')", + " .find(one => one.id === targetCone.id);", + " if (!cone) return null;", + " return Math.hypot(", + " cone.centerX - coneBefore.centerX,", + " cone.centerY - coneBefore.centerY,", + " (cone.centerZ || 0) - (coneBefore.centerZ || 0)", + " );", + "};", + "", + "// Accelerate into it, and stop as soon as it has been knocked away.", + "harness.setKeyPressed('Up', true);", + "const knockedAway = await harness.stepUntil(() => coneDisplacement() > 25, {", + " maxFrames: 45,", + "});", + "harness.releaseAllInputs();", + "await harness.stepFrames(3);", + "", + "const coneAfter = harness", + " .getObjects('TrafficCone')", + " .find(cone => cone.id === targetCone.id);", + "harness.assert(!!coneAfter, 'The cone is still in the scene after the impact.');", + "console.log(", + " 'displacement=' + Math.round(coneDisplacement()) +", + " ' carX=' + Math.round(getCar().centerX) +", + " ' coneX=' + Math.round(coneBefore.centerX)", + ");", + "harness.assert(", + " getCar().centerX > coneBefore.centerX - runUp + 20,", + " 'The car drove toward the cone (it reached x=' +", + " Math.round(getCar().centerX) + ').'", + ");", + "harness.assert(", + " knockedAway,", + " 'Running the cone over knocks it out of the way (it moved ' +", + " Math.round(coneDisplacement()) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-endless-runner/starting-3d-endless-runner.json b/examples/starting-3d-endless-runner/starting-3d-endless-runner.json index 486fb4079..e23833838 100644 --- a/examples/starting-3d-endless-runner/starting-3d-endless-runner.json +++ b/examples/starting-3d-endless-runner/starting-3d-endless-runner.json @@ -1990,6 +1990,161 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Running and jumping", + "type": "gameplay", + "description": "The player runs to the right on its own, and Space makes it jump as high as its behavior is configured to.", + "source": [ + "// The player runs on its own: the only control is jumping.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const platformerState = () => getPlayer().behaviors.PlatformerObject.state;", + "const isOnFloor = () => platformerState().IsOnFloor === true;", + "", + "// No key pressed at all: the player must keep running to the right. This is", + "// measured straight away, while the player is still falling onto the first", + "// platform — running is not something it only does once it has landed.", + "await harness.stepFrames(2);", + "const beforeRun = getPlayer();", + "await harness.stepFrames(24);", + "const afterRun = getPlayer();", + "console.log('ranBy=' + Math.round(afterRun.centerX - beforeRun.centerX));", + "harness.assert(", + " afterRun.centerX > beforeRun.centerX + 40,", + " 'The player runs to the right on its own (it moved ' +", + " Math.round(afterRun.centerX - beforeRun.centerX) + 'px without any key).'", + ");", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 45 });", + "harness.assert(landed, 'The player lands on a platform.');", + "", + "// The height a jump should reach, from the configured jump speed and gravity.", + "const { JumpSpeed, Gravity } = platformerState();", + "const expectedHeight = (JumpSpeed * JumpSpeed) / (2 * Gravity);", + "", + "const groundY = getPlayer().centerY;", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('Space', false);", + "", + "let highestY = groundY;", + "let leftTheFloor = false;", + "await harness.stepFrames(22, {", + " onFrame: () => {", + " const player = getPlayer();", + " highestY = Math.min(highestY, player.centerY);", + " if (player.behaviors.PlatformerObject.state.IsOnFloor === false)", + " leftTheFloor = true;", + " },", + "});", + "const jumpHeight = groundY - highestY;", + "console.log(", + " 'jumpHeight=' + Math.round(jumpHeight) + ' expected=' + Math.round(expectedHeight)", + ");", + "harness.assert(leftTheFloor, 'Pressing Space takes the player off the ground.');", + "harness.assert(", + " jumpHeight > 0.6 * expectedHeight,", + " 'The player jumps as high as its jump speed and gravity say it should (rose ' +", + " Math.round(jumpHeight) + 'px, expected at least ' +", + " Math.round(0.6 * expectedHeight) + 'px).'", + ");" + ] + }, + { + "name": "Touching a hazard restarts the run", + "type": "gameplay", + "description": "The player runs into a hazard placed on its path: the run ends and the scene restarts.", + "source": [ + "// Touching a hazard must end the run: the events slow time right down and", + "// restart the scene. The player runs on its own, so it only has to be put on", + "// a collision course with one of the hazards the level already has.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isOnFloor = () =>", + " getPlayer().behaviors.PlatformerObject.state.IsOnFloor === true;", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 60 });", + "harness.assert(landed, 'The player lands on a platform.');", + "const restartX = getPlayer().centerX;", + "", + "// The nearest hazard ahead, at the height the player is running at.", + "const hazardAhead = harness", + " .getObjects('Hazard')", + " .filter(", + " (hazard) =>", + " hazard.centerX > getPlayer().centerX &&", + " Math.abs(hazard.centerY - getPlayer().centerY) < 200", + " )", + " .sort((a, b) => a.centerX - b.centerX)[0];", + "harness.assert(!!hazardAhead, 'There is a hazard on the path ahead.');", + "", + "// Arrange: shorten the run-up to that hazard, so the test does not spend its", + "// whole budget running. Running into it, and what that costs, is still up to", + "// the game.", + "const RUN_UP = 150;", + "const player = getPlayer();", + "harness.setObjectPosition(", + " player.id,", + " player.x + (hazardAhead.centerX - RUN_UP - player.centerX),", + " player.y", + ");", + "await harness.stepFrames(4);", + "const startX = getPlayer().centerX;", + "console.log(", + " 'startX=' + Math.round(startX) +", + " ' hazardX=' + Math.round(hazardAhead.centerX) +", + " ' restartX=' + Math.round(restartX)", + ");", + "", + "// Run (on its own) into it. The run ends when the player is put back where", + "// the scene starts it. Touching a hazard slows time down to 0.025 first, so", + "// the last part of this takes about twenty five frames during which almost", + "// nothing moves.", + "let furthestX = startX;", + "let hazardDistanceAtFurthestX = Infinity;", + "const restarted = await harness.stepUntil(", + " () => {", + " const currentX = getPlayer().centerX;", + " if (currentX > furthestX) {", + " furthestX = currentX;", + " const nearestHazard = harness.getNearby('Hazard', 'Player', 5000)[0];", + " if (nearestHazard) hazardDistanceAtFurthestX = nearestHazard.distance;", + " }", + " return furthestX > startX + 50 && currentX < startX - 20;", + " },", + " { maxFrames: 70 }", + ");", + "", + "console.log(", + " 'furthestX=' + Math.round(furthestX) +", + " ' hazardDistanceThere=' + Math.round(hazardDistanceAtFurthestX) +", + " ' finalX=' + Math.round(getPlayer().centerX)", + ");", + "", + "harness.assert(", + " furthestX > startX + 50,", + " 'The player ran forward before the run ended (it reached x=' +", + " Math.round(furthestX) + ' from x=' + Math.round(startX) + ').'", + ");", + "harness.assert(", + " hazardDistanceAtFurthestX < 120,", + " 'What stopped the player is a hazard (the nearest one was ' +", + " Math.round(hazardDistanceAtFurthestX) +", + " 'px away when it stopped going forward).'", + ");", + "harness.assert(", + " restarted,", + " 'Touching the hazard restarts the run: the player is back at the start of the level (it is at x=' +", + " Math.round(getPlayer().centerX) + ', it ran from x=' + Math.round(startX) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-rts-unit-selection/starting-3d-rts-unit-selection.json b/examples/starting-3d-rts-unit-selection/starting-3d-rts-unit-selection.json index b0e1af22f..d4e304930 100644 --- a/examples/starting-3d-rts-unit-selection/starting-3d-rts-unit-selection.json +++ b/examples/starting-3d-rts-unit-selection/starting-3d-rts-unit-selection.json @@ -1546,6 +1546,314 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Selecting a unit and ordering it to move", + "type": "gameplay", + "description": "A drag box selects one unit, and a click sends it walking there while the other units stay put.", + "source": [ + "// The core of the game: units are selected with a drag box, and a click", + "// then orders the selected ones (and only those) to walk there.", + "await harness.goToScene('Game Scene');", + "harness.watch('RTSUnit');", + "", + "const getUnitById = (id) => harness.getObjects('RTSUnit').find((one) => one.id === id);", + "", + "/**", + " * Where a point of a layer is on the screen.", + " *", + " * `setMousePosition` cannot be used on this map: it is drawn by a 3D camera,", + " * and the scene-to-screen conversion it uses ignores 3D rotations. The", + " * layer's screen-to-scene conversion does handle them (it is the one the game", + " * reads the cursor with), so the screen position is searched for with it", + " * instead: start at the middle of the screen and step toward the wanted", + " * point, following the conversion's own slope. It costs no frame.", + " */", + "const screenOf = (sceneX, sceneY, layerName) => {", + " const layer = harness.getRuntimeLayer(layerName);", + " const sceneAt = (screenX, screenY) => {", + " const point = layer.convertCoords(screenX, screenY, 0, [0, 0]);", + " return { x: point[0], y: point[1] };", + " };", + " let screenX = harness.getGameResolutionWidth() / 2;", + " let screenY = harness.getGameResolutionHeight() / 2;", + " for (let iteration = 0; iteration < 15; iteration++) {", + " const here = sceneAt(screenX, screenY);", + " const errorX = sceneX - here.x;", + " const errorY = sceneY - here.y;", + " if (Math.hypot(errorX, errorY) < 0.5) break;", + " const step = 4;", + " const right = sceneAt(screenX + step, screenY);", + " const down = sceneAt(screenX, screenY + step);", + " const a = (right.x - here.x) / step;", + " const b = (down.x - here.x) / step;", + " const c = (right.y - here.y) / step;", + " const d = (down.y - here.y) / step;", + " const determinant = a * d - b * c;", + " if (!determinant) break;", + " screenX += (d * errorX - b * errorY) / determinant;", + " screenY += (-c * errorX + a * errorY) / determinant;", + " }", + " return { x: screenX, y: screenY };", + "};", + "", + "await harness.stepFrames(5);", + "", + "const units = harness.getObjects('RTSUnit');", + "harness.assert(units.length > 1, 'There are several units on the map.');", + "const target = units[0];", + "const others = units.slice(1);", + "console.log(", + " 'units=' + units.length +", + " ' selecting the one at ' + Math.round(target.centerX) + ',' + Math.round(target.centerY)", + ");", + "", + "/**", + " * Drag a selection box over a rectangle of the screen. The box the player", + " * draws is a screen rectangle, not a scene one — through a 3D camera the two", + " * are different shapes — so it is given in screen coordinates. It is held", + " * long enough to be a selection and not a move order (the events use a 0.2", + " * second threshold).", + " */", + "const dragSelectionBox = async (from, to) => {", + " harness.setMousePositionScreen(from.x, from.y);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(14);", + " const STEPS = 6;", + " for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePositionScreen(", + " from.x + ((to.x - from.x) * step) / STEPS,", + " from.y + ((to.y - from.y) * step) / STEPS", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(3);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "/** A short click on a point of the map: this is what orders a move. */", + "const clickAt = async (sceneX, sceneY, layerName) => {", + " const screen = screenOf(sceneX, sceneY, layerName);", + " harness.setMousePositionScreen(screen.x, screen.y);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "// A box on the screen just around the first unit, small enough not to catch", + "// any of the others.", + "const onScreen = screenOf(target.centerX, target.centerY, target.layer);", + "const MARGIN = 25;", + "await dragSelectionBox(", + " { x: onScreen.x - MARGIN, y: onScreen.y - MARGIN },", + " { x: onScreen.x + MARGIN, y: onScreen.y + MARGIN }", + ");", + "", + "const positionsBeforeOrder = new Map(", + " harness.getObjects('RTSUnit').map((one) => [one.id, one])", + ");", + "", + "// Order a move, well away from where the units are.", + "await clickAt(target.centerX + 220, target.centerY + 160, target.layer);", + "", + "const movedTowardDestination = await harness.stepUntil(", + " () => {", + " const unit = getUnitById(target.id);", + " const before = positionsBeforeOrder.get(target.id);", + " return (", + " Math.hypot(unit.centerX - before.centerX, unit.centerY - before.centerY) >", + " 60", + " );", + " },", + " { maxFrames: 60 }", + ");", + "", + "const movedUnit = getUnitById(target.id);", + "const movedDistance = Math.hypot(", + " movedUnit.centerX - positionsBeforeOrder.get(target.id).centerX,", + " movedUnit.centerY - positionsBeforeOrder.get(target.id).centerY", + ");", + "let othersThatMoved = 0;", + "for (const other of others) {", + " const now = getUnitById(other.id);", + " const before = positionsBeforeOrder.get(other.id);", + " if (", + " now &&", + " before &&", + " Math.hypot(now.centerX - before.centerX, now.centerY - before.centerY) > 10", + " ) {", + " othersThatMoved++;", + " }", + "}", + "console.log(", + " 'selectedUnitMoved=' + Math.round(movedDistance) +", + " ' othersThatMoved=' + othersThatMoved", + ");", + "", + "harness.assert(", + " movedTowardDestination,", + " 'The selected unit walks to where it was ordered (it moved ' +", + " Math.round(movedDistance) + 'px).'", + ");", + "harness.assert(", + " othersThatMoved === 0,", + " 'The units that were not selected stay where they are (' +", + " othersThatMoved + ' of them moved).'", + ");" + ] + }, + { + "name": "Selecting every unit at once", + "type": "gameplay", + "description": "A drag box over all the units selects them, and one click sends the whole group walking.", + "source": [ + "// A drag box over several units selects them all, and one click sends the", + "// whole group.", + "await harness.goToScene('Game Scene');", + "harness.watch('RTSUnit');", + "", + "const getUnitById = (id) => harness.getObjects('RTSUnit').find((one) => one.id === id);", + "", + "/**", + " * Where a point of a layer is on the screen. `setMousePosition` cannot be", + " * used on this map: it is drawn by a 3D camera, and the scene-to-screen", + " * conversion it uses ignores 3D rotations. The layer's screen-to-scene", + " * conversion does handle them (it is the one the game reads the cursor with),", + " * so the screen position is searched for with it instead. It costs no frame.", + " */", + "const screenOf = (sceneX, sceneY, layerName) => {", + " const layer = harness.getRuntimeLayer(layerName);", + " const sceneAt = (screenX, screenY) => {", + " const point = layer.convertCoords(screenX, screenY, 0, [0, 0]);", + " return { x: point[0], y: point[1] };", + " };", + " let screenX = harness.getGameResolutionWidth() / 2;", + " let screenY = harness.getGameResolutionHeight() / 2;", + " for (let iteration = 0; iteration < 15; iteration++) {", + " const here = sceneAt(screenX, screenY);", + " const errorX = sceneX - here.x;", + " const errorY = sceneY - here.y;", + " if (Math.hypot(errorX, errorY) < 0.5) break;", + " const step = 4;", + " const right = sceneAt(screenX + step, screenY);", + " const down = sceneAt(screenX, screenY + step);", + " const a = (right.x - here.x) / step;", + " const b = (down.x - here.x) / step;", + " const c = (right.y - here.y) / step;", + " const d = (down.y - here.y) / step;", + " const determinant = a * d - b * c;", + " if (!determinant) break;", + " screenX += (d * errorX - b * errorY) / determinant;", + " screenY += (-c * errorX + a * errorY) / determinant;", + " }", + " return { x: screenX, y: screenY };", + "};", + "", + "await harness.stepFrames(5);", + "", + "const units = harness.getObjects('RTSUnit');", + "harness.assert(units.length > 1, 'There are several units on the map.');", + "", + "// A box that covers every unit. The box the player draws is a rectangle of", + "// the screen, and through a 3D camera a rectangle of the map is not one — so", + "// it is the units' screen positions that have to be covered, not their", + "// positions on the map.", + "const layerName = units[0].layer;", + "const onScreen = units.map((unit) => screenOf(unit.centerX, unit.centerY, layerName));", + "const MARGIN = 45;", + "const from = {", + " x: Math.min(...onScreen.map((one) => one.x)) - MARGIN,", + " y: Math.min(...onScreen.map((one) => one.y)) - MARGIN,", + "};", + "const to = {", + " x: Math.max(...onScreen.map((one) => one.x)) + MARGIN,", + " y: Math.max(...onScreen.map((one) => one.y)) + MARGIN,", + "};", + "console.log(", + " 'units=' + units.length +", + " ' screenBox=' + Math.round(from.x) + ',' + Math.round(from.y) +", + " ' -> ' + Math.round(to.x) + ',' + Math.round(to.y)", + ");", + "", + "harness.setMousePositionScreen(from.x, from.y);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(14);", + "const STEPS = 6;", + "for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePositionScreen(", + " from.x + ((to.x - from.x) * step) / STEPS,", + " from.y + ((to.y - from.y) * step) / STEPS", + " );", + " await harness.stepFrames(1);", + "}", + "await harness.stepFrames(3);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(3);", + "", + "const positionsBeforeOrder = new Map(", + " harness.getObjects('RTSUnit').map((one) => [one.id, one])", + ");", + "", + "// One short click orders the whole selection. The destination is the middle", + "// of where the units stand, so every one of them has ground to walk on.", + "const destinationX =", + " units.reduce((total, one) => total + one.centerX, 0) / units.length;", + "const destinationY =", + " units.reduce((total, one) => total + one.centerY, 0) / units.length;", + "const destination = screenOf(destinationX, destinationY, layerName);", + "harness.setMousePositionScreen(destination.x, destination.y);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "", + "let unitsThatMoved = 0;", + "await harness.stepUntil(", + " () => {", + " unitsThatMoved = 0;", + " for (const [id, before] of positionsBeforeOrder) {", + " const now = getUnitById(id);", + " if (", + " now &&", + " Math.hypot(now.centerX - before.centerX, now.centerY - before.centerY) >", + " 40", + " ) {", + " unitsThatMoved++;", + " }", + " }", + " return unitsThatMoved === positionsBeforeOrder.size;", + " },", + " { maxFrames: 60 }", + ");", + "", + "const distances = [];", + "for (const [id, before] of positionsBeforeOrder) {", + " const now = getUnitById(id);", + " distances.push(", + " now", + " ? Math.round(", + " Math.hypot(now.centerX - before.centerX, now.centerY - before.centerY)", + " )", + " : null", + " );", + "}", + "console.log(", + " 'unitsThatMoved=' + unitsThatMoved + ' of ' + positionsBeforeOrder.size +", + " ' distances=' + JSON.stringify(distances)", + ");", + "harness.assert(", + " unitsThatMoved === positionsBeforeOrder.size,", + " 'Every unit of the selection walks to where the group was ordered (' +", + " unitsThatMoved + ' of ' + positionsBeforeOrder.size + ' moved).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "Slash, Tristan Rhodes, @VictrisGames", diff --git a/examples/starting-3d-shootemup/starting-3d-shootemup.json b/examples/starting-3d-shootemup/starting-3d-shootemup.json index ff5ee1145..e0e53804a 100644 --- a/examples/starting-3d-shootemup/starting-3d-shootemup.json +++ b/examples/starting-3d-shootemup/starting-3d-shootemup.json @@ -1625,6 +1625,130 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "The ship fires on its own", + "type": "gameplay", + "description": "The ship shoots without any input, the bullets fly right, and the arrow keys move the ship.", + "source": [ + "// The ship fires on its own: the core loop of the game is dodging while the", + "// bullets keep coming out toward the right of the screen.", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerBullet');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "harness.assert(", + " harness.getObjects('PlayerBullet').length === 0,", + " 'No bullet is in the air when the scene starts.'", + ");", + "", + "// No input at all: the ship must still shoot.", + "await harness.stepFrames(20);", + "const bullets = harness.getObjects('PlayerBullet');", + "console.log('bulletsAfter20Frames=' + bullets.length);", + "harness.assert(", + " bullets.length > 0,", + " 'The ship fires without any input (' + bullets.length + ' bullet(s) in the air).'", + ");", + "", + "// The bullets fly to the right, away from the ship.", + "const bullet = bullets[0];", + "const startX = bullet.centerX;", + "await harness.stepFrames(8);", + "const flying = harness.getObjects('PlayerBullet').find((one) => one.id === bullet.id);", + "harness.assert(!!flying, 'A bullet is still flying eight frames later.');", + "console.log('bulletTravel=' + Math.round(flying.centerX - startX));", + "harness.assert(", + " flying.centerX - startX > 30,", + " 'The bullets fly toward the right of the screen (moved ' +", + " Math.round(flying.centerX - startX) + 'px).'", + ");", + "harness.assert(", + " Math.abs(flying.centerY - bullet.centerY) < 5,", + " 'The bullets fly straight.'", + ");", + "", + "// The ship is moved with the arrow keys.", + "const beforeMove = getPlayer();", + "harness.setKeyPressed('Up', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('Up', false);", + "await harness.stepFrames(3);", + "const afterMove = getPlayer();", + "console.log('movedUpBy=' + Math.round(beforeMove.centerY - afterMove.centerY));", + "harness.assert(", + " afterMove.centerY < beforeMove.centerY - 20,", + " 'Holding Up moves the ship up (it moved ' +", + " Math.round(beforeMove.centerY - afterMove.centerY) + 'px).'", + ");" + ] + }, + { + "name": "Enemies take several hits to be destroyed", + "type": "gameplay", + "description": "An enemy put in the line of fire loses health with each bullet and is destroyed once it runs out.", + "source": [ + "// Enemies take several hits before being destroyed: an enemy is put in the", + "// line of fire and the ship's own bullets have to bring it down.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "/** Read one of the object variables holding the state of an enemy. */", + "const getHealth = (enemy) => {", + " const variable = enemy.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(4);", + "const player = getPlayer();", + "", + "// Arrange: put an enemy right in front of the ship, close enough for the", + "// bullets to reach it quickly. Destroying it is still up to the game.", + "const AHEAD = 200;", + "const spawned = harness.spawn('Enemy', player.centerX + AHEAD, player.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (player.centerX + AHEAD - spawned.centerX),", + " spawned.y + (player.centerY - spawned.centerY)", + ");", + "await harness.stepFrames(2);", + "", + "const enemy = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + "harness.assert(!!enemy, 'An enemy is in front of the ship.');", + "const startingHealth = getHealth(enemy);", + "console.log('startingHealth=' + startingHealth);", + "harness.assert(", + " startingHealth !== null && startingHealth > 1,", + " 'The enemy needs more than one hit to be destroyed (health ' + startingHealth + ').'", + ");", + "", + "// The ship fires on its own: watch the enemy lose health hit after hit.", + "let lowestHealth = startingHealth;", + "const destroyed = await harness.stepUntil(", + " () => {", + " const alive = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + " if (!alive) return true;", + " const health = getHealth(alive);", + " if (health !== null) lowestHealth = Math.min(lowestHealth, health);", + " return false;", + " },", + " { maxFrames: 90 }", + ");", + "console.log('lowestHealthSeen=' + lowestHealth);", + "", + "harness.assert(", + " lowestHealth < startingHealth,", + " 'The bullets hit the enemy and take its health down (it went down to ' +", + " lowestHealth + ' from ' + startingHealth + ').'", + ");", + "harness.assert(", + " destroyed,", + " 'The enemy is destroyed once it ran out of health.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-tank/starting-3d-tank.json b/examples/starting-3d-tank/starting-3d-tank.json index 43718bc9f..f601aba51 100644 --- a/examples/starting-3d-tank/starting-3d-tank.json +++ b/examples/starting-3d-tank/starting-3d-tank.json @@ -2285,6 +2285,197 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Firing a shell with F", + "type": "gameplay", + "description": "Pressing F fires a single shell out of the cannon, flying in the direction the turret points at.", + "source": [ + "// Pressing \"f\" must fire one shell out of the cannon, in the direction the", + "// turret is pointing at (tank angle + turret rotation) - and only one, as", + "// the firing event is a \"trigger once\".", + "await harness.goToScene('Game Scene');", + "harness.watch('Bullet');", + "", + "const getTank = () => harness.getObjects('PlayerTank')[0];", + "await harness.stepFrames(8);", + "", + "harness.assert(", + " harness.getObjects('Bullet').length === 0,", + " 'No shell is in the air before firing.'", + ");", + "", + "const tank = getTank();", + "const turretAngle = tank.angle + tank.state.TopRotation;", + "const turretRadians = (turretAngle * Math.PI) / 180;", + "", + "harness.setKeyPressed('f', true);", + "await harness.stepFrames(2);", + "", + "const shells = harness.getObjects('Bullet');", + "harness.assert(", + " shells.length === 1,", + " 'Pressing \"f\" fires exactly one shell (found ' + shells.length + ').'", + ");", + "const shell = shells[0];", + "", + "// The shell leaves from the end of the cannon, not from inside the tank.", + "const muzzleDistance = Math.hypot(", + " shell.centerX - tank.centerX,", + " shell.centerY - tank.centerY", + ");", + "harness.assert(", + " muzzleDistance > 50,", + " 'The shell leaves from the end of the cannon (' +", + " Math.round(muzzleDistance) + 'px away from the tank center).'", + ");", + "", + "// It flies away along the cannon direction. \"f\" is kept pressed all along:", + "// the \"trigger once\" must not turn it into a machine gun.", + "let maxShellsWhileHeld = shells.length;", + "const countShells = () => {", + " maxShellsWhileHeld = Math.max(", + " maxShellsWhileHeld,", + " harness.getObjects('Bullet').length", + " );", + "};", + "await harness.stepFrames(8, { onFrame: countShells });", + "const flying = harness.getObjects('Bullet').find(one => one.id === shell.id);", + "harness.assert(!!flying, 'The shell is still flying eight frames after the shot.');", + "const travelX = flying.centerX - shell.centerX;", + "const travelY = flying.centerY - shell.centerY;", + "const travelled = Math.hypot(travelX, travelY);", + "const alongCannon =", + " travelX * Math.cos(turretRadians) + travelY * Math.sin(turretRadians);", + "console.log(", + " 'turretAngle=' + Math.round(turretAngle) +", + " ' travelled=' + Math.round(travelled) +", + " ' alongCannon=' + Math.round(alongCannon)", + ");", + "harness.assert(", + " travelled > 40,", + " 'The shell travels away from the tank (' + Math.round(travelled) +", + " 'px in eight frames).'", + ");", + "harness.assert(", + " alongCannon > 0.9 * travelled,", + " 'The shell flies in the direction the cannon points at.'", + ");", + "", + "await harness.stepFrames(14, { onFrame: countShells });", + "harness.releaseAllInputs();", + "console.log('maxShellsWhileHeld=' + maxShellsWhileHeld);", + "harness.assert(", + " maxShellsWhileHeld === 1,", + " 'Keeping \"f\" pressed does not fire a stream of shells (saw ' +", + " maxShellsWhileHeld + ' at once).'", + ");" + ] + }, + { + "name": "Blowing a target away with a shell", + "type": "gameplay", + "description": "The turret is turned onto a target and fired: the explosion knocks the target away.", + "source": [ + "// The core loop: rotate the turret onto a target, fire, and the explosion", + "// must blow the target away.", + "await harness.goToScene('Game Scene');", + "harness.watch('Target');", + "const getTank = () => harness.getObjects('PlayerTank')[0];", + "const normalize = angle => (((angle % 360) + 540) % 360) - 180;", + "const distanceBetween = (a, b) =>", + " Math.hypot(a.centerX - b.centerX, a.centerY - b.centerY, (a.centerZ || 0) - (b.centerZ || 0));", + "await harness.stepFrames(8);", + "", + "const targets = harness.getNearby('Target', 'PlayerTank', 6000);", + "harness.assert(targets.length > 0, 'There is a target to shoot at.');", + "const targetId = targets[0].id;", + "const getTarget = () => harness.getObjects('Target').find(one => one.id === targetId);", + "", + "// Arrange: park the tank a short distance from that target, with the target", + "// off to one side so the turret really has to be turned onto it. Hitting it", + "// is still up to the game.", + "const target = getTarget();", + "const tank = getTank();", + "const bearing = ((tank.angle + 15) * Math.PI) / 180;", + "const distance = 340;", + "harness.setObjectPosition(", + " tank.id,", + " tank.x + (target.centerX - Math.cos(bearing) * distance - tank.centerX),", + " tank.y + (target.centerY - Math.sin(bearing) * distance - tank.centerY),", + " tank.z", + ");", + "await harness.stepFrames(8);", + "harness.assert(", + " getTank().behaviors.PhysicsCar3D.state.IsOnFloor === true,", + " 'The tank is parked on solid ground, in front of the target.'", + ");", + "", + "// Aim the turret: \"a\" and \"d\" rotate it by one degree per frame.", + "const aimError = () => {", + " const from = getTank();", + " const to = getTarget();", + " const wanted =", + " (Math.atan2(to.centerY - from.centerY, to.centerX - from.centerX) * 180) / Math.PI;", + " return normalize(wanted - (from.angle + from.state.TopRotation));", + "};", + "const errorBefore = aimError();", + "harness.assert(", + " Math.abs(errorBefore) > 5,", + " 'The turret does not already point at the target (' + Math.round(errorBefore) + ' degrees off).'", + ");", + "const aimed = await harness.stepUntil(() => Math.abs(aimError()) < 2, {", + " maxFrames: 30,", + " onFrame: () => {", + " const error = aimError();", + " harness.setKeyPressed('d', error > 0);", + " harness.setKeyPressed('a', error < 0);", + " },", + "});", + "harness.releaseAllInputs();", + "console.log('aimError before=' + Math.round(errorBefore) + ' after=' + aimError().toFixed(1));", + "harness.assert(", + " aimed,", + " 'The turret can be turned onto the target with \"a\"/\"d\" (' +", + " aimError().toFixed(1) + ' degrees off after aiming).'", + ");", + "", + "const before = getTarget();", + "", + "// Fire (\"trigger once\": press, step, release).", + "harness.setKeyPressed('f', true);", + "await harness.stepFrames(2);", + "harness.setKeyPressed('f', false);", + "harness.assert(harness.getObjects('Bullet').length === 1, 'A shell is on its way.');", + "", + "// The shell is deleted when it explodes on whatever it hits.", + "const exploded = await harness.stepUntil(", + " () => harness.getObjects('Bullet').length === 0,", + " { maxFrames: 40 }", + ");", + "harness.assert(exploded, 'The shell reaches the target area and explodes.');", + "", + "// Stop as soon as the target has been blown away: this scene is one of the", + "// slowest to render, so every frame that is not needed is worth dropping.", + "const blownAway = await harness.stepUntil(", + " () => {", + " const target = getTarget();", + " return !!target && distanceBetween(target, before) > 30;", + " },", + " { maxFrames: 15 }", + ");", + "", + "const after = getTarget();", + "harness.assert(!!after, 'The target is still in the scene after the shot.');", + "const knockback = distanceBetween(after, before);", + "console.log('knockback=' + Math.round(knockback));", + "harness.assert(", + " blownAway,", + " 'The explosion blows the target away (it moved ' + Math.round(knockback) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-tile-placement/starting-3d-tile-placement.json b/examples/starting-3d-tile-placement/starting-3d-tile-placement.json index 8cd120126..92c551d4b 100644 --- a/examples/starting-3d-tile-placement/starting-3d-tile-placement.json +++ b/examples/starting-3d-tile-placement/starting-3d-tile-placement.json @@ -1489,6 +1489,242 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Picking a tile type", + "type": "gameplay", + "description": "Picking a tile type in the toolbar shows what is about to be placed, and picking it again stops placing.", + "source": [ + "// Picking a tile type in the toolbar shows what is about to be placed.", + "await harness.goToScene('Game Scene');", + "harness.watch('TilePlacement_Indicator');", + "", + "const getIndicator = () => harness.getObjects('TilePlacement_Indicator')[0];", + "", + "/**", + " * Put the mouse cursor on a point of a layer.", + " *", + " * `setMousePosition` cannot be used on the board: it is drawn by a 3D camera,", + " * and the scene-to-screen conversion it uses ignores 3D rotations, so the", + " * cursor lands somewhere else than asked. The screen-to-scene conversion of", + " * the layer does handle them (it is the one the game itself reads the cursor", + " * with), so the screen position is searched for instead: start at the middle", + " * of the screen and step toward the wanted point, following the conversion's", + " * own slope. It converges in a handful of iterations and costs no frame.", + " */", + "const pointMouseAt = (sceneX, sceneY, layerName) => {", + " const layer = harness.getRuntimeLayer(layerName);", + " const sceneAt = (screenX, screenY) => {", + " const point = layer.convertCoords(screenX, screenY, 0, [0, 0]);", + " return { x: point[0], y: point[1] };", + " };", + " let screenX = harness.getGameResolutionWidth() / 2;", + " let screenY = harness.getGameResolutionHeight() / 2;", + " for (let iteration = 0; iteration < 15; iteration++) {", + " const here = sceneAt(screenX, screenY);", + " const errorX = sceneX - here.x;", + " const errorY = sceneY - here.y;", + " if (Math.hypot(errorX, errorY) < 0.5) break;", + " const step = 4;", + " const right = sceneAt(screenX + step, screenY);", + " const down = sceneAt(screenX, screenY + step);", + " const a = (right.x - here.x) / step;", + " const b = (down.x - here.x) / step;", + " const c = (right.y - here.y) / step;", + " const d = (down.y - here.y) / step;", + " const determinant = a * d - b * c;", + " if (!determinant) break;", + " screenX += (d * errorX - b * errorY) / determinant;", + " screenY += (-c * errorX + a * errorY) / determinant;", + " }", + " harness.setMousePositionScreen(screenX, screenY);", + "};", + "", + "/** Click on a position of a layer. */", + "const clickAt = async (x, y, layerName) => {", + " pointMouseAt(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "await harness.stepFrames(5);", + "harness.assert(", + " getIndicator().hidden === true,", + " 'Nothing is about to be placed before a tile type is picked.'", + ");", + "", + "const buttons = harness.getObjects('TileType_SelectButton');", + "harness.assert(buttons.length > 0, 'The toolbar has tile types to pick from.');", + "const button = buttons[0];", + "console.log(", + " 'buttons=' + JSON.stringify(buttons.map((one) => one.animation)) +", + " ' layer=' + JSON.stringify(button.layer)", + ");", + "", + "await clickAt(button.centerX, button.centerY, button.layer);", + "const picked = getIndicator();", + "console.log(", + " 'afterPicking: hidden=' + picked.hidden +", + " ' animation=' + JSON.stringify(picked.animation)", + ");", + "harness.assert(", + " picked.hidden === false,", + " 'Picking a tile type shows what is about to be placed.'", + ");", + "harness.assert(", + " picked.animation === button.animation,", + " 'What is about to be placed is the tile type that was picked (the indicator shows \"' +", + " picked.animation + '\", the button is \"' + button.animation + '\").'", + ");", + "", + "// Picking the same type again stops placing: the toolbar is a toggle.", + "await clickAt(button.centerX, button.centerY, button.layer);", + "const unpicked = getIndicator();", + "console.log('afterPickingAgain: hidden=' + unpicked.hidden);", + "harness.assert(", + " unpicked.hidden === true,", + " 'Picking the same tile type again stops placing.'", + ");" + ] + }, + { + "name": "Placing a tile on the board", + "type": "gameplay", + "description": "With a tile type picked, clicking a buildable cell places that tile on the grid, and clicking it again does not stack a second one.", + "source": [ + "// The core of the game: with a tile type picked, clicking a free cell of the", + "// board places that tile there — and only once per cell.", + "await harness.goToScene('Game Scene');", + "harness.watch('TilePlacement_Indicator');", + "", + "const getIndicator = () => harness.getObjects('TilePlacement_Indicator')[0];", + "const placedTiles = () =>", + " ['Unit', 'Tower', 'Tree'].reduce(", + " (total, name) => total + harness.getObjects(name).length,", + " 0", + " );", + "", + "/**", + " * Put the mouse cursor on a point of a layer. `setMousePosition` cannot be", + " * used on the board: it is drawn by a 3D camera, and the scene-to-screen", + " * conversion it uses ignores 3D rotations. The layer's screen-to-scene", + " * conversion does handle them (it is the one the game reads the cursor with),", + " * so the screen position is searched for with it instead.", + " */", + "const pointMouseAt = (sceneX, sceneY, layerName) => {", + " const layer = harness.getRuntimeLayer(layerName);", + " const sceneAt = (screenX, screenY) => {", + " const point = layer.convertCoords(screenX, screenY, 0, [0, 0]);", + " return { x: point[0], y: point[1] };", + " };", + " let screenX = harness.getGameResolutionWidth() / 2;", + " let screenY = harness.getGameResolutionHeight() / 2;", + " for (let iteration = 0; iteration < 15; iteration++) {", + " const here = sceneAt(screenX, screenY);", + " const errorX = sceneX - here.x;", + " const errorY = sceneY - here.y;", + " if (Math.hypot(errorX, errorY) < 0.5) break;", + " const step = 4;", + " const right = sceneAt(screenX + step, screenY);", + " const down = sceneAt(screenX, screenY + step);", + " const a = (right.x - here.x) / step;", + " const b = (down.x - here.x) / step;", + " const c = (right.y - here.y) / step;", + " const d = (down.y - here.y) / step;", + " const determinant = a * d - b * c;", + " if (!determinant) break;", + " screenX += (d * errorX - b * errorY) / determinant;", + " screenY += (-c * errorX + a * errorY) / determinant;", + " }", + " harness.setMousePositionScreen(screenX, screenY);", + "};", + "", + "/** Click on a position of a layer. */", + "const clickAt = async (x, y, layerName) => {", + " pointMouseAt(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "await harness.stepFrames(5);", + "const button = harness.getObjects('TileType_SelectButton')[0];", + "const tileType = button.animation;", + "harness.assert(placedTiles() === 0, 'The board starts empty.');", + "", + "// The middle of the buildable area: the game only accepts a tile where the", + "// indicator overlaps the placement grid.", + "const grid = harness.getObjects('PlacementGrid')[0];", + "harness.assert(!!grid, 'The board has a buildable area.');", + "", + "// Clicking the board before picking a type places nothing: what follows is", + "// then really the type being picked, not just any click.", + "await clickAt(grid.centerX, grid.centerY, grid.layer);", + "harness.assert(", + " placedTiles() === 0,", + " 'Clicking the board without a tile type picked places nothing.'", + ");", + "", + "// Pick a tile type.", + "await clickAt(button.centerX, button.centerY, button.layer);", + "harness.assert(", + " getIndicator().hidden === false,", + " 'A tile type is picked (' + tileType + ').'", + ");", + "", + "await clickAt(grid.centerX, grid.centerY, grid.layer);", + "console.log(", + " 'gridCentre=' + Math.round(grid.centerX) + ',' + Math.round(grid.centerY) +", + " ' tiles=' + placedTiles()", + ");", + "harness.assert(", + " placedTiles() === 1,", + " 'Clicking a buildable cell of the board places the picked tile there (' +", + " placedTiles() + ' tiles on the board).'", + ");", + "harness.assert(", + " harness.getObjects(tileType).length === 1,", + " 'The tile that was placed is the type that was picked (there is ' +", + " harness.getObjects(tileType).length + ' \"' + tileType + '\" on the board).'", + ");", + "", + "// The indicator is what the game snaps onto the 64x64 grid, and the tile is", + "// created at its centre — so the tile lands on a cell, not under the cursor.", + "const placed = harness.getObjects(tileType)[0];", + "const indicator = getIndicator();", + "console.log(", + " 'placedAt=' + Math.round(placed.x) + ',' + Math.round(placed.y) +", + " ' indicatorAt=' + Math.round(indicator.x) + ',' + Math.round(indicator.y)", + ");", + "harness.assert(", + " indicator.x % 64 === 0 && indicator.y % 64 === 0,", + " 'What is about to be placed is snapped onto the grid (it is at ' +", + " Math.round(indicator.x) + ',' + Math.round(indicator.y) + ').'", + ");", + "harness.assert(", + " Math.abs(placed.x - indicator.centerX) < 1 &&", + " Math.abs(placed.y - indicator.centerY) < 1,", + " 'The tile is placed on the cell the indicator was showing (the tile is at ' +", + " Math.round(placed.x) + ',' + Math.round(placed.y) + ', the cell is at ' +", + " Math.round(indicator.centerX) + ',' + Math.round(indicator.centerY) + ').'", + ");", + "", + "// Clicking the same cell again must not stack a second tile on it.", + "await clickAt(grid.centerX, grid.centerY, grid.layer);", + "console.log('afterSecondClick: tiles=' + placedTiles());", + "harness.assert(", + " placedTiles() === 1,", + " 'Clicking a cell that already holds a tile does not place another one (' +", + " placedTiles() + ' tiles on the board).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-twin-stick-shooter/starting-3d-twin-stick-shooter.json b/examples/starting-3d-twin-stick-shooter/starting-3d-twin-stick-shooter.json index ffcdd8d96..bd86f43e6 100644 --- a/examples/starting-3d-twin-stick-shooter/starting-3d-twin-stick-shooter.json +++ b/examples/starting-3d-twin-stick-shooter/starting-3d-twin-stick-shooter.json @@ -1676,6 +1676,152 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Aiming and firing with the mouse", + "type": "gameplay", + "description": "The player turns toward the mouse and fires while the button is held, and the bullets fly where it aims.", + "source": [ + "// The twin stick part: the player aims where the mouse points and fires", + "// while the button is held.", + "//", + "// The aims used here are all straight up or straight down from the player.", + "// The scene is drawn with a 3D camera placed above and behind the player, so", + "// `setMousePosition` (which converts scene coordinates the 2D way) lands", + "// somewhere else than asked on the horizontal axis — measured at 29 degrees", + "// off for a straight-right aim. Points on the vertical line through the", + "// player stay on that line whatever the camera tilt, so those aims are exact.", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerBullet');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const normalizeAngle = (angle) => (((angle % 360) + 540) % 360) - 180;", + "", + "await harness.stepFrames(4);", + "const player = getPlayer();", + "", + "// Aiming without firing: nothing comes out.", + "harness.setMousePosition(player.centerX, player.centerY - 300, player.layer);", + "await harness.stepFrames(8);", + "harness.assert(", + " harness.getObjects('PlayerBullet').length === 0,", + " 'Nothing is fired while the mouse button is not pressed.'", + ");", + "", + "// Aim up, and hold the fire button.", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(12);", + "const aimedUp = getPlayer();", + "console.log('angleAimingUp=' + Math.round(aimedUp.angle));", + "harness.assert(", + " Math.abs(normalizeAngle(aimedUp.angle + 90)) < 10,", + " 'The player turns toward the mouse (it is at ' +", + " Math.round(aimedUp.angle) + ' degrees, the mouse is straight up at -90).'", + ");", + "", + "const bullets = harness.getObjects('PlayerBullet');", + "console.log('bullets=' + bullets.length);", + "harness.assert(", + " bullets.length > 0,", + " 'Holding the fire button shoots (' + bullets.length + ' bullet(s) in the air).'", + ");", + "", + "// The bullets fly toward where the player is aiming.", + "const bullet = bullets[0];", + "const before = { x: bullet.centerX, y: bullet.centerY };", + "await harness.stepFrames(6);", + "const flying = harness.getObjects('PlayerBullet').find((one) => one.id === bullet.id);", + "harness.assert(!!flying, 'A bullet is still flying.');", + "const travelAngle =", + " (Math.atan2(flying.centerY - before.y, flying.centerX - before.x) * 180) / Math.PI;", + "console.log('travelAngle=' + Math.round(travelAngle));", + "harness.assert(", + " Math.abs(normalizeAngle(travelAngle + 90)) < 15,", + " 'The bullets fly where the player aims (they travel toward ' +", + " Math.round(travelAngle) + ' degrees, the aim is -90).'", + ");", + "", + "// Pointing the other way turns the player around: the aim follows the mouse,", + "// it is not stuck on the direction it started with.", + "harness.setMousePosition(player.centerX, player.centerY + 300, player.layer);", + "await harness.stepFrames(8);", + "harness.releaseAllInputs();", + "const aimedDown = getPlayer();", + "console.log('angleAimingDown=' + Math.round(aimedDown.angle));", + "harness.assert(", + " Math.abs(normalizeAngle(aimedDown.angle - 90)) < 10,", + " 'The player turns around when the mouse moves to the other side (it is at ' +", + " Math.round(aimedDown.angle) + ' degrees, the mouse is straight down at 90).'", + ");" + ] + }, + { + "name": "Enemies take several hits to be destroyed", + "type": "gameplay", + "description": "An enemy in the line of fire loses health with each bullet and is destroyed once it runs out.", + "source": [ + "// Enemies take several hits before being destroyed: an enemy is put in the", + "// line of fire and the player has to shoot it down.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "/** Read one of the object variables holding the state of an enemy. */", + "const getHealth = (enemy) => {", + " const variable = enemy.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(4);", + "const player = getPlayer();", + "", + "// Arrange: put an enemy straight above the player and aim at it. Straight up", + "// is the one aim `setMousePosition` gets exactly right through this game's 3D", + "// camera (see the aiming test), and the enemy walks toward the player, so it", + "// stays on that line while it is being shot at.", + "const AHEAD = 200;", + "const spawned = harness.spawn('Enemy', player.centerX, player.centerY - AHEAD);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (player.centerX - spawned.centerX),", + " spawned.y + (player.centerY - AHEAD - spawned.centerY)", + ");", + "await harness.stepFrames(2);", + "const enemy = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + "harness.assert(!!enemy, 'An enemy is in front of the player.');", + "const startingHealth = getHealth(enemy);", + "console.log('startingHealth=' + startingHealth);", + "harness.assert(", + " startingHealth !== null && startingHealth > 1,", + " 'The enemy needs more than one hit to be destroyed (health ' + startingHealth + ').'", + ");", + "", + "// Keep the aim on it and keep firing. Shooting it down is up to the game.", + "harness.setMousePosition(player.centerX, player.centerY - 400, player.layer);", + "harness.setMouseButtonPressed(true);", + "let lowestHealth = startingHealth;", + "const destroyed = await harness.stepUntil(", + " () => {", + " const alive = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + " if (!alive) return true;", + " const health = getHealth(alive);", + " if (health !== null) lowestHealth = Math.min(lowestHealth, health);", + " return false;", + " },", + " { maxFrames: 75 }", + ");", + "harness.releaseAllInputs();", + "console.log('lowestHealthSeen=' + lowestHealth);", + "", + "harness.assert(", + " lowestHealth < startingHealth,", + " 'The bullets hit the enemy and take its health down (it went down to ' +", + " lowestHealth + ' from ' + startingHealth + ').'", + ");", + "harness.assert(destroyed, 'The enemy is destroyed once it ran out of health.');" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-3d-vampire-survivor/starting-3d-vampire-survivor.json b/examples/starting-3d-vampire-survivor/starting-3d-vampire-survivor.json index 8fab4b0f8..514eb8d2e 100644 --- a/examples/starting-3d-vampire-survivor/starting-3d-vampire-survivor.json +++ b/examples/starting-3d-vampire-survivor/starting-3d-vampire-survivor.json @@ -1514,6 +1514,130 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "The player shoots the nearest enemy on its own", + "type": "gameplay", + "description": "With an enemy in range, the player fires at it without any input, and the enemy is destroyed after several hits.", + "source": [ + "// The core of the game: the player shoots the nearest enemy on its own, the", + "// player only has to move.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const getHealth = (enemy) => {", + " const variable = enemy.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(4);", + "const player = getPlayer();", + "harness.assert(", + " harness.getObjects('PlayerBullet').length === 0,", + " 'Nothing is fired while there is no enemy around.'", + ");", + "", + "// Arrange: put an enemy within reach. Shooting it is still up to the game.", + "const spawned = harness.spawn('Enemy', player.centerX + 250, player.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (player.centerX + 250 - spawned.centerX),", + " spawned.y + (player.centerY - spawned.centerY)", + ");", + "await harness.stepFrames(2);", + "const enemy = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + "harness.assert(!!enemy, 'An enemy is near the player.');", + "const startingHealth = getHealth(enemy);", + "harness.assert(", + " startingHealth > 1,", + " 'The enemy needs more than one hit to be destroyed (health ' + startingHealth + ').'", + ");", + "", + "// No input at all: the player must shoot at it by itself.", + "await harness.stepFrames(12);", + "const bullets = harness.getObjects('PlayerBullet');", + "console.log('bulletsWithoutAnyInput=' + bullets.length);", + "harness.assert(", + " bullets.length > 0,", + " 'The player fires at the enemy without any input (' + bullets.length + ' bullet(s)).'", + ");", + "", + "let lowestHealth = startingHealth;", + "const destroyed = await harness.stepUntil(", + " () => {", + " const alive = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + " if (!alive) return true;", + " const health = getHealth(alive);", + " if (health !== null) lowestHealth = Math.min(lowestHealth, health);", + " return false;", + " },", + " { maxFrames: 90 }", + ");", + "console.log('lowestHealthSeen=' + lowestHealth);", + "harness.assert(", + " lowestHealth < startingHealth,", + " 'The bullets hit the enemy and take its health down (down to ' +", + " lowestHealth + ' from ' + startingHealth + ').'", + ");", + "harness.assert(destroyed, 'The enemy is destroyed once it ran out of health.');" + ] + }, + { + "name": "Being touched by an enemy ends the run", + "type": "gameplay", + "description": "An enemy reaching the player restarts the scene.", + "source": [ + "// Being touched by an enemy ends the run: the scene restarts, which puts the", + "// player back at its starting point.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(4);", + "const spawnX = getPlayer().centerX;", + "const spawnY = getPlayer().centerY;", + "", + "// Walk away from the starting point, so that coming back to it can only be", + "// the scene restarting.", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(25);", + "harness.releaseAllInputs();", + "await harness.stepFrames(10);", + "const walked = getPlayer();", + "console.log('walkedTo=' + Math.round(walked.centerX) + ' from ' + Math.round(spawnX));", + "harness.assert(", + " walked.centerX > spawnX + 50,", + " 'The player walked away from its starting point (it is at x=' +", + " Math.round(walked.centerX) + ', it started at x=' + Math.round(spawnX) + ').'", + ");", + "", + "// Arrange: put an enemy right on the player.", + "const spawned = harness.spawn('Enemy', walked.centerX, walked.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (walked.centerX - spawned.centerX),", + " spawned.y + (walked.centerY - spawned.centerY)", + ");", + "", + "// The run must end: the player is back at its starting point. The events", + "// slow time down to 0.15 and wait 0.15 second before restarting, so this", + "// takes about a second of real time to happen.", + "const restarted = await harness.stepUntil(", + " () =>", + " Math.abs(getPlayer().centerX - spawnX) < 5 &&", + " Math.abs(getPlayer().centerY - spawnY) < 5,", + " { maxFrames: 80 }", + ");", + "console.log('finalX=' + Math.round(getPlayer().centerX));", + "harness.assert(", + " restarted,", + " 'Being touched by an enemy restarts the run: the player is back at its starting point (it is at x=' +", + " Math.round(getPlayer().centerX) + ', it started at x=' + Math.round(spawnX) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-beatemup/starting-beatemup.json b/examples/starting-beatemup/starting-beatemup.json index 8d433c8b3..17bb40705 100644 --- a/examples/starting-beatemup/starting-beatemup.json +++ b/examples/starting-beatemup/starting-beatemup.json @@ -2191,6 +2191,173 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Attacking hits the enemy", + "type": "gameplay", + "description": "Pressing X strikes the enemy in front of the player: it loses a point of health, plays its hurt animation and is knocked back.", + "source": [ + "// The core of the game: attacking with X hits the enemy in front, taking a", + "// point of its health and knocking it back.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const getEnemy = () => harness.getObjects('Enemy')[0];", + "const getHealth = (fighter) => {", + " const variable = fighter.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(5);", + "const enemy = getEnemy();", + "harness.assert(!!enemy, 'There is an enemy to fight.');", + "", + "// Arrange: stand the player just in front of the enemy, on its left, so it", + "// faces it. Landing the hit is still up to the game.", + "const player = getPlayer();", + "harness.setObjectPosition(", + " player.id,", + " player.x + (enemy.centerX - 70 - player.centerX),", + " player.y + (enemy.centerY - player.centerY)", + ");", + "await harness.stepFrames(3);", + "", + "const healthBefore = getHealth(getEnemy());", + "const enemyBefore = getEnemy();", + "console.log('enemyHealthBefore=' + healthBefore);", + "harness.assert(healthBefore > 0, 'The enemy has health (' + healthBefore + ').');", + "", + "// Attack.", + "harness.setKeyPressed('x', true);", + "await harness.stepFrames(3);", + "harness.setKeyPressed('x', false);", + "harness.assert(", + " getPlayer().animation !== 'Idle',", + " 'Pressing X starts the attack animation (it is playing \"' +", + " getPlayer().animation + '\").'", + ");", + "", + "// The strike lands during the attack animation.", + "let sawAttackStrike = false;", + "let sawEnemyHurt = false;", + "const hit = await harness.stepUntil(", + " () => getHealth(getEnemy()) < healthBefore,", + " {", + " maxFrames: 120,", + " onFrame: () => {", + " if (getPlayer().animation === 'AttackStrike') sawAttackStrike = true;", + " if (getEnemy().animation === 'Hurt') sawEnemyHurt = true;", + " },", + " }", + ");", + "console.log(", + " 'sawAttackStrike=' + sawAttackStrike +", + " ' sawEnemyHurt=' + sawEnemyHurt +", + " ' healthAfter=' + getHealth(getEnemy())", + ");", + "", + "harness.assert(sawAttackStrike, 'The attack reaches its striking frames.');", + "harness.assert(", + " hit,", + " 'The attack takes a point of the enemy health (it went from ' +", + " healthBefore + ' to ' + getHealth(getEnemy()) + ').'", + ");", + "harness.assert(sawEnemyHurt, 'The enemy plays its hurt animation when it is hit.');", + "", + "// ...and it is knocked back, away from the player.", + "await harness.stepFrames(20);", + "const enemyAfter = getEnemy();", + "const knockback = enemyAfter.centerX - enemyBefore.centerX;", + "console.log('knockback=' + Math.round(knockback));", + "harness.assert(", + " knockback > 5,", + " 'The enemy is knocked back away from the player (it moved ' +", + " Math.round(knockback) + 'px).'", + ");" + ] + }, + { + "name": "The player cannot walk while attacking", + "type": "gameplay", + "description": "The movement behavior is deactivated for the duration of the attack animation, and comes back afterwards.", + "source": [ + "// While an attack is playing the player is rooted in place: the events", + "// deactivate its movement behavior until it is idle again.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(5);", + "", + "// Idle: the player can walk.", + "const beforeWalk = getPlayer();", + "harness.assert(", + " beforeWalk.animation === 'Idle',", + " 'The player starts idle (it is playing \"' + beforeWalk.animation + '\").'", + ");", + "harness.assert(", + " beforeWalk.behaviors.TopDownMovement.act === true,", + " 'The movement behavior is active while idle.'", + ");", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(20);", + "harness.releaseAllInputs();", + "await harness.stepFrames(20);", + "const walked = getPlayer();", + "console.log('walkedBy=' + Math.round(walked.centerX - beforeWalk.centerX));", + "harness.assert(", + " walked.centerX > beforeWalk.centerX + 20,", + " 'The player walks while idle (it moved ' +", + " Math.round(walked.centerX - beforeWalk.centerX) + 'px).'", + ");", + "", + "// Attacking: the player is rooted, even holding a direction.", + "harness.setKeyPressed('x', true);", + "await harness.stepFrames(3);", + "harness.setKeyPressed('x', false);", + "const attacking = getPlayer();", + "harness.assert(", + " attacking.animation !== 'Idle',", + " 'The player is attacking (it is playing \"' + attacking.animation + '\").'", + ");", + "harness.assert(", + " attacking.behaviors.TopDownMovement.act === false,", + " 'The movement behavior is deactivated while attacking.'", + ");", + "", + "harness.setKeyPressed('Right', true);", + "let movedWhileAttacking = 0;", + "let stillAttacking = false;", + "await harness.stepFrames(20, {", + " onFrame: () => {", + " const player = getPlayer();", + " if (player.animation !== 'Idle') {", + " stillAttacking = true;", + " movedWhileAttacking = Math.max(", + " movedWhileAttacking,", + " Math.abs(player.centerX - attacking.centerX)", + " );", + " }", + " },", + "});", + "harness.releaseAllInputs();", + "console.log(", + " 'movedWhileAttacking=' + Math.round(movedWhileAttacking) +", + " ' stillAttacking=' + stillAttacking", + ");", + "harness.assert(", + " stillAttacking,", + " 'The attack animation was still playing while the direction key was held.'", + ");", + "harness.assert(", + " movedWhileAttacking < 5,", + " 'The player does not walk while attacking (it moved ' +", + " Math.round(movedWhileAttacking) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-card-game/starting-card-game.json b/examples/starting-card-game/starting-card-game.json index 44efe292a..6b13da1a7 100644 --- a/examples/starting-card-game/starting-card-game.json +++ b/examples/starting-card-game/starting-card-game.json @@ -3055,6 +3055,212 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Drawing a card from the deck", + "type": "gameplay", + "description": "Dragging the face down card off the deck turns it face up, takes it out of the deck, and puts a new face down card on the deck.", + "source": [ + "// The core of the game: dragging the face down card off the deck draws the", + "// top card of the deck, and puts a new face down card in its place.", + "await harness.goToScene('Game Scene');", + "harness.watch('Card');", + "", + "const getDeck = () => harness.getObjects('CardDeck')[0];", + "/** How many cards are left in the deck. */", + "const deckSize = () => {", + " const contents = getDeck().variables.find(", + " (one) => one.name === 'DeckContents'", + " );", + " if (!contents || !contents.children) return 0;", + " return Array.isArray(contents.children)", + " ? contents.children.length", + " : Object.keys(contents.children).length;", + "};", + "", + "/** Drag an object by its centre to a position, slowly. */", + "const dragTo = async (name, id, destinationX, destinationY) => {", + " const dragged = harness.getObjects(name).find((one) => one.id === id);", + " const grabX = dragged.centerX;", + " const grabY = dragged.centerY;", + " harness.setMousePosition(grabX, grabY, dragged.layer);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " const STEPS = 25;", + " for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePosition(", + " grabX + ((destinationX - grabX) * step) / STEPS,", + " grabY + ((destinationY - grabY) * step) / STEPS,", + " dragged.layer", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(5);", + "};", + "", + "await harness.stepFrames(5);", + "const deck = getDeck();", + "const cardsBefore = harness.getObjects('Card');", + "harness.assert(cardsBefore.length === 1, 'One card is waiting on the deck.');", + "const topCard = cardsBefore[0];", + "harness.assert(", + " topCard.animation === 'card_back',", + " 'The card on the deck is face down (it shows \"' + topCard.animation + '\").'", + ");", + "const deckSizeBefore = deckSize();", + "console.log('deckSizeBefore=' + deckSizeBefore);", + "harness.assert(deckSizeBefore > 0, 'The deck has cards in it.');", + "", + "// Drag it onto the table, away from the deck.", + "// The cards are large: drop on the placement area furthest from the deck,", + "// so that the dropped card does not still overlap it (which would put it", + "// straight back into the deck).", + "const placement = harness", + " .getObjects('CardPlacement_Background')", + " .reduce((furthest, one) =>", + " Math.hypot(one.centerX - deck.centerX, one.centerY - deck.centerY) >", + " Math.hypot(furthest.centerX - deck.centerX, furthest.centerY - deck.centerY)", + " ? one", + " : furthest", + " );", + "await dragTo('Card', topCard.id, placement.centerX, placement.centerY);", + "", + "const drawn = harness.getObjects('Card').find((one) => one.id === topCard.id);", + "harness.assert(!!drawn, 'The drawn card is on the table.');", + "console.log(", + " 'drawnCard=' + JSON.stringify(drawn.animation) +", + " ' deckSizeAfter=' + deckSize() +", + " ' cardsOnTable=' + harness.getObjects('Card').length", + ");", + "", + "harness.assert(", + " drawn.animation !== 'card_back',", + " 'The card is turned face up when it is drawn (it shows \"' +", + " drawn.animation + '\").'", + ");", + "harness.assert(", + " deckSize() === deckSizeBefore - 1,", + " 'The drawn card is taken out of the deck (it has ' + deckSize() +", + " ' cards left, it had ' + deckSizeBefore + ').'", + ");", + "", + "const stillOnDeck = harness", + " .getObjects('Card')", + " .filter((one) => one.id !== topCard.id);", + "harness.assert(", + " stillOnDeck.length === 1,", + " 'A new card is waiting on the deck (' + stillOnDeck.length + ' found).'", + ");", + "harness.assert(", + " stillOnDeck[0].animation === 'card_back',", + " 'The new card on the deck is face down.'", + ");", + "harness.assert(", + " Math.hypot(", + " stillOnDeck[0].centerX - deck.centerX,", + " stillOnDeck[0].centerY - deck.centerY", + " ) < 40,", + " 'The new card is on the deck.'", + ");" + ] + }, + { + "name": "Putting a card back in the deck", + "type": "gameplay", + "description": "A drawn card dropped back onto the deck is taken off the table and goes back into the deck.", + "source": [ + "// A card dropped back onto the deck goes back into it.", + "await harness.goToScene('Game Scene');", + "harness.watch('Card');", + "", + "const getDeck = () => harness.getObjects('CardDeck')[0];", + "const deckSize = () => {", + " const contents = getDeck().variables.find(", + " (one) => one.name === 'DeckContents'", + " );", + " if (!contents || !contents.children) return 0;", + " return Array.isArray(contents.children)", + " ? contents.children.length", + " : Object.keys(contents.children).length;", + "};", + "", + "const dragTo = async (name, id, destinationX, destinationY) => {", + " const dragged = harness.getObjects(name).find((one) => one.id === id);", + " const grabX = dragged.centerX;", + " const grabY = dragged.centerY;", + " harness.setMousePosition(grabX, grabY, dragged.layer);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " const STEPS = 25;", + " for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePosition(", + " grabX + ((destinationX - grabX) * step) / STEPS,", + " grabY + ((destinationY - grabY) * step) / STEPS,", + " dragged.layer", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(5);", + "};", + "", + "await harness.stepFrames(5);", + "const deck = getDeck();", + "const deckSizeAtStart = deckSize();", + "", + "// Draw a card first.", + "const topCard = harness.getObjects('Card')[0];", + "// The cards are large: drop on the placement area furthest from the deck,", + "// so that the dropped card does not still overlap it (which would put it", + "// straight back into the deck).", + "const placement = harness", + " .getObjects('CardPlacement_Background')", + " .reduce((furthest, one) =>", + " Math.hypot(one.centerX - deck.centerX, one.centerY - deck.centerY) >", + " Math.hypot(furthest.centerX - deck.centerX, furthest.centerY - deck.centerY)", + " ? one", + " : furthest", + " );", + "await dragTo('Card', topCard.id, placement.centerX, placement.centerY);", + "const drawn = harness.getObjects('Card').find((one) => one.id === topCard.id);", + "harness.assert(!!drawn, 'A card was drawn onto the table.');", + "const drawnFace = drawn.animation;", + "const deckSizeWithCardOut = deckSize();", + "console.log(", + " 'drawn=' + JSON.stringify(drawnFace) +", + " ' deckSizeAtStart=' + deckSizeAtStart +", + " ' deckSizeWithCardOut=' + deckSizeWithCardOut +", + " ' cards=' + harness.getObjects('Card').length", + ");", + "harness.assert(", + " deckSizeWithCardOut === deckSizeAtStart - 1,", + " 'The deck is one card shorter while the card is out.'", + ");", + "", + "// Put it back on the deck.", + "await dragTo('Card', drawn.id, deck.centerX, deck.centerY);", + "", + "console.log(", + " 'afterPuttingBack: deckSize=' + deckSize() +", + " ' cards=' + harness.getObjects('Card').length", + ");", + "harness.assert(", + " !harness.getObjects('Card').some((one) => one.id === drawn.id),", + " 'The card is taken off the table once it is dropped on the deck.'", + ");", + "harness.assert(", + " deckSize() === deckSizeAtStart,", + " 'The card is back in the deck (it holds ' + deckSize() +", + " ' cards, it held ' + deckSizeAtStart + ' at the start).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-clicker/starting-clicker.json b/examples/starting-clicker/starting-clicker.json index 2a99b9be3..d9e64220a 100644 --- a/examples/starting-clicker/starting-clicker.json +++ b/examples/starting-clicker/starting-clicker.json @@ -814,6 +814,158 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Clicking earns money", + "type": "gameplay", + "description": "Clicking the main clicker earns money, and clicking next to it earns nothing.", + "source": [ + "// The core of the game: clicking the main clicker earns money.", + "await harness.goToScene('Game Scene');", + "harness.watch('MoneyCounter');", + "", + "const getCounter = () => harness.getObjects('MoneyCounter')[0];", + "const getScore = () => getCounter().state.Score;", + "", + "await harness.stepFrames(5);", + "const clicker = harness.getObjects('MainClicker')[0];", + "harness.assert(!!clicker, 'The main clicker is in the scene.');", + "console.log('counterState=' + JSON.stringify(Object.keys(getCounter().state)));", + "", + "const scoreAtStart = getScore();", + "harness.assert(scoreAtStart === 0, 'The player starts with no money.');", + "", + "// Point at the clicker. Hovering it must not earn anything by itself.", + "harness.setMousePosition(clicker.centerX, clicker.centerY, clicker.layer);", + "await harness.stepFrames(20);", + "harness.assert(", + " getScore() === scoreAtStart,", + " 'Hovering the clicker without clicking earns nothing.'", + ");", + "", + "/** Press and release the mouse: a click is only complete once released. */", + "const click = async () => {", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(2);", + "};", + "", + "await click();", + "harness.assert(", + " getScore() === scoreAtStart + 1,", + " 'One click on the main clicker earns one (money is now ' + getScore() + ').'", + ");", + "", + "for (let i = 0; i < 4; i++) await click();", + "console.log('scoreAfter5Clicks=' + getScore());", + "harness.assert(", + " getScore() === scoreAtStart + 5,", + " 'Five clicks earn five (money is now ' + getScore() + ').'", + ");", + "", + "// Clicking away from the clicker must not earn anything.", + "harness.setMousePosition(clicker.centerX + 500, clicker.centerY, clicker.layer);", + "await harness.stepFrames(3);", + "const scoreBeforeMissedClick = getScore();", + "await click();", + "harness.assert(", + " getScore() === scoreBeforeMissedClick,", + " 'Clicking next to the clicker earns nothing.'", + ");" + ] + }, + { + "name": "Buying the passive upgrade", + "type": "gameplay", + "description": "The passive upgrade can only be bought with enough money: it then costs more and starts earning on its own.", + "source": [ + "// The other half of the game: money can be spent on the passive upgrade,", + "// which then earns money on its own.", + "await harness.goToScene('Game Scene');", + "harness.watch('PurchasePassive_Button1');", + "", + "const getCounter = () => harness.getObjects('MoneyCounter')[0];", + "const getScore = () => getCounter().state.Score;", + "const getButton = () => harness.getObjects('PurchasePassive_Button1')[0];", + "/** Read one of the object variables holding the state of the upgrade. */", + "const getButtonVariable = (name) => {", + " const variable = getButton().variables.find((one) => one.name === name);", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(5);", + "const clicker = harness.getObjects('MainClicker')[0];", + "const button = getButton();", + "const cost = getButtonVariable('PurchaseCost');", + "console.log(", + " 'cost=' + cost + ' level=' + getButtonVariable('Level') +", + " ' earning=' + getButtonVariable('Earning')", + ");", + "harness.assert(cost > 0, 'The upgrade has a price (' + cost + ').');", + "harness.assert(getButtonVariable('Level') === 0, 'The upgrade is not bought yet.');", + "", + "/** Click at a position of the scene. */", + "const clickAt = async (x, y, layerName) => {", + " harness.setMousePosition(x, y, layerName);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(2);", + "};", + "", + "// Not enough money yet: buying must not work.", + "await clickAt(button.centerX, button.centerY, button.layer);", + "harness.assert(", + " getButtonVariable('Level') === 0,", + " 'The upgrade cannot be bought without money.'", + ");", + "", + "// Earn what it costs by clicking.", + "for (let i = 0; i < cost; i++) {", + " await clickAt(clicker.centerX, clicker.centerY, clicker.layer);", + "}", + "harness.assert(", + " getScore() >= cost,", + " 'The player earned enough money to buy the upgrade (has ' + getScore() + ', needs ' + cost + ').'", + ");", + "const scoreBeforeBuying = getScore();", + "", + "// Buy it.", + "await clickAt(button.centerX, button.centerY, button.layer);", + "const levelAfterBuying = getButtonVariable('Level');", + "const costAfterBuying = getButtonVariable('PurchaseCost');", + "console.log(", + " 'afterBuying: score=' + getScore() + ' level=' + levelAfterBuying + ' cost=' + costAfterBuying", + ");", + "harness.assert(", + " levelAfterBuying === 1,", + " 'Clicking the upgrade button buys it (its level is now ' + levelAfterBuying + ').'", + ");", + "harness.assert(", + " getScore() === scoreBeforeBuying - cost,", + " 'Buying the upgrade costs its price (money went from ' +", + " scoreBeforeBuying + ' to ' + getScore() + ').'", + ");", + "harness.assert(", + " costAfterBuying > cost,", + " 'The next level of the upgrade is more expensive (' + costAfterBuying + ' after ' + cost + ').'", + ");", + "", + "// The upgrade now earns on its own, without any click.", + "const progressBefore = getButtonVariable('Progress');", + "harness.setMousePosition(clicker.centerX + 500, clicker.centerY, clicker.layer);", + "await harness.stepFrames(60);", + "const progressAfter = getButtonVariable('Progress');", + "console.log('progress=' + progressBefore + ' -> ' + progressAfter);", + "harness.assert(", + " progressAfter > progressBefore,", + " 'Once bought, the upgrade makes progress on its own without clicking (' +", + " progressBefore + ' -> ' + progressAfter + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-draggable-tiles/starting-draggable-tiles.json b/examples/starting-draggable-tiles/starting-draggable-tiles.json index a70546698..9beb1de53 100644 --- a/examples/starting-draggable-tiles/starting-draggable-tiles.json +++ b/examples/starting-draggable-tiles/starting-draggable-tiles.json @@ -1099,6 +1099,180 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Dragging a piece onto a free cell", + "type": "gameplay", + "description": "A piece dragged with the mouse follows it and is snapped onto the board grid where it is dropped.", + "source": [ + "// The core of the game: a piece can be dragged to a free cell, and is", + "// snapped onto the 64x64 grid of the board when dropped.", + "await harness.goToScene('Game Scene');", + "harness.watch('Tree');", + "", + "const GRID = 64;", + "const getPieceById = (name, id) =>", + " harness.getObjects(name).find((one) => one.id === id);", + "", + "await harness.stepFrames(5);", + "const piece = harness.getObjects('Tree')[0];", + "harness.assert(!!piece, 'There is a piece to drag on the board.');", + "const startX = piece.x;", + "const startY = piece.y;", + "", + "/** All the cells taken by a piece, as \"x,y\" keys. */", + "const occupiedCells = () => {", + " const cells = new Set();", + " for (const name of ['Unit', 'Tower', 'Tree']) {", + " for (const one of harness.getObjects(name)) cells.add(one.x + ',' + one.y);", + " }", + " return cells;", + "};", + "const taken = occupiedCells();", + "// A free cell, a couple of cells away from the piece.", + "let targetX = null;", + "let targetY = null;", + "for (let dx = 1; dx <= 3 && targetX === null; dx++) {", + " for (let dy = 1; dy <= 3 && targetX === null; dy++) {", + " const candidateX = startX + dx * GRID;", + " const candidateY = startY + dy * GRID;", + " if (!taken.has(candidateX + ',' + candidateY)) {", + " targetX = candidateX;", + " targetY = candidateY;", + " }", + " }", + "}", + "harness.assert(targetX !== null, 'There is a free cell to drag the piece to.');", + "console.log(", + " 'from=' + startX + ',' + startY + ' to=' + targetX + ',' + targetY", + ");", + "", + "/** Drag a piece by its centre to a position, slowly. */", + "const dragTo = async (name, id, destinationX, destinationY) => {", + " const dragged = getPieceById(name, id);", + " const grabX = dragged.centerX;", + " const grabY = dragged.centerY;", + " // Where the cursor must end up for the piece's origin to land there.", + " const dropX = destinationX + (grabX - dragged.x);", + " const dropY = destinationY + (grabY - dragged.y);", + " harness.setMousePosition(grabX, grabY, dragged.layer);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " const STEPS = 30;", + " for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePosition(", + " grabX + ((dropX - grabX) * step) / STEPS,", + " grabY + ((dropY - grabY) * step) / STEPS,", + " dragged.layer", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(5);", + "};", + "", + "await dragTo('Tree', piece.id, targetX, targetY);", + "", + "const dropped = getPieceById('Tree', piece.id);", + "console.log(", + " 'droppedAt=' + Math.round(dropped.x) + ',' + Math.round(dropped.y)", + ");", + "harness.assert(", + " dropped.x !== startX || dropped.y !== startY,", + " 'The piece was moved by the drag.'", + ");", + "harness.assert(", + " dropped.x % GRID === 0 && dropped.y % GRID === 0,", + " 'The piece is snapped onto the grid (it is at ' +", + " Math.round(dropped.x) + ',' + Math.round(dropped.y) + ').'", + ");", + "harness.assert(", + " dropped.x === targetX && dropped.y === targetY,", + " 'The piece is dropped on the cell it was dragged to (it is at ' +", + " Math.round(dropped.x) + ',' + Math.round(dropped.y) +", + " ', expected ' + targetX + ',' + targetY + ').'", + ");" + ] + }, + { + "name": "Dropping a piece on a taken cell sends it back", + "type": "gameplay", + "description": "A piece dropped on a cell that already holds another piece returns to the cell it came from.", + "source": [ + "// A piece dropped on a cell that is already taken must go back where it", + "// came from.", + "await harness.goToScene('Game Scene');", + "harness.watch('Tree');", + "", + "const getPieceById = (name, id) =>", + " harness.getObjects(name).find((one) => one.id === id);", + "", + "await harness.stepFrames(5);", + "const piece = harness.getObjects('Tree')[0];", + "harness.assert(!!piece, 'There is a piece to drag on the board.');", + "const startX = piece.x;", + "const startY = piece.y;", + "", + "// The nearest other piece: its cell is taken.", + "const occupant = harness", + " .getNearby('Unit', 'Tree', 5000)", + " .find((one) => one.x !== startX || one.y !== startY);", + "harness.assert(!!occupant, 'There is another piece on the board.');", + "console.log(", + " 'from=' + startX + ',' + startY +", + " ' onto=' + Math.round(occupant.x) + ',' + Math.round(occupant.y)", + ");", + "", + "const dragTo = async (name, id, destinationX, destinationY) => {", + " const dragged = getPieceById(name, id);", + " const grabX = dragged.centerX;", + " const grabY = dragged.centerY;", + " const dropX = destinationX + (grabX - dragged.x);", + " const dropY = destinationY + (grabY - dragged.y);", + " harness.setMousePosition(grabX, grabY, dragged.layer);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " const STEPS = 30;", + " for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePosition(", + " grabX + ((dropX - grabX) * step) / STEPS,", + " grabY + ((dropY - grabY) * step) / STEPS,", + " dragged.layer", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(5);", + "};", + "", + "await dragTo('Tree', piece.id, occupant.x, occupant.y);", + "", + "const dropped = getPieceById('Tree', piece.id);", + "const occupantAfter = harness", + " .getObjects('Unit')", + " .find((one) => one.id === occupant.id);", + "console.log(", + " 'droppedAt=' + Math.round(dropped.x) + ',' + Math.round(dropped.y) +", + " ' occupantAt=' + Math.round(occupantAfter.x) + ',' + Math.round(occupantAfter.y)", + ");", + "", + "harness.assert(", + " dropped.x === startX && dropped.y === startY,", + " 'The piece goes back to the cell it came from (it is at ' +", + " Math.round(dropped.x) + ',' + Math.round(dropped.y) +", + " ', it started at ' + startX + ',' + startY + ').'", + ");", + "harness.assert(", + " occupantAfter.x === occupant.x && occupantAfter.y === occupant.y,", + " 'The piece that was already there did not move.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "@Lizard-13", diff --git a/examples/starting-endless-runner/starting-endless-runner.json b/examples/starting-endless-runner/starting-endless-runner.json index 7e5ba4c7b..009b4cf80 100644 --- a/examples/starting-endless-runner/starting-endless-runner.json +++ b/examples/starting-endless-runner/starting-endless-runner.json @@ -1775,6 +1775,132 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Running and jumping", + "type": "gameplay", + "description": "The player runs to the right on its own, and Space makes it jump as high as its behavior is configured to.", + "source": [ + "// The player runs on its own: the only control is jumping.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const platformerState = () => getPlayer().behaviors.PlatformerObject.state;", + "const isOnFloor = () => platformerState().IsOnFloor === true;", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player lands on the ground.');", + "", + "// No key pressed at all: the player must keep running to the right.", + "const beforeRun = getPlayer();", + "await harness.stepFrames(30);", + "const afterRun = getPlayer();", + "console.log('ranBy=' + Math.round(afterRun.centerX - beforeRun.centerX));", + "harness.assert(", + " afterRun.centerX > beforeRun.centerX + 60,", + " 'The player runs to the right on its own (it moved ' +", + " Math.round(afterRun.centerX - beforeRun.centerX) + 'px without any key).'", + ");", + "", + "// The height a jump should reach, from the configured jump speed and gravity.", + "const { JumpSpeed, Gravity } = platformerState();", + "const expectedHeight = (JumpSpeed * JumpSpeed) / (2 * Gravity);", + "", + "await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "const groundY = getPlayer().centerY;", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('Space', false);", + "", + "let highestY = groundY;", + "let leftTheFloor = false;", + "await harness.stepFrames(30, {", + " onFrame: () => {", + " const player = getPlayer();", + " highestY = Math.min(highestY, player.centerY);", + " if (player.behaviors.PlatformerObject.state.IsOnFloor === false)", + " leftTheFloor = true;", + " },", + "});", + "const jumpHeight = groundY - highestY;", + "console.log(", + " 'jumpHeight=' + Math.round(jumpHeight) + ' expected=' + Math.round(expectedHeight)", + ");", + "harness.assert(leftTheFloor, 'Pressing Space takes the player off the ground.');", + "harness.assert(", + " jumpHeight > 0.6 * expectedHeight,", + " 'The player jumps as high as its jump speed and gravity say it should (rose ' +", + " Math.round(jumpHeight) + 'px, expected at least ' +", + " Math.round(0.6 * expectedHeight) + 'px).'", + ");" + ] + }, + { + "name": "Touching a hazard restarts the run", + "type": "gameplay", + "description": "The player runs into a hazard placed on its path: the run ends and the scene restarts.", + "source": [ + "// Touching a hazard must end the run: the events slow time down and restart", + "// the scene. The player runs on its own, so it reaches the first hazard of", + "// the level without any input — the run has to end there.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isOnFloor = () =>", + " getPlayer().behaviors.PlatformerObject.state.IsOnFloor === true;", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player lands on the ground.');", + "const spawnX = getPlayer().centerX;", + "harness.assert(", + " harness.getObjects('Hazard').length > 0,", + " 'There are hazards on the path.'", + ");", + "", + "// Run (on its own) until the player is put back at its starting point: that", + "// is the scene restarting, which is how a run ends.", + "let furthestX = spawnX;", + "let hazardDistanceAtFurthestX = Infinity;", + "const restarted = await harness.stepUntil(", + " () => {", + " const currentX = getPlayer().centerX;", + " if (currentX > furthestX) {", + " furthestX = currentX;", + " const nearestHazard = harness.getNearby('Hazard', 'Player', 5000)[0];", + " if (nearestHazard) hazardDistanceAtFurthestX = nearestHazard.distance;", + " }", + " return furthestX > spawnX + 100 && Math.abs(currentX - spawnX) < 5;", + " },", + " { maxFrames: 300 }", + ");", + "", + "console.log(", + " 'spawnX=' + Math.round(spawnX) +", + " ' furthestX=' + Math.round(furthestX) +", + " ' hazardDistanceThere=' + Math.round(hazardDistanceAtFurthestX) +", + " ' finalX=' + Math.round(getPlayer().centerX)", + ");", + "", + "harness.assert(", + " furthestX > spawnX + 100,", + " 'The player ran forward before the run ended (it reached x=' +", + " Math.round(furthestX) + ' from x=' + Math.round(spawnX) + ').'", + ");", + "harness.assert(", + " hazardDistanceAtFurthestX < 100,", + " 'What stopped the player is a hazard (the nearest one was ' +", + " Math.round(hazardDistanceAtFurthestX) + 'px away when it stopped going forward).'", + ");", + "harness.assert(", + " restarted,", + " 'Touching the hazard restarts the run: the player is back at its starting point (it is at x=' +", + " Math.round(getPlayer().centerX) + ', it started at x=' + Math.round(spawnX) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-first-person-horror/starting-first-person-horror.json b/examples/starting-first-person-horror/starting-first-person-horror.json index 6544d35a4..bfb1a0a1e 100644 --- a/examples/starting-first-person-horror/starting-first-person-horror.json +++ b/examples/starting-first-person-horror/starting-first-person-horror.json @@ -2678,6 +2678,176 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Walking and strafing with WASD", + "type": "gameplay", + "description": "\"w\" walks the player toward what it faces and \"d\" strafes it sideways, without turning it.", + "source": [ + "// First person movement: \"w\" walks toward what the player faces, \"d\"", + "// strafes to its right (Shooter3DKeyboardMapper, camera relative).", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const characterState = () => getPlayer().behaviors.PhysicsCharacter3D.state;", + "", + "await harness.stepFrames(4);", + "harness.assert(characterState().IsOnFloor === true, 'The player stands on the ground.');", + "", + "const start = getPlayer();", + "const facingRadians = (start.angle * Math.PI) / 180;", + "", + "// Nothing pressed: the player stays where it is.", + "await harness.stepFrames(5);", + "const idle = getPlayer();", + "const drift = Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY);", + "harness.assert(", + " drift < 3,", + " 'The player stands still while no key is pressed (drifted ' + drift.toFixed(2) + ').'", + ");", + "", + "// Walk forward.", + "harness.setKeyPressed('w', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('w', false);", + "await harness.stepFrames(2);", + "", + "const afterWalk = getPlayer();", + "const walkX = afterWalk.centerX - idle.centerX;", + "const walkY = afterWalk.centerY - idle.centerY;", + "const walked = Math.hypot(walkX, walkY);", + "const forward = walkX * Math.cos(facingRadians) + walkY * Math.sin(facingRadians);", + "console.log(", + " 'walked=' + Math.round(walked) + ' forward=' + Math.round(forward) +", + " ' facing=' + Math.round(start.angle)", + ");", + "harness.assert(", + " walked > 15,", + " 'Holding \"w\" walks the player (moved ' + Math.round(walked) + ' units).'", + ");", + "harness.assert(", + " forward > 0.9 * walked,", + " 'The player walks toward what it is facing, not sideways or backwards.'", + ");", + "", + "// Strafe right.", + "const beforeStrafe = getPlayer();", + "harness.setKeyPressed('d', true);", + "await harness.stepFrames(14);", + "harness.setKeyPressed('d', false);", + "await harness.stepFrames(2);", + "const afterStrafe = getPlayer();", + "const strafeX = afterStrafe.centerX - beforeStrafe.centerX;", + "const strafeY = afterStrafe.centerY - beforeStrafe.centerY;", + "const strafed = Math.hypot(strafeX, strafeY);", + "// The player's right hand side, in scene coordinates.", + "const sideways = -strafeX * Math.sin(facingRadians) + strafeY * Math.cos(facingRadians);", + "console.log('strafed=' + Math.round(strafed) + ' sideways=' + Math.round(sideways));", + "harness.assert(", + " strafed > 15,", + " 'Holding \"d\" moves the player (moved ' + Math.round(strafed) + ' units).'", + ");", + "harness.assert(", + " Math.abs(sideways) > 0.9 * strafed,", + " 'Holding \"d\" strafes sideways instead of walking forward (' +", + " Math.round(sideways) + ' of ' + Math.round(strafed) + ' units sideways).'", + ");", + "harness.assert(", + " Math.abs(afterStrafe.angle - start.angle) < 1,", + " 'Strafing does not turn the player around.'", + ");" + ] + }, + { + "name": "The monster comes after the player", + "type": "gameplay", + "description": "Once the player is within its reach, the monster stops patrolling and closes in on them.", + "source": [ + "// The monster hunts the player down: once the player is close enough, it", + "// comes after them.", + "await harness.goToScene('Game Scene');", + "harness.watch('Monster');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const getMonster = () => harness.getObjects('Monster')[0];", + "const distanceToPlayer = () => {", + " const monster = getMonster();", + " const player = getPlayer();", + " return Math.hypot(", + " monster.centerX - player.centerX,", + " monster.centerY - player.centerY", + " );", + "};", + "", + "await harness.stepFrames(8);", + "harness.assert(!!getMonster(), 'There is a monster in the level.');", + "", + "// Arrange: bring the monster within reach of the player, along the line", + "// between them (so it stays on ground both of them can stand on). Coming", + "// after the player is still up to the game.", + "const monster = getMonster();", + "const player = getPlayer();", + "const towardX = monster.centerX - player.centerX;", + "const towardY = monster.centerY - player.centerY;", + "const length = Math.hypot(towardX, towardY) || 1;", + "const APPROACH = 400;", + "harness.setObjectPosition(", + " monster.id,", + " monster.x + (player.centerX + (towardX / length) * APPROACH - monster.centerX),", + " monster.y + (player.centerY + (towardY / length) * APPROACH - monster.centerY)", + ");", + "await harness.stepFrames(5);", + "", + "const playerAtStart = getPlayer();", + "const monsterAtStart = getMonster();", + "const distanceAtStart = distanceToPlayer();", + "console.log('distanceAtStart=' + Math.round(distanceAtStart));", + "harness.assert(", + " distanceAtStart < 600,", + " 'The monster is within reach of the player (' + Math.round(distanceAtStart) + ' units).'", + ");", + "", + "// The player is left alone: any closing of the gap is the monster moving.", + "let closestDistance = distanceAtStart;", + "await harness.stepFrames(40, {", + " onFrame: () => {", + " closestDistance = Math.min(closestDistance, distanceToPlayer());", + " },", + "});", + "const monsterAfter = getMonster();", + "const playerAfter = getPlayer();", + "const monsterMoved = Math.hypot(", + " monsterAfter.centerX - monsterAtStart.centerX,", + " monsterAfter.centerY - monsterAtStart.centerY", + ");", + "const playerMoved = Math.hypot(", + " playerAfter.centerX - playerAtStart.centerX,", + " playerAfter.centerY - playerAtStart.centerY", + ");", + "console.log(", + " 'monsterMoved=' + Math.round(monsterMoved) +", + " ' playerMoved=' + Math.round(playerMoved) +", + " ' closestDistance=' + Math.round(closestDistance)", + ");", + "", + "harness.assert(", + " playerMoved < 20,", + " 'The player stayed where it was (it moved ' + Math.round(playerMoved) + ' units).'", + ");", + "harness.assert(", + " monsterMoved > 30,", + " 'The monster does not stay put once the player is near (it moved ' +", + " Math.round(monsterMoved) + ' units).'", + ");", + "harness.assert(", + " closestDistance < distanceAtStart - 30,", + " 'The monster comes after the player (it closed in from ' +", + " Math.round(distanceAtStart) + ' to ' + Math.round(closestDistance) + ' units).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json b/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json index 3b4d2b91e..2433b737e 100644 --- a/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json +++ b/examples/starting-first-person-shooter-horror/starting-first-person-shooter-horror.json @@ -3594,6 +3594,151 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Walking and strafing with WASD", + "type": "gameplay", + "description": "\"w\" walks the player toward what it faces and \"d\" strafes it sideways, without turning it.", + "source": [ + "// First person movement: \"w\" walks toward what the player faces, \"d\"", + "// strafes to its right (Shooter3DKeyboardMapper, camera relative).", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const characterState = () => getPlayer().behaviors.PhysicsCharacter3D.state;", + "", + "await harness.stepFrames(4);", + "harness.assert(characterState().IsOnFloor === true, 'The player stands on the ground.');", + "", + "const start = getPlayer();", + "const facingRadians = (start.angle * Math.PI) / 180;", + "", + "// Nothing pressed: the player stays where it is.", + "await harness.stepFrames(5);", + "const idle = getPlayer();", + "const drift = Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY);", + "harness.assert(", + " drift < 3,", + " 'The player stands still while no key is pressed (drifted ' + drift.toFixed(2) + ').'", + ");", + "", + "// Walk forward.", + "harness.setKeyPressed('w', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('w', false);", + "await harness.stepFrames(2);", + "", + "const afterWalk = getPlayer();", + "const walkX = afterWalk.centerX - idle.centerX;", + "const walkY = afterWalk.centerY - idle.centerY;", + "const walked = Math.hypot(walkX, walkY);", + "const forward = walkX * Math.cos(facingRadians) + walkY * Math.sin(facingRadians);", + "console.log(", + " 'walked=' + Math.round(walked) + ' forward=' + Math.round(forward) +", + " ' facing=' + Math.round(start.angle)", + ");", + "harness.assert(", + " walked > 15,", + " 'Holding \"w\" walks the player (moved ' + Math.round(walked) + ' units).'", + ");", + "harness.assert(", + " forward > 0.9 * walked,", + " 'The player walks toward what it is facing, not sideways or backwards.'", + ");", + "", + "// Strafe right.", + "const beforeStrafe = getPlayer();", + "harness.setKeyPressed('d', true);", + "await harness.stepFrames(14);", + "harness.setKeyPressed('d', false);", + "await harness.stepFrames(2);", + "const afterStrafe = getPlayer();", + "const strafeX = afterStrafe.centerX - beforeStrafe.centerX;", + "const strafeY = afterStrafe.centerY - beforeStrafe.centerY;", + "const strafed = Math.hypot(strafeX, strafeY);", + "// The player's right hand side, in scene coordinates.", + "const sideways = -strafeX * Math.sin(facingRadians) + strafeY * Math.cos(facingRadians);", + "console.log('strafed=' + Math.round(strafed) + ' sideways=' + Math.round(sideways));", + "harness.assert(", + " strafed > 15,", + " 'Holding \"d\" moves the player (moved ' + Math.round(strafed) + ' units).'", + ");", + "harness.assert(", + " Math.abs(sideways) > 0.9 * strafed,", + " 'Holding \"d\" strafes sideways instead of walking forward (' +", + " Math.round(sideways) + ' of ' + Math.round(strafed) + ' units sideways).'", + ");", + "harness.assert(", + " Math.abs(afterStrafe.angle - start.angle) < 1,", + " 'Strafing does not turn the player around.'", + ");" + ] + }, + { + "name": "Shooting leaves an impact", + "type": "gameplay", + "description": "A shot raycasts from the crosshair and leaves an impact effect in front of the player.", + "source": [ + "// The gun works: a shot raycasts from the crosshair and leaves an impact", + "// where it lands.", + "await harness.goToScene('Game Scene');", + "harness.watch('HitParticle');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(10);", + "", + "// The shooting events are ignored while the cursor is over the controls", + "// toggle: point at the middle of the screen. No warm up click is needed —", + "// shooting only reads the mouse button, so the very first click is the shot", + "// (and that keeps the impact effects unambiguous: there are none before it).", + "harness.setMousePositionScreen(", + " harness.getGameResolutionWidth() / 2,", + " harness.getGameResolutionHeight() / 2", + ");", + "await harness.stepFrames(3);", + "", + "const particlesBefore = harness.getObjects('HitParticle').length;", + "const player = getPlayer();", + "harness.assert(", + " particlesBefore === 0,", + " 'Nothing has been shot yet (' + particlesBefore + ' impact effects).'", + ");", + "", + "// Shoot (\"trigger once\": press, step, release).", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(4);", + "", + "const particles = harness.getObjects('HitParticle');", + "console.log(", + " 'particles=' + particlesBefore + ' -> ' + particles.length", + ");", + "harness.assert(", + " particles.length > particlesBefore,", + " 'Shooting leaves an impact where the bullet lands (' +", + " particlesBefore + ' -> ' + particles.length + ' impact effects).'", + ");", + "", + "// The impact is in front of the player, not behind it.", + "const impact = particles[particles.length - 1];", + "const facingRadians = (player.angle * Math.PI) / 180;", + "const forward =", + " (impact.centerX - player.centerX) * Math.cos(facingRadians) +", + " (impact.centerY - player.centerY) * Math.sin(facingRadians);", + "console.log(", + " 'impactForwardDistance=' + Math.round(forward) +", + " ' facing=' + Math.round(player.angle)", + ");", + "harness.assert(", + " forward > 0,", + " 'The bullet lands in front of the player (the impact is ' +", + " Math.round(forward) + ' units ahead).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-first-person-shooter/starting-first-person-shooter.json b/examples/starting-first-person-shooter/starting-first-person-shooter.json index 0d4e90e1c..e4e839d68 100644 --- a/examples/starting-first-person-shooter/starting-first-person-shooter.json +++ b/examples/starting-first-person-shooter/starting-first-person-shooter.json @@ -2644,6 +2644,194 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Walking and strafing with WASD", + "type": "gameplay", + "description": "\"w\" walks the player toward what it faces and \"d\" strafes it sideways, without turning it.", + "source": [ + "// First person movement: \"w\" walks toward what the player faces, \"d\"", + "// strafes to its right (Shooter3DKeyboardMapper, camera relative).", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const characterState = () => getPlayer().behaviors.PhysicsCharacter3D.state;", + "", + "await harness.stepFrames(4);", + "harness.assert(characterState().IsOnFloor === true, 'The player stands on the ground.');", + "", + "const start = getPlayer();", + "const facingRadians = (start.angle * Math.PI) / 180;", + "", + "// Nothing pressed: the player stays where it is.", + "await harness.stepFrames(5);", + "const idle = getPlayer();", + "const drift = Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY);", + "harness.assert(", + " drift < 3,", + " 'The player stands still while no key is pressed (drifted ' + drift.toFixed(2) + ').'", + ");", + "", + "// Walk forward.", + "harness.setKeyPressed('w', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('w', false);", + "await harness.stepFrames(2);", + "", + "const afterWalk = getPlayer();", + "const walkX = afterWalk.centerX - idle.centerX;", + "const walkY = afterWalk.centerY - idle.centerY;", + "const walked = Math.hypot(walkX, walkY);", + "const forward = walkX * Math.cos(facingRadians) + walkY * Math.sin(facingRadians);", + "console.log(", + " 'walked=' + Math.round(walked) + ' forward=' + Math.round(forward) +", + " ' facing=' + Math.round(start.angle)", + ");", + "harness.assert(", + " walked > 15,", + " 'Holding \"w\" walks the player (moved ' + Math.round(walked) + ' units).'", + ");", + "harness.assert(", + " forward > 0.9 * walked,", + " 'The player walks toward what it is facing, not sideways or backwards.'", + ");", + "", + "// Strafe right.", + "const beforeStrafe = getPlayer();", + "harness.setKeyPressed('d', true);", + "await harness.stepFrames(14);", + "harness.setKeyPressed('d', false);", + "await harness.stepFrames(2);", + "const afterStrafe = getPlayer();", + "const strafeX = afterStrafe.centerX - beforeStrafe.centerX;", + "const strafeY = afterStrafe.centerY - beforeStrafe.centerY;", + "const strafed = Math.hypot(strafeX, strafeY);", + "// The player's right hand side, in scene coordinates.", + "const sideways = -strafeX * Math.sin(facingRadians) + strafeY * Math.cos(facingRadians);", + "console.log('strafed=' + Math.round(strafed) + ' sideways=' + Math.round(sideways));", + "harness.assert(", + " strafed > 15,", + " 'Holding \"d\" moves the player (moved ' + Math.round(strafed) + ' units).'", + ");", + "harness.assert(", + " Math.abs(sideways) > 0.9 * strafed,", + " 'Holding \"d\" strafes sideways instead of walking forward (' +", + " Math.round(sideways) + ' of ' + Math.round(strafed) + ' units sideways).'", + ");", + "harness.assert(", + " Math.abs(afterStrafe.angle - start.angle) < 1,", + " 'Strafing does not turn the player around.'", + ");" + ] + }, + { + "name": "Shooting a target", + "type": "gameplay", + "description": "The player aims down at a target with the mouse and shoots it: the bullet lands on the target and knocks it over.", + "source": [ + "// The core of the game: aim at a target with the mouse and shoot it. The", + "// shot raycasts from the camera center; a hit spawns a HitParticle at the", + "// impact point and pushes the target.", + "await harness.goToScene('Game Scene');", + "harness.watch('Target');", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const distanceBetween = (a, b) =>", + " Math.hypot(a.centerX - b.centerX, a.centerY - b.centerY, (a.centerZ || 0) - (b.centerZ || 0));", + "await harness.stepFrames(8);", + "", + "// The shooting events are ignored while the cursor is over the controls", + "// toggle (top left corner): point at the middle of the screen. The first", + "// click is what makes the game request the pointer lock, without which the", + "// mouse movements are ignored.", + "harness.setMousePositionScreen(", + " harness.getGameResolutionWidth() / 2,", + " harness.getGameResolutionHeight() / 2", + ");", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(2);", + "", + "const targets = harness.getNearby('Target', 'Player', 6000);", + "harness.assert(targets.length > 0, 'There is a target to shoot at.');", + "const targetId = targets[0].id;", + "const getTarget = () => harness.getObjects('Target').find(one => one.id === targetId);", + "", + "// The view is taken from the top of the player capsule, so the targets are", + "// below the line of sight: the player has to aim down at them.", + "const pitchWanted = () => {", + " const player = getPlayer();", + " const target = getTarget();", + " const horizontal = Math.hypot(", + " target.centerX - player.centerX,", + " target.centerY - player.centerY", + " );", + " return (Math.atan2(player.z + player.depth - target.centerZ, horizontal) * 180) / Math.PI;", + "};", + "const pitchError = () => pitchWanted() - getPlayer().rotationY;", + "harness.assert(", + " Math.abs(pitchError()) > 1,", + " 'The player is not already aiming at the target (' +", + " pitchWanted().toFixed(1) + ' degrees to look down).'", + ");", + "const aimed = await harness.stepUntil(() => Math.abs(pitchError()) < 0.2, {", + " maxFrames: 20,", + " onFrame: () =>", + " harness.setMouseDelta(0, Math.max(-200, Math.min(200, pitchError() * 4.5))),", + "});", + "console.log('aim wanted=' + pitchWanted().toFixed(2) + ' got=' + getPlayer().rotationY.toFixed(2));", + "harness.assert(", + " aimed,", + " 'Moving the mouse aims the view down onto the target (' +", + " pitchError().toFixed(2) + ' degrees off).'", + ");", + "", + "await harness.stepFrames(8);", + "const before = getTarget();", + "const particlesBefore = harness.getObjects('HitParticle').length;", + "", + "// Shoot (\"trigger once\": press, step, release).", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(3);", + "", + "// The raycast hit is materialized by an impact effect at the hit point.", + "const particles = harness.getObjects('HitParticle');", + "harness.assert(", + " particles.length > particlesBefore,", + " 'The shot hits something and spawns an impact effect.'", + ");", + "const impactDistance = distanceBetween(particles[particles.length - 1], before);", + "console.log('impactDistanceToTarget=' + Math.round(impactDistance));", + "harness.assert(", + " impactDistance < 60,", + " 'The bullet lands on the target that was aimed at (impact ' +", + " Math.round(impactDistance) + ' units away from it).'", + ");", + "", + "// ...and the target is knocked about by the hit. This stops as soon as the", + "// target has been pushed: the scene is a slow one to render, so the frames", + "// that are not needed are worth dropping.", + "const knockedOver = await harness.stepUntil(", + " () => {", + " const target = getTarget();", + " return !!target && distanceBetween(target, before) > 10;", + " },", + " { maxFrames: 20 }", + ");", + "const after = getTarget();", + "harness.assert(!!after, 'The target is still in the scene.');", + "const pushed = distanceBetween(after, before);", + "console.log('pushed=' + pushed.toFixed(1));", + "harness.assert(", + " knockedOver,", + " 'Being shot knocks the target over (it moved ' + pushed.toFixed(1) + ' units).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-first-person/starting-first-person.json b/examples/starting-first-person/starting-first-person.json index c6a046dca..77c22955b 100644 --- a/examples/starting-first-person/starting-first-person.json +++ b/examples/starting-first-person/starting-first-person.json @@ -1693,6 +1693,148 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Walking and strafing with WASD", + "type": "gameplay", + "description": "\"w\" walks the player toward what it faces and \"d\" strafes it sideways, without turning it.", + "source": [ + "// First person movement: \"w\" walks toward what the player faces, \"d\"", + "// strafes to its right (Shooter3DKeyboardMapper, camera relative).", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const characterState = () => getPlayer().behaviors.PhysicsCharacter3D.state;", + "", + "await harness.stepFrames(4);", + "harness.assert(characterState().IsOnFloor === true, 'The player stands on the ground.');", + "", + "const start = getPlayer();", + "const facingRadians = (start.angle * Math.PI) / 180;", + "", + "// Nothing pressed: the player stays where it is.", + "await harness.stepFrames(5);", + "const idle = getPlayer();", + "const drift = Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY);", + "harness.assert(", + " drift < 3,", + " 'The player stands still while no key is pressed (drifted ' + drift.toFixed(2) + ').'", + ");", + "", + "// Walk forward.", + "harness.setKeyPressed('w', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('w', false);", + "await harness.stepFrames(2);", + "", + "const afterWalk = getPlayer();", + "const walkX = afterWalk.centerX - idle.centerX;", + "const walkY = afterWalk.centerY - idle.centerY;", + "const walked = Math.hypot(walkX, walkY);", + "const forward = walkX * Math.cos(facingRadians) + walkY * Math.sin(facingRadians);", + "console.log(", + " 'walked=' + Math.round(walked) + ' forward=' + Math.round(forward) +", + " ' facing=' + Math.round(start.angle)", + ");", + "harness.assert(", + " walked > 15,", + " 'Holding \"w\" walks the player (moved ' + Math.round(walked) + ' units).'", + ");", + "harness.assert(", + " forward > 0.9 * walked,", + " 'The player walks toward what it is facing, not sideways or backwards.'", + ");", + "", + "// Strafe right.", + "const beforeStrafe = getPlayer();", + "harness.setKeyPressed('d', true);", + "await harness.stepFrames(14);", + "harness.setKeyPressed('d', false);", + "await harness.stepFrames(2);", + "const afterStrafe = getPlayer();", + "const strafeX = afterStrafe.centerX - beforeStrafe.centerX;", + "const strafeY = afterStrafe.centerY - beforeStrafe.centerY;", + "const strafed = Math.hypot(strafeX, strafeY);", + "// The player's right hand side, in scene coordinates.", + "const sideways = -strafeX * Math.sin(facingRadians) + strafeY * Math.cos(facingRadians);", + "console.log('strafed=' + Math.round(strafed) + ' sideways=' + Math.round(sideways));", + "harness.assert(", + " strafed > 15,", + " 'Holding \"d\" moves the player (moved ' + Math.round(strafed) + ' units).'", + ");", + "harness.assert(", + " Math.abs(sideways) > 0.9 * strafed,", + " 'Holding \"d\" strafes sideways instead of walking forward (' +", + " Math.round(sideways) + ' of ' + Math.round(strafed) + ' units sideways).'", + ");", + "harness.assert(", + " Math.abs(afterStrafe.angle - start.angle) < 1,", + " 'Strafing does not turn the player around.'", + ");" + ] + }, + { + "name": "Jumping with Space", + "type": "gameplay", + "description": "Pressing Space makes the character jump off the ground, as high as its behavior is configured to.", + "source": [ + "// Space must make the first person character jump off the ground, as high", + "// as its behavior is configured to.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const characterState = () => getPlayer().behaviors.PhysicsCharacter3D.state;", + "", + "const landed = await harness.stepUntil(() => characterState().IsOnFloor === true, {", + " maxFrames: 200,", + "});", + "harness.assert(landed, 'The player stands on the ground.');", + "const groundZ = getPlayer().z;", + "", + "// Nothing pressed: the player stays on the ground.", + "await harness.stepFrames(8);", + "harness.assert(", + " Math.abs(getPlayer().z - groundZ) < 1 && characterState().IsOnFloor === true,", + " 'The player stays on the ground while no key is pressed.'", + ");", + "", + "// The height a jump should reach, from the configured jump height.", + "const { JumpSpeed, Gravity } = characterState();", + "const expectedHeight = (JumpSpeed * JumpSpeed) / (2 * Gravity);", + "", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(15);", + "harness.setKeyPressed('Space', false);", + "", + "let highestZ = groundZ;", + "let sawJumping = false;", + "let leftTheFloor = false;", + "await harness.stepFrames(25, {", + " onFrame: () => {", + " const state = characterState();", + " highestZ = Math.max(highestZ, getPlayer().z);", + " if (state.IsJumping === true) sawJumping = true;", + " if (state.IsOnFloor === false) leftTheFloor = true;", + " },", + "});", + "const jumpHeight = highestZ - groundZ;", + "console.log(", + " 'jumpHeight=' + Math.round(jumpHeight) + ' expected=' + Math.round(expectedHeight)", + ");", + "", + "harness.assert(sawJumping, 'The character reports it is jumping after pressing Space.');", + "harness.assert(leftTheFloor, 'The player leaves the floor when jumping.');", + "harness.assert(", + " jumpHeight > 0.5 * expectedHeight,", + " 'The player jumps as high as its jump speed and gravity say it should (rose ' +", + " Math.round(jumpHeight) + ' units, expected at least ' +", + " Math.round(0.5 * expectedHeight) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-flappy-bird/starting-flappy-bird.json b/examples/starting-flappy-bird/starting-flappy-bird.json index 4cf2b088e..7dabe711a 100644 --- a/examples/starting-flappy-bird/starting-flappy-bird.json +++ b/examples/starting-flappy-bird/starting-flappy-bird.json @@ -937,6 +937,115 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Flapping with Space", + "type": "gameplay", + "description": "Pressing Space makes the bird flap upwards, and gravity pulls it back down.", + "source": [ + "// The one control of the game: pressing Space (or clicking) makes the bird", + "// flap upwards, against the gravity that otherwise pulls it down.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const start = getPlayer();", + "", + "// Left alone, the bird falls.", + "await harness.stepFrames(15);", + "const falling = getPlayer();", + "console.log('fellBy=' + Math.round(falling.centerY - start.centerY));", + "harness.assert(", + " falling.centerY > start.centerY + 10,", + " 'The bird falls when nothing is pressed (fell ' +", + " Math.round(falling.centerY - start.centerY) + 'px).'", + ");", + "harness.assert(", + " falling.behaviors.PlatformerObject.state.IsFalling === true,", + " 'The bird reports that it is falling.'", + ");", + "", + "// Flap.", + "const beforeFlap = getPlayer();", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(3);", + "harness.setKeyPressed('Space', false);", + "", + "let highestY = beforeFlap.centerY;", + "await harness.stepFrames(25, {", + " onFrame: () => {", + " highestY = Math.min(highestY, getPlayer().centerY);", + " },", + "});", + "const rise = beforeFlap.centerY - highestY;", + "console.log('rise=' + Math.round(rise));", + "harness.assert(", + " rise > 40,", + " 'Pressing Space makes the bird flap upwards (it rose ' + Math.round(rise) + 'px).'", + ");", + "", + "// ...and gravity takes over again.", + "const afterFlap = getPlayer();", + "await harness.stepFrames(20);", + "harness.assert(", + " getPlayer().centerY > afterFlap.centerY,", + " 'The bird falls again after the flap.'", + ");" + ] + }, + { + "name": "Touching a hazard restarts the run", + "type": "gameplay", + "description": "The bird falls into the hazard at the bottom of the screen: the run ends and the scene restarts.", + "source": [ + "// Touching a hazard must end the run: the events shake the bird, slow time", + "// down and restart the scene. Here the bird simply falls into the boundary", + "// at the bottom of the screen.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const spawnY = getPlayer().centerY;", + "", + "// Let the bird fall, and remember how low it got.", + "let lowestY = spawnY;", + "const fellFar = await harness.stepUntil(", + " () => {", + " const player = getPlayer();", + " lowestY = Math.max(lowestY, player.centerY);", + " return player.centerY > spawnY + 250;", + " },", + " { maxFrames: 180 }", + ");", + "harness.assert(", + " fellFar,", + " 'The bird falls down toward the hazard at the bottom of the screen (it reached ' +", + " Math.round(lowestY) + ', spawned at ' + Math.round(spawnY) + ').'", + ");", + "", + "// Hitting the hazard restarts the scene, which puts a new bird back at the", + "// spawn point: this is how the end of a run is observed.", + "const restarted = await harness.stepUntil(", + " () => Math.abs(getPlayer().centerY - spawnY) < 5,", + " { maxFrames: 240 }", + ");", + "console.log(", + " 'spawnY=' + Math.round(spawnY) +", + " ' lowestY=' + Math.round(lowestY) +", + " ' finalY=' + Math.round(getPlayer().centerY)", + ");", + "harness.assert(", + " restarted,", + " 'Touching the hazard restarts the run: the bird is back at its starting height (it is at ' +", + " Math.round(getPlayer().centerY) + ', it started at ' + Math.round(spawnY) + ').'", + ");", + "harness.assert(", + " harness.getSceneName() === 'Game Scene',", + " 'The game is still on the game scene after the restart.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "Tristan Rhodes (https://victrisgames.itch.io/)", diff --git a/examples/starting-physics-pixel/starting-physics-pixel.json b/examples/starting-physics-pixel/starting-physics-pixel.json index 61d2004d7..920a24502 100644 --- a/examples/starting-physics-pixel/starting-physics-pixel.json +++ b/examples/starting-physics-pixel/starting-physics-pixel.json @@ -922,6 +922,139 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "The ball falls and rests on the ground", + "type": "gameplay", + "description": "The physics simulation makes the ball fall and come to rest on top of the ground instead of going through it.", + "source": [ + "// The whole game is the physics simulation: this game has no events at all.", + "// The ball must fall, land on the ground and come to rest on top of it.", + "await harness.goToScene('Game Scene');", + "harness.watch('Ball');", + "", + "const getBall = () => harness.getObjects('Ball')[0];", + "const start = getBall();", + "harness.assert(!!start, 'There is a ball in the scene.');", + "", + "// It falls on its own.", + "await harness.stepFrames(40);", + "const falling = getBall();", + "console.log('fellBy=' + Math.round(falling.centerY - start.centerY));", + "harness.assert(", + " falling.centerY > start.centerY + 20,", + " 'The ball falls (it went down by ' +", + " Math.round(falling.centerY - start.centerY) + 'px).'", + ");", + "", + "// ...and it stops falling once it reaches the ground: wait for the height to", + "// stop changing over twenty consecutive frames.", + "let previousY = getBall().centerY;", + "let stillFrames = 0;", + "const settled = await harness.stepUntil(() => stillFrames >= 20, {", + " maxFrames: 400,", + " onFrame: () => {", + " const currentY = getBall().centerY;", + " if (Math.abs(currentY - previousY) < 0.5) stillFrames++;", + " else stillFrames = 0;", + " previousY = currentY;", + " },", + "});", + "harness.assert(settled, 'The ball comes to rest instead of falling forever.');", + "", + "// It rests on top of the floor, not through it. The floor is the widest of", + "// the Ground pieces (the others are small angled ramps).", + "const ball = getBall();", + "const floor = harness", + " .getObjects('Ground')", + " .reduce((widest, ground) => (ground.width > widest.width ? ground : widest));", + "const ballBottom = ball.centerY + ball.height / 2;", + "const floorTop = floor.centerY - floor.height / 2;", + "console.log(", + " 'ballBottom=' + Math.round(ballBottom) + ' floorTop=' + Math.round(floorTop)", + ");", + "harness.assert(", + " ballBottom < floorTop + 20,", + " 'The ball rests on top of the floor instead of sinking through it (its bottom is at ' +", + " Math.round(ballBottom) + ', the floor starts at ' + Math.round(floorTop) + ').'", + ");" + ] + }, + { + "name": "Dragging the ball with the mouse", + "type": "gameplay", + "description": "The ball follows the mouse while it is held, and falls again once released.", + "source": [ + "// The ball can be picked up and moved with the mouse (the DraggablePhysics", + "// behavior), and physics takes over again once it is released.", + "await harness.goToScene('Game Scene');", + "harness.watch('Ball');", + "", + "const getBall = () => harness.getObjects('Ball')[0];", + "", + "// Let the ball settle first, so that what follows can only come from dragging.", + "await harness.stepFrames(180);", + "const resting = getBall();", + "await harness.stepFrames(20);", + "harness.assert(", + " Math.abs(getBall().centerY - resting.centerY) < 2,", + " 'The ball is at rest before being dragged.'", + ");", + "", + "const from = getBall();", + "const targetX = from.centerX + 250;", + "const targetY = from.centerY - 250;", + "", + "// Grab it, then drag slowly: a physics drag is a spring, not a teleport.", + "harness.setMousePosition(from.centerX, from.centerY, from.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(3);", + "const DRAG_STEPS = 30;", + "for (let step = 1; step <= DRAG_STEPS; step++) {", + " harness.setMousePosition(", + " from.centerX + ((targetX - from.centerX) * step) / DRAG_STEPS,", + " from.centerY + ((targetY - from.centerY) * step) / DRAG_STEPS,", + " from.layer", + " );", + " await harness.stepFrames(2);", + "}", + "// Hold the mouse still and let the ball catch up.", + "await harness.stepFrames(30);", + "", + "const dragged = getBall();", + "const distanceToMouse = Math.hypot(", + " dragged.centerX - targetX,", + " dragged.centerY - targetY", + ");", + "console.log(", + " 'draggedTo=' + Math.round(dragged.centerX) + ',' + Math.round(dragged.centerY) +", + " ' mouseAt=' + Math.round(targetX) + ',' + Math.round(targetY) +", + " ' distance=' + Math.round(distanceToMouse)", + ");", + "harness.assert(", + " distanceToMouse < 80,", + " 'The ball follows the mouse while it is held (it ended ' +", + " Math.round(distanceToMouse) + 'px away from the cursor).'", + ");", + "harness.assert(", + " dragged.centerY < from.centerY - 100,", + " 'The ball was lifted away from the ground (it went up by ' +", + " Math.round(from.centerY - dragged.centerY) + 'px).'", + ");", + "", + "// Released, it falls again.", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(45);", + "const released = getBall();", + "console.log('fellAfterRelease=' + Math.round(released.centerY - dragged.centerY));", + "harness.assert(", + " released.centerY > dragged.centerY + 50,", + " 'Once released, the ball falls again (it went down by ' +", + " Math.round(released.centerY - dragged.centerY) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-physics/starting-physics.json b/examples/starting-physics/starting-physics.json index c145af93e..7a91934eb 100644 --- a/examples/starting-physics/starting-physics.json +++ b/examples/starting-physics/starting-physics.json @@ -921,6 +921,139 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "The ball falls and rests on the ground", + "type": "gameplay", + "description": "The physics simulation makes the ball fall and come to rest on top of the ground instead of going through it.", + "source": [ + "// The whole game is the physics simulation: this game has no events at all.", + "// The ball must fall, land on the ground and come to rest on top of it.", + "await harness.goToScene('Game Scene');", + "harness.watch('Ball');", + "", + "const getBall = () => harness.getObjects('Ball')[0];", + "const start = getBall();", + "harness.assert(!!start, 'There is a ball in the scene.');", + "", + "// It falls on its own.", + "await harness.stepFrames(40);", + "const falling = getBall();", + "console.log('fellBy=' + Math.round(falling.centerY - start.centerY));", + "harness.assert(", + " falling.centerY > start.centerY + 20,", + " 'The ball falls (it went down by ' +", + " Math.round(falling.centerY - start.centerY) + 'px).'", + ");", + "", + "// ...and it stops falling once it reaches the ground: wait for the height to", + "// stop changing over twenty consecutive frames.", + "let previousY = getBall().centerY;", + "let stillFrames = 0;", + "const settled = await harness.stepUntil(() => stillFrames >= 20, {", + " maxFrames: 400,", + " onFrame: () => {", + " const currentY = getBall().centerY;", + " if (Math.abs(currentY - previousY) < 0.5) stillFrames++;", + " else stillFrames = 0;", + " previousY = currentY;", + " },", + "});", + "harness.assert(settled, 'The ball comes to rest instead of falling forever.');", + "", + "// It rests on top of the floor, not through it. The floor is the widest of", + "// the Ground pieces (the others are small angled ramps).", + "const ball = getBall();", + "const floor = harness", + " .getObjects('Ground')", + " .reduce((widest, ground) => (ground.width > widest.width ? ground : widest));", + "const ballBottom = ball.centerY + ball.height / 2;", + "const floorTop = floor.centerY - floor.height / 2;", + "console.log(", + " 'ballBottom=' + Math.round(ballBottom) + ' floorTop=' + Math.round(floorTop)", + ");", + "harness.assert(", + " ballBottom < floorTop + 20,", + " 'The ball rests on top of the floor instead of sinking through it (its bottom is at ' +", + " Math.round(ballBottom) + ', the floor starts at ' + Math.round(floorTop) + ').'", + ");" + ] + }, + { + "name": "Dragging the ball with the mouse", + "type": "gameplay", + "description": "The ball follows the mouse while it is held, and falls again once released.", + "source": [ + "// The ball can be picked up and moved with the mouse (the DraggablePhysics", + "// behavior), and physics takes over again once it is released.", + "await harness.goToScene('Game Scene');", + "harness.watch('Ball');", + "", + "const getBall = () => harness.getObjects('Ball')[0];", + "", + "// Let the ball settle first, so that what follows can only come from dragging.", + "await harness.stepFrames(180);", + "const resting = getBall();", + "await harness.stepFrames(20);", + "harness.assert(", + " Math.abs(getBall().centerY - resting.centerY) < 2,", + " 'The ball is at rest before being dragged.'", + ");", + "", + "const from = getBall();", + "const targetX = from.centerX + 250;", + "const targetY = from.centerY - 250;", + "", + "// Grab it, then drag slowly: a physics drag is a spring, not a teleport.", + "harness.setMousePosition(from.centerX, from.centerY, from.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(3);", + "const DRAG_STEPS = 30;", + "for (let step = 1; step <= DRAG_STEPS; step++) {", + " harness.setMousePosition(", + " from.centerX + ((targetX - from.centerX) * step) / DRAG_STEPS,", + " from.centerY + ((targetY - from.centerY) * step) / DRAG_STEPS,", + " from.layer", + " );", + " await harness.stepFrames(2);", + "}", + "// Hold the mouse still and let the ball catch up.", + "await harness.stepFrames(30);", + "", + "const dragged = getBall();", + "const distanceToMouse = Math.hypot(", + " dragged.centerX - targetX,", + " dragged.centerY - targetY", + ");", + "console.log(", + " 'draggedTo=' + Math.round(dragged.centerX) + ',' + Math.round(dragged.centerY) +", + " ' mouseAt=' + Math.round(targetX) + ',' + Math.round(targetY) +", + " ' distance=' + Math.round(distanceToMouse)", + ");", + "harness.assert(", + " distanceToMouse < 80,", + " 'The ball follows the mouse while it is held (it ended ' +", + " Math.round(distanceToMouse) + 'px away from the cursor).'", + ");", + "harness.assert(", + " dragged.centerY < from.centerY - 100,", + " 'The ball was lifted away from the ground (it went up by ' +", + " Math.round(from.centerY - dragged.centerY) + 'px).'", + ");", + "", + "// Released, it falls again.", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(45);", + "const released = getBall();", + "console.log('fellAfterRelease=' + Math.round(released.centerY - dragged.centerY));", + "harness.assert(", + " released.centerY > dragged.centerY + 50,", + " 'Once released, the ball falls again (it went down by ' +", + " Math.round(released.centerY - dragged.centerY) + 'px).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-platformer-pixel/starting-platformer-pixel.json b/examples/starting-platformer-pixel/starting-platformer-pixel.json index 7ccbf355f..873ef716e 100644 --- a/examples/starting-platformer-pixel/starting-platformer-pixel.json +++ b/examples/starting-platformer-pixel/starting-platformer-pixel.json @@ -1037,6 +1037,163 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Jumping with Space", + "type": "gameplay", + "description": "Pressing Space makes the player jump as high as its behavior is configured to, and gravity brings it back down at the same place.", + "source": [ + "// The player must be able to jump off the platform it stands on. How high it", + "// should go is taken from the behavior's own configuration, so this stays", + "// true whatever the game tunes it to.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const platformerState = () => getPlayer().behaviors.PlatformerObject.state;", + "const isOnFloor = () => platformerState().IsOnFloor === true;", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player falls and lands on a platform.');", + "", + "const groundY = getPlayer().centerY;", + "const groundX = getPlayer().centerX;", + "", + "// Standing still must not move the player vertically.", + "await harness.stepFrames(20);", + "harness.assert(", + " Math.abs(getPlayer().centerY - groundY) < 1,", + " 'The player stays on the ground while no key is pressed.'", + ");", + "", + "// The height a jump should reach, from the configured jump speed and gravity.", + "const { JumpSpeed, Gravity } = platformerState();", + "const expectedHeight = (JumpSpeed * JumpSpeed) / (2 * Gravity);", + "", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(20);", + "harness.setKeyPressed('Space', false);", + "", + "let highestY = groundY;", + "let leftTheFloor = false;", + "await harness.stepFrames(40, {", + " onFrame: () => {", + " const player = getPlayer();", + " highestY = Math.min(highestY, player.centerY);", + " if (player.behaviors.PlatformerObject.state.IsOnFloor === false)", + " leftTheFloor = true;", + " },", + "});", + "const jumpHeight = groundY - highestY;", + "console.log(", + " 'jumpHeight=' + Math.round(jumpHeight) +", + " ' expected=' + Math.round(expectedHeight)", + ");", + "", + "harness.assert(leftTheFloor, 'The player leaves the floor when jumping.');", + "harness.assert(", + " jumpHeight > 0.8 * expectedHeight,", + " 'The player jumps as high as its jump speed and gravity say it should (rose ' +", + " Math.round(jumpHeight) + 'px, expected at least ' +", + " Math.round(0.8 * expectedHeight) + 'px).'", + ");", + "", + "const landedBack = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landedBack, 'The player falls back onto the floor.');", + "harness.assert(", + " Math.abs(getPlayer().centerY - groundY) < 2,", + " 'The player lands back at the height it jumped from.'", + ");", + "harness.assert(", + " Math.abs(getPlayer().centerX - groundX) < 2,", + " 'A jump without a direction key does not move the player horizontally.'", + ");" + ] + }, + { + "name": "Collecting the coins by running into them", + "type": "gameplay", + "description": "The player runs to the ledge holding the coins, jumps onto it, and collects every coin it touches.", + "source": [ + "// Coins are picked up by touching them. They sit on a ledge above the ground", + "// the player starts on: it has to run there and jump onto it.", + "await harness.goToScene('Game Scene');", + "harness.watch('Coins');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const platformerState = () =>", + " getPlayer().behaviors.PlatformerObject.state;", + "const isOnFloor = () => platformerState().IsOnFloor === true;", + "const coinIds = () => harness.getObjects('Coins').map((coin) => coin.id);", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player lands on its starting platform.');", + "await harness.stepFrames(10);", + "", + "const coinsBefore = coinIds();", + "harness.assert(coinsBefore.length > 0, 'There are coins to collect in the level.');", + "const startY = getPlayer().centerY;", + "", + "// Standing still collects nothing: this is what makes the check below a test", + "// of the player running into the coins.", + "await harness.stepFrames(25);", + "harness.assert(", + " coinIds().length === coinsBefore.length,", + " 'Standing still collects no coin.'", + ");", + "", + "// Run right, jumping while the coins ahead are out of reach above.", + "let jumpFramesLeft = 0;", + "harness.setKeyPressed('Right', true);", + "const collectedThemAll = await harness.stepUntil(", + " () => coinIds().length === 0,", + " {", + " maxFrames: 400,", + " onFrame: () => {", + " const player = getPlayer();", + " const onFloor =", + " player.behaviors.PlatformerObject.state.IsOnFloor === true;", + " const nextCoin = harness.getNearby('Coins', 'Player', 5000)[0];", + "", + " if (jumpFramesLeft > 0) {", + " jumpFramesLeft--;", + " if (jumpFramesLeft === 0) harness.setKeyPressed('Space', false);", + " return;", + " }", + " if (!onFloor || !nextCoin) return;", + " const isAhead = nextCoin.centerX > player.centerX;", + " const isAbove = player.centerY - nextCoin.centerY > 30;", + " if (isAhead && isAbove) {", + " harness.setKeyPressed('Space', true);", + " jumpFramesLeft = 14;", + " }", + " },", + " }", + ");", + "harness.releaseAllInputs();", + "await harness.stepFrames(5);", + "", + "const player = getPlayer();", + "console.log(", + " 'coinsBefore=' + coinsBefore.length +", + " ' coinsLeft=' + coinIds().length +", + " ' playerX=' + Math.round(player.centerX) +", + " ' playerY=' + Math.round(player.centerY)", + ");", + "harness.assert(", + " player.centerY < startY + 400,", + " 'The player did not fall out of the level while going for the coins (it ended at y=' +", + " Math.round(player.centerY) + ', it started at y=' + Math.round(startY) + ').'", + ");", + "harness.assert(", + " collectedThemAll,", + " 'Running and jumping to the coins collects every one of them (' +", + " coinIds().length + ' were left behind, the player ended at x=' +", + " Math.round(player.centerX) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-platformer/starting-platformer.json b/examples/starting-platformer/starting-platformer.json index 715095d33..f16e55659 100644 --- a/examples/starting-platformer/starting-platformer.json +++ b/examples/starting-platformer/starting-platformer.json @@ -1133,6 +1133,169 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Jumping with Space", + "type": "gameplay", + "description": "Pressing Space makes the player jump off the platform it stands on, and gravity brings it back down at the same place.", + "source": [ + "// The player must be able to jump off the ground with the Space key", + "// (PlatformerObject default controls, jumpSpeed 717 / gravity 1050).", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isOnFloor = () =>", + " getPlayer().behaviors.PlatformerObject.state.IsOnFloor === true;", + "", + "// The player starts in the air: wait for it to land on the ground platform.", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player falls and lands on the ground platform.');", + "", + "const groundY = getPlayer().centerY;", + "const groundX = getPlayer().centerX;", + "", + "// Standing still must not move the player vertically.", + "await harness.stepFrames(20);", + "harness.assert(", + " Math.abs(getPlayer().centerY - groundY) < 1,", + " 'The player stays on the ground while no key is pressed.'", + ");", + "", + "// Jump: hold Space long enough to benefit from the jump sustain time.", + "harness.setKeyPressed('Space', true);", + "await harness.stepFrames(20);", + "harness.setKeyPressed('Space', false);", + "", + "let highestY = groundY;", + "let leftTheFloor = false;", + "await harness.stepFrames(40, {", + " onFrame: () => {", + " const player = getPlayer();", + " highestY = Math.min(highestY, player.centerY);", + " if (player.behaviors.PlatformerObject.state.IsOnFloor === false)", + " leftTheFloor = true;", + " },", + "});", + "const jumpHeight = groundY - highestY;", + "console.log('jumpHeight=' + Math.round(jumpHeight));", + "", + "harness.assert(leftTheFloor, 'The player leaves the floor when jumping.');", + "harness.assert(", + " jumpHeight > 150,", + " 'The player rises well above the ground when jumping (rose by ' +", + " Math.round(jumpHeight) +", + " 'px).'", + ");", + "", + "// ...and gravity brings it back down on the same platform.", + "const landedBack = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landedBack, 'The player falls back onto the floor.');", + "harness.assert(", + " Math.abs(getPlayer().centerY - groundY) < 2,", + " 'The player lands back at the height it jumped from.'", + ");", + "harness.assert(", + " Math.abs(getPlayer().centerX - groundX) < 2,", + " 'A jump without a direction key does not move the player horizontally.'", + ");" + ] + }, + { + "name": "Collecting the coins by running into them", + "type": "gameplay", + "description": "The player drops down to the ground and runs over the row of coins: every coin it touches is collected.", + "source": [ + "// Coins are picked up by touching them: the player starts on a raised", + "// platform and has to drop down to the ground to run over the coins there.", + "await harness.goToScene('Game Scene');", + "harness.watch('Coins');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isOnFloor = () =>", + " getPlayer().behaviors.PlatformerObject.state.IsOnFloor === true;", + "const coinIds = () => harness.getObjects('Coins').map(coin => coin.id);", + "", + "const landed = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.assert(landed, 'The player lands on its starting platform.');", + "await harness.stepFrames(8);", + "", + "const coinsBefore = coinIds().length;", + "harness.assert(coinsBefore > 0, 'There are coins to collect in the level.');", + "", + "// Standing still collects nothing: this is what makes the checks below a", + "// test of the player moving into the coins.", + "await harness.stepFrames(20);", + "harness.assert(", + " coinIds().length === coinsBefore,", + " 'Standing still collects no coin.'", + ");", + "", + "const startingPoint = getPlayer();", + "const coinsOnTheGroundBelow = harness", + " .getObjects('Coins')", + " .filter(coin => coin.centerY > startingPoint.centerY + 100);", + "harness.assert(", + " coinsOnTheGroundBelow.length >= 3,", + " 'There is a row of coins on the ground below the starting platform (found ' +", + " coinsOnTheGroundBelow.length +", + " ').'", + ");", + "const targetIds = coinsOnTheGroundBelow.map(coin => coin.id);", + "", + "// Run right until the platform ends and the player drops down.", + "harness.setKeyPressed('Right', true);", + "const fell = await harness.stepUntil(() => !isOnFloor(), { maxFrames: 120 });", + "harness.assert(fell, 'Running right takes the player off the edge of the platform.');", + "const landedBelow = await harness.stepUntil(isOnFloor, { maxFrames: 120 });", + "harness.releaseAllInputs();", + "await harness.stepFrames(5);", + "harness.assert(landedBelow, 'The player lands on the ground below.');", + "harness.assert(", + " getPlayer().centerY > startingPoint.centerY + 100,", + " 'The player is now down on the ground, next to the coins.'", + ");", + "harness.assert(", + " coinIds().length === coinsBefore,", + " 'Falling down next to the coins does not collect them yet.'", + ");", + "", + "// Run back left, over the row of coins.", + "harness.setKeyPressed('Left', true);", + "const collectedThemAll = await harness.stepUntil(", + " () => {", + " const remaining = coinIds();", + " return targetIds.every(id => !remaining.includes(id));", + " },", + " { maxFrames: 150 }", + ");", + "harness.releaseAllInputs();", + "await harness.stepFrames(5);", + "", + "const remaining = coinIds();", + "console.log(", + " 'coinsBefore=' +", + " coinsBefore +", + " ' ranOver=' +", + " targetIds.length +", + " ' remaining=' +", + " remaining.length +", + " ' playerX=' +", + " Math.round(getPlayer().centerX)", + ");", + "harness.assert(", + " collectedThemAll,", + " 'Running over the row of coins collects every one of them (' +", + " targetIds.filter(id => remaining.includes(id)).length +", + " ' were left behind).'", + ");", + "harness.assert(", + " remaining.length === coinsBefore - targetIds.length,", + " 'Only the coins the player ran into were collected.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-point-and-click-pixel/starting-point-and-click-pixel.json b/examples/starting-point-and-click-pixel/starting-point-and-click-pixel.json index d8317071d..7ef03eba3 100644 --- a/examples/starting-point-and-click-pixel/starting-point-and-click-pixel.json +++ b/examples/starting-point-and-click-pixel/starting-point-and-click-pixel.json @@ -601,6 +601,181 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Clicking sends the player there", + "type": "gameplay", + "description": "The player walks to where the mouse clicked and stops once it has arrived.", + "source": [ + "// The only control of the game: clicking somewhere sends the player walking", + "// there, using the Pathfinding behavior.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const pathfindingState = () => getPlayer().behaviors.Pathfinding.state;", + "", + "await harness.stepFrames(5);", + "const start = getPlayer();", + "", + "// Without a click, the player stays put.", + "await harness.stepFrames(30);", + "harness.assert(", + " Math.hypot(", + " getPlayer().centerX - start.centerX,", + " getPlayer().centerY - start.centerY", + " ) < 2,", + " 'The player stands still until it is told where to go.'", + ");", + "", + "// Click a free spot away from the player.", + "const destinationX = start.centerX + 180;", + "const destinationY = start.centerY + 120;", + "harness.setMousePosition(destinationX, destinationY, start.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "", + "harness.assert(", + " pathfindingState().PathFound === true,", + " 'A path to the clicked position was found.'", + ");", + "", + "const arrived = await harness.stepUntil(", + " () => pathfindingState().DestinationReached === true,", + " { maxFrames: 400 }", + ");", + "const finish = getPlayer();", + "const distanceToDestination = Math.hypot(", + " finish.centerX - destinationX,", + " finish.centerY - destinationY", + ");", + "console.log(", + " 'walkedTo=' + Math.round(finish.centerX) + ',' + Math.round(finish.centerY) +", + " ' destination=' + Math.round(destinationX) + ',' + Math.round(destinationY) +", + " ' distance=' + Math.round(distanceToDestination)", + ");", + "", + "harness.assert(", + " Math.hypot(finish.centerX - start.centerX, finish.centerY - start.centerY) > 50,", + " 'Clicking sends the player walking.'", + ");", + "harness.assert(arrived, 'The player reports it reached its destination.');", + "harness.assert(", + " distanceToDestination < 50,", + " 'The player ends up where it was told to go (it stopped ' +", + " Math.round(distanceToDestination) + 'px away from it).'", + ");", + "", + "// Once arrived it stops.", + "const settled = getPlayer();", + "await harness.stepFrames(30);", + "harness.assert(", + " Math.hypot(", + " getPlayer().centerX - settled.centerX,", + " getPlayer().centerY - settled.centerY", + " ) < 2,", + " 'The player stops once it has arrived.'", + ");" + ] + }, + { + "name": "The player walks around obstacles", + "type": "gameplay", + "description": "Sent to the other side of an impassable obstacle, the player finds a way around instead of going through it.", + "source": [ + "// The pathfinding must walk around the impassable obstacles instead of", + "// through them.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const pathfindingState = () => getPlayer().behaviors.Pathfinding.state;", + "", + "await harness.stepFrames(5);", + "const obstacles = harness.getObjects('Obstacle_Impassable');", + "harness.assert(obstacles.length > 0, 'There are impassable obstacles in the level.');", + "", + "// Arrange: stand the player on one side of an obstacle, and send it straight", + "// to the other side. Finding a way around is still up to the game. The", + "// obstacle nearest the middle of the screen is used, so that both sides stay", + "// well inside the level.", + "const middleX = harness.getGameResolutionWidth() / 2;", + "const middleY = harness.getGameResolutionHeight() / 2;", + "const obstacle = obstacles.reduce((closest, one) =>", + " Math.hypot(one.centerX - middleX, one.centerY - middleY) <", + " Math.hypot(closest.centerX - middleX, closest.centerY - middleY)", + " ? one", + " : closest", + ");", + "const sideOffset = obstacle.width / 2 + 130;", + "const player = getPlayer();", + "harness.setObjectPosition(", + " player.id,", + " player.x + (obstacle.centerX - sideOffset - player.centerX),", + " player.y + (obstacle.centerY - player.centerY)", + ");", + "await harness.stepFrames(3);", + "const start = getPlayer();", + "", + "const destinationX = obstacle.centerX + sideOffset;", + "const destinationY = obstacle.centerY;", + "console.log(", + " 'from=' + Math.round(start.centerX) + ',' + Math.round(start.centerY) +", + " ' obstacle=' + Math.round(obstacle.centerX) + ',' + Math.round(obstacle.centerY) +", + " ' (' + Math.round(obstacle.width) + 'x' + Math.round(obstacle.height) + ')' +", + " ' to=' + Math.round(destinationX) + ',' + Math.round(destinationY)", + ");", + "", + "harness.setMousePosition(destinationX, destinationY, start.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "harness.assert(", + " pathfindingState().PathFound === true,", + " 'A path around the obstacle was found.'", + ");", + "", + "// Walk there. The start and the destination are at the same height, so", + "// walking straight would keep the player on that line: going around shows up", + "// as a detour away from it.", + "let furthestFromTheStraightLine = 0;", + "const arrived = await harness.stepUntil(", + " () => pathfindingState().DestinationReached === true,", + " {", + " maxFrames: 600,", + " onFrame: () => {", + " furthestFromTheStraightLine = Math.max(", + " furthestFromTheStraightLine,", + " Math.abs(getPlayer().centerY - start.centerY)", + " );", + " },", + " }", + ");", + "const finish = getPlayer();", + "console.log(", + " 'arrived=' + arrived +", + " ' detour=' + Math.round(furthestFromTheStraightLine) +", + " ' finish=' + Math.round(finish.centerX) + ',' + Math.round(finish.centerY)", + ");", + "", + "harness.assert(", + " arrived,", + " 'The player reaches the other side of the obstacle (it ended at ' +", + " Math.round(finish.centerX) + ',' + Math.round(finish.centerY) + ').'", + ");", + "harness.assert(", + " Math.abs(finish.centerX - destinationX) < 50,", + " 'The player really crossed to the other side.'", + ");", + "harness.assert(", + " furthestFromTheStraightLine > obstacle.height / 3,", + " 'The player made a detour around the obstacle instead of walking straight through it (it went ' +", + " Math.round(furthestFromTheStraightLine) + 'px off the straight line).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [], "externalLayouts": [] } diff --git a/examples/starting-point-and-click/starting-point-and-click.json b/examples/starting-point-and-click/starting-point-and-click.json index cfee8d31a..f71eaec35 100644 --- a/examples/starting-point-and-click/starting-point-and-click.json +++ b/examples/starting-point-and-click/starting-point-and-click.json @@ -557,6 +557,181 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Clicking sends the player there", + "type": "gameplay", + "description": "The player walks to where the mouse clicked and stops once it has arrived.", + "source": [ + "// The only control of the game: clicking somewhere sends the player walking", + "// there, using the Pathfinding behavior.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const pathfindingState = () => getPlayer().behaviors.Pathfinding.state;", + "", + "await harness.stepFrames(5);", + "const start = getPlayer();", + "", + "// Without a click, the player stays put.", + "await harness.stepFrames(30);", + "harness.assert(", + " Math.hypot(", + " getPlayer().centerX - start.centerX,", + " getPlayer().centerY - start.centerY", + " ) < 2,", + " 'The player stands still until it is told where to go.'", + ");", + "", + "// Click a free spot away from the player.", + "const destinationX = start.centerX + 180;", + "const destinationY = start.centerY + 120;", + "harness.setMousePosition(destinationX, destinationY, start.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "", + "harness.assert(", + " pathfindingState().PathFound === true,", + " 'A path to the clicked position was found.'", + ");", + "", + "const arrived = await harness.stepUntil(", + " () => pathfindingState().DestinationReached === true,", + " { maxFrames: 400 }", + ");", + "const finish = getPlayer();", + "const distanceToDestination = Math.hypot(", + " finish.centerX - destinationX,", + " finish.centerY - destinationY", + ");", + "console.log(", + " 'walkedTo=' + Math.round(finish.centerX) + ',' + Math.round(finish.centerY) +", + " ' destination=' + Math.round(destinationX) + ',' + Math.round(destinationY) +", + " ' distance=' + Math.round(distanceToDestination)", + ");", + "", + "harness.assert(", + " Math.hypot(finish.centerX - start.centerX, finish.centerY - start.centerY) > 50,", + " 'Clicking sends the player walking.'", + ");", + "harness.assert(arrived, 'The player reports it reached its destination.');", + "harness.assert(", + " distanceToDestination < 50,", + " 'The player ends up where it was told to go (it stopped ' +", + " Math.round(distanceToDestination) + 'px away from it).'", + ");", + "", + "// Once arrived it stops.", + "const settled = getPlayer();", + "await harness.stepFrames(30);", + "harness.assert(", + " Math.hypot(", + " getPlayer().centerX - settled.centerX,", + " getPlayer().centerY - settled.centerY", + " ) < 2,", + " 'The player stops once it has arrived.'", + ");" + ] + }, + { + "name": "The player walks around obstacles", + "type": "gameplay", + "description": "Sent to the other side of an impassable obstacle, the player finds a way around instead of going through it.", + "source": [ + "// The pathfinding must walk around the impassable obstacles instead of", + "// through them.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const pathfindingState = () => getPlayer().behaviors.Pathfinding.state;", + "", + "await harness.stepFrames(5);", + "const obstacles = harness.getObjects('Obstacle_Impassable');", + "harness.assert(obstacles.length > 0, 'There are impassable obstacles in the level.');", + "", + "// Arrange: stand the player on one side of an obstacle, and send it straight", + "// to the other side. Finding a way around is still up to the game. The", + "// obstacle nearest the middle of the screen is used, so that both sides stay", + "// well inside the level.", + "const middleX = harness.getGameResolutionWidth() / 2;", + "const middleY = harness.getGameResolutionHeight() / 2;", + "const obstacle = obstacles.reduce((closest, one) =>", + " Math.hypot(one.centerX - middleX, one.centerY - middleY) <", + " Math.hypot(closest.centerX - middleX, closest.centerY - middleY)", + " ? one", + " : closest", + ");", + "const sideOffset = obstacle.width / 2 + 130;", + "const player = getPlayer();", + "harness.setObjectPosition(", + " player.id,", + " player.x + (obstacle.centerX - sideOffset - player.centerX),", + " player.y + (obstacle.centerY - player.centerY)", + ");", + "await harness.stepFrames(3);", + "const start = getPlayer();", + "", + "const destinationX = obstacle.centerX + sideOffset;", + "const destinationY = obstacle.centerY;", + "console.log(", + " 'from=' + Math.round(start.centerX) + ',' + Math.round(start.centerY) +", + " ' obstacle=' + Math.round(obstacle.centerX) + ',' + Math.round(obstacle.centerY) +", + " ' (' + Math.round(obstacle.width) + 'x' + Math.round(obstacle.height) + ')' +", + " ' to=' + Math.round(destinationX) + ',' + Math.round(destinationY)", + ");", + "", + "harness.setMousePosition(destinationX, destinationY, start.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "harness.assert(", + " pathfindingState().PathFound === true,", + " 'A path around the obstacle was found.'", + ");", + "", + "// Walk there. The start and the destination are at the same height, so", + "// walking straight would keep the player on that line: going around shows up", + "// as a detour away from it.", + "let furthestFromTheStraightLine = 0;", + "const arrived = await harness.stepUntil(", + " () => pathfindingState().DestinationReached === true,", + " {", + " maxFrames: 600,", + " onFrame: () => {", + " furthestFromTheStraightLine = Math.max(", + " furthestFromTheStraightLine,", + " Math.abs(getPlayer().centerY - start.centerY)", + " );", + " },", + " }", + ");", + "const finish = getPlayer();", + "console.log(", + " 'arrived=' + arrived +", + " ' detour=' + Math.round(furthestFromTheStraightLine) +", + " ' finish=' + Math.round(finish.centerX) + ',' + Math.round(finish.centerY)", + ");", + "", + "harness.assert(", + " arrived,", + " 'The player reaches the other side of the obstacle (it ended at ' +", + " Math.round(finish.centerX) + ',' + Math.round(finish.centerY) + ').'", + ");", + "harness.assert(", + " Math.abs(finish.centerX - destinationX) < 50,", + " 'The player really crossed to the other side.'", + ");", + "harness.assert(", + " furthestFromTheStraightLine > obstacle.height / 3,", + " 'The player made a detour around the obstacle instead of walking straight through it (it went ' +", + " Math.round(furthestFromTheStraightLine) + 'px off the straight line).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [], "externalLayouts": [] } diff --git a/examples/starting-quiz/starting-quiz.json b/examples/starting-quiz/starting-quiz.json index cbfc273f0..f22774c2a 100644 --- a/examples/starting-quiz/starting-quiz.json +++ b/examples/starting-quiz/starting-quiz.json @@ -1250,6 +1250,162 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Only the right answer moves on", + "type": "gameplay", + "description": "A wrong answer leaves the quiz on the same question, the right one shows the next question.", + "source": [ + "// The core of the game: only the right answer moves the quiz forward.", + "await harness.goToScene('Game Scene');", + "", + "await harness.stepFrames(10);", + "const questionList = harness.getSceneVariable('QuestionList');", + "console.log('questionListShape=' + JSON.stringify(questionList).slice(0, 400));", + "", + "/** The children of a variable, whatever shape they are stored in. */", + "const childrenOf = (variable) => {", + " if (!variable || !variable.children) return [];", + " if (Array.isArray(variable.children)) return variable.children;", + " return Object.keys(variable.children).map((name) => ({", + " name,", + " ...variable.children[name],", + " }));", + "};", + "const questions = childrenOf(questionList);", + "harness.assert(questions.length > 0, 'The quiz has questions.');", + "", + "const questionNumber = () =>", + " Number(harness.getSceneVariable('QuestionNumber').value);", + "const correctAnswerOf = (index) => {", + " const entry = childrenOf(questions[index]).find(", + " (child) => child.name === 'CorrectAnswer'", + " );", + " return entry ? String(entry.value) : null;", + "};", + "", + "const firstCorrectAnswer = correctAnswerOf(0);", + "console.log('firstCorrectAnswer=' + firstCorrectAnswer);", + "harness.assert(", + " !!firstCorrectAnswer,", + " 'The first question declares which answer is the right one.'", + ");", + "", + "/** Click the button of an answer (\"Answer1\"..\"Answer4\"). */", + "const clickAnswer = async (answerName) => {", + " const button = harness.getObjects(answerName + '_Button')[0];", + " harness.assert(!!button, 'The button \"' + answerName + '_Button\" is on screen.');", + " harness.setMousePosition(button.centerX, button.centerY, button.layer);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "const questionTextBefore = harness.getObjects('QuestionText')[0].text;", + "harness.assert(", + " !!questionTextBefore,", + " 'A question is displayed (' + JSON.stringify(questionTextBefore) + ').'", + ");", + "", + "// A wrong answer must not move the quiz forward.", + "const wrongAnswer = ['Answer1', 'Answer2', 'Answer3', 'Answer4'].find(", + " (name) => name !== firstCorrectAnswer", + ");", + "await clickAnswer(wrongAnswer);", + "console.log(", + " 'afterWrongAnswer: questionNumber=' + questionNumber() +", + " ' text=' + JSON.stringify(harness.getObjects('QuestionText')[0].text)", + ");", + "harness.assert(", + " questionNumber() === 0,", + " 'Picking a wrong answer does not move on to the next question (the quiz is still on question ' +", + " questionNumber() + ').'", + ");", + "harness.assert(", + " harness.getObjects('QuestionText')[0].text === questionTextBefore,", + " 'The question stays the same after a wrong answer.'", + ");", + "", + "// The right one does.", + "await clickAnswer(firstCorrectAnswer);", + "console.log(", + " 'afterRightAnswer: questionNumber=' + questionNumber() +", + " ' text=' + JSON.stringify(harness.getObjects('QuestionText')[0].text)", + ");", + "harness.assert(", + " questionNumber() === 1,", + " 'Picking the right answer moves on to the next question (the quiz is on question ' +", + " questionNumber() + ').'", + ");", + "harness.assert(", + " harness.getObjects('QuestionText')[0].text !== questionTextBefore,", + " 'The next question is displayed.'", + ");" + ] + }, + { + "name": "Answering every question finishes the quiz", + "type": "gameplay", + "description": "Answering all the questions right removes the answer buttons and announces the quiz is completed.", + "source": [ + "// Answering every question right must finish the quiz.", + "await harness.goToScene('Game Scene');", + "", + "await harness.stepFrames(10);", + "const childrenOf = (variable) => {", + " if (!variable || !variable.children) return [];", + " if (Array.isArray(variable.children)) return variable.children;", + " return Object.keys(variable.children).map((name) => ({", + " name,", + " ...variable.children[name],", + " }));", + "};", + "const questions = childrenOf(harness.getSceneVariable('QuestionList'));", + "harness.assert(questions.length > 0, 'The quiz has questions.');", + "const correctAnswerOf = (index) => {", + " const entry = childrenOf(questions[index]).find(", + " (child) => child.name === 'CorrectAnswer'", + " );", + " return entry ? String(entry.value) : null;", + "};", + "", + "// Answer them all, correctly.", + "for (let index = 0; index < questions.length; index++) {", + " const answerName = correctAnswerOf(index);", + " const button = harness.getObjects(answerName + '_Button')[0];", + " harness.assert(", + " !!button,", + " 'The answer buttons are still on screen for question ' + (index + 1) + '.'", + " );", + " harness.setMousePosition(button.centerX, button.centerY, button.layer);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + " harness.assert(", + " Number(harness.getSceneVariable('QuestionNumber').value) === index + 1,", + " 'Question ' + (index + 1) + ' was answered right.'", + " );", + "}", + "", + "const finalText = harness.getObjects('QuestionText')[0].text;", + "console.log(", + " 'questions=' + questions.length +", + " ' finalText=' + JSON.stringify(finalText) +", + " ' buttonsLeft=' + harness.getObjects('Answer1_Button').length", + ");", + "harness.assert(", + " finalText === 'Quiz Completed',", + " 'The quiz announces it is finished (it shows ' + JSON.stringify(finalText) + ').'", + ");", + "harness.assert(", + " harness.getObjects('Answer1_Button').length === 0,", + " 'The answer buttons are removed once the quiz is over.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-rts-unit-selection/starting-rts-unit-selection.json b/examples/starting-rts-unit-selection/starting-rts-unit-selection.json index 8229d7e96..480270957 100644 --- a/examples/starting-rts-unit-selection/starting-rts-unit-selection.json +++ b/examples/starting-rts-unit-selection/starting-rts-unit-selection.json @@ -1111,6 +1111,215 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Selecting a unit and ordering it to move", + "type": "gameplay", + "description": "A drag box selects one unit, and a click sends it walking there while the other units stay put.", + "source": [ + "// The core of the game: units are selected with a drag box, and a click", + "// then orders the selected ones (and only those) to walk there.", + "await harness.goToScene('Game Scene');", + "harness.watch('RTSUnit');", + "", + "const getUnitById = (id) => harness.getObjects('RTSUnit').find((one) => one.id === id);", + "await harness.stepFrames(5);", + "", + "const units = harness.getObjects('RTSUnit');", + "harness.assert(units.length > 1, 'There are several units on the map.');", + "const target = units[0];", + "const others = units.slice(1);", + "console.log(", + " 'units=' + units.length +", + " ' selecting the one at ' + Math.round(target.centerX) + ',' + Math.round(target.centerY)", + ");", + "", + "/** Drag a selection box over a rectangle. Held long enough to be a", + " * selection and not a move order (the events use a 0.2s threshold). */", + "const dragSelectionBox = async (fromX, fromY, toX, toY, layerName) => {", + " harness.setMousePosition(fromX, fromY, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(20);", + " const STEPS = 15;", + " for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePosition(", + " fromX + ((toX - fromX) * step) / STEPS,", + " fromY + ((toY - fromY) * step) / STEPS,", + " layerName", + " );", + " await harness.stepFrames(1);", + " }", + " await harness.stepFrames(5);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "/** A short click: this is what orders the selected units to move. */", + "const clickAt = async (x, y, layerName) => {", + " harness.setMousePosition(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "// Box just around the first unit.", + "const margin = target.width / 2 + 10;", + "await dragSelectionBox(", + " target.centerX - margin,", + " target.centerY - margin,", + " target.centerX + margin,", + " target.centerY + margin,", + " target.layer", + ");", + "", + "const positionsBeforeOrder = new Map(", + " harness.getObjects('RTSUnit').map((one) => [one.id, one])", + ");", + "", + "// Order a move, well away from where the units are.", + "const destinationX = target.centerX + 220;", + "const destinationY = target.centerY + 160;", + "await clickAt(destinationX, destinationY, target.layer);", + "", + "const movedTowardDestination = await harness.stepUntil(", + " () => {", + " const unit = getUnitById(target.id);", + " const before = positionsBeforeOrder.get(target.id);", + " return (", + " Math.hypot(", + " unit.centerX - before.centerX,", + " unit.centerY - before.centerY", + " ) > 60", + " );", + " },", + " { maxFrames: 300 }", + ");", + "", + "const movedUnit = getUnitById(target.id);", + "const movedDistance = Math.hypot(", + " movedUnit.centerX - positionsBeforeOrder.get(target.id).centerX,", + " movedUnit.centerY - positionsBeforeOrder.get(target.id).centerY", + ");", + "let othersThatMoved = 0;", + "for (const other of others) {", + " const now = getUnitById(other.id);", + " const before = positionsBeforeOrder.get(other.id);", + " if (", + " now &&", + " before &&", + " Math.hypot(now.centerX - before.centerX, now.centerY - before.centerY) > 10", + " ) {", + " othersThatMoved++;", + " }", + "}", + "console.log(", + " 'selectedUnitMoved=' + Math.round(movedDistance) +", + " ' othersThatMoved=' + othersThatMoved", + ");", + "", + "harness.assert(", + " movedTowardDestination,", + " 'The selected unit walks to where it was ordered (it moved ' +", + " Math.round(movedDistance) + 'px).'", + ");", + "harness.assert(", + " othersThatMoved === 0,", + " 'The units that were not selected stay where they are (' +", + " othersThatMoved + ' of them moved).'", + ");" + ] + }, + { + "name": "Selecting every unit at once", + "type": "gameplay", + "description": "A drag box over all the units selects them, and one click sends the whole group walking.", + "source": [ + "// A drag box over several units selects them all, and one click sends the", + "// whole group.", + "await harness.goToScene('Game Scene');", + "harness.watch('RTSUnit');", + "", + "const getUnitById = (id) => harness.getObjects('RTSUnit').find((one) => one.id === id);", + "await harness.stepFrames(5);", + "", + "const units = harness.getObjects('RTSUnit');", + "harness.assert(units.length > 1, 'There are several units on the map.');", + "", + "// A box that covers every unit.", + "const left = Math.min(...units.map((one) => one.centerX)) - 60;", + "const right = Math.max(...units.map((one) => one.centerX)) + 60;", + "const top = Math.min(...units.map((one) => one.centerY)) - 60;", + "const bottom = Math.max(...units.map((one) => one.centerY)) + 60;", + "console.log(", + " 'units=' + units.length +", + " ' box=' + Math.round(left) + ',' + Math.round(top) +", + " ' -> ' + Math.round(right) + ',' + Math.round(bottom)", + ");", + "", + "const layerName = units[0].layer;", + "harness.setMousePosition(left, top, layerName);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(20);", + "const STEPS = 15;", + "for (let step = 1; step <= STEPS; step++) {", + " harness.setMousePosition(", + " left + ((right - left) * step) / STEPS,", + " top + ((bottom - top) * step) / STEPS,", + " layerName", + " );", + " await harness.stepFrames(1);", + "}", + "await harness.stepFrames(5);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(3);", + "", + "const positionsBeforeOrder = new Map(", + " harness.getObjects('RTSUnit').map((one) => [one.id, one])", + ");", + "", + "// One short click orders the whole selection.", + "const destinationX = (left + right) / 2;", + "const destinationY = bottom + 120;", + "harness.setMousePosition(destinationX, destinationY, layerName);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "", + "let unitsThatMoved = 0;", + "await harness.stepUntil(", + " () => {", + " unitsThatMoved = 0;", + " for (const [id, before] of positionsBeforeOrder) {", + " const now = getUnitById(id);", + " if (", + " now &&", + " Math.hypot(now.centerX - before.centerX, now.centerY - before.centerY) >", + " 40", + " ) {", + " unitsThatMoved++;", + " }", + " }", + " return unitsThatMoved === positionsBeforeOrder.size;", + " },", + " { maxFrames: 400 }", + ");", + "", + "console.log(", + " 'unitsThatMoved=' + unitsThatMoved + ' of ' + positionsBeforeOrder.size", + ");", + "harness.assert(", + " unitsThatMoved === positionsBeforeOrder.size,", + " 'Every unit of the selection walks to where the group was ordered (' +", + " unitsThatMoved + ' of ' + positionsBeforeOrder.size + ' moved).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "Slash, Tristan Rhodes, @VictrisGames", diff --git a/examples/starting-shootemup/starting-shootemup.json b/examples/starting-shootemup/starting-shootemup.json index a6714ad08..189c3ab63 100644 --- a/examples/starting-shootemup/starting-shootemup.json +++ b/examples/starting-shootemup/starting-shootemup.json @@ -1492,6 +1492,129 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "The ship fires on its own", + "type": "gameplay", + "description": "The ship shoots without any input, the bullets fly right, and the arrow keys move the ship.", + "source": [ + "// The ship fires on its own: the core loop of the game is dodging while the", + "// bullets keep coming out toward the right of the screen.", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerBullet');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "harness.assert(", + " harness.getObjects('PlayerBullet').length === 0,", + " 'No bullet is in the air when the scene starts.'", + ");", + "", + "// No input at all: the ship must still shoot.", + "await harness.stepFrames(30);", + "const bullets = harness.getObjects('PlayerBullet');", + "console.log('bulletsAfter30Frames=' + bullets.length);", + "harness.assert(", + " bullets.length > 0,", + " 'The ship fires without any input (' + bullets.length + ' bullet(s) in the air).'", + ");", + "", + "// The bullets fly to the right, away from the ship.", + "const bullet = bullets[0];", + "const startX = bullet.centerX;", + "await harness.stepFrames(10);", + "const flying = harness.getObjects('PlayerBullet').find((one) => one.id === bullet.id);", + "harness.assert(!!flying, 'A bullet is still flying ten frames later.');", + "console.log('bulletTravel=' + Math.round(flying.centerX - startX));", + "harness.assert(", + " flying.centerX - startX > 30,", + " 'The bullets fly toward the right of the screen (moved ' +", + " Math.round(flying.centerX - startX) + 'px).'", + ");", + "harness.assert(", + " Math.abs(flying.centerY - bullet.centerY) < 5,", + " 'The bullets fly straight.'", + ");", + "", + "// The ship is moved with the arrow keys.", + "const beforeMove = getPlayer();", + "harness.setKeyPressed('Up', true);", + "await harness.stepFrames(20);", + "harness.setKeyPressed('Up', false);", + "await harness.stepFrames(5);", + "const afterMove = getPlayer();", + "console.log('movedUpBy=' + Math.round(beforeMove.centerY - afterMove.centerY));", + "harness.assert(", + " afterMove.centerY < beforeMove.centerY - 20,", + " 'Holding Up moves the ship up (it moved ' +", + " Math.round(beforeMove.centerY - afterMove.centerY) + 'px).'", + ");" + ] + }, + { + "name": "Enemies take several hits to be destroyed", + "type": "gameplay", + "description": "An enemy put in the line of fire loses health with each bullet and is destroyed once it runs out.", + "source": [ + "// Enemies take several hits before being destroyed: an enemy is put in the", + "// line of fire and the ship's own bullets have to bring it down.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "/** Read one of the object variables holding the state of an enemy. */", + "const getHealth = (enemy) => {", + " const variable = enemy.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "", + "// Arrange: put an enemy right in front of the ship. Destroying it is still", + "// up to the game.", + "const spawned = harness.spawn('Enemy', player.centerX + 500, player.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (player.centerX + 500 - spawned.centerX),", + " spawned.y + (player.centerY - spawned.centerY)", + ");", + "await harness.stepFrames(2);", + "", + "const enemy = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + "harness.assert(!!enemy, 'An enemy is in front of the ship.');", + "const startingHealth = getHealth(enemy);", + "console.log('startingHealth=' + startingHealth);", + "harness.assert(", + " startingHealth !== null && startingHealth > 1,", + " 'The enemy needs more than one hit to be destroyed (health ' + startingHealth + ').'", + ");", + "", + "// The ship fires on its own: watch the enemy lose health hit after hit.", + "let lowestHealth = startingHealth;", + "const destroyed = await harness.stepUntil(", + " () => {", + " const alive = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + " if (!alive) return true;", + " const health = getHealth(alive);", + " if (health !== null) lowestHealth = Math.min(lowestHealth, health);", + " return false;", + " },", + " { maxFrames: 400 }", + ");", + "console.log('lowestHealthSeen=' + lowestHealth);", + "", + "harness.assert(", + " lowestHealth < startingHealth,", + " 'The bullets hit the enemy and take its health down (it went down to ' +", + " lowestHealth + ' from ' + startingHealth + ').'", + ");", + "harness.assert(", + " destroyed,", + " 'The enemy is destroyed once it ran out of health.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-tile-placement/starting-tile-placement.json b/examples/starting-tile-placement/starting-tile-placement.json index d64c0cde7..e1cf231b4 100644 --- a/examples/starting-tile-placement/starting-tile-placement.json +++ b/examples/starting-tile-placement/starting-tile-placement.json @@ -1359,6 +1359,149 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Picking a tile type", + "type": "gameplay", + "description": "Picking a tile type in the toolbar shows what is about to be placed, and picking it again stops placing.", + "source": [ + "// Picking a tile type in the toolbar shows what is about to be placed.", + "await harness.goToScene('Game Scene');", + "harness.watch('TilePlacement_Indicator');", + "", + "const getIndicator = () => harness.getObjects('TilePlacement_Indicator')[0];", + "await harness.stepFrames(5);", + "", + "harness.assert(", + " getIndicator().hidden === true,", + " 'Nothing is about to be placed before a tile type is picked.'", + ");", + "", + "const buttons = harness.getObjects('TileType_Button');", + "harness.assert(buttons.length > 0, 'The toolbar has tile types to pick from.');", + "const button = buttons[0];", + "console.log(", + " 'buttons=' + JSON.stringify(buttons.map((one) => one.animation)) +", + " ' layer=' + JSON.stringify(button.layer)", + ");", + "", + "/** Click on a position of a layer. */", + "const clickAt = async (x, y, layerName) => {", + " harness.setMousePosition(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "await clickAt(button.centerX, button.centerY, button.layer);", + "const selected = getIndicator();", + "console.log(", + " 'afterPicking: hidden=' + selected.hidden + ' animation=' + JSON.stringify(selected.animation)", + ");", + "harness.assert(", + " selected.hidden === false,", + " 'Picking a tile type shows what is about to be placed.'", + ");", + "harness.assert(", + " selected.animation === button.animation,", + " 'What is about to be placed is the tile type that was picked (the indicator shows \"' +", + " selected.animation + '\", the button is \"' + button.animation + '\").'", + ");", + "", + "// Picking it again turns it off.", + "await clickAt(button.centerX, button.centerY, button.layer);", + "console.log('afterPickingAgain: hidden=' + getIndicator().hidden);", + "harness.assert(", + " getIndicator().hidden === true,", + " 'Picking the same tile type again stops placing.'", + ");" + ] + }, + { + "name": "Placing a tile on the board", + "type": "gameplay", + "description": "With a tile type picked, clicking a buildable cell places that tile on the grid, and clicking it again does not stack a second one.", + "source": [ + "// The core of the game: with a tile type picked, clicking a free cell of the", + "// board places that tile there — and only once per cell.", + "await harness.goToScene('Game Scene');", + "harness.watch('TilePlacement_Indicator');", + "", + "const getIndicator = () => harness.getObjects('TilePlacement_Indicator')[0];", + "const placedTiles = () =>", + " ['Unit', 'Tower', 'Tree'].reduce(", + " (total, name) => total + harness.getObjects(name).length,", + " 0", + " );", + "", + "await harness.stepFrames(5);", + "const button = harness.getObjects('TileType_Button')[0];", + "const tileType = button.animation;", + "harness.assert(placedTiles() === 0, 'The board starts empty.');", + "", + "const clickAt = async (x, y, layerName) => {", + " harness.setMousePosition(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "// Pick a tile type.", + "await clickAt(button.centerX, button.centerY, button.layer);", + "harness.assert(", + " getIndicator().hidden === false,", + " 'A tile type is picked (' + tileType + ').'", + ");", + "", + "// Find a cell of the board where the game accepts to place it. Only some", + "// cells are buildable, and which ones is decided by the tilemap: the test", + "// tries a few until one is accepted (this is setup, the checks come after).", + "let placedAtX = null;", + "let placedAtY = null;", + "for (let x = 320; x <= 960 && placedAtX === null; x += 64) {", + " for (let y = 192; y <= 448 && placedAtX === null; y += 64) {", + " await clickAt(x, y, '');", + " if (placedTiles() > 0) {", + " placedAtX = x;", + " placedAtY = y;", + " }", + " }", + "}", + "console.log(", + " 'placedAt=' + placedAtX + ',' + placedAtY + ' tiles=' + placedTiles()", + ");", + "harness.assert(", + " placedAtX !== null,", + " 'Clicking a buildable cell of the board places the picked tile there.'", + ");", + "harness.assert(", + " harness.getObjects(tileType).length === 1,", + " 'The tile that was placed is the type that was picked (there is ' +", + " harness.getObjects(tileType).length + ' \"' + tileType + '\" on the board).'", + ");", + "", + "const placed = harness.getObjects(tileType)[0];", + "harness.assert(", + " placed.x % 64 === 0 && placed.y % 64 === 0,", + " 'The tile is placed on the grid (it is at ' +", + " Math.round(placed.x) + ',' + Math.round(placed.y) + ').'", + ");", + "", + "// Clicking the same cell again must not stack a second tile on it.", + "await clickAt(placedAtX, placedAtY, '');", + "console.log('afterSecondClick: tiles=' + placedTiles());", + "harness.assert(", + " placedTiles() === 1,", + " 'Clicking a cell that already holds a tile does not place another one (' +", + " placedTiles() + ' tiles on the board).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-top-down-pixel/starting-top-down-pixel.json b/examples/starting-top-down-pixel/starting-top-down-pixel.json index e409ebd5d..5ce6f2f85 100644 --- a/examples/starting-top-down-pixel/starting-top-down-pixel.json +++ b/examples/starting-top-down-pixel/starting-top-down-pixel.json @@ -923,6 +923,177 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Moving in the four directions", + "type": "gameplay", + "description": "The arrow keys move the player up, down, left and right, and turn it toward the direction it is moving to.", + "source": [ + "// The core of the game: the Top-Down Movement behavior moves the player in", + "// the four directions with the arrow keys. How far it should travel is", + "// derived from the behavior's own configuration, so this stays true whatever", + "// the game tunes it to.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const movementState = () => getPlayer().behaviors.TopDownMovement.state;", + "", + "await harness.stepFrames(5);", + "console.log('movementState=' + JSON.stringify(movementState()));", + "const start = getPlayer();", + "", + "// Nothing pressed: the player stays where it is.", + "await harness.stepFrames(20);", + "const idle = getPlayer();", + "harness.assert(", + " Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY) < 1,", + " 'The player stands still while no key is pressed.'", + ");", + "", + "const HELD_FRAMES = 22;", + "/** How far the player should travel while a direction is held. */", + "const expectedDistance = () => {", + " const { Acceleration, MaxSpeed } = movementState();", + " const heldSeconds = HELD_FRAMES / 60;", + " const secondsToMaxSpeed = MaxSpeed / Acceleration;", + " return heldSeconds <= secondsToMaxSpeed", + " ? 0.5 * Acceleration * heldSeconds * heldSeconds", + " : 0.5 * MaxSpeed * secondsToMaxSpeed +", + " MaxSpeed * (heldSeconds - secondsToMaxSpeed);", + "};", + "", + "/**", + " * Hold one direction key and report how far the player moved. Each direction", + " * is measured from a fresh scene: the player always starts from the same", + " * clear spot, away from the obstacles it would otherwise be pushed against.", + " */", + "const moveWith = async (keyName) => {", + " await harness.goToScene('Game Scene');", + " await harness.stepFrames(3);", + " const before = getPlayer();", + " harness.setKeyPressed(keyName, true);", + " await harness.stepFrames(HELD_FRAMES);", + " const movementAngle = movementState().Angle;", + " harness.setKeyPressed(keyName, false);", + " await harness.stepFrames(5);", + " const after = getPlayer();", + " return {", + " dx: Math.round(after.centerX - before.centerX),", + " dy: Math.round(after.centerY - before.centerY),", + " movementAngle: Math.round(movementAngle),", + " };", + "};", + "", + "const normalizeAngle = (angle) => (((angle % 360) + 540) % 360) - 180;", + "", + "/**", + " * Check that a direction key moves the player along the expected axis, as", + " * far as the behavior is configured to, and in the expected direction.", + " */", + "const assertMoves = (keyName, moved, axis, sign, expectedAngle) => {", + " const along = axis === 'x' ? moved.dx : moved.dy;", + " const across = axis === 'x' ? moved.dy : moved.dx;", + " const expected = expectedDistance();", + " harness.assert(", + " sign * along > 0.6 * expected,", + " `Holding ${keyName} moves the player ${", + " axis === 'x' ? (sign > 0 ? 'right' : 'left') : sign > 0 ? 'down' : 'up'", + " } (moved ${along}px, expected around ${Math.round(expected)}px).`", + " );", + " harness.assert(", + " Math.abs(across) < 0.4 * Math.abs(along),", + " `Holding ${keyName} moves the player along one axis only (${across}px across, ${along}px along).`", + " );", + " harness.assert(", + " Math.abs(normalizeAngle(moved.movementAngle - expectedAngle)) < 5,", + " `Holding ${keyName} makes the player move toward ${expectedAngle} degrees (it moved toward ${moved.movementAngle}).`", + " );", + "};", + "", + "const right = await moveWith('Right');", + "console.log('right=' + JSON.stringify(right));", + "assertMoves('Right', right, 'x', 1, 0);", + "", + "const down = await moveWith('Down');", + "console.log('down=' + JSON.stringify(down));", + "assertMoves('Down', down, 'y', 1, 90);", + "", + "const up = await moveWith('Up');", + "console.log('up=' + JSON.stringify(up));", + "assertMoves('Up', up, 'y', -1, -90);", + "", + "const left = await moveWith('Left');", + "console.log('left=' + JSON.stringify(left));", + "assertMoves('Left', left, 'x', -1, 180);" + ] + }, + { + "name": "Walls block the player", + "type": "gameplay", + "description": "The player walks into a wall and is stopped by it instead of going through.", + "source": [ + "// The player must not go through the obstacles: the events separate it from", + "// the walls and the plants on every frame.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "", + "// Take the obstacle nearest to the player and line the player up with it,", + "// a bit to its left. Walking into it is still up to the game.", + "const obstacles = harness.getNearby('Wall_Obstacle', 'Player', 5000);", + "harness.assert(obstacles.length > 0, 'There is a wall in the level.');", + "const wall = obstacles[0];", + "const approachDistance = wall.width / 2 + player.width / 2 + 120;", + "harness.setObjectPosition(", + " player.id,", + " player.x + (wall.centerX - approachDistance - player.centerX),", + " player.y + (wall.centerY - player.centerY)", + ");", + "await harness.stepFrames(5);", + "const startX = getPlayer().centerX;", + "harness.assert(", + " startX < wall.centerX,", + " 'The player starts on the left of the wall.'", + ");", + "", + "// Walk right, into the wall, and keep pushing.", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(120);", + "harness.releaseAllInputs();", + "await harness.stepFrames(5);", + "", + "const blocked = getPlayer();", + "const wallLeftEdge = wall.centerX - wall.width / 2;", + "const playerRightEdge = blocked.centerX + blocked.width / 2;", + "console.log(", + " 'playerRightEdge=' + Math.round(playerRightEdge) +", + " ' wallLeftEdge=' + Math.round(wallLeftEdge) +", + " ' travelled=' + Math.round(blocked.centerX - startX)", + ");", + "", + "harness.assert(", + " blocked.centerX > startX + 30,", + " 'The player did walk toward the wall (moved ' +", + " Math.round(blocked.centerX - startX) + 'px).'", + ");", + "harness.assert(", + " blocked.centerX < wall.centerX,", + " 'The player did not go through the wall (it is at x=' +", + " Math.round(blocked.centerX) + ', the wall is at x=' +", + " Math.round(wall.centerX) + ').'", + ");", + "harness.assert(", + " playerRightEdge < wallLeftEdge + 8,", + " 'The player is stopped against the wall instead of overlapping it (its right edge is at ' +", + " Math.round(playerRightEdge) + ', the wall starts at ' + Math.round(wallLeftEdge) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-top-down-rpg/starting-top-down-rpg.json b/examples/starting-top-down-rpg/starting-top-down-rpg.json index ab6d5ea54..258a5863a 100644 --- a/examples/starting-top-down-rpg/starting-top-down-rpg.json +++ b/examples/starting-top-down-rpg/starting-top-down-rpg.json @@ -1520,6 +1520,169 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Talking to an NPC", + "type": "gameplay", + "description": "Pressing the action key next to an NPC opens the dialog and takes control away from the player, and does nothing away from an NPC.", + "source": [ + "// Talking to an NPC opens the dialog and takes control away from the player.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isDialogOpen = () => {", + " const layer = harness.getRuntimeLayer('Dialog Layer');", + " return !!layer && layer.isVisible();", + "};", + "", + "await harness.stepFrames(5);", + "harness.assert(!isDialogOpen(), 'No dialog is open when the game starts.');", + "", + "const npcs = harness.getNearby('NPC', 'Player', 5000);", + "harness.assert(npcs.length > 0, 'There is an NPC to talk to.');", + "const npc = npcs[0];", + "", + "// Pressing the action key away from any NPC must do nothing.", + "harness.setKeyPressed('x', true);", + "await harness.stepFrames(3);", + "harness.setKeyPressed('x', false);", + "await harness.stepFrames(3);", + "harness.assert(", + " !isDialogOpen(),", + " 'Pressing the action key away from an NPC opens nothing.'", + ");", + "", + "// Arrange: stand the player on the NPC. Talking to it is still up to the game.", + "const player = getPlayer();", + "harness.setObjectPosition(", + " player.id,", + " player.x + (npc.centerX - player.centerX),", + " player.y + (npc.centerY - player.centerY)", + ");", + "await harness.stepFrames(5);", + "harness.assert(", + " getPlayer().behaviors.TopDownMovement.act === true,", + " 'The player can still move before talking.'", + ");", + "", + "harness.setKeyPressed('x', true);", + "await harness.stepFrames(3);", + "harness.setKeyPressed('x', false);", + "await harness.stepFrames(5);", + "", + "console.log(", + " 'dialogOpen=' + isDialogOpen() +", + " ' movementActive=' + getPlayer().behaviors.TopDownMovement.act", + ");", + "harness.assert(", + " isDialogOpen(),", + " 'Pressing the action key next to an NPC opens the dialog.'", + ");", + "harness.assert(", + " getPlayer().behaviors.TopDownMovement.act === false,", + " 'The player cannot walk away while the dialog is open.'", + ");", + "", + "// ...and it really cannot move.", + "const beforeTrying = getPlayer();", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(25);", + "harness.releaseAllInputs();", + "const afterTrying = getPlayer();", + "console.log(", + " 'movedWhileTalking=' +", + " Math.round(", + " Math.hypot(", + " afterTrying.centerX - beforeTrying.centerX,", + " afterTrying.centerY - beforeTrying.centerY", + " )", + " )", + ");", + "harness.assert(", + " Math.hypot(", + " afterTrying.centerX - beforeTrying.centerX,", + " afterTrying.centerY - beforeTrying.centerY", + " ) < 5,", + " 'Holding a direction does not move the player while the dialog is open.'", + ");" + ] + }, + { + "name": "Saying yes in the dialog", + "type": "gameplay", + "description": "Clicking yes in the dialog makes the NPC that was talked to leave.", + "source": [ + "// Saying yes in the dialog makes the NPC leave.", + "await harness.goToScene('Game Scene');", + "harness.watch('NPC');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const isDialogOpen = () => {", + " const layer = harness.getRuntimeLayer('Dialog Layer');", + " return !!layer && layer.isVisible();", + "};", + "", + "await harness.stepFrames(5);", + "const npcsBefore = harness.getObjects('NPC').length;", + "harness.assert(npcsBefore > 0, 'There are NPCs in the level.');", + "", + "// Arrange: stand the player on an NPC and talk to it.", + "const npc = harness.getNearby('NPC', 'Player', 5000)[0];", + "const player = getPlayer();", + "harness.setObjectPosition(", + " player.id,", + " player.x + (npc.centerX - player.centerX),", + " player.y + (npc.centerY - player.centerY)", + ");", + "await harness.stepFrames(5);", + "harness.setKeyPressed('x', true);", + "await harness.stepFrames(3);", + "harness.setKeyPressed('x', false);", + "await harness.stepFrames(5);", + "harness.assert(isDialogOpen(), 'The dialog is open.');", + "", + "const dialog = harness.getObjects('TwoChoicesDialogBox')[0];", + "harness.assert(!!dialog, 'The dialog box is in the scene.');", + "console.log(", + " 'dialogChildren=' + JSON.stringify(Object.keys(dialog.children || {}))", + ");", + "", + "// Find the \"yes\" button among the parts of the dialog box.", + "const children = dialog.children || {};", + "const yesName = Object.keys(children).find((name) =>", + " name.toLowerCase().includes('yes')", + ");", + "harness.assert(!!yesName, 'The dialog has a \"yes\" button.');", + "const yesButton = children[yesName][0];", + "console.log(", + " 'yesButton=' + yesName +", + " ' at=' + Math.round(yesButton.centerX) + ',' + Math.round(yesButton.centerY) +", + " ' onLayer=' + JSON.stringify(yesButton.layer)", + ");", + "", + "harness.setMousePosition(yesButton.centerX, yesButton.centerY, yesButton.layer);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(10);", + "", + "console.log(", + " 'npcsBefore=' + npcsBefore + ' npcsAfter=' + harness.getObjects('NPC').length", + ");", + "harness.assert(", + " harness.getObjects('NPC').length === npcsBefore - 1,", + " 'Saying yes makes the NPC leave (' + harness.getObjects('NPC').length +", + " ' NPCs left of ' + npcsBefore + ').'", + ");", + "harness.assert(", + " !harness.getObjects('NPC').some((one) => one.id === npc.id),", + " 'The NPC that was talked to is the one that left.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-top-down/starting-top-down.json b/examples/starting-top-down/starting-top-down.json index 4dec93dd4..ca4687fe2 100644 --- a/examples/starting-top-down/starting-top-down.json +++ b/examples/starting-top-down/starting-top-down.json @@ -947,6 +947,177 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Moving in the four directions", + "type": "gameplay", + "description": "The arrow keys move the player up, down, left and right, and turn it toward the direction it is moving to.", + "source": [ + "// The core of the game: the Top-Down Movement behavior moves the player in", + "// the four directions with the arrow keys. How far it should travel is", + "// derived from the behavior's own configuration, so this stays true whatever", + "// the game tunes it to.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const movementState = () => getPlayer().behaviors.TopDownMovement.state;", + "", + "await harness.stepFrames(5);", + "console.log('movementState=' + JSON.stringify(movementState()));", + "const start = getPlayer();", + "", + "// Nothing pressed: the player stays where it is.", + "await harness.stepFrames(20);", + "const idle = getPlayer();", + "harness.assert(", + " Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY) < 1,", + " 'The player stands still while no key is pressed.'", + ");", + "", + "const HELD_FRAMES = 22;", + "/** How far the player should travel while a direction is held. */", + "const expectedDistance = () => {", + " const { Acceleration, MaxSpeed } = movementState();", + " const heldSeconds = HELD_FRAMES / 60;", + " const secondsToMaxSpeed = MaxSpeed / Acceleration;", + " return heldSeconds <= secondsToMaxSpeed", + " ? 0.5 * Acceleration * heldSeconds * heldSeconds", + " : 0.5 * MaxSpeed * secondsToMaxSpeed +", + " MaxSpeed * (heldSeconds - secondsToMaxSpeed);", + "};", + "", + "/**", + " * Hold one direction key and report how far the player moved. Each direction", + " * is measured from a fresh scene: the player always starts from the same", + " * clear spot, away from the obstacles it would otherwise be pushed against.", + " */", + "const moveWith = async (keyName) => {", + " await harness.goToScene('Game Scene');", + " await harness.stepFrames(3);", + " const before = getPlayer();", + " harness.setKeyPressed(keyName, true);", + " await harness.stepFrames(HELD_FRAMES);", + " const movementAngle = movementState().Angle;", + " harness.setKeyPressed(keyName, false);", + " await harness.stepFrames(5);", + " const after = getPlayer();", + " return {", + " dx: Math.round(after.centerX - before.centerX),", + " dy: Math.round(after.centerY - before.centerY),", + " movementAngle: Math.round(movementAngle),", + " };", + "};", + "", + "const normalizeAngle = (angle) => (((angle % 360) + 540) % 360) - 180;", + "", + "/**", + " * Check that a direction key moves the player along the expected axis, as", + " * far as the behavior is configured to, and in the expected direction.", + " */", + "const assertMoves = (keyName, moved, axis, sign, expectedAngle) => {", + " const along = axis === 'x' ? moved.dx : moved.dy;", + " const across = axis === 'x' ? moved.dy : moved.dx;", + " const expected = expectedDistance();", + " harness.assert(", + " sign * along > 0.6 * expected,", + " `Holding ${keyName} moves the player ${", + " axis === 'x' ? (sign > 0 ? 'right' : 'left') : sign > 0 ? 'down' : 'up'", + " } (moved ${along}px, expected around ${Math.round(expected)}px).`", + " );", + " harness.assert(", + " Math.abs(across) < 0.4 * Math.abs(along),", + " `Holding ${keyName} moves the player along one axis only (${across}px across, ${along}px along).`", + " );", + " harness.assert(", + " Math.abs(normalizeAngle(moved.movementAngle - expectedAngle)) < 5,", + " `Holding ${keyName} makes the player move toward ${expectedAngle} degrees (it moved toward ${moved.movementAngle}).`", + " );", + "};", + "", + "const right = await moveWith('Right');", + "console.log('right=' + JSON.stringify(right));", + "assertMoves('Right', right, 'x', 1, 0);", + "", + "const down = await moveWith('Down');", + "console.log('down=' + JSON.stringify(down));", + "assertMoves('Down', down, 'y', 1, 90);", + "", + "const up = await moveWith('Up');", + "console.log('up=' + JSON.stringify(up));", + "assertMoves('Up', up, 'y', -1, -90);", + "", + "const left = await moveWith('Left');", + "console.log('left=' + JSON.stringify(left));", + "assertMoves('Left', left, 'x', -1, 180);" + ] + }, + { + "name": "Walls block the player", + "type": "gameplay", + "description": "The player walks into a wall and is stopped by it instead of going through.", + "source": [ + "// The player must not go through the obstacles: the events separate it from", + "// the walls and the plants on every frame.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "", + "// Take the obstacle nearest to the player and line the player up with it,", + "// a bit to its left. Walking into it is still up to the game.", + "const obstacles = harness.getNearby('Wall_Obstacle', 'Player', 5000);", + "harness.assert(obstacles.length > 0, 'There is a wall in the level.');", + "const wall = obstacles[0];", + "const approachDistance = wall.width / 2 + player.width / 2 + 120;", + "harness.setObjectPosition(", + " player.id,", + " player.x + (wall.centerX - approachDistance - player.centerX),", + " player.y + (wall.centerY - player.centerY)", + ");", + "await harness.stepFrames(5);", + "const startX = getPlayer().centerX;", + "harness.assert(", + " startX < wall.centerX,", + " 'The player starts on the left of the wall.'", + ");", + "", + "// Walk right, into the wall, and keep pushing.", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(120);", + "harness.releaseAllInputs();", + "await harness.stepFrames(5);", + "", + "const blocked = getPlayer();", + "const wallLeftEdge = wall.centerX - wall.width / 2;", + "const playerRightEdge = blocked.centerX + blocked.width / 2;", + "console.log(", + " 'playerRightEdge=' + Math.round(playerRightEdge) +", + " ' wallLeftEdge=' + Math.round(wallLeftEdge) +", + " ' travelled=' + Math.round(blocked.centerX - startX)", + ");", + "", + "harness.assert(", + " blocked.centerX > startX + 30,", + " 'The player did walk toward the wall (moved ' +", + " Math.round(blocked.centerX - startX) + 'px).'", + ");", + "harness.assert(", + " blocked.centerX < wall.centerX,", + " 'The player did not go through the wall (it is at x=' +", + " Math.round(blocked.centerX) + ', the wall is at x=' +", + " Math.round(wall.centerX) + ').'", + ");", + "harness.assert(", + " playerRightEdge < wallLeftEdge + 8,", + " 'The player is stopped against the wall instead of overlapping it (its right edge is at ' +", + " Math.round(playerRightEdge) + ', the wall starts at ' + Math.round(wallLeftEdge) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-twin-stick-shooter/starting-twin-stick-shooter.json b/examples/starting-twin-stick-shooter/starting-twin-stick-shooter.json index 5c2edc031..62cb21284 100644 --- a/examples/starting-twin-stick-shooter/starting-twin-stick-shooter.json +++ b/examples/starting-twin-stick-shooter/starting-twin-stick-shooter.json @@ -1542,6 +1542,133 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Aiming and firing with the mouse", + "type": "gameplay", + "description": "The player turns toward the mouse and fires while the button is held, and the bullets fly where it aims.", + "source": [ + "// The twin stick part: the player aims where the mouse points and fires", + "// while the button is held.", + "await harness.goToScene('Game Scene');", + "harness.watch('PlayerBullet');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "const startAngle = player.angle;", + "", + "// Aiming without firing: nothing comes out.", + "harness.setMousePosition(player.centerX + 300, player.centerY, player.layer);", + "await harness.stepFrames(15);", + "harness.assert(", + " harness.getObjects('PlayerBullet').length === 0,", + " 'Nothing is fired while the mouse button is not pressed.'", + ");", + "", + "// Aim up and to the right, and hold the fire button.", + "const aimX = player.centerX + 300;", + "const aimY = player.centerY - 300;", + "const expectedAngle = -45;", + "harness.setMousePosition(aimX, aimY, player.layer);", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(20);", + "harness.setMouseButtonPressed(false);", + "", + "const aimed = getPlayer();", + "console.log('angle=' + Math.round(startAngle) + ' -> ' + Math.round(aimed.angle));", + "harness.assert(", + " Math.abs(aimed.angle - expectedAngle) < 10,", + " 'The player turns toward the mouse (it is at ' +", + " Math.round(aimed.angle) + ' degrees, the mouse is at ' + expectedAngle + ').'", + ");", + "", + "const bullets = harness.getObjects('PlayerBullet');", + "console.log('bullets=' + bullets.length);", + "harness.assert(", + " bullets.length > 0,", + " 'Holding the fire button shoots (' + bullets.length + ' bullet(s) in the air).'", + ");", + "", + "// The bullets fly toward where the player is aiming.", + "const bullet = bullets[0];", + "const before = { x: bullet.centerX, y: bullet.centerY };", + "await harness.stepFrames(8);", + "const flying = harness.getObjects('PlayerBullet').find((one) => one.id === bullet.id);", + "harness.assert(!!flying, 'A bullet is still flying.');", + "const travelAngle =", + " (Math.atan2(flying.centerY - before.y, flying.centerX - before.x) * 180) / Math.PI;", + "console.log('travelAngle=' + Math.round(travelAngle));", + "harness.assert(", + " Math.abs(travelAngle - expectedAngle) < 15,", + " 'The bullets fly where the player aims (they travel toward ' +", + " Math.round(travelAngle) + ' degrees, the aim is ' + expectedAngle + ').'", + ");" + ] + }, + { + "name": "Enemies take several hits to be destroyed", + "type": "gameplay", + "description": "An enemy in front of the player loses health with each bullet and is destroyed once it runs out.", + "source": [ + "// Enemies take several hits before being destroyed.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const getHealth = (enemy) => {", + " const variable = enemy.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "", + "// Arrange: put an enemy in front of the player. Shooting it down is still", + "// up to the game.", + "const spawned = harness.spawn('Enemy', player.centerX + 350, player.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (player.centerX + 350 - spawned.centerX),", + " spawned.y + (player.centerY - spawned.centerY)", + ");", + "await harness.stepFrames(2);", + "const enemy = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + "harness.assert(!!enemy, 'An enemy is in front of the player.');", + "const startingHealth = getHealth(enemy);", + "console.log('startingHealth=' + startingHealth);", + "harness.assert(", + " startingHealth > 1,", + " 'The enemy needs more than one hit to be destroyed (health ' + startingHealth + ').'", + ");", + "", + "// Aim at it and keep firing.", + "let lowestHealth = startingHealth;", + "harness.setMouseButtonPressed(true);", + "const destroyed = await harness.stepUntil(", + " () => {", + " const alive = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + " if (!alive) return true;", + " const health = getHealth(alive);", + " if (health !== null) lowestHealth = Math.min(lowestHealth, health);", + " // Keep the crosshair on the enemy: it walks toward the player.", + " harness.setMousePosition(alive.centerX, alive.centerY, player.layer);", + " return false;", + " },", + " { maxFrames: 300 }", + ");", + "harness.releaseAllInputs();", + "console.log('lowestHealthSeen=' + lowestHealth);", + "", + "harness.assert(", + " lowestHealth < startingHealth,", + " 'The bullets hit the enemy and take its health down (it went down to ' +", + " lowestHealth + ' from ' + startingHealth + ').'", + ");", + "harness.assert(destroyed, 'The enemy is destroyed once it ran out of health.');" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/examples/starting-vampire-survivor/starting-vampire-survivor.json b/examples/starting-vampire-survivor/starting-vampire-survivor.json index 50ef860f1..f99263ee9 100644 --- a/examples/starting-vampire-survivor/starting-vampire-survivor.json +++ b/examples/starting-vampire-survivor/starting-vampire-survivor.json @@ -1056,6 +1056,128 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "The player shoots the nearest enemy on its own", + "type": "gameplay", + "description": "With an enemy in range, the player fires at it without any input, and the enemy is destroyed after several hits.", + "source": [ + "// The core of the game: the player shoots the nearest enemy on its own, the", + "// player only has to move.", + "await harness.goToScene('Game Scene');", + "harness.watch('Enemy');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const getHealth = (enemy) => {", + " const variable = enemy.variables.find((one) => one.name === 'Health');", + " return variable ? Number(variable.value) : null;", + "};", + "", + "await harness.stepFrames(5);", + "const player = getPlayer();", + "harness.assert(", + " harness.getObjects('PlayerBullet').length === 0,", + " 'Nothing is fired while there is no enemy around.'", + ");", + "", + "// Arrange: put an enemy within reach. Shooting it is still up to the game.", + "const spawned = harness.spawn('Enemy', player.centerX + 300, player.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (player.centerX + 300 - spawned.centerX),", + " spawned.y + (player.centerY - spawned.centerY)", + ");", + "await harness.stepFrames(2);", + "const enemy = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + "harness.assert(!!enemy, 'An enemy is near the player.');", + "const startingHealth = getHealth(enemy);", + "harness.assert(", + " startingHealth > 1,", + " 'The enemy needs more than one hit to be destroyed (health ' + startingHealth + ').'", + ");", + "", + "// No input at all: the player must shoot at it by itself.", + "await harness.stepFrames(20);", + "const bullets = harness.getObjects('PlayerBullet');", + "console.log('bulletsWithoutAnyInput=' + bullets.length);", + "harness.assert(", + " bullets.length > 0,", + " 'The player fires at the enemy without any input (' + bullets.length + ' bullet(s)).'", + ");", + "", + "let lowestHealth = startingHealth;", + "const destroyed = await harness.stepUntil(", + " () => {", + " const alive = harness.getObjects('Enemy').find((one) => one.id === spawned.id);", + " if (!alive) return true;", + " const health = getHealth(alive);", + " if (health !== null) lowestHealth = Math.min(lowestHealth, health);", + " return false;", + " },", + " { maxFrames: 300 }", + ");", + "console.log('lowestHealthSeen=' + lowestHealth);", + "harness.assert(", + " lowestHealth < startingHealth,", + " 'The bullets hit the enemy and take its health down (down to ' +", + " lowestHealth + ' from ' + startingHealth + ').'", + ");", + "harness.assert(destroyed, 'The enemy is destroyed once it ran out of health.');" + ] + }, + { + "name": "Being touched by an enemy ends the run", + "type": "gameplay", + "description": "An enemy reaching the player restarts the scene.", + "source": [ + "// Being touched by an enemy ends the run: the scene restarts, which puts the", + "// player back at its starting point.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "await harness.stepFrames(5);", + "const spawnX = getPlayer().centerX;", + "const spawnY = getPlayer().centerY;", + "", + "// Walk away from the starting point, so that coming back to it can only be", + "// the scene restarting.", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(30);", + "harness.releaseAllInputs();", + "await harness.stepFrames(20);", + "const walked = getPlayer();", + "console.log('walkedTo=' + Math.round(walked.centerX) + ' from ' + Math.round(spawnX));", + "harness.assert(", + " walked.centerX > spawnX + 50,", + " 'The player walked away from its starting point (it is at x=' +", + " Math.round(walked.centerX) + ', it started at x=' + Math.round(spawnX) + ').'", + ");", + "", + "// Arrange: put an enemy right on the player.", + "const spawned = harness.spawn('Enemy', walked.centerX, walked.centerY);", + "harness.setObjectPosition(", + " spawned.id,", + " spawned.x + (walked.centerX - spawned.centerX),", + " spawned.y + (walked.centerY - spawned.centerY)", + ");", + "", + "// The run must end: the player is back at its starting point.", + "const restarted = await harness.stepUntil(", + " () =>", + " Math.abs(getPlayer().centerX - spawnX) < 5 &&", + " Math.abs(getPlayer().centerY - spawnY) < 5,", + " { maxFrames: 120 }", + ");", + "console.log('finalX=' + Math.round(getPlayer().centerX));", + "harness.assert(", + " restarted,", + " 'Being touched by an enemy restarts the run: the player is back at its starting point (it is at x=' +", + " Math.round(getPlayer().centerX) + ', it started at x=' + Math.round(spawnX) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", diff --git a/scripts/lib/ChangedProjectFiles.js b/scripts/lib/ChangedProjectFiles.js new file mode 100644 index 000000000..55d4d9ea1 --- /dev/null +++ b/scripts/lib/ChangedProjectFiles.js @@ -0,0 +1,93 @@ +// @ts-check +/** + * Find the game project files changed by a branch, so that a Pull Request + * only runs the gameplay tests of the games it touches. + */ +const shell = require('shelljs'); + +/** + * Files whose change makes "only test the changed games" meaningless: if + * the runner or the CI configuration itself changed, every game is tested. + */ +const FILES_REQUIRING_A_FULL_RUN = [ + '.circleci/config.yml', + 'scripts/run-gameplay-tests.js', + 'scripts/lib/ChangedProjectFiles.js', + 'scripts/lib/GDevelopPortableBuild.js', + 'scripts/lib/GameplayTestsJUnitReport.js', + 'scripts/lib/GameplayTestsProjectFinder.js', +]; + +/** + * @param {string} command + * @returns {string | null} The trimmed output, or null if the command failed. + */ +const runGitCommand = (command) => { + const output = shell.exec(command, { silent: true }); + if (output.code !== 0) return null; + return output.stdout.trim(); +}; + +/** + * List the files changed by the current checkout compared to a base branch. + * + * The comparison is made against the merge base, so that commits landed on + * the base branch in the meantime are not reported as changes. + * @param {Object} options + * @param {string} options.baseRef For example `origin/main`. + * @returns {{ changedFiles: string[], requiresFullRun: boolean } | null} Null + * when the changed files could not be determined (the caller should then + * test everything rather than nothing). + */ +const findChangedFiles = ({ baseRef }) => { + // The base branch is not always fetched on CI checkouts. + if (runGitCommand(`git rev-parse --verify --quiet ${baseRef}`) === null) { + const remoteBranch = baseRef.replace(/^origin\//, ''); + runGitCommand(`git fetch --no-tags origin ${remoteBranch}`); + } + if (runGitCommand(`git rev-parse --verify --quiet ${baseRef}`) === null) { + shell.echo(`⚠️ Could not resolve the base ref "${baseRef}".`); + return null; + } + + const mergeBase = runGitCommand(`git merge-base ${baseRef} HEAD`); + if (!mergeBase) { + shell.echo(`⚠️ Could not find a merge base with "${baseRef}".`); + return null; + } + + const diffOutput = runGitCommand( + `git diff --name-only --diff-filter=d ${mergeBase} HEAD` + ); + if (diffOutput === null) { + shell.echo(`⚠️ Could not diff against "${mergeBase}".`); + return null; + } + + const changedFiles = diffOutput.split('\n').filter(Boolean); + const requiresFullRun = changedFiles.some((changedFile) => + FILES_REQUIRING_A_FULL_RUN.includes(changedFile) + ); + return { changedFiles, requiresFullRun }; +}; + +/** + * Keep only the example game project files of a list of changed files. + * @param {string[]} changedFiles + * @returns {string[]} + */ +const keepExampleProjectFiles = (changedFiles) => + changedFiles.filter( + (changedFile) => + changedFile.startsWith('examples/') && + changedFile.endsWith('.json') && + // Only the project files at the root of an example folder: + // `examples//.json`. + changedFile.split('/').length === 3 + ); + +module.exports = { + findChangedFiles, + keepExampleProjectFiles, + FILES_REQUIRING_A_FULL_RUN, +}; diff --git a/scripts/lib/GDevelopPortableBuild.js b/scripts/lib/GDevelopPortableBuild.js new file mode 100644 index 000000000..5d5e07e56 --- /dev/null +++ b/scripts/lib/GDevelopPortableBuild.js @@ -0,0 +1,183 @@ +// @ts-check +/** + * Download and extract the Linux "portable" build of GDevelop that its own + * CI publishes on S3, so that the examples can be opened and tested with a + * real GDevelop, without building it. + * + * The layout of the bucket is set by GDevelop's `.circleci/config.yml`: + * s3://gdevelop-releases//latest/gdevelop-.zip + * The bucket does not allow listing, so the version is read from the + * `newIDE/electron-app/app/package.json` of the same branch (the same + * source of truth GDevelop's own smoke test uses). + */ +const fs = require('fs'); +const path = require('path'); +const shell = require('shelljs'); + +const RELEASES_BASE_URL = 'https://gdevelop-releases.s3.amazonaws.com'; +const GDEVELOP_RAW_BASE_URL = 'https://raw.githubusercontent.com/4ian/GDevelop'; + +/** + * Download a URL with curl, which streams straight to disk (the build is a + * few hundred megabytes) and follows redirects. + * @param {string} url + * @param {string} destinationPath + */ +const downloadFile = (url, destinationPath) => { + const output = shell.exec( + `curl --silent --show-error --fail --location --retry 3 --retry-delay 5 ` + + `--output "${destinationPath}" "${url}"`, + { silent: true } + ); + if (output.code !== 0) { + shell.rm('-f', destinationPath); + throw new Error( + `Could not download ${url}: ${ + output.stderr.trim() || `curl exited with code ${output.code}` + }` + ); + } +}; + +/** + * Read the GDevelop version published on a branch: the S3 bucket cannot be + * listed, so the name of the zip is rebuilt from the version of the branch. + * @param {string} branch + * @returns {string} + */ +const fetchGDevelopVersion = (branch) => { + const url = `${GDEVELOP_RAW_BASE_URL}/${branch}/newIDE/electron-app/app/package.json`; + const output = shell.exec( + `curl --silent --show-error --fail --location --retry 3 --retry-delay 5 "${url}"`, + { silent: true } + ); + if (output.code !== 0) { + throw new Error( + `Could not read ${url}: ${ + output.stderr.trim() || `curl exited with code ${output.code}` + }` + ); + } + let version; + try { + version = JSON.parse(output.stdout).version; + } catch (error) { + throw new Error(`Could not parse the package.json read from ${url}.`); + } + if (typeof version !== 'string' || !version) { + throw new Error(`Could not read the GDevelop version from ${url}.`); + } + return version; +}; + +/** + * Find the GDevelop executable in an extracted portable build. On Linux, + * electron-builder names it after the `name` field of the package.json. + * @param {string} rootPath + * @returns {string | null} + */ +const findExecutable = (rootPath) => { + const executableNames = ['gdevelop', 'GDevelop']; + /** @type {string[]} */ + const directoriesToVisit = [rootPath]; + while (directoriesToVisit.length > 0) { + const directoryPath = directoriesToVisit.pop(); + if (!directoryPath) continue; + /** @type {fs.Dirent[]} */ + let entries; + try { + entries = fs.readdirSync(directoryPath, { withFileTypes: true }); + } catch (error) { + continue; + } + for (const entry of entries) { + const entryPath = path.join(directoryPath, entry.name); + if (entry.isDirectory()) directoriesToVisit.push(entryPath); + else if (entry.isFile() && executableNames.includes(entry.name)) + return entryPath; + } + } + return null; +}; + +/** + * Get a ready to run GDevelop executable: the portable build is downloaded + * and extracted once, then reused (which is what makes it cheap to cache + * the work folder between CI jobs). + * @param {Object} options + * @param {string} options.branch Branch of the GDevelop repository to take + * the build from. + * @param {string} [options.version] Version to download, when it should not + * be read from the branch. + * @param {string} options.workPath Folder where the build is downloaded and + * extracted. + * @returns {Promise<{ executablePath: string, version: string }>} + */ +const getGDevelopExecutable = async ({ branch, version, workPath }) => { + const gdevelopVersion = version || fetchGDevelopVersion(branch); + const zipName = `gdevelop-${gdevelopVersion}.zip`; + const zipPath = path.join(workPath, zipName); + const extractedPath = path.join(workPath, `gdevelop-${gdevelopVersion}`); + + shell.mkdir('-p', workPath); + + const alreadyExtractedExecutablePath = fs.existsSync(extractedPath) + ? findExecutable(extractedPath) + : null; + if (alreadyExtractedExecutablePath) { + shell.echo( + `ℹ️ Reusing the GDevelop ${gdevelopVersion} build already extracted in ${extractedPath}.` + ); + shell.chmod('+x', alreadyExtractedExecutablePath); + return { + executablePath: alreadyExtractedExecutablePath, + version: gdevelopVersion, + }; + } + + if (!fs.existsSync(zipPath)) { + const zipUrl = `${RELEASES_BASE_URL}/${branch}/latest/${zipName}`; + shell.echo(`🌐 Downloading ${zipUrl}...`); + downloadFile(zipUrl, zipPath); + shell.echo( + `✅ Downloaded ${zipName} (${Math.round( + fs.statSync(zipPath).size / 1024 / 1024 + )} MiB).` + ); + } + + shell.echo(`📂 Extracting ${zipName} to ${extractedPath}...`); + shell.rm('-rf', extractedPath); + shell.mkdir('-p', extractedPath); + const unzipOutput = shell.exec( + `unzip -q "${zipPath}" -d "${extractedPath}"`, + { + silent: true, + } + ); + if (unzipOutput.code !== 0) { + throw new Error( + `Could not extract ${zipPath}: ${ + unzipOutput.stderr || unzipOutput.stdout + }` + ); + } + + const executablePath = findExecutable(extractedPath); + if (!executablePath) { + throw new Error( + `Could not find a GDevelop executable in ${extractedPath} (contents: ${shell + .ls(extractedPath) + .join(', ')}).` + ); + } + shell.chmod('+x', executablePath); + shell.echo(`✅ GDevelop ${gdevelopVersion} ready: ${executablePath}`); + return { executablePath, version: gdevelopVersion }; +}; + +module.exports = { + fetchGDevelopVersion, + findExecutable, + getGDevelopExecutable, +}; diff --git a/scripts/lib/GameplayTestsJUnitReport.js b/scripts/lib/GameplayTestsJUnitReport.js new file mode 100644 index 000000000..45354766c --- /dev/null +++ b/scripts/lib/GameplayTestsJUnitReport.js @@ -0,0 +1,157 @@ +// @ts-check +/** + * Turn the results written by the GDevelop CLI into a JUnit XML report, so + * that CircleCI shows each gameplay test individually (with the reason it + * failed) and can split the next runs by recorded timings. + */ +const fs = require('fs'); +const path = require('path'); + +/** + * @typedef {Object} GameplayTestResult + * @property {string} testName + * @property {string} status 'passed' | 'failed' | 'error' | 'timeout' | 'stopped' + * @property {number} [durationMs] + * @property {number} [framesExecuted] + * @property {string[]} [errors] + * @property {{ message: string, passed: boolean }[]} [assertions] + * @property {string[]} [consoleLogs] + * @property {{ file?: string, label?: string, frame?: number }[]} [screenshots] + */ + +/** + * @typedef {Object} ProjectRunResult + * @property {string} relativePath Project file, relative to the repository root. + * @property {string} exampleSlug + * @property {number} durationMs Wall clock time of the whole GDevelop run. + * @property {GameplayTestResult[]} results + * @property {string} [runError] Set when GDevelop itself could not be run + * (crash, timeout, no results file...). + */ + +/** + * @param {string} value + * @returns {string} + */ +const escapeXml = (value) => + value + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') + // Control characters are not valid in XML and would break the report. + .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, ' '); + +/** + * Describe why a gameplay test did not pass, using everything the result + * carries: the failed assertions, the errors and the last console logs. + * @param {GameplayTestResult} result + * @returns {string} + */ +const makeFailureDescription = (result) => { + const lines = [`Status: ${result.status}.`]; + const failedAssertions = (result.assertions || []).filter( + (assertion) => !assertion.passed + ); + if (failedAssertions.length > 0) { + lines.push('Failed assertions:'); + for (const assertion of failedAssertions) + lines.push(` - ${assertion.message}`); + } + if (result.errors && result.errors.length > 0) { + lines.push('Errors:'); + for (const error of result.errors) lines.push(` - ${error}`); + } + /** @type {string[]} */ + const screenshotFiles = []; + for (const screenshot of result.screenshots || []) { + if (screenshot.file) screenshotFiles.push(screenshot.file); + } + if (screenshotFiles.length > 0) { + lines.push('Screenshots (see the artifacts of this job):'); + for (const file of screenshotFiles) + lines.push(` - ${path.basename(file)}`); + } + const consoleLogs = result.consoleLogs || []; + if (consoleLogs.length > 0) { + lines.push('Last console logs:'); + for (const log of consoleLogs.slice(-30)) lines.push(` ${log}`); + } + return lines.join('\n'); +}; + +/** + * Write a JUnit report for the given project runs. + * + * The `file` attribute of each test case is the project file path: this is + * what `circleci tests split --split-by=timings --timings-type=filename` + * uses to balance the next runs across the parallel containers. + * @param {Object} options + * @param {ProjectRunResult[]} options.projectRunResults + * @param {string} options.junitPath + */ +const writeJUnitReport = ({ projectRunResults, junitPath }) => { + const testSuites = projectRunResults.map((projectRunResult) => { + const { relativePath, exampleSlug, results, runError } = projectRunResult; + + /** @type {string[]} */ + const testCases = []; + for (const result of results) { + const durationSeconds = ((result.durationMs || 0) / 1000).toFixed(3); + const testCaseAttributes = + `classname="${escapeXml(exampleSlug)}" ` + + `name="${escapeXml(result.testName)}" ` + + `file="${escapeXml(relativePath)}" ` + + `time="${durationSeconds}"`; + if (result.status === 'passed') { + testCases.push(` `); + } else { + testCases.push( + ` \n` + + ` ${escapeXml(makeFailureDescription(result))}\n` + + ` ` + ); + } + } + + // A run that could not even produce results is reported as a failing + // test case of its own, so it is never silently green. + if (runError) { + testCases.push( + ` \n` + + ` ${escapeXml( + runError + )}\n` + + ` ` + ); + } + + const failureCount = + results.filter((result) => result.status !== 'passed').length + + (runError ? 1 : 0); + return ( + ` \n` + + `${testCases.join('\n')}\n` + + ` ` + ); + }); + + const xml = + '\n' + + '\n' + + `${testSuites.join('\n')}\n` + + '\n'; + + fs.mkdirSync(path.dirname(junitPath), { recursive: true }); + fs.writeFileSync(junitPath, xml); +}; + +module.exports = { writeJUnitReport, makeFailureDescription, escapeXml }; diff --git a/scripts/lib/GameplayTestsProjectFinder.js b/scripts/lib/GameplayTestsProjectFinder.js new file mode 100644 index 000000000..8accd094a --- /dev/null +++ b/scripts/lib/GameplayTestsProjectFinder.js @@ -0,0 +1,116 @@ +// @ts-check +/** + * Find the example game projects that contain gameplay tests. + * + * Gameplay tests are stored in a game project file as a top level `tests` + * array (a sibling of `layouts`), so a project "has gameplay tests" when + * that array exists and is not empty. + */ +const fs = require('fs'); +const path = require('path'); + +/** + * Serialized game projects are written with a two space indentation, so a + * top level key always appears at exactly one indentation level. Looking + * for this before parsing avoids parsing the ~350 MB of project files just + * to find the few that have tests. + */ +const TOP_LEVEL_TESTS_KEY = '\n "tests": ['; + +/** + * @typedef {Object} ProjectWithGameplayTests + * @property {string} relativePath Path of the project file, relative to the + * repository root (this is the identity used everywhere: CLI arguments, + * JUnit report, test splitting). + * @property {string} exampleSlug Name of the example folder. + * @property {string[]} testNames Names of the gameplay tests it contains. + */ + +/** + * List the example game projects of the repository, in a stable order. + * @param {string} examplesPath + * @returns {string[]} The project file paths, relative to the repository root. + */ +const listExampleProjectFiles = (examplesPath) => { + /** @type {string[]} */ + const projectFiles = []; + const exampleFolderNames = fs.readdirSync(examplesPath).sort(); + for (const exampleFolderName of exampleFolderNames) { + const exampleFolderPath = path.join(examplesPath, exampleFolderName); + if (!fs.statSync(exampleFolderPath).isDirectory()) continue; + + // Most examples are named `/.json`, but not all of them: + // list every JSON file at the root of the example folder. + const fileNames = fs.readdirSync(exampleFolderPath).sort(); + for (const fileName of fileNames) { + if (!fileName.endsWith('.json')) continue; + projectFiles.push(`examples/${exampleFolderName}/${fileName}`); + } + } + return projectFiles; +}; + +/** + * Read a project file and return its gameplay tests, or null if it has none + * (or is not a game project at all). + * @param {string} repositoryPath + * @param {string} relativePath + * @returns {ProjectWithGameplayTests | null} + */ +const readProjectGameplayTests = (repositoryPath, relativePath) => { + const absolutePath = path.join(repositoryPath, relativePath); + const content = fs.readFileSync(absolutePath, 'utf8'); + if (content.indexOf(TOP_LEVEL_TESTS_KEY) === -1) return null; + + let project; + try { + project = JSON.parse(content); + } catch (error) { + // Not a valid JSON file: other checks of the repository report this. + return null; + } + if (!project || !Array.isArray(project.tests) || project.tests.length === 0) { + return null; + } + + return { + relativePath, + exampleSlug: path.basename(path.dirname(relativePath)), + testNames: project.tests.map( + (/** @type {{ name?: string }} */ test) => test.name || '(unnamed test)' + ), + }; +}; + +/** + * Find every example game project containing at least one gameplay test. + * @param {Object} options + * @param {string} options.repositoryPath Root of the repository. + * @param {string[]} [options.onlyRelativePaths] When given, only these + * project files are considered (used to test the games changed in a branch). + * @returns {ProjectWithGameplayTests[]} + */ +const findProjectsWithGameplayTests = ({ + repositoryPath, + onlyRelativePaths, +}) => { + const candidateRelativePaths = onlyRelativePaths + ? onlyRelativePaths.filter((relativePath) => + fs.existsSync(path.join(repositoryPath, relativePath)) + ) + : listExampleProjectFiles(path.join(repositoryPath, 'examples')); + + /** @type {ProjectWithGameplayTests[]} */ + const projects = []; + for (const relativePath of candidateRelativePaths) { + const project = readProjectGameplayTests(repositoryPath, relativePath); + if (project) projects.push(project); + } + return projects; +}; + +module.exports = { + findProjectsWithGameplayTests, + listExampleProjectFiles, + readProjectGameplayTests, +}; diff --git a/scripts/run-gameplay-tests.js b/scripts/run-gameplay-tests.js new file mode 100644 index 000000000..e36c8cae5 --- /dev/null +++ b/scripts/run-gameplay-tests.js @@ -0,0 +1,438 @@ +// @ts-check +/** + * Run the gameplay tests of the example games, with a real GDevelop. + * + * The Linux "portable" build published on S3 by GDevelop's own CI is + * downloaded, and every example game project that contains gameplay tests + * is opened with it in CLI mode (`--run-command RUN_ALL_TESTS`). + * + * Usage: + * node scripts/run-gameplay-tests.js [options] + * + * --only-changed Only test the games changed compared to + * --base-ref (used on branches / Pull Requests). + * --base-ref=origin/main Base to compare against for --only-changed. + * --projects=a.json,b.json Test exactly these project files (takes + * precedence over --only-changed). + * --projects-file=file.txt Same, with one project file per line (this is + * how the CI feeds the output of + * `circleci tests split`). + * --list Print the project files that would be tested, + * one per line, and exit. Nothing is run. + * --shard-index=0 Test only a slice of the projects. Defaults to + * --shard-total=1 CIRCLE_NODE_INDEX / CIRCLE_NODE_TOTAL. + * --gdevelop-branch=master Branch of GDevelop to take the build from. + * --gdevelop-version=5.6.277 Skip reading the version from the branch. + * --work-dir=... Where GDevelop is downloaded and extracted + * (cached between CI jobs). + * --artifacts-dir=... Where the per game results, logs and failure + * screenshots are written. + * --junit-path=... Where the JUnit report is written. + * --timeout-ms=900000 Time budget for a single game. + */ +const fs = require('fs'); +const path = require('path'); +const { spawn } = require('child_process'); +const shell = require('shelljs'); +const args = require('minimist')(process.argv.slice(2)); + +const { + findProjectsWithGameplayTests, +} = require('./lib/GameplayTestsProjectFinder'); +const { getGDevelopExecutable } = require('./lib/GDevelopPortableBuild'); +const { writeJUnitReport } = require('./lib/GameplayTestsJUnitReport'); +const { + findChangedFiles, + keepExampleProjectFiles, +} = require('./lib/ChangedProjectFiles'); + +/** @typedef {import('./lib/GameplayTestsJUnitReport').ProjectRunResult} ProjectRunResult */ +/** @typedef {import('./lib/GameplayTestsJUnitReport').GameplayTestResult} GameplayTestResult */ + +const repositoryPath = path.resolve(__dirname, '..'); + +const gdevelopBranch = args['gdevelop-branch'] || 'master'; +const gdevelopVersion = args['gdevelop-version'] || undefined; +const baseRef = args['base-ref'] || 'origin/main'; +const onlyChanged = !!args['only-changed']; +const listOnly = !!args['list']; +const workPath = path.resolve( + args['work-dir'] || path.join(repositoryPath, '.gameplay-tests-work') +); +const artifactsPath = path.resolve( + args['artifacts-dir'] || path.join(repositoryPath, 'gameplay-tests-artifacts') +); +const junitPath = path.resolve( + args['junit-path'] || + path.join(repositoryPath, 'gameplay-tests-results/results.xml') +); +const timeoutMs = Number(args['timeout-ms']) || 15 * 60 * 1000; +const shardTotal = + Number(args['shard-total'] || process.env.CIRCLE_NODE_TOTAL) || 1; +const shardIndex = + Number(args['shard-index'] || process.env.CIRCLE_NODE_INDEX) || 0; + +/** + * Print an informational message. With `--list`, stdout is reserved for the + * list of project files (it is piped into `circleci tests split`), so the + * messages go to stderr instead. + * @param {string} message + */ +const log = (message) => { + if (listOnly) console.error(message); + else shell.echo(message); +}; + +/** + * @param {unknown} value + * @returns {string[]} + */ +const parseCommaSeparatedList = (value) => + typeof value === 'string' + ? value + .split(',') + .map((item) => item.trim()) + .filter(Boolean) + : []; + +/** + * Decide which project files should be considered for this run. + * @returns {string[] | null} Null means "every example game" (no restriction). + */ +const getRestrictedProjectFiles = () => { + const explicitProjects = parseCommaSeparatedList(args['projects']); + if (explicitProjects.length > 0) return explicitProjects; + + if (typeof args['projects-file'] === 'string') { + return fs + .readFileSync(args['projects-file'], 'utf8') + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + } + + if (!onlyChanged) return null; + + const changes = findChangedFiles({ baseRef }); + if (!changes) { + log( + '⚠️ Falling back to testing every game, as the changed files could not be determined.' + ); + return null; + } + if (changes.requiresFullRun) { + log( + 'ℹ️ The gameplay tests runner or the CI configuration changed: testing every game.' + ); + return null; + } + const changedProjectFiles = keepExampleProjectFiles(changes.changedFiles); + log( + `ℹ️ ${changes.changedFiles.length} file(s) changed compared to ${baseRef}, ` + + `including ${changedProjectFiles.length} game project(s).` + ); + return changedProjectFiles; +}; + +/** + * Whether anything went wrong in a game's run. + * @param {ProjectRunResult} projectRunResult + */ +const hasFailure = (projectRunResult) => + !!projectRunResult.runError || + projectRunResult.results.some((result) => result.status !== 'passed'); + +/** + * The tests of a game that ran out of the wall-clock budget GDevelop gives + * a single test. + * @param {ProjectRunResult} projectRunResult + */ +const timedOutTestNames = (projectRunResult) => + projectRunResult.results + .filter((result) => result.status === 'timeout') + .map((result) => result.testName); + +/** + * Whether a game's run failed, and failed *only* because tests ran out of + * wall clock — nothing was actually asserted wrong. + * @param {ProjectRunResult} projectRunResult + */ +const onlyFailedWithTimeouts = (projectRunResult) => + hasFailure(projectRunResult) && + !projectRunResult.runError && + timedOutTestNames(projectRunResult).length > 0 && + projectRunResult.results.every( + (result) => result.status === 'passed' || result.status === 'timeout' + ); + +/** + * Run the gameplay tests of a single game project. + * @param {Object} options + * @param {string} options.executablePath + * @param {string} options.relativePath + * @param {string} options.exampleSlug + * @returns {Promise} + */ +const runProjectGameplayTests = async ({ + executablePath, + relativePath, + exampleSlug, +}) => { + const projectPath = path.join(repositoryPath, relativePath); + const projectArtifactsPath = path.join( + artifactsPath, + relativePath.replace(/\//g, '__').replace(/\.json$/, '') + ); + shell.mkdir('-p', projectArtifactsPath); + + // GDevelop writes its results next to the project file. Its `--results-path` + // option cannot be used: the main process only forwards the CLI flags it + // knows about, and drops that one (its value would even end up parsed as a + // second project to open). So the files are moved to the artifacts folder + // after the run instead. + const resultsPath = path.join( + path.dirname(projectPath), + 'gameplay-test-results.json' + ); + const screenshotsPath = path.join( + path.dirname(projectPath), + 'gameplay-test-screenshots' + ); + // Leftovers from a previous run would be mistaken for this run's results. + shell.rm('-f', resultsPath); + shell.rm('-rf', screenshotsPath); + + const startTime = Date.now(); + const { exitCode, output, timedOut } = await runGDevelopCli({ + executablePath, + cliArguments: [ + projectPath, + '--run-command', + 'RUN_ALL_TESTS', + '--no-sandbox', + '--disable-update-check', + ], + }); + const durationMs = Date.now() - startTime; + + fs.writeFileSync( + path.join(projectArtifactsPath, 'gdevelop-output.log'), + output + ); + + /** @type {GameplayTestResult[]} */ + let results = []; + let runError; + if (fs.existsSync(resultsPath)) { + try { + results = JSON.parse(fs.readFileSync(resultsPath, 'utf8')); + } catch (error) { + runError = `The results file written by GDevelop could not be read: ${error}`; + } + shell.mv(resultsPath, path.join(projectArtifactsPath, 'results.json')); + } + if (fs.existsSync(screenshotsPath)) { + shell.mv(screenshotsPath, projectArtifactsPath); + } + if (!runError && results.length === 0) { + runError = timedOut + ? `GDevelop did not finish within ${Math.round( + timeoutMs / 1000 + )}s and was killed.` + : `GDevelop exited with code ${exitCode} without writing any test result. ` + + `Last lines of its output:\n${output + .split('\n') + .slice(-30) + .join('\n')}`; + } + + return { relativePath, exampleSlug, durationMs, results, runError }; +}; + +/** + * Spawn the GDevelop executable, capturing its output and killing it if it + * takes too long. Never rejects: the caller reports the failure. + * @param {Object} options + * @param {string} options.executablePath + * @param {string[]} options.cliArguments + * @returns {Promise<{ exitCode: number | null, output: string, timedOut: boolean }>} + */ +const runGDevelopCli = ({ executablePath, cliArguments }) => + new Promise((resolve) => { + // Even in CLI mode, Electron needs a display to initialize its graphics + // stack on a headless Linux machine. + const child = spawn( + 'xvfb-run', + ['-a', '--server-args=-screen 0 1280x800x24', executablePath].concat( + cliArguments + ), + { cwd: repositoryPath } + ); + + let output = ''; + let timedOut = false; + /** @param {Buffer} data */ + const onData = (data) => { + const text = data.toString(); + output += text; + // Only echo the lines of the test runner itself: the editor is very + // verbose (asset loading, network errors on a CI machine...). + for (const line of text.split('\n')) { + if (line.includes('[CLI]')) shell.echo(` ${line.trim()}`); + } + }; + child.stdout.on('data', onData); + child.stderr.on('data', onData); + + const timeoutId = setTimeout(() => { + timedOut = true; + child.kill('SIGKILL'); + }, timeoutMs); + + child.on('error', (error) => { + clearTimeout(timeoutId); + resolve({ exitCode: null, output: `${output}\n${error}`, timedOut }); + }); + child.on('close', (exitCode) => { + clearTimeout(timeoutId); + resolve({ exitCode, output, timedOut }); + }); + }); + +(async () => { + const restrictedProjectFiles = getRestrictedProjectFiles(); + const allProjects = findProjectsWithGameplayTests({ + repositoryPath, + onlyRelativePaths: restrictedProjectFiles || undefined, + }); + + if (shardTotal > 1) { + // With `--list`, stdout is the list of projects (it is piped into + // `circleci tests split`): everything else must go to stderr. + log( + `ℹ️ Shard ${shardIndex + 1}/${shardTotal} of ${ + allProjects.length + } game(s) with gameplay tests.` + ); + } + const projects = + shardTotal > 1 + ? allProjects.filter((_, index) => index % shardTotal === shardIndex) + : allProjects; + + if (listOnly) { + for (const project of projects) console.log(project.relativePath); + return; + } + + if (projects.length === 0) { + shell.echo( + restrictedProjectFiles + ? '✅ None of the games to test has gameplay tests: nothing to run.' + : '✅ No game with gameplay tests was found: nothing to run.' + ); + // Still write an (empty) report, so that a CI job always has test + // results to collect. + writeJUnitReport({ projectRunResults: [], junitPath }); + return; + } + + shell.echo( + `ℹ️ Running the gameplay tests of ${projects.length} game(s):\n` + + projects + .map( + (project) => + ` - ${project.relativePath} (${project.testNames.length} test(s))` + ) + .join('\n') + ); + + const { executablePath, version } = await getGDevelopExecutable({ + branch: gdevelopBranch, + version: gdevelopVersion, + workPath, + }); + shell.mkdir('-p', artifactsPath); + + /** @type {ProjectRunResult[]} */ + const projectRunResults = []; + for (const project of projects) { + shell.echo(`\n▶ ${project.relativePath}`); + let projectRunResult = await runProjectGameplayTests({ + executablePath, + relativePath: project.relativePath, + exampleSlug: project.exampleSlug, + }); + + // A test that ran out of wall clock is telling us about the machine, not + // about the game: a gameplay test cannot ask for more than the 30s + // GDevelop gives it, and the time a frame takes to render on a CI + // container has been measured to double from one run to the next (the + // same test, same number of frames stepped, 14s then 30s). So a run whose + // only failures are timeouts is given a second chance. A failed assertion + // is never retried: that is a real result, and retrying it would only + // hide a flaky test. + if (onlyFailedWithTimeouts(projectRunResult)) { + shell.echo( + ` ⏱️ Only wall-clock timeouts failed (${timedOutTestNames( + projectRunResult + ).join(', ')}). Running this game once more.` + ); + const retryResult = await runProjectGameplayTests({ + executablePath, + relativePath: project.relativePath, + exampleSlug: project.exampleSlug, + }); + if (!hasFailure(retryResult)) { + shell.echo( + ' ⏱️ The second run passed: keeping it, but this game is close to the time budget.' + ); + } + projectRunResult = retryResult; + } + + projectRunResults.push(projectRunResult); + } + + writeJUnitReport({ projectRunResults, junitPath }); + + // Summary. + let passedCount = 0; + let failedCount = 0; + /** @type {string[]} */ + const failureLines = []; + for (const projectRunResult of projectRunResults) { + for (const result of projectRunResult.results) { + if (result.status === 'passed') passedCount++; + else { + failedCount++; + failureLines.push( + ` ❌ ${projectRunResult.relativePath} — "${result.testName}" (${result.status})` + ); + } + } + if (projectRunResult.runError) { + failedCount++; + failureLines.push( + ` ❌ ${projectRunResult.relativePath} — ${ + projectRunResult.runError.split('\n')[0] + }` + ); + } + } + + shell.echo( + `\nℹ️ GDevelop ${version} — ${passedCount} gameplay test(s) passed, ${failedCount} failed.` + ); + shell.echo(`ℹ️ JUnit report: ${junitPath}`); + shell.echo(`ℹ️ Results and screenshots: ${artifactsPath}`); + if (failedCount > 0) { + shell.echo(failureLines.join('\n')); + shell.echo('❌ Some gameplay tests did not pass.'); + shell.exit(1); + } + shell.echo('🎉 All gameplay tests passed.'); +})().catch((error) => { + shell.echo(`❌ ${error && error.stack ? error.stack : error}`); + shell.exit(1); +});