From 8d034b98a4bfb1b602d602f6d1eb31110c59ac24 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:46:24 +0000 Subject: [PATCH 1/7] Add gameplay tests to starting-point-and-click-adventure Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- .../starting-point-and-click-adventure.json | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) diff --git a/examples/starting-point-and-click-adventure/starting-point-and-click-adventure.json b/examples/starting-point-and-click-adventure/starting-point-and-click-adventure.json index 85fb8813f..73c140875 100644 --- a/examples/starting-point-and-click-adventure/starting-point-and-click-adventure.json +++ b/examples/starting-point-and-click-adventure/starting-point-and-click-adventure.json @@ -1312,6 +1312,212 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Clicking sends the player walking there", + "type": "gameplay", + "description": "Clicking a spot gives the player a path and it walks toward it; it stands still while nothing is clicked.", + "source": [ + "// The core of the game: clicking somewhere sends the player walking there.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const pathfinding = () => getPlayer().behaviors.Pathfinding.state;", + "const dialogLayer = harness.getRuntimeLayer('Dialog layer');", + "const isDialogOpen = () => dialogLayer.isVisible();", + "", + "/** Click on a point of a layer. */", + "const clickAt = async (x, y, layerName) => {", + " harness.setMousePosition(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(3);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "/**", + " * Close the dialog if one is open. The game opens one straight away: the", + " * player is spawned overlapping two NPCs, and an NPC counts as \"being talked", + " * to\" by default (see the feedback document), so the level starts with the", + " * dialog up and the player frozen. Saying \"no\" is how a player gets out of", + " * it, and it leaves the game in the state the rest of the test needs.", + " */", + "await harness.stepFrames(3);", + "if (isDialogOpen()) {", + " const dialog = harness.getObjects('TwoChoicesDialogBox')[0];", + " const no = dialog.children.NoButton[0];", + " await clickAt(no.centerX, no.centerY, no.layer);", + "}", + "harness.assert(!isDialogOpen(), 'No dialog is in the way.');", + "", + "// Nothing happens on its own: the player only walks when told to.", + "const start = getPlayer();", + "await harness.stepFrames(15);", + "const idle = getPlayer();", + "harness.assert(", + " Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY) < 5,", + " 'The player stands still while nothing is clicked.'", + ");", + "", + "/** Whether a point is clear of every NPC (clicking one talks to it). */", + "const isClearOfNPCs = (x, y) =>", + " harness.getObjects('NPC').every(", + " (npc) =>", + " x < npc.centerX - npc.width / 2 ||", + " x > npc.centerX + npc.width / 2 ||", + " y < npc.centerY - npc.height / 2 ||", + " y > npc.centerY + npc.height / 2", + " );", + "", + "// Click on a spot with no NPC on it: clicking an NPC talks to it instead of", + "// just walking. The NPCs are wide, so the candidates are tried in turn.", + "let destinationX = null;", + "let destinationY = null;", + "for (const [dx, dy] of [[90, 300], [-90, 300], [200, 250], [90, -230]]) {", + " if (destinationX === null && isClearOfNPCs(start.centerX + dx, start.centerY + dy)) {", + " destinationX = start.centerX + dx;", + " destinationY = start.centerY + dy;", + " }", + "}", + "harness.assert(", + " destinationX !== null,", + " 'There is a spot to walk to with no NPC standing on it.'", + ");", + "await clickAt(destinationX, destinationY, start.layer);", + "", + "console.log(", + " 'destinationAsked=' + Math.round(destinationX) + ',' + Math.round(destinationY) +", + " ' pathFound=' + pathfinding().PathFound +", + " ' destinationSet=' + Math.round(pathfinding().DestinationX) + ',' +", + " Math.round(pathfinding().DestinationY)", + ");", + "harness.assert(", + " pathfinding().PathFound === true,", + " 'Clicking gives the player a path to walk (it found ' +", + " pathfinding().NodeCount + ' node(s)).'", + ");", + "", + "const walked = await harness.stepUntil(", + " () => {", + " const player = getPlayer();", + " return (", + " Math.hypot(player.centerX - start.centerX, player.centerY - start.centerY) >", + " 100", + " );", + " },", + " { maxFrames: 120 }", + ");", + "const after = getPlayer();", + "const travelled = Math.hypot(", + " after.centerX - start.centerX,", + " after.centerY - start.centerY", + ");", + "const closerToDestination =", + " Math.hypot(destinationX - after.centerX, destinationY - after.centerY) <", + " Math.hypot(destinationX - start.centerX, destinationY - start.centerY);", + "console.log(", + " 'walked=' + Math.round(travelled) +", + " ' from=' + Math.round(start.centerX) + ',' + Math.round(start.centerY) +", + " ' to=' + Math.round(after.centerX) + ',' + Math.round(after.centerY)", + ");", + "harness.assert(", + " walked,", + " 'The player walks after the click (it moved ' + Math.round(travelled) + 'px).'", + ");", + "harness.assert(", + " closerToDestination,", + " 'The player walks toward where it was sent, not away from it.'", + ");" + ] + }, + { + "name": "Talking to an NPC and saying yes", + "type": "gameplay", + "description": "Clicking an NPC next to the player opens the dialog, and saying yes makes that NPC leave.", + "source": [ + "// Talking to an NPC: clicking one picks it, walking into it opens the", + "// dialog, and saying yes makes it leave.", + "await harness.goToScene('Game Scene');", + "harness.watch('NPC');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const dialogLayer = harness.getRuntimeLayer('Dialog layer');", + "const isDialogOpen = () => dialogLayer.isVisible();", + "const getDialog = () => harness.getObjects('TwoChoicesDialogBox')[0];", + "", + "/** Click on a point of a layer. */", + "const clickAt = async (x, y, layerName) => {", + " harness.setMousePosition(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(3);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "// The level starts with a dialog already up (the player is spawned", + "// overlapping two NPCs, and an NPC counts as \"being talked to\" by default —", + "// see the feedback document). Say no to it, which is how a player gets out.", + "await harness.stepFrames(3);", + "if (isDialogOpen()) {", + " const no = getDialog().children.NoButton[0];", + " await clickAt(no.centerX, no.centerY, no.layer);", + "}", + "harness.assert(!isDialogOpen(), 'No dialog is in the way to begin with.');", + "", + "const npcsBefore = harness.getObjects('NPC').length;", + "harness.assert(npcsBefore > 0, 'There are NPCs to talk to.');", + "", + "// Click on an NPC standing where the player is: clicking is what picks the", + "// NPC to talk to, and being next to it is what opens the dialog. One of the", + "// NPCs the player spawns on is used, so that the dialog depends on the click", + "// and not on a walk across the level.", + "const player = getPlayer();", + "const isWithinReach = (npc) =>", + " Math.abs(npc.centerX - player.centerX) < (npc.width + player.width) / 2 &&", + " Math.abs(npc.centerY - player.centerY) < (npc.height + player.height) / 2;", + "const npc = harness", + " .getNearby('NPC', 'Player', 9000)", + " .find((one) => isWithinReach(one));", + "harness.assert(!!npc, 'There is an NPC within reach to talk to.');", + "console.log(", + " 'npc=' + npc.id + ' at ' + Math.round(npc.centerX) + ',' + Math.round(npc.centerY) +", + " ' npcsBefore=' + npcsBefore", + ");", + "await clickAt(npc.centerX, npc.centerY, npc.layer);", + "", + "const opened = await harness.stepUntil(isDialogOpen, { maxFrames: 60 });", + "console.log(", + " 'dialogOpened=' + opened +", + " ' playerAt=' + Math.round(getPlayer().centerX) + ',' + Math.round(getPlayer().centerY)", + ");", + "harness.assert(opened, 'Clicking the NPC the player stands next to opens the dialog.');", + "harness.assert(", + " harness.getObjects('NPC').length === npcsBefore,", + " 'Opening the dialog does not make the NPC leave on its own.'", + ");", + "", + "// Saying yes is what makes the NPC leave.", + "const yes = getDialog().children.YesButton[0];", + "await clickAt(yes.centerX, yes.centerY, yes.layer);", + "await harness.stepFrames(5);", + "", + "const npcsAfter = harness.getObjects('NPC').length;", + "console.log('npcsAfter=' + npcsAfter + ' dialogStillOpen=' + isDialogOpen());", + "harness.assert(", + " npcsAfter === npcsBefore - 1,", + " 'Saying yes makes the NPC leave (' + npcsAfter + ' 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.'", + ");", + "harness.assert(!isDialogOpen(), 'The dialog closes once it is answered.');" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", From 302bf3aad844387cb6abc18b789fdfa2c8f25a25 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:46:25 +0000 Subject: [PATCH 2/7] Add gameplay tests to starting-3d-point-and-click-adventure Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- ...starting-3d-point-and-click-adventure.json | 182 ++++++++++++++++++ 1 file changed, 182 insertions(+) diff --git a/examples/starting-3d-point-and-click-adventure/starting-3d-point-and-click-adventure.json b/examples/starting-3d-point-and-click-adventure/starting-3d-point-and-click-adventure.json index d82e09114..a4c68deb9 100644 --- a/examples/starting-3d-point-and-click-adventure/starting-3d-point-and-click-adventure.json +++ b/examples/starting-3d-point-and-click-adventure/starting-3d-point-and-click-adventure.json @@ -1680,6 +1680,188 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Clicking sends the player walking there", + "type": "gameplay", + "description": "Clicking a spot gives the player a path and it walks toward it; it stands still while nothing is clicked.", + "source": [ + "// The core of the game: clicking somewhere sends the player walking there.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const pathfinding = () => getPlayer().behaviors.Pathfinding.state;", + "", + "/** Click on a point of the ground. */", + "const clickAt = async (x, y) => {", + " harness.setMousePosition(x, y, '');", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(3);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "await harness.stepFrames(4);", + "const start = getPlayer();", + "", + "// Nothing happens on its own: the player only walks when told to.", + "await harness.stepFrames(12);", + "const idle = getPlayer();", + "harness.assert(", + " Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY) < 5,", + " 'The player stands still while nothing is clicked.'", + ");", + "", + "// Click on a spot with no NPC near it: clicking an NPC talks to it instead of", + "// simply walking (the game raycasts the cursor onto the NPCs).", + "const isClearOfNPCs = (x, y) =>", + " harness", + " .getObjects('NPC')", + " .every((npc) => Math.hypot(npc.centerX - x, npc.centerY - y) > 200);", + "let destinationX = null;", + "let destinationY = null;", + "for (const [dx, dy] of [[200, 150], [-200, 150], [200, -150], [0, 250]]) {", + " if (destinationX === null && isClearOfNPCs(start.centerX + dx, start.centerY + dy)) {", + " destinationX = start.centerX + dx;", + " destinationY = start.centerY + dy;", + " }", + "}", + "harness.assert(", + " destinationX !== null,", + " 'There is a spot to walk to with no NPC standing on it.'", + ");", + "await clickAt(destinationX, destinationY);", + "", + "console.log(", + " 'destinationAsked=' + Math.round(destinationX) + ',' + Math.round(destinationY) +", + " ' pathFound=' + pathfinding().PathFound +", + " ' nodes=' + pathfinding().NodeCount", + ");", + "harness.assert(", + " pathfinding().PathFound === true,", + " 'Clicking gives the player a path to walk (it found ' +", + " pathfinding().NodeCount + ' node(s)).'", + ");", + "", + "const walked = await harness.stepUntil(", + " () => {", + " const player = getPlayer();", + " return (", + " Math.hypot(player.centerX - start.centerX, player.centerY - start.centerY) >", + " 100", + " );", + " },", + " { maxFrames: 90 }", + ");", + "const after = getPlayer();", + "const travelled = Math.hypot(", + " after.centerX - start.centerX,", + " after.centerY - start.centerY", + ");", + "const closerToDestination =", + " Math.hypot(destinationX - after.centerX, destinationY - after.centerY) <", + " Math.hypot(destinationX - start.centerX, destinationY - start.centerY);", + "console.log(", + " 'walked=' + Math.round(travelled) +", + " ' to=' + Math.round(after.centerX) + ',' + Math.round(after.centerY)", + ");", + "harness.assert(", + " walked,", + " 'The player walks after the click (it moved ' + Math.round(travelled) + ' units).'", + ");", + "harness.assert(", + " closerToDestination,", + " 'The player walks toward where it was sent, not away from it.'", + ");" + ] + }, + { + "name": "Talking to an NPC and saying yes", + "type": "gameplay", + "description": "Clicking an NPC walks the player to it and opens the dialog, and saying yes makes that NPC leave.", + "source": [ + "// Talking to an NPC: clicking one sends the player to it, being next to it", + "// opens the dialog, and saying yes makes it leave.", + "await harness.goToScene('Game Scene');", + "harness.watch('NPC');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const dialogLayer = harness.getRuntimeLayer('Dialog layer');", + "const isDialogOpen = () => dialogLayer.isVisible();", + "const getDialog = () => harness.getObjects('TwoChoicesDialogBox')[0];", + "", + "/** Click on a point of a layer (the ground by default). */", + "const clickAt = async (x, y, layerName = '') => {", + " harness.setMousePosition(x, y, layerName);", + " await harness.stepFrames(2);", + " harness.setMouseButtonPressed(true);", + " await harness.stepFrames(3);", + " harness.setMouseButtonPressed(false);", + " await harness.stepFrames(3);", + "};", + "", + "await harness.stepFrames(4);", + "harness.assert(!isDialogOpen(), 'No dialog is open before talking to anyone.');", + "const npcsBefore = harness.getObjects('NPC').length;", + "harness.assert(npcsBefore > 0, 'There are NPCs to talk to.');", + "", + "// Click on the nearest NPC: the game raycasts the cursor onto it, picks it", + "// and walks the player over.", + "const npc = harness.getNearby('NPC', 'Player', 9000)[0];", + "const start = getPlayer();", + "console.log(", + " 'npc=' + npc.id + ' at ' + Math.round(npc.centerX) + ',' + Math.round(npc.centerY) +", + " ' distance=' + Math.round(npc.distance) + ' npcsBefore=' + npcsBefore", + ");", + "harness.assert(", + " npc.distance > 150,", + " 'The NPC is out of reach to start with (it is ' +", + " Math.round(npc.distance) + ' units away), so the dialog has to be walked to.'", + ");", + "await clickAt(npc.centerX, npc.centerY);", + "harness.assert(!isDialogOpen(), 'Clicking the NPC does not open the dialog on its own.');", + "", + "const opened = await harness.stepUntil(isDialogOpen, { maxFrames: 120 });", + "const walkedDistance = Math.hypot(", + " getPlayer().centerX - start.centerX,", + " getPlayer().centerY - start.centerY", + ");", + "console.log(", + " 'dialogOpened=' + opened + ' walked=' + Math.round(walkedDistance) +", + " ' playerAt=' + Math.round(getPlayer().centerX) + ',' + Math.round(getPlayer().centerY)", + ");", + "harness.assert(", + " walkedDistance > 100,", + " 'The player walked to the NPC it was told to talk to (it moved ' +", + " Math.round(walkedDistance) + ' units).'", + ");", + "harness.assert(opened, 'Reaching the NPC opens the dialog.');", + "harness.assert(", + " harness.getObjects('NPC').length === npcsBefore,", + " 'Opening the dialog does not make the NPC leave on its own.'", + ");", + "", + "// Saying yes is what makes the NPC leave.", + "const yes = getDialog().children.YesButton[0];", + "await clickAt(yes.centerX, yes.centerY, yes.layer);", + "await harness.stepFrames(5);", + "", + "const npcsAfter = harness.getObjects('NPC').length;", + "console.log('npcsAfter=' + npcsAfter + ' dialogStillOpen=' + isDialogOpen());", + "harness.assert(", + " npcsAfter === npcsBefore - 1,", + " 'Saying yes makes the NPC leave (' + npcsAfter + ' 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.'", + ");", + "harness.assert(!isDialogOpen(), 'The dialog closes once it is answered.');" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", From a9ad9637ca88496a376be924fbfa587a28991832 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:50:47 +0000 Subject: [PATCH 3/7] Add gameplay tests to starting-top-down-farming Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- .../starting-top-down-farming.json | 189 ++++++++++++++++++ 1 file changed, 189 insertions(+) diff --git a/examples/starting-top-down-farming/starting-top-down-farming.json b/examples/starting-top-down-farming/starting-top-down-farming.json index 24c242b88..b9bf85079 100644 --- a/examples/starting-top-down-farming/starting-top-down-farming.json +++ b/examples/starting-top-down-farming/starting-top-down-farming.json @@ -4785,6 +4785,195 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Walking and facing", + "type": "gameplay", + "description": "The arrow keys walk the player as far as its behavior is configured to, and the selection box is put ahead of it.", + "source": [ + "// The character walks with the arrow keys, and what it faces decides where", + "// the selection box — everything this game does happens there — is put.", + "await harness.goToScene('Game Scene');", + "harness.watch('Player');", + "", + "const getCharacter = () => harness.getObjects('Player')[0];", + "const movement = () => getCharacter().behaviors.TopDownMovement.state;", + "const getSelectionBox = () => harness.getObjects('Ground_SelectionBox')[0];", + "const normalizeAngle = (angle) => (((angle % 360) + 540) % 360) - 180;", + "", + "await harness.stepFrames(5);", + "const start = getCharacter();", + "", + "// Nothing pressed: the character stays where it is.", + "await harness.stepFrames(15);", + "const idle = getCharacter();", + "harness.assert(", + " Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY) < 2,", + " 'The character stands still while no key is pressed.'", + ");", + "", + "// Walk right. How far it should get comes from the behavior's own settings.", + "const HELD_FRAMES = 20;", + "const { Acceleration, MaxSpeed } = movement();", + "const heldSeconds = HELD_FRAMES / 60;", + "const secondsToMaxSpeed = MaxSpeed / Acceleration;", + "const expectedDistance =", + " heldSeconds <= secondsToMaxSpeed", + " ? 0.5 * Acceleration * heldSeconds * heldSeconds", + " : 0.5 * MaxSpeed * secondsToMaxSpeed +", + " MaxSpeed * (heldSeconds - secondsToMaxSpeed);", + "", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(HELD_FRAMES);", + "const movementAngle = movement().Angle;", + "harness.releaseAllInputs();", + "await harness.stepFrames(3);", + "", + "const after = getCharacter();", + "const movedX = after.centerX - idle.centerX;", + "const movedY = after.centerY - idle.centerY;", + "console.log(", + " 'movedX=' + Math.round(movedX) + ' movedY=' + Math.round(movedY) +", + " ' expected=' + Math.round(expectedDistance) +", + " ' angle=' + Math.round(movementAngle)", + ");", + "harness.assert(", + " movedX > 0.6 * expectedDistance,", + " 'Holding Right walks the character right (it moved ' + Math.round(movedX) +", + " 'px, expected around ' + Math.round(expectedDistance) + 'px).'", + ");", + "harness.assert(", + " Math.abs(movedY) < 0.4 * Math.abs(movedX),", + " 'The character walks along one axis only (' + Math.round(movedY) + 'px across).'", + ");", + "harness.assert(", + " Math.abs(normalizeAngle(movementAngle)) < 5,", + " 'The character faces the way it walks (it moved toward ' +", + " Math.round(movementAngle) + ' degrees).'", + ");", + "", + "// The selection box is placed ahead of the character, in the direction it", + "// last walked: that is what the whole game acts on.", + "const box = getSelectionBox();", + "const aheadX = box.centerX - after.centerX;", + "const aheadY = box.centerY - after.centerY;", + "console.log(", + " 'selectionBoxOffset=' + Math.round(aheadX) + ',' + Math.round(aheadY)", + ");", + "harness.assert(", + " aheadX > 0 && Math.abs(aheadY) < Math.abs(aheadX),", + " 'The selection box is put ahead of the character, on the side it faces (it is ' +", + " Math.round(aheadX) + ',' + Math.round(aheadY) + ' from it).'", + ");" + ] + }, + { + "name": "Harvesting a seed patch fills the inventory", + "type": "gameplay", + "description": "The player walks to a seed patch and acts on it: a carrot seed goes into the inventory. Pointing alone does nothing.", + "source": [ + "// The core loop: walk to something harvestable, act on it, and what comes", + "// out ends up in the inventory.", + "await harness.goToScene('Game Scene');", + "harness.watch('Seed_Carrot');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "const getSelectionBox = () => harness.getObjects('Ground_SelectionBox')[0];", + "", + "/** The inventory the game keeps in a scene variable, slot by slot. */", + "const inventorySlots = () => {", + " const inventory = harness.getSceneVariable('Inventory');", + " const slots = (inventory && inventory.children) || [];", + " return slots.map((slot) => {", + " const entries = slot.children || [];", + " const read = (name) => {", + " const entry = entries.find((one) => one.name === name);", + " return entry ? entry.value : null;", + " };", + " return { name: read('Name'), quantity: Number(read('Quantity')) };", + " });", + "};", + "const filledSlots = () => inventorySlots().filter((slot) => slot.name !== 'Empty');", + "", + "await harness.stepFrames(5);", + "harness.assert(", + " filledSlots().length === 0,", + " 'The inventory starts empty.'", + ");", + "", + "const patch = harness.getObjects('Harvest_Seed_Carrot')[0];", + "harness.assert(!!patch, 'There is a seed patch to harvest.');", + "const distanceToPatch = () => {", + " const player = getPlayer();", + " return Math.hypot(patch.centerX - player.centerX, patch.centerY - player.centerY);", + "};", + "console.log('patchDistanceAtStart=' + Math.round(distanceToPatch()));", + "", + "// It is out of reach: the selection box only follows the cursor within 200", + "// units of the player, so the player has to walk over first.", + "harness.assert(", + " distanceToPatch() > 200,", + " 'The seed patch is out of reach to start with (' +", + " Math.round(distanceToPatch()) + ' units away).'", + ");", + "const walkedThere = await harness.stepUntil(() => distanceToPatch() < 170, {", + " maxFrames: 150,", + " onFrame: () => {", + " const player = getPlayer();", + " const dx = patch.centerX - player.centerX;", + " const dy = patch.centerY - player.centerY;", + " harness.setKeyPressed('Right', dx > 20);", + " harness.setKeyPressed('Left', dx < -20);", + " harness.setKeyPressed('Down', dy > 20);", + " harness.setKeyPressed('Up', dy < -20);", + " },", + "});", + "harness.releaseAllInputs();", + "await harness.stepFrames(3);", + "console.log('patchDistanceAfterWalking=' + Math.round(distanceToPatch()));", + "harness.assert(", + " walkedThere,", + " 'The player can walk to the seed patch (it stopped ' +", + " Math.round(distanceToPatch()) + ' units away).'", + ");", + "", + "// Point at it: the selection box has to land on the patch.", + "harness.setMousePosition(patch.centerX, patch.centerY, getPlayer().layer);", + "await harness.stepFrames(3);", + "const box = getSelectionBox();", + "const boxToPatch = Math.hypot(", + " box.centerX - patch.centerX,", + " box.centerY - patch.centerY", + ");", + "console.log('selectionBoxAwayFromPatch=' + Math.round(boxToPatch));", + "harness.assert(", + " boxToPatch < 64,", + " 'Pointing at the patch puts the selection box on it (it is ' +", + " Math.round(boxToPatch) + ' units away).'", + ");", + "harness.assert(", + " filledSlots().length === 0,", + " 'Nothing is harvested just by pointing at the patch.'", + ");", + "", + "// Act on it.", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(4);", + "harness.setMouseButtonPressed(false);", + "const harvested = await harness.stepUntil(", + " () => filledSlots().some((slot) => slot.name === 'Seed_Carrot'),", + " { maxFrames: 90 }", + ");", + "", + "console.log('inventoryAfter=' + JSON.stringify(filledSlots()));", + "harness.assert(", + " harvested,", + " 'Acting on the patch puts a carrot seed in the inventory (it holds ' +", + " JSON.stringify(filledSlots()) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", From e7baf2aa339b5182f869256963adaf7dd117f4a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 14:50:47 +0000 Subject: [PATCH 4/7] Add gameplay tests to starting-top-down-survival-crafting Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- .../starting-top-down-survival-crafting.json | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) diff --git a/examples/starting-top-down-survival-crafting/starting-top-down-survival-crafting.json b/examples/starting-top-down-survival-crafting/starting-top-down-survival-crafting.json index 6f36f754a..23b1e8607 100644 --- a/examples/starting-top-down-survival-crafting/starting-top-down-survival-crafting.json +++ b/examples/starting-top-down-survival-crafting/starting-top-down-survival-crafting.json @@ -4027,6 +4027,189 @@ } ], "externalEvents": [], + "tests": [ + { + "name": "Walking and facing", + "type": "gameplay", + "description": "The arrow keys walk the character as far as its behavior is configured to, and the selection box is put ahead of it.", + "source": [ + "// The character walks with the arrow keys, and what it faces decides where", + "// the selection box — everything this game does happens there — is put.", + "await harness.goToScene('Game Scene');", + "harness.watch('TopDown_Character');", + "", + "const getCharacter = () => harness.getObjects('TopDown_Character')[0];", + "const movement = () => getCharacter().behaviors.TopDownMovement.state;", + "const getSelectionBox = () => harness.getObjects('Ground_SelectionBox')[0];", + "const normalizeAngle = (angle) => (((angle % 360) + 540) % 360) - 180;", + "", + "await harness.stepFrames(5);", + "const start = getCharacter();", + "", + "// Nothing pressed: the character stays where it is.", + "await harness.stepFrames(15);", + "const idle = getCharacter();", + "harness.assert(", + " Math.hypot(idle.centerX - start.centerX, idle.centerY - start.centerY) < 2,", + " 'The character stands still while no key is pressed.'", + ");", + "", + "// Walk right. How far it should get comes from the behavior's own settings.", + "const HELD_FRAMES = 20;", + "const { Acceleration, MaxSpeed } = movement();", + "const heldSeconds = HELD_FRAMES / 60;", + "const secondsToMaxSpeed = MaxSpeed / Acceleration;", + "const expectedDistance =", + " heldSeconds <= secondsToMaxSpeed", + " ? 0.5 * Acceleration * heldSeconds * heldSeconds", + " : 0.5 * MaxSpeed * secondsToMaxSpeed +", + " MaxSpeed * (heldSeconds - secondsToMaxSpeed);", + "", + "harness.setKeyPressed('Right', true);", + "await harness.stepFrames(HELD_FRAMES);", + "const movementAngle = movement().Angle;", + "harness.releaseAllInputs();", + "await harness.stepFrames(3);", + "", + "const after = getCharacter();", + "const movedX = after.centerX - idle.centerX;", + "const movedY = after.centerY - idle.centerY;", + "console.log(", + " 'movedX=' + Math.round(movedX) + ' movedY=' + Math.round(movedY) +", + " ' expected=' + Math.round(expectedDistance) +", + " ' angle=' + Math.round(movementAngle)", + ");", + "harness.assert(", + " movedX > 0.6 * expectedDistance,", + " 'Holding Right walks the character right (it moved ' + Math.round(movedX) +", + " 'px, expected around ' + Math.round(expectedDistance) + 'px).'", + ");", + "harness.assert(", + " Math.abs(movedY) < 0.4 * Math.abs(movedX),", + " 'The character walks along one axis only (' + Math.round(movedY) + 'px across).'", + ");", + "harness.assert(", + " Math.abs(normalizeAngle(movementAngle)) < 5,", + " 'The character faces the way it walks (it moved toward ' +", + " Math.round(movementAngle) + ' degrees).'", + ");", + "", + "// The selection box is placed ahead of the character, in the direction it", + "// last walked: that is what the whole game acts on.", + "const box = getSelectionBox();", + "const aheadX = box.centerX - after.centerX;", + "const aheadY = box.centerY - after.centerY;", + "console.log(", + " 'selectionBoxOffset=' + Math.round(aheadX) + ',' + Math.round(aheadY)", + ");", + "harness.assert(", + " aheadX > 0 && Math.abs(aheadY) < Math.abs(aheadX),", + " 'The selection box is put ahead of the character, on the side it faces (it is ' +", + " Math.round(aheadX) + ',' + Math.round(aheadY) + ' from it).'", + ");" + ] + }, + { + "name": "Harvesting a tree fills the inventory", + "type": "gameplay", + "description": "Pointing at a tree and acting on it puts a log in the inventory; pointing alone does nothing.", + "source": [ + "// The core loop: point at something harvestable, act on it, and what comes", + "// out ends up in the inventory.", + "await harness.goToScene('Game Scene');", + "harness.watch('Log');", + "", + "const getCharacter = () => harness.getObjects('TopDown_Character')[0];", + "const getSelectionBox = () => harness.getObjects('Ground_SelectionBox')[0];", + "", + "/** The inventory the game keeps in a scene variable, slot by slot. */", + "const inventorySlots = () => {", + " const inventory = harness.getSceneVariable('Inventory');", + " const slots = (inventory && inventory.children) || [];", + " return slots.map((slot) => {", + " const entries = slot.children || [];", + " const read = (name) => {", + " const entry = entries.find((one) => one.name === name);", + " return entry ? entry.value : null;", + " };", + " return { name: read('Name'), quantity: Number(read('Quantity')) };", + " });", + "};", + "const slotsHolding = (itemName) =>", + " inventorySlots().filter((slot) => slot.name === itemName && slot.quantity > 0);", + "", + "await harness.stepFrames(5);", + "const character = getCharacter();", + "", + "console.log('inventoryAtStart=' + JSON.stringify(inventorySlots()));", + "harness.assert(", + " inventorySlots().every((slot) => slot.name === 'Empty' && slot.quantity === 0),", + " 'The inventory starts empty.'", + ");", + "", + "// The tree to harvest: the selection box follows the cursor while it is", + "// within 200 units of the character, so a tree in that range can be pointed", + "// at directly.", + "const tree = harness", + " .getObjects('Harvest_Tree')", + " .map((one) => ({", + " one,", + " distance: Math.hypot(one.centerX - character.centerX, one.centerY - character.centerY),", + " }))", + " .filter((candidate) => candidate.distance < 200)", + " .sort((a, b) => a.distance - b.distance)[0];", + "harness.assert(!!tree, 'There is a tree within reach of the character.');", + "console.log(", + " 'tree=' + Math.round(tree.one.centerX) + ',' + Math.round(tree.one.centerY) +", + " ' distance=' + Math.round(tree.distance)", + ");", + "", + "// Point at it: the selection box has to land on the tree.", + "harness.setMousePosition(tree.one.centerX, tree.one.centerY, character.layer);", + "await harness.stepFrames(3);", + "const box = getSelectionBox();", + "const boxToTree = Math.hypot(", + " box.centerX - tree.one.centerX,", + " box.centerY - tree.one.centerY", + ");", + "console.log(", + " 'selectionBox=' + Math.round(box.centerX) + ',' + Math.round(box.centerY) +", + " ' awayFromTree=' + Math.round(boxToTree)", + ");", + "harness.assert(", + " boxToTree < 64,", + " 'Pointing at the tree puts the selection box on it (it is ' +", + " Math.round(boxToTree) + ' units away).'", + ");", + "", + "// Pointing at it is not enough: nothing is harvested until the action is", + "// taken.", + "harness.assert(", + " slotsHolding('Log').length === 0,", + " 'Nothing is harvested just by pointing at the tree.'", + ");", + "", + "// Act on it.", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(4);", + "harness.setMouseButtonPressed(false);", + "const harvested = await harness.stepUntil(() => slotsHolding('Log').length > 0, {", + " maxFrames: 90,", + "});", + "", + "console.log('inventoryAfter=' + JSON.stringify(inventorySlots()));", + "harness.assert(", + " harvested,", + " 'Acting on the tree puts a log in the inventory (it holds ' +", + " JSON.stringify(inventorySlots().filter((slot) => slot.name !== 'Empty')) + ').'", + ");", + "harness.assert(", + " slotsHolding('Log')[0].quantity >= 1,", + " 'The log that was harvested is counted in its slot.'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", From 66cf2d92828c8aceccc31140a7c7d9234e9d3370 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:02:03 +0000 Subject: [PATCH 5/7] Add gameplay tests to starting-first-person-farming Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- .../starting-first-person-farming.json | 198 ++++++++++++++++++ 1 file changed, 198 insertions(+) diff --git a/examples/starting-first-person-farming/starting-first-person-farming.json b/examples/starting-first-person-farming/starting-first-person-farming.json index 041b3c57b..5bea1ed12 100644 --- a/examples/starting-first-person-farming/starting-first-person-farming.json +++ b/examples/starting-first-person-farming/starting-first-person-farming.json @@ -6312,6 +6312,204 @@ } ], "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": "Harvesting a seed patch fills the inventory", + "type": "gameplay", + "description": "A seed patch on the ground is harvested by looking down at it and acting; the seed goes into the inventory.", + "source": [ + "// The core loop: look at something harvestable, act on it, and what comes", + "// out ends up in the inventory. Farming happens on the ground, so this is", + "// also the test that the player can act on what it is looking down at.", + "await harness.goToScene('Game Scene');", + "harness.watch('Seed_Carrot');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "", + "/** The inventory the game keeps in a scene variable, slot by slot. */", + "const inventorySlots = () => {", + " const inventory = harness.getSceneVariable('Inventory');", + " const slots = (inventory && inventory.children) || [];", + " return slots.map((slot) => {", + " const entries = slot.children || [];", + " const read = (name) => {", + " const entry = entries.find((one) => one.name === name);", + " return entry ? entry.value : null;", + " };", + " return { name: read('Name'), quantity: Number(read('Quantity')) };", + " });", + "};", + "const filledSlots = () => inventorySlots().filter((slot) => slot.name !== 'Empty');", + "", + "await harness.stepFrames(6);", + "const player = getPlayer();", + "console.log(", + " 'player=' + Math.round(player.centerX) + ',' + Math.round(player.centerY) +", + " ' angle=' + Math.round(player.angle)", + ");", + "harness.assert(filledSlots().length === 0, 'The inventory starts empty.');", + "", + "// Arrange: bring a seed patch in front of the player. The game harvests what", + "// the camera is looking at, and the patches are static, so the patch is moved", + "// to the player rather than the player walked over to it. Every other patch", + "// is taken out of the level, so what ends up in the inventory can only have", + "// come from this one. Harvesting it is still up to the game.", + "const patch = harness.getObjects('Harvest_Seed_Carrot')[0];", + "harness.assert(!!patch, 'There is a seed patch to harvest.');", + "for (const other of [", + " ...harness.getObjects('Harvest_Seed_Carrot'),", + " ...harness.getObjects('Harvest_Seed_Beet'),", + "]) {", + " if (other.id !== patch.id) harness.removeObject(other.id);", + "}", + "const facing = (player.angle * Math.PI) / 180;", + "const AHEAD = 120;", + "harness.setObjectPosition(", + " patch.id,", + " patch.x + (player.centerX + Math.cos(facing) * AHEAD - patch.centerX),", + " patch.y + (player.centerY + Math.sin(facing) * AHEAD - patch.centerY),", + " patch.z", + ");", + "await harness.stepFrames(4);", + "const moved = harness.getObjects('Harvest_Seed_Carrot').find((one) => one.id === patch.id);", + "console.log(", + " 'patchInFront=' + Math.round(moved.centerX) + ',' + Math.round(moved.centerY) +", + " ',' + Math.round(moved.centerZ) + ' distance=' + Math.round(AHEAD)", + ");", + "", + "// The action is ignored while the cursor sits over the controls toggle in the", + "// top left corner, which is where it starts: put it in the middle of the", + "// screen. The first click is also what makes the game take the pointer lock,", + "// without which the mouse movements below are ignored.", + "harness.setMousePositionScreen(", + " harness.getGameResolutionWidth() / 2,", + " harness.getGameResolutionHeight() / 2", + ");", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(2);", + "harness.setMouseButtonPressed(false);", + "await harness.stepFrames(2);", + "harness.assert(", + " filledSlots().length === 0,", + " 'Acting while looking straight ahead, over the patch, harvests nothing.'", + ");", + "", + "// Look down at it. The patch lies flat on the ground, so the view has to be", + "// lowered onto it; the view is swept down a little at a time and the action", + "// taken at each step, which is what a player does to find the right spot.", + "let steps = 0;", + "const harvested = await harness.stepUntil(", + " () => filledSlots().some((slot) => slot.name === 'Seed_Carrot'),", + " {", + " maxFrames: 60,", + " onFrame: () => {", + " steps++;", + " // Sweep for one frame, then act on the next two.", + " const phase = steps % 3;", + " harness.setMouseDelta(0, phase === 1 ? 30 : 0);", + " harness.setMouseButtonPressed(phase === 2);", + " },", + " }", + ");", + "harness.releaseAllInputs();", + "", + "console.log(", + " 'lookedDownTo=' + getPlayer().rotationY.toFixed(1) +", + " ' degrees, inventory=' + JSON.stringify(filledSlots())", + ");", + "harness.assert(", + " harvested,", + " 'Looking down at the seed patch and acting puts a carrot seed in the inventory (it holds ' +", + " JSON.stringify(filledSlots()) + ').'", + ");", + "harness.assert(", + " getPlayer().rotationY > 2,", + " 'The view really had to be lowered onto the patch (it ended at ' +", + " getPlayer().rotationY.toFixed(1) + ' degrees).'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", From b1d40a3a89ed6f37487a1e49b3a95e516d062ff5 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:02:04 +0000 Subject: [PATCH 6/7] Add gameplay tests to starting-first-person-survival-crafting Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- ...arting-first-person-survival-crafting.json | 168 ++++++++++++++++++ 1 file changed, 168 insertions(+) diff --git a/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json b/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json index 953cd45b3..1054d1454 100644 --- a/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json +++ b/examples/starting-first-person-survival-crafting/starting-first-person-survival-crafting.json @@ -4676,6 +4676,174 @@ } ], "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": "Harvesting a tree fills the inventory", + "type": "gameplay", + "description": "A tree in the crosshair is harvested by acting on it, and the log goes into the inventory.", + "source": [ + "// The core loop: look at something harvestable, act on it, and what comes", + "// out ends up in the inventory.", + "await harness.goToScene('Game Scene');", + "harness.watch('Log');", + "", + "const getPlayer = () => harness.getObjects('Player')[0];", + "", + "/** The inventory the game keeps in a scene variable, slot by slot. */", + "const inventorySlots = () => {", + " const inventory = harness.getSceneVariable('Inventory');", + " const slots = (inventory && inventory.children) || [];", + " return slots.map((slot) => {", + " const entries = slot.children || [];", + " const read = (name) => {", + " const entry = entries.find((one) => one.name === name);", + " return entry ? entry.value : null;", + " };", + " return { name: read('Name'), quantity: Number(read('Quantity')) };", + " });", + "};", + "const filledSlots = () => inventorySlots().filter((slot) => slot.name !== 'Empty');", + "", + "await harness.stepFrames(6);", + "const player = getPlayer();", + "console.log(", + " 'player=' + Math.round(player.centerX) + ',' + Math.round(player.centerY) +", + " ',' + Math.round(player.centerZ) + ' angle=' + Math.round(player.angle) +", + " ' inventory=' + JSON.stringify(filledSlots())", + ");", + "harness.assert(filledSlots().length === 0, 'The inventory starts empty.');", + "", + "// Arrange: bring a tree into the crosshair. The game harvests whatever the", + "// camera is looking at within 200 units, and the trees are static bodies, so", + "// the tree is moved in front of the player rather than the player turned", + "// toward it. Harvesting it is still up to the game.", + "const tree = harness.getObjects('Harvest_Tree')[0];", + "harness.assert(!!tree, 'There is a tree to harvest.');", + "// Take every other harvestable out of the level, so that whatever is", + "// harvested can only have come from this tree.", + "for (const other of [...harness.getObjects('Harvest_Tree'), ...harness.getObjects('Harvest_Rock')]) {", + " if (other.id !== tree.id) harness.removeObject(other.id);", + "}", + "console.log('harvestablesLeft=' + (harness.getObjects('Harvest_Tree').length + harness.getObjects('Harvest_Rock').length));", + "const facing = (player.angle * Math.PI) / 180;", + "const AHEAD = 120;", + "harness.setObjectPosition(", + " tree.id,", + " tree.x + (player.centerX + Math.cos(facing) * AHEAD - tree.centerX),", + " tree.y + (player.centerY + Math.sin(facing) * AHEAD - tree.centerY),", + " tree.z", + ");", + "await harness.stepFrames(4);", + "", + "const movedTree = harness.getObjects('Harvest_Tree').find((one) => one.id === tree.id);", + "console.log(", + " 'treeInFront=' + Math.round(movedTree.centerX) + ',' + Math.round(movedTree.centerY) +", + " ',' + Math.round(movedTree.centerZ) +", + " ' distance=' + Math.round(Math.hypot(movedTree.centerX - player.centerX, movedTree.centerY - player.centerY))", + ");", + "harness.assert(", + " filledSlots().length === 0,", + " 'Nothing is harvested just by standing in front of the tree.'", + ");", + "", + "// Act on it.", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(4);", + "harness.setMouseButtonPressed(false);", + "const harvested = await harness.stepUntil(", + " () => filledSlots().some((slot) => slot.name === 'Log'),", + " { maxFrames: 60 }", + ");", + "", + "console.log('inventoryAfter=' + JSON.stringify(filledSlots()));", + "harness.assert(", + " harvested,", + " 'Acting on the tree puts a log in the inventory (it holds ' +", + " JSON.stringify(filledSlots()) + ').'", + ");" + ] + } + ], "eventsFunctionsExtensions": [ { "author": "", From d636906ddd756606975b475654d8d7c421734b6f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 15:55:27 +0000 Subject: [PATCH 7/7] Aim the first person tests with lookTowardWithMouseDelta GDevelop master d1b3797 fixes the inverted camera-measured pitch, the last thing that stopped the aiming helper being usable, so the two tests that aim now use it instead of doing the trigonometry themselves: - starting-first-person-shooter drops its hand-rolled pitch controller (which computed the eye height and knew the game pitches around Y). The shot now lands 5 units from the target. - starting-first-person-farming drops the sweep that looked down a step at a time and acted along the way. It now aims straight at the seed patch, and ends at 34 degrees - the true angle to it. Both need `toleranceDegrees`: the default 3 degrees misses a target at this range. The shooting test now waits for the impact effects of the click that takes the pointer lock to expire before counting them. They are short lived, and the helper is fast enough that an old one dying as the new one is born kept the count at 1, making a good shot look like it did nothing. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M2jc7PVAvmMmirAQude2v1 --- .../starting-first-person-farming.json | 33 ++++++----- .../starting-first-person-shooter.json | 59 ++++++++++--------- 2 files changed, 50 insertions(+), 42 deletions(-) diff --git a/examples/starting-first-person-farming/starting-first-person-farming.json b/examples/starting-first-person-farming/starting-first-person-farming.json index 5bea1ed12..01536b2f5 100644 --- a/examples/starting-first-person-farming/starting-first-person-farming.json +++ b/examples/starting-first-person-farming/starting-first-person-farming.json @@ -6474,22 +6474,27 @@ " 'Acting while looking straight ahead, over the patch, harvests nothing.'", ");", "", - "// Look down at it. The patch lies flat on the ground, so the view has to be", - "// lowered onto it; the view is swept down a little at a time and the action", - "// taken at each step, which is what a player does to find the right spot.", - "let steps = 0;", + "// Look down at it: the patch lies flat on the ground, so a level view goes", + "// straight over it. The helper aims on the camera of the player's layer,", + "// which is what the game casts its ray from.", + "const aim = await harness.lookTowardWithMouseDelta(", + " 'Player',", + " { name: 'Harvest_Seed_Carrot', id: patch.id },", + " { toleranceDegrees: 1 }", + ");", + "console.log('aim=' + JSON.stringify(aim));", + "harness.assert(", + " !!aim && aim.aimed,", + " 'The view can be aimed down onto the patch.'", + ");", + "", + "// Act on it.", + "harness.setMouseButtonPressed(true);", + "await harness.stepFrames(3);", + "harness.setMouseButtonPressed(false);", "const harvested = await harness.stepUntil(", " () => filledSlots().some((slot) => slot.name === 'Seed_Carrot'),", - " {", - " maxFrames: 60,", - " onFrame: () => {", - " steps++;", - " // Sweep for one frame, then act on the next two.", - " const phase = steps % 3;", - " harness.setMouseDelta(0, phase === 1 ? 30 : 0);", - " harness.setMouseButtonPressed(phase === 2);", - " },", - " }", + " { maxFrames: 30 }", ");", "harness.releaseAllInputs();", "", 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 e4e839d68..521da3b75 100644 --- a/examples/starting-first-person-shooter/starting-first-person-shooter.json +++ b/examples/starting-first-person-shooter/starting-first-person-shooter.json @@ -2758,38 +2758,40 @@ "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).'", + "// Aim at it. The helper measures the aim on the camera of the player's", + "// layer, which is the ground truth of a first person view: the right eye", + "// height, and the rotations the game actually drives (this one pitches the", + "// player around Y, not X). The default three degrees of tolerance would", + "// still send the shot past a target this far away, so it is tightened.", + "const aim = await harness.lookTowardWithMouseDelta(", + " 'Player',", + " { name: 'Target', id: targetId },", + " { toleranceDegrees: 0.4 }", ");", - "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));", + "console.log('aim=' + JSON.stringify(aim));", "harness.assert(", - " aimed,", - " 'Moving the mouse aims the view down onto the target (' +", - " pitchError().toFixed(2) + ' degrees off).'", + " !!aim && aim.aimed,", + " 'Moving the mouse aims the view onto the target (' +", + " (aim", + " ? aim.yawDiff.toFixed(2) + ' degrees of yaw and ' +", + " aim.pitchDiff.toFixed(2) + ' of pitch off'", + " : 'the aim could not be measured') + ').'", ");", "", - "await harness.stepFrames(8);", + "// Wait for the impact effects of the click that took the pointer lock to", + "// expire. They are short lived, so counting them before and after the shot", + "// is only meaningful once the field is empty — otherwise an old one that", + "// dies while the new one is born keeps the count at 1 and the shot looks", + "// like it did nothing.", + "const fieldCleared = await harness.stepUntil(", + " () => harness.getObjects('HitParticle').length === 0,", + " { maxFrames: 60 }", + ");", + "harness.assert(", + " fieldCleared,", + " 'No impact effect is left over from taking the pointer lock.'", + ");", "const before = getTarget();", - "const particlesBefore = harness.getObjects('HitParticle').length;", "", "// Shoot (\"trigger once\": press, step, release).", "harness.setMouseButtonPressed(true);", @@ -2799,8 +2801,9 @@ "", "// The raycast hit is materialized by an impact effect at the hit point.", "const particles = harness.getObjects('HitParticle');", + "console.log('impactEffects=' + particles.length);", "harness.assert(", - " particles.length > particlesBefore,", + " particles.length > 0,", " 'The shot hits something and spawns an impact effect.'", ");", "const impactDistance = distanceBetween(particles[particles.length - 1], before);",