Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions changes/drive-mouse-hold.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
### Added

- `drive --key mouse0:<ms>` (also `mouse1`, `mouse2`) holds that mouse button over the play canvas, matching the shell's pointer binding codes, so hold-to-fire weapons can be driven headlessly. Keyboard codes are unchanged.
27 changes: 21 additions & 6 deletions packages/jgengine/src/templates/gameFiles.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { dispatchClickAt, parseClickAt } from "../clickInput";
import { escapeHtml } from "../escapeHtml";
import { driveInputPointExpr, parseDriveFill } from "./driveInput";
import { focusGameSurface, holdComplete } from "./gameSurfaceFocus";
import { focusGameSurface, gameSurfaceCenter, holdComplete, mouseButtonForCode } from "./gameSurfaceFocus";
import type { EditorSceneDoc, TemplateVariant } from "./types";

const indexHtml = (name: string) => `<!doctype html>
Expand Down Expand Up @@ -855,6 +855,7 @@ const driveMjs = `#!/usr/bin/env node
*
* node scripts/drive.mjs --click "START" --shot menu-cleared
* node scripts/drive.mjs --key KeyW:2500 --shot walked
* node scripts/drive.mjs --key mouse0:1200 --shot fired (hold a mouse button over the canvas)
* node scripts/drive.mjs --rpc '{"method":"agent_status"}'
* node scripts/drive.mjs --rpc '{"method":"debug_snapshot"}'
* node scripts/drive.mjs --playtest --key KeyW:4000 --strict
Expand Down Expand Up @@ -1004,22 +1005,36 @@ async function fill(session, label, value) {

const focusGameSurface = ${focusGameSurface.toString()};
const holdComplete = ${holdComplete.toString()};
const mouseButtonForCode = ${mouseButtonForCode.toString()};
const gameSurfaceCenter = ${gameSurfaceCenter.toString()};

async function holdKey(session, code, holdMs) {
await session.send("Page.bringToFront");
await session.evaluate("(" + focusGameSurface.toString() + ")(document)");
await session.evaluate("(() => { if (globalThis.__jgFrames !== undefined) return; globalThis.__jgFrames = 0; const tick = () => { globalThis.__jgFrames++; requestAnimationFrame(tick); }; requestAnimationFrame(tick); })()");
const startFrames = await session.evaluate("globalThis.__jgFrames ?? 0");
const deadlineMs = Date.now() + holdMs;
const key = code.startsWith("Key") ? code.slice(3).toLowerCase() : code;
await session.send("Input.dispatchKeyEvent", { type: "keyDown", code, key });
try {
const waitHold = async () => {
for (;;) {
await sleep(100);
const framesElapsed = (await session.evaluate("globalThis.__jgFrames ?? 0")) - startFrames;
if (holdComplete({ nowMs: Date.now(), deadlineMs, hardCapMs: deadlineMs + 20000, framesElapsed })) break;
}
} finally {
};
const mouse = mouseButtonForCode(code);
if (mouse !== null) {
const point = JSON.parse(await session.evaluate("JSON.stringify((" + gameSurfaceCenter.toString() + ")(document))"));
if (point === null) throw new Error("--key " + code + " needs a play canvas to hold the button over");
await session.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: point.x, y: point.y, button: "none", buttons: 0 });
await session.send("Input.dispatchMouseEvent", { type: "mousePressed", x: point.x, y: point.y, button: mouse.button, buttons: mouse.buttons, clickCount: 1 });
try { await waitHold(); } finally {
await session.send("Input.dispatchMouseEvent", { type: "mouseReleased", x: point.x, y: point.y, button: mouse.button, buttons: 0, clickCount: 1 });
}
return;
}
const key = code.startsWith("Key") ? code.slice(3).toLowerCase() : code;
await session.send("Input.dispatchKeyEvent", { type: "keyDown", code, key });
try { await waitHold(); } finally {
await session.send("Input.dispatchKeyEvent", { type: "keyUp", code, key });
}
}
Expand Down Expand Up @@ -1703,7 +1718,7 @@ Title it \`[BUG] …\` for wrong behavior or \`[FEATURE] …\` for a missing cap
- Visual claims are screenshot-judged, by you, harshly — flat untextured ground and an empty horizon fail. Prove content with \`bun test\`, prove looks with your eyes (\`jgengine-verify\` skill).
- Models live in \`public/models\`, pulled — not shipped inside the package. \`jgengine create\` pulls them for you; if that was skipped (offline, \`--no-assets\`, \`--no-install\`) run \`npx assets pull starter\` in this folder, or every \`asset:\` id falls back to an untextured placeholder primitive and fails the bar above.
- Screenshots: \`bun run shoot\` (or \`node scripts/shoot.mjs\`) captures the running game to \`shots/shot.png\` — it starts the dev server if needed, forces a real viewport so the WebGL canvas is not stuck at 300x150, waits for an honest frame, and works headless. Add \`--device mobile\`, \`--out shots/hud.png\`, \`--settle <ms>\`, or \`--url <page>\`; \`--help\` for all flags. Do **not** rely on a browser tool's "screenshot" button for the 3D canvas — it captures before the GPU draws.
- Play & test from the CLI: \`bun run drive\` (or \`node scripts/drive.mjs\`) drives the running game headlessly — ordered \`--click "TEXT"\`, \`--click-at 240,180\` (CSS viewport pixels), \`--double-click "TEXT"\`, \`--fill "Label=value"\`, \`--key KeyW:2500\`, \`--wait <ms>\`, \`--shot <name>\`, and \`--rpc '{"method":"agent_status"}'\` steps, plus \`--playtest --strict\` for a progress/softlock verdict off the game's \`capture.probe\`; \`--help\` for all flags. **Never hand-roll a Playwright/Puppeteer/CDP script to play or test this game** — if drive cannot express what you need, that is an engine gap: file it upstream (see below).
- Play & test from the CLI: \`bun run drive\` (or \`node scripts/drive.mjs\`) drives the running game headlessly — ordered \`--click "TEXT"\`, \`--click-at 240,180\` (CSS viewport pixels), \`--double-click "TEXT"\`, \`--fill "Label=value"\`, \`--key KeyW:2500\` (or \`mouse0:1200\` to hold a mouse button over the play canvas), \`--wait <ms>\`, \`--shot <name>\`, and \`--rpc '{"method":"agent_status"}'\` steps, plus \`--playtest --strict\` for a progress/softlock verdict off the game's \`capture.probe\`; \`--help\` for all flags. **Never hand-roll a Playwright/Puppeteer/CDP script to play or test this game** — if drive cannot express what you need, that is an engine gap: file it upstream (see below).
`;

const artDirectionMd = `# Art direction
Expand Down
35 changes: 35 additions & 0 deletions packages/jgengine/src/templates/gameSurfaceFocus.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, test } from "bun:test";
import { gameSurfaceCenter, mouseButtonForCode } from "./gameSurfaceFocus";

describe("mouseButtonForCode", () => {
test("maps the shell's pointer binding codes to CDP buttons and masks", () => {
expect(mouseButtonForCode("mouse0")).toEqual({ button: "left", buttons: 1 });
expect(mouseButtonForCode("mouse1")).toEqual({ button: "middle", buttons: 4 });
expect(mouseButtonForCode("mouse2")).toEqual({ button: "right", buttons: 2 });
});

test("leaves keyboard codes to the key path", () => {
expect(mouseButtonForCode("KeyW")).toBeNull();
expect(mouseButtonForCode("Space")).toBeNull();
});

test("serializes without module references, as drive.mjs embeds it", () => {
const rebuilt = new Function(`return (${mouseButtonForCode.toString()})`)() as typeof mouseButtonForCode;
expect(rebuilt("mouse2")).toEqual({ button: "right", buttons: 2 });
});
});

describe("gameSurfaceCenter", () => {
const doc = (rect: { left: number; top: number; width: number; height: number } | null) => ({
querySelector: () => (rect === null ? null : { getBoundingClientRect: () => rect }),
});

test("returns the canvas centre in viewport pixels", () => {
expect(gameSurfaceCenter(doc({ left: 0, top: 40, width: 1280, height: 640 }))).toEqual({ x: 640, y: 360 });
});

test("is null without a visible canvas", () => {
expect(gameSurfaceCenter(doc(null))).toBeNull();
expect(gameSurfaceCenter(doc({ left: 0, top: 0, width: 0, height: 0 }))).toBeNull();
});
});
25 changes: 25 additions & 0 deletions packages/jgengine/src/templates/gameSurfaceFocus.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,3 +48,28 @@ export function holdComplete(input: {
if (input.nowMs >= input.hardCapMs) return true;
return input.nowMs >= input.deadlineMs && input.framesElapsed >= 1;
}

/**
* Maps a binding code (`mouse0`/`mouse1`/`mouse2`, as the shell's action tracker names pointer
* buttons) to the CDP mouse button and its `buttons` mask, or `null` for a keyboard code — so
* `--key mouse0:1200` holds the primary button over the play surface (hold-to-fire weapons).
* Self-contained for browser serialization.
*/
export function mouseButtonForCode(code: string): { button: "left" | "middle" | "right"; buttons: number } | null {
if (code === "mouse0") return { button: "left", buttons: 1 };
if (code === "mouse1") return { button: "middle", buttons: 4 };
if (code === "mouse2") return { button: "right", buttons: 2 };
return null;
}

/**
* Centre of the play canvas in CSS viewport pixels, or `null` without one: where a held mouse
* button lands. Self-contained for browser serialization.
*/
export function gameSurfaceCenter(doc: { querySelector(selector: string): { getBoundingClientRect(): { left: number; top: number; width: number; height: number } } | null }): { x: number; y: number } | null {
const canvas = doc.querySelector("canvas");
if (canvas === null) return null;
const rect = canvas.getBoundingClientRect();
if (rect.width <= 0 || rect.height <= 0) return null;
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) };
}
Loading