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": "", 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..01536b2f5 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,209 @@ } ], "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 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: 30 }", + ");", + "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": "", 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);", 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": "", 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": "", 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": "", 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": "",