From b0e867180375ecea4746d5b0d579a9a689d0c5ed Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Tue, 15 Sep 2026 23:09:54 -0700 Subject: [PATCH 01/49] Add the game skin's frame: a lazy route, a way in, and a way out The console is correct and it is boring. This begins a second skin over the same product -- Shell Keep -- reached from a controller in the top bar and left again through a pause screen that says "Quit to boring UI" in those words, because somebody looking for the exit has stopped playing along. Nothing of the game is in the bundle the session list downloads. It is a dynamic import in App.tsx and a stylesheet imported only from inside it, so a visitor who never opens it never fetches it. That property is now checked rather than trusted: check-bundle reads the entry script and the chunks index.html preloads beside it, and fails the build if the game's marker turns up in any of them. It caught its first leak immediately. Naming the game in manualChunks looks tidier and is a trap -- Vite treats a manual chunk as part of the initial graph, so index.html came back with a modulepreload for the game and a link to its stylesheet, and every visitor paid for the keep on their way to a list of sessions. Left alone, the lazy import produces a genuine async chunk. What is here is the frame rather than the game: the options that decide whether the thing is legible on a given screen, and the input layer under it. Both exist this early because they are not decoration. A television crops the edges of the picture, so there is a safe area with a calibration target and a slider to widen it. Motion makes some people ill, so it can be stopped, and the system setting is watched rather than read once. Colour alone tells a colourblind player nothing, so every state carries an icon and a word too. Menus are driven by keyboard, pad and thumb alike. They are real buttons with real focus rather than a painted selection, because a menu whose current item is only a CSS class cannot be read aloud or reached by Tab. The index arithmetic that keeps a pad from getting trapped is pure and tested: wrapping at both ends, stepping over what cannot be chosen, and never looping when nothing can be. No button is named anywhere but in one table. Screens ask for "confirm" and are told what this player is holding, so a PlayStation pad is never told to press A and a phone is never told to press anything. --- app/scripts/check-bundle.mjs | 55 +++ app/src/App.tsx | 29 ++ app/src/components/AppShell.tsx | 84 ++++ app/src/game/GameRoute.tsx | 145 ++++++ app/src/game/engine/input.test.ts | 103 +++++ app/src/game/engine/input.ts | 195 ++++++++ app/src/game/engine/menu.test.ts | 74 +++ app/src/game/engine/menu.ts | 53 +++ app/src/game/engine/use-gamepad.ts | 101 ++++ app/src/game/engine/use-input-device.ts | 79 ++++ app/src/game/keep.ts | 29 ++ app/src/game/state/context.ts | 35 ++ app/src/game/state/options.test.ts | 80 ++++ app/src/game/state/options.ts | 127 ++++++ app/src/game/ui/Menu.tsx | 147 ++++++ app/src/game/ui/OptionsPanel.tsx | 138 ++++++ app/src/game/ui/PauseMenu.tsx | 130 ++++++ app/src/game/ui/Prompt.tsx | 27 ++ app/src/styles/game.css | 582 ++++++++++++++++++++++++ app/src/styles/shell.css | 49 ++ app/vite.config.ts | 14 + 21 files changed, 2276 insertions(+) create mode 100644 app/src/game/GameRoute.tsx create mode 100644 app/src/game/engine/input.test.ts create mode 100644 app/src/game/engine/input.ts create mode 100644 app/src/game/engine/menu.test.ts create mode 100644 app/src/game/engine/menu.ts create mode 100644 app/src/game/engine/use-gamepad.ts create mode 100644 app/src/game/engine/use-input-device.ts create mode 100644 app/src/game/keep.ts create mode 100644 app/src/game/state/context.ts create mode 100644 app/src/game/state/options.test.ts create mode 100644 app/src/game/state/options.ts create mode 100644 app/src/game/ui/Menu.tsx create mode 100644 app/src/game/ui/OptionsPanel.tsx create mode 100644 app/src/game/ui/PauseMenu.tsx create mode 100644 app/src/game/ui/Prompt.tsx create mode 100644 app/src/styles/game.css diff --git a/app/scripts/check-bundle.mjs b/app/scripts/check-bundle.mjs index 535fec0..238b4a6 100644 --- a/app/scripts/check-bundle.mjs +++ b/app/scripts/check-bundle.mjs @@ -85,4 +85,59 @@ if (blank.length > 0) { process.exit(1); } +// The game skin must not be in what the corporate view downloads. +// +// src/game carries a renderer, a sprite atlas and a stylesheet of its own, for +// a screen most sessions never open. It is reached through a dynamic import so +// that none of it is fetched until somebody asks for it, and that is the sort +// of property which holds right up until a convenient-looking direct import +// puts it back. So it is checked rather than trusted. +// +// What is checked is everything the browser loads before first paint: the entry +// script, plus the chunks Vite preloads alongside it because the entry imports +// them statically. A lazily-imported chunk appears in neither, which is exactly +// the point. +const KEEP_MARKER = "__SHELL_KEEP__"; + +async function eagerChunks(dir) { + let html; + try { + html = await readFile(join(dir, "index.html"), "utf8"); + } catch { + // No index.html means this is not a client build; nothing to check. + return []; + } + const paths = new Set(); + for (const [, src] of html.matchAll(/]+src="([^"]+\.js)"/gu)) paths.add(src); + for (const [, href] of html.matchAll( + /]+rel="modulepreload"[^>]+href="([^"]+\.js)"/gu, + )) { + paths.add(href); + } + // Written as absolute URLs against the site root; read them against dist. + return [...paths].map((path) => join(dir, path.replace(/^\//u, ""))); +} + +const leaked = []; +for (const path of await eagerChunks(directory)) { + let body; + try { + body = await readFile(path, "utf8"); + } catch { + continue; + } + if (body.includes(KEEP_MARKER)) leaked.push(path); +} + +if (leaked.length > 0) { + console.error("check-bundle: the game skin is in the bundle the session list loads"); + for (const path of leaked) console.error(` ${path}`); + console.error(" src/game is meant to be reached only through the dynamic import in"); + console.error(" src/App.tsx. Something now imports it directly, so every visitor pays"); + console.error(" for a renderer and a sprite atlas to look at a list of sessions."); + console.error(" Find the static import and make it lazy again."); + process.exit(1); +} + console.log("check-bundle: no loopback addresses in the production build."); +console.log("check-bundle: the game skin is not in the entry bundle."); diff --git a/app/src/App.tsx b/app/src/App.tsx index 7609896..16397e0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -1,5 +1,7 @@ +import { Suspense, lazy } from "react"; import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom"; import { AuthProvider } from "./auth/AuthProvider"; +import { Booting } from "./components/Booting"; import { RequireAuth, RedirectIfAuthed } from "./auth/RequireAuth"; import { SignIn } from "./routes/SignIn"; import AuthCallback from "./routes/AuthCallback"; @@ -20,6 +22,22 @@ import { VaultProvider } from "./vault/VaultProvider"; import { TeamKeyProvider } from "./vault/TeamKeyProvider"; import { FeedbackProvider } from "./feedback/FeedbackProvider"; +/* + * The game skin, and the only reference to it anywhere outside src/game. + * + * Imported this way on purpose: the keep carries an engine, a sprite atlas and + * a stylesheet of its own, and none of that belongs in the bundle somebody + * downloads to look at a list of sessions. The dynamic import puts all of it in + * a separate chunk that is fetched the first time somebody asks for it, and + * `npm run verify:bundle` fails the build if it ever leaks back into the entry + * chunk. + * + * The fallback is the app's ordinary Booting card rather than something + * game-shaped, for the same reason: anything prettier would have to be imported + * here, and then it would not be in the game's chunk either. + */ +const GameRoute = lazy(() => import("./game/GameRoute")); + export default function App() { return ( @@ -103,6 +121,17 @@ export default function App() { } /> + {/* The same product, in armour. See src/game/GameRoute.tsx. */} + + }> + + + + } + /> {/* No guard here. CliAuthorize handles the signed-out case itself so it can send the user back to this exact URL, query string included. diff --git a/app/src/components/AppShell.tsx b/app/src/components/AppShell.tsx index 4bb677c..3bdb3d2 100644 --- a/app/src/components/AppShell.tsx +++ b/app/src/components/AppShell.tsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState, type ReactNode } from "react"; import { Link, NavLink, useNavigate } from "react-router-dom"; import { Terminal, Desktop, User, UsersThree, ClockCounterClockwise, SignOut, Copy, Check, Warning, ChatCircleDots, + GameController, } from "@phosphor-icons/react"; import { Inbox } from "./Inbox"; import { Avatar } from "./Avatar"; @@ -48,6 +49,87 @@ export function LinkHint({ className = "rail-hint" }: { className?: string }) { ); } +/* + * A tooltip is not worth a flash. Hovering across a row of controls on the way + * somewhere else should not leave a trail of labels behind it, so the label + * waits to see whether the pointer meant to stop. + */ +const TOOLTIP_DELAY_MS = 300; + +/** + * The way into the game skin. + * + * Deliberately the last thing in the bar: it is a different way to look at the + * same product rather than another destination within it, and putting it in the + * rail with Sessions and Machines would have said otherwise. + * + * Pointing at it fetches the game's chunk. The click then has nothing to wait + * for, while somebody who never points at it never downloads it -- which is the + * whole reason the game is a separate chunk in the first place. + */ +function LaunchGame() { + const [hinting, setHinting] = useState(false); + const timer = useRef(0); + const warmed = useRef(false); + + const warm = () => { + if (warmed.current) return; + warmed.current = true; + /* + * The same specifier App.tsx lazily imports, so this warms that chunk + * rather than fetching a second copy of it. A failure here is not worth + * reporting: the click will simply load it the ordinary way. + */ + void import("../game/GameRoute").catch(() => { + warmed.current = false; + }); + }; + + const show = () => { + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => setHinting(true), TOOLTIP_DELAY_MS); + }; + + const hide = () => { + window.clearTimeout(timer.current); + setHinting(false); + }; + + useEffect(() => () => window.clearTimeout(timer.current), []); + + return ( +
+ { + warm(); + show(); + }} + onMouseLeave={hide} + onFocus={() => { + warm(); + setHinting(true); + }} + onBlur={hide} + > + + + {hinting && ( + + Launch Game + + )} +
+ ); +} + function AccountMenu() { const { user, signOutUser } = useAuth(); const [open, setOpen] = useState(false); @@ -204,6 +286,8 @@ export function AppShell({ title, aside, children }: AppShellProps) { + {/* Last in the bar, on purpose: see LaunchGame. */} + diff --git a/app/src/game/GameRoute.tsx b/app/src/game/GameRoute.tsx new file mode 100644 index 0000000..1be0f82 --- /dev/null +++ b/app/src/game/GameRoute.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useMemo, useState } from "react"; +import { usePageTitle } from "../lib/page-title"; +import { useInputDevice } from "./engine/use-input-device"; +import { useGamepadActions } from "./engine/use-gamepad"; +import { KEEP_TITLE, SHELL_KEEP_MARKER } from "./keep"; +import { GameShellContext, type GameShell } from "./state/context"; +import { motionReduced, optionsToStyle, readOptions, writeOptions, type GameOptions } from "./state/options"; +import { PauseMenu } from "./ui/PauseMenu"; +import { Prompt } from "./ui/Prompt"; +import "../styles/game.css"; + +/** + * The keep. + * + * This module is the only thing `App.tsx` knows about the game, and it is + * reached through a dynamic import, so none of it -- not the engine, not the + * sprites, not this stylesheet -- is in the bundle somebody gets when they + * open the session list. `scripts/check-bundle.mjs` fails the build if that + * ever stops being true. + * + * What it owns is the frame around the game: the options that decide whether + * the thing is legible on this screen, which device is in the player's hands, + * and whether the simulation is running. The field itself is drawn by scenes + * mounted inside it. + */ +export default function GameRoute() { + usePageTitle(KEEP_TITLE); + + const [options, setOptionsState] = useState(readOptions); + const [paused, setPaused] = useState(false); + const device = useInputDevice(); + + /* + * The OS setting is watched rather than read once. Somebody who turns + * "reduce motion" on because the game is making them ill should not have to + * reload the game to get the benefit of it. + */ + const [systemReduced, setSystemReduced] = useState( + () => window.matchMedia?.("(prefers-reduced-motion: reduce)").matches ?? false, + ); + useEffect(() => { + const query = window.matchMedia?.("(prefers-reduced-motion: reduce)"); + if (!query) return; + const onChange = (event: MediaQueryListEvent) => setSystemReduced(event.matches); + query.addEventListener("change", onChange); + return () => query.removeEventListener("change", onChange); + }, []); + + const setOptions = useCallback((next: GameOptions) => { + setOptionsState(next); + writeOptions(next); + }, []); + + const reducedMotion = motionReduced(options, systemReduced); + + /* + * The game takes the window. The corporate shell scrolls; a field that + * scrolls underneath a fixed HUD is a field somebody loses their heroes off + * the bottom of, so the body is held still for as long as this is mounted. + */ + useEffect(() => { + const previous = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { + document.body.style.overflow = previous; + }; + }, []); + + /* Escape pauses, and pauses again out of whatever the pause menu opened. */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Escape" || paused) return; + event.preventDefault(); + setPaused(true); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [paused]); + + /* + * Start on the pad opens the pause menu. Only while play is running: the + * menu handles its own input once it is up, and two listeners fighting over + * the same button is a menu that opens and closes on one press. + */ + useGamepadActions( + useCallback((action) => { + if (action === "pause") setPaused(true); + }, []), + !paused, + ); + + const shell = useMemo( + () => ({ options, setOptions, reducedMotion, device, paused, setPaused }), + [options, setOptions, reducedMotion, device, paused], + ); + + const style = optionsToStyle(options, systemReduced) as React.CSSProperties; + + return ( + +
+ {/* + * Everything that must survive a television sits inside this. The + * field may bleed to the edges; the things you need to read may not. + */} +
+
+

{KEEP_TITLE}

+ +
+ +
+ {/* + * Stage 2 mounts the canvas layers here. Until then the frame is + * real, which is what makes the lazy-loading and the pause screen + * testable before there is anything to look at. + */} +

The field is being surveyed.

+ +
+
+ + {paused && setPaused(false)} />} +
+
+ ); +} diff --git a/app/src/game/engine/input.test.ts b/app/src/game/engine/input.test.ts new file mode 100644 index 0000000..ed3a841 --- /dev/null +++ b/app/src/game/engine/input.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; +import { + DEVICE_SWITCH_GRACE_MS, + actionForKey, + actionForPadButton, + deviceForGamepad, + promptFor, + promptLabel, + shouldSwitchDevice, + type GameAction, + type InputDevice, +} from "./input"; + +const DEVICES: InputDevice[] = ["keyboard", "xbox", "playstation", "nintendo", "touch"]; +const ACTIONS: GameAction[] = [ + "confirm", "cancel", "pause", "inspect", + "up", "down", "left", "right", "tabPrev", "tabNext", +]; + +describe("naming the button", () => { + it("has a name for every action on every device", () => { + /* + * The point of the whole module: no combination may come back empty, or a + * prompt somewhere reads "Press to muster". + */ + for (const device of DEVICES) { + for (const action of ACTIONS) { + expect(promptFor(action, device)).toBeTruthy(); + } + } + }); + + it("names the button each platform actually has", () => { + expect(promptFor("confirm", "playstation")).toBe("✕"); + expect(promptFor("cancel", "playstation")).toBe("◯"); + expect(promptFor("confirm", "xbox")).toBe("A"); + expect(promptFor("confirm", "keyboard")).toBe("Enter"); + }); + + it("never tells a touch player to press anything", () => { + expect(promptLabel("confirm", "touch", "muster")).toBe("Tap to muster"); + expect(promptLabel("confirm", "touch", "muster")).not.toContain("Press"); + }); + + it("puts the verb after the button, so the sentence reads", () => { + expect(promptLabel("confirm", "xbox", "muster")).toBe("Press A to muster"); + }); +}); + +describe("recognising a pad", () => { + it("reads the vendor out of the id", () => { + expect(deviceForGamepad("Wireless Controller (STANDARD GAMEPAD Vendor: 054c)")).toBe("xbox"); + expect(deviceForGamepad("DualSense Wireless Controller")).toBe("playstation"); + expect(deviceForGamepad("Xbox Wireless Controller")).toBe("xbox"); + expect(deviceForGamepad("Pro Controller (Nintendo)")).toBe("nintendo"); + }); + + it("guesses the commonest layout for a pad it does not know", () => { + /* + * Better than refusing to show a prompt at all: confirm and cancel are in + * the same two physical places on almost everything. + */ + expect(deviceForGamepad("Generic USB Joystick")).toBe("xbox"); + expect(deviceForGamepad("")).toBe("xbox"); + }); +}); + +describe("switching between devices", () => { + it("ignores an input from the device already in use", () => { + expect(shouldSwitchDevice("xbox", "xbox", 10_000)).toBe(false); + }); + + it("refuses a switch while the current device is still being used", () => { + /* A mouse brushed on the desk should not relabel a pad player's screen. */ + expect(shouldSwitchDevice("xbox", "keyboard", 10)).toBe(false); + }); + + it("switches once the old device has gone quiet", () => { + expect(shouldSwitchDevice("xbox", "keyboard", DEVICE_SWITCH_GRACE_MS)).toBe(true); + }); +}); + +describe("what a press means", () => { + it("maps the keys a menu needs", () => { + expect(actionForKey("Enter")).toBe("confirm"); + expect(actionForKey(" ")).toBe("confirm"); + expect(actionForKey("Escape")).toBe("cancel"); + expect(actionForKey("ArrowDown")).toBe("down"); + }); + + it("ignores a key that is not bound", () => { + expect(actionForKey("F7")).toBeUndefined(); + expect(actionForKey("z")).toBeUndefined(); + }); + + it("maps the standard pad buttons, and nothing beyond them", () => { + expect(actionForPadButton(0)).toBe("confirm"); + expect(actionForPadButton(1)).toBe("cancel"); + expect(actionForPadButton(9)).toBe("pause"); + expect(actionForPadButton(13)).toBe("down"); + expect(actionForPadButton(17)).toBeUndefined(); + }); +}); diff --git a/app/src/game/engine/input.ts b/app/src/game/engine/input.ts new file mode 100644 index 0000000..eef5633 --- /dev/null +++ b/app/src/game/engine/input.ts @@ -0,0 +1,195 @@ +/** + * What the player can ask for, and what to call the button that asks for it. + * + * Nothing in the game writes "Press A". It writes `promptFor("confirm", device)` + * and gets back whatever that player's hardware actually has, because "Press A" + * is wrong for a PlayStation pad, wrong for a keyboard, wrong for a phone, and + * wrong for anybody who has rebound it. Showing a button somebody does not have + * is how a tutorial becomes unfollowable. + * + * The device is whichever one was used last. Games that pick a device at launch + * and keep it get this wrong the moment somebody puts the pad down. + */ + +export type GameAction = + | "confirm" + | "cancel" + | "pause" + | "inspect" + | "up" + | "down" + | "left" + | "right" + | "tabPrev" + | "tabNext"; + +export type InputDevice = "keyboard" | "xbox" | "playstation" | "nintendo" | "touch"; + +/** The default binding per device. Rebinding replaces the entry, not the call site. */ +const BINDINGS: Record> = { + keyboard: { + confirm: "Enter", + cancel: "Esc", + pause: "Esc", + inspect: "Shift", + up: "↑", + down: "↓", + left: "←", + right: "→", + tabPrev: "Q", + tabNext: "E", + }, + xbox: { + confirm: "A", + cancel: "B", + pause: "Menu", + inspect: "Y", + up: "D-pad ↑", + down: "D-pad ↓", + left: "D-pad ←", + right: "D-pad →", + tabPrev: "LB", + tabNext: "RB", + }, + playstation: { + confirm: "✕", + cancel: "◯", + pause: "Options", + inspect: "△", + up: "D-pad ↑", + down: "D-pad ↓", + left: "D-pad ←", + right: "D-pad →", + tabPrev: "L1", + tabNext: "R1", + }, + nintendo: { + confirm: "A", + cancel: "B", + pause: "+", + inspect: "X", + up: "D-pad ↑", + down: "D-pad ↓", + left: "D-pad ←", + right: "D-pad →", + tabPrev: "L", + tabNext: "R", + }, + touch: { + confirm: "Tap", + cancel: "Back", + pause: "Pause", + inspect: "Hold", + up: "Swipe up", + down: "Swipe down", + left: "Swipe left", + right: "Swipe right", + tabPrev: "Swipe left", + tabNext: "Swipe right", + }, +}; + +/** What to call the button for an action on the device in the player's hands. */ +export function promptFor(action: GameAction, device: InputDevice): string { + return BINDINGS[device][action]; +} + +/** + * A whole prompt, verb first. + * + * "Press Enter to muster" reads better to somebody who has never played this + * than "Enter — muster", and a new player is the only one who needs it. + */ +export function promptLabel(action: GameAction, device: InputDevice, verb: string): string { + const button = promptFor(action, device); + if (device === "touch") return `${button} to ${verb}`; + return `Press ${button} to ${verb}`; +} + +/** + * Which pad this is, from the id the browser reports. + * + * Gamepad ids are vendor strings with no schema, so this is a best guess that + * falls back to the most common layout rather than to nothing. Guessing Xbox + * for an unknown pad is right far more often than it is wrong, and the labels + * for confirm and cancel are in the same physical places either way. + */ +export function deviceForGamepad(id: string): InputDevice { + const lower = id.toLowerCase(); + if (/playstation|dualshock|dualsense|sony|\bps[345]\b/.test(lower)) return "playstation"; + if (/nintendo|switch|joy-con|joycon|pro controller/.test(lower)) return "nintendo"; + return "xbox"; +} + +/** + * How long a device stays "current" after it was used. + * + * A mouse that brushes the desk while a pad is held should not flip every + * prompt on screen for one frame, so a switch has to be deliberate: the new + * device wins only once the old one has been quiet this long. + */ +export const DEVICE_SWITCH_GRACE_MS = 400; + +/** + * Whether an input from `next` should take over from `current`. + * + * Pure, so the flapping rule is testable without wiring up real hardware. + */ +export function shouldSwitchDevice( + current: InputDevice, + next: InputDevice, + msSinceCurrentUsed: number, +): boolean { + if (current === next) return false; + return msSinceCurrentUsed >= DEVICE_SWITCH_GRACE_MS; +} + +/** Standard gamepad button indices, named so the mapping below reads. */ +const PAD_BUTTON: Partial> = { + 0: "confirm", + 1: "cancel", + 3: "inspect", + 4: "tabPrev", + 5: "tabNext", + 9: "pause", + 12: "up", + 13: "down", + 14: "left", + 15: "right", +}; + +export function actionForPadButton(index: number): GameAction | undefined { + return PAD_BUTTON[index]; +} + +/** + * The action a key press means, or nothing when the key is not bound. + * + * Escape is both cancel and pause; which one it is depends on whether anything + * is open to cancel, and that is the caller's question rather than this one's. + */ +export function actionForKey(key: string): GameAction | undefined { + switch (key) { + case "Enter": + case " ": + return "confirm"; + case "Escape": + return "cancel"; + case "ArrowUp": + return "up"; + case "ArrowDown": + return "down"; + case "ArrowLeft": + return "left"; + case "ArrowRight": + return "right"; + case "q": + case "Q": + return "tabPrev"; + case "e": + case "E": + return "tabNext"; + default: + return undefined; + } +} diff --git a/app/src/game/engine/menu.test.ts b/app/src/game/engine/menu.test.ts new file mode 100644 index 0000000..afa5be0 --- /dev/null +++ b/app/src/game/engine/menu.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from "vitest"; +import { firstEnabled, nextIndex, restoreIndex } from "./menu"; + +/* + * The rule these protect is "a pad can always get out of a menu". Every case + * below is a dead end somebody would otherwise find by picking up a controller + * and discovering the game had stopped responding. + */ + +const all = (count: number) => Array.from({ length: count }, () => true); + +describe("moving through a menu", () => { + it("steps forward and back", () => { + expect(nextIndex(0, 1, all(3))).toBe(1); + expect(nextIndex(2, -1, all(3))).toBe(1); + }); + + it("wraps at both ends, so neither is a wall", () => { + expect(nextIndex(2, 1, all(3))).toBe(0); + expect(nextIndex(0, -1, all(3))).toBe(2); + }); + + it("steps over a disabled item rather than landing on it", () => { + /* Resume, [unaffordable], Quit. */ + expect(nextIndex(0, 1, [true, false, true])).toBe(2); + expect(nextIndex(2, 1, [true, false, true])).toBe(0); + }); + + it("steps over a run of disabled items", () => { + expect(nextIndex(0, 1, [true, false, false, false, true])).toBe(4); + }); + + it("stays put when nothing at all can be selected", () => { + /* Rather than looping forever looking for somewhere to go. */ + expect(nextIndex(1, 1, [false, false, false])).toBe(1); + }); + + it("stays put in an empty menu", () => { + expect(nextIndex(0, 1, [])).toBe(0); + }); + + it("does not get stuck on the only selectable item", () => { + expect(nextIndex(1, 1, [false, true, false])).toBe(1); + }); +}); + +describe("where a menu opens", () => { + it("lands on the first thing that can be chosen", () => { + expect(firstEnabled([false, false, true])).toBe(2); + }); + + it("falls back to the top when nothing can be chosen", () => { + expect(firstEnabled([false, false])).toBe(0); + }); +}); + +describe("returning to a menu", () => { + it("puts you back where you were", () => { + expect(restoreIndex(2, all(4))).toBe(2); + }); + + it("clamps a position the menu has since outgrown", () => { + /* Items were removed while this menu was closed. */ + expect(restoreIndex(9, all(3))).toBe(2); + }); + + it("moves on when the remembered item has become unaffordable", () => { + expect(restoreIndex(1, [true, false, true])).toBe(2); + }); + + it("survives a menu that has emptied", () => { + expect(restoreIndex(3, [])).toBe(0); + }); +}); diff --git a/app/src/game/engine/menu.ts b/app/src/game/engine/menu.ts new file mode 100644 index 0000000..45edd58 --- /dev/null +++ b/app/src/game/engine/menu.ts @@ -0,0 +1,53 @@ +/** + * Menu navigation that a pad can actually drive. + * + * The rule the skill is emphatic about is that every interactive element must + * be reachable and nothing may trap focus. That is mostly index arithmetic, so + * it lives here as pure functions: a dead end is a bug you want a test to + * catch, not one you want to find by picking up a controller. + */ + +/** + * The next selectable index in a direction, wrapping at the ends. + * + * Wrapping matters more than it sounds: without it the last item is a wall, + * and on a pad a wall reads as the menu having stopped responding. Disabled + * items are stepped over rather than landed on, so a greyed-out purchase never + * swallows the stick. + * + * Returns the current index when nothing is selectable, which is the only + * honest answer and keeps the caller from looping forever. + */ +export function nextIndex(current: number, delta: number, enabled: boolean[]): number { + const count = enabled.length; + if (count === 0) return current; + if (!enabled.some(Boolean)) return current; + + let index = current; + for (let step = 0; step < count; step += 1) { + index = (index + delta + count) % count; + if (enabled[index]) return index; + } + return current; +} + +/** The first thing a menu should land on when it opens. */ +export function firstEnabled(enabled: boolean[]): number { + const index = enabled.findIndex(Boolean); + return index === -1 ? 0 : index; +} + +/** + * Keeps a remembered position usable after the menu behind it has changed. + * + * Coming back to a menu should put you where you were -- but "where you were" + * may since have been removed, or become unaffordable and therefore disabled. + * Clamping and then re-seeking is what stops a remembered index from selecting + * nothing at all. + */ +export function restoreIndex(remembered: number, enabled: boolean[]): number { + if (enabled.length === 0) return 0; + const clamped = Math.min(Math.max(remembered, 0), enabled.length - 1); + if (enabled[clamped]) return clamped; + return nextIndex(clamped, 1, enabled); +} diff --git a/app/src/game/engine/use-gamepad.ts b/app/src/game/engine/use-gamepad.ts new file mode 100644 index 0000000..2ad66af --- /dev/null +++ b/app/src/game/engine/use-gamepad.ts @@ -0,0 +1,101 @@ +import { useEffect, useRef } from "react"; +import { actionForPadButton, type GameAction } from "./input"; + +/** How far the stick must move before it counts as a direction, not a wobble. */ +const STICK_THRESHOLD = 0.6; +/** Held-direction repeat, matching a comfortable key-repeat rather than frame rate. */ +const REPEAT_FIRST_MS = 420; +const REPEAT_NEXT_MS = 120; + +const DIRECTIONS: GameAction[] = ["up", "down", "left", "right"]; + +/** + * Turns a gamepad into the same actions a keyboard produces. + * + * Menus should not know which of the two they are being driven by; they get + * "down" and "confirm" either way. Doing the translation here is what makes + * "every menu is navigable with a pad" a property of the app rather than + * something each screen has to remember to implement. + * + * Polls, because the Gamepad API has no events for button state. Runs only + * while mounted, does nothing while the document is hidden, and allocates + * nothing per frame when no pad is connected. + */ +export function useGamepadActions(onAction: (action: GameAction) => void, active = true): void { + /* Through a ref so a changing handler does not restart the polling loop. */ + const handler = useRef(onAction); + handler.current = onAction; + + useEffect(() => { + if (!active) return; + if (typeof navigator.getGamepads !== "function") return; + + let frame = 0; + /* Which actions were held on the previous frame, to find the edges. */ + let previous = new Set(); + /* When a held direction is due to fire again. */ + const repeatAt = new Map(); + + const poll = () => { + frame = requestAnimationFrame(poll); + if (document.hidden) return; + + const now = performance.now(); + /* + * Gathered across every pad and every source first, then compared. The + * d-pad and the left stick both produce "up", and two passes that each + * set the held state would cancel each other out -- the pad would fire + * "up" on every single frame, which reads as a menu that has gone mad. + */ + const held = new Set(); + const pads = navigator.getGamepads?.() ?? []; + + for (const pad of pads) { + if (!pad) continue; + + pad.buttons.forEach((button, index) => { + if (!button.pressed) return; + const action = actionForPadButton(index); + if (action) held.add(action); + }); + + /* The left stick does what the d-pad does; players expect both. */ + const [x = 0, y = 0] = pad.axes; + if (x < -STICK_THRESHOLD) held.add("left"); + if (x > STICK_THRESHOLD) held.add("right"); + if (y < -STICK_THRESHOLD) held.add("up"); + if (y > STICK_THRESHOLD) held.add("down"); + } + + /* Newly pressed fires immediately and arms the repeat. */ + for (const action of held) { + if (!previous.has(action)) { + handler.current(action); + repeatAt.set(action, now + REPEAT_FIRST_MS); + } + } + + /* + * Only directions repeat while held. A held confirm that fired every + * 120ms would buy the whole shop, which is the sort of thing a player + * discovers only once the gold has gone. + */ + for (const action of DIRECTIONS) { + if (!held.has(action)) { + repeatAt.delete(action); + continue; + } + const due = repeatAt.get(action); + if (due !== undefined && now >= due) { + handler.current(action); + repeatAt.set(action, now + REPEAT_NEXT_MS); + } + } + + previous = held; + }; + + poll(); + return () => cancelAnimationFrame(frame); + }, [active]); +} diff --git a/app/src/game/engine/use-input-device.ts b/app/src/game/engine/use-input-device.ts new file mode 100644 index 0000000..503cf4d --- /dev/null +++ b/app/src/game/engine/use-input-device.ts @@ -0,0 +1,79 @@ +import { useEffect, useRef, useState } from "react"; +import { + deviceForGamepad, + shouldSwitchDevice, + type InputDevice, +} from "./input"; + +/** + * Which device the player is using right now. + * + * Listens rather than asks: there is no way to enquire what somebody is + * holding, only to notice what they last touched. A pad that is connected but + * resting should not win over the keyboard being typed on, so connection alone + * does not count -- a button has to move. + */ +export function useInputDevice(initial: InputDevice = "keyboard"): InputDevice { + const [device, setDevice] = useState(initial); + /* When the current device was last used, for the anti-flapping grace. */ + const lastUsed = useRef(0); + const currentRef = useRef(device); + currentRef.current = device; + + useEffect(() => { + const now = () => performance.now(); + lastUsed.current = now(); + + const offer = (next: InputDevice) => { + const elapsed = now() - lastUsed.current; + if (currentRef.current === next) { + lastUsed.current = now(); + return; + } + if (!shouldSwitchDevice(currentRef.current, next, elapsed)) return; + lastUsed.current = now(); + setDevice(next); + }; + + const onKey = () => offer("keyboard"); + const onPointer = (event: PointerEvent) => { + offer(event.pointerType === "touch" || event.pointerType === "pen" ? "touch" : "keyboard"); + }; + + window.addEventListener("keydown", onKey); + window.addEventListener("pointerdown", onPointer); + + /* + * Pads do not raise events. The only way to know a button moved is to look, + * so this polls -- but only while the game is mounted and visible, and only + * at a rate a menu needs. The game loop polls its own copy at frame rate + * for actual play; this is just for keeping the prompts honest. + */ + let frame = 0; + const pressed = new Map(); + const poll = () => { + frame = window.setTimeout(poll, 120); + const pads = navigator.getGamepads?.() ?? []; + for (const pad of pads) { + if (!pad) continue; + let moved = false; + pad.buttons.forEach((button, index) => { + const was = pressed.get(index) ?? false; + if (button.pressed && !was) moved = true; + pressed.set(index, button.pressed); + }); + if (pad.axes.some((axis) => Math.abs(axis) > 0.5)) moved = true; + if (moved) offer(deviceForGamepad(pad.id)); + } + }; + poll(); + + return () => { + window.removeEventListener("keydown", onKey); + window.removeEventListener("pointerdown", onPointer); + window.clearTimeout(frame); + }; + }, []); + + return device; +} diff --git a/app/src/game/keep.ts b/app/src/game/keep.ts new file mode 100644 index 0000000..b62d65a --- /dev/null +++ b/app/src/game/keep.ts @@ -0,0 +1,29 @@ +/** + * Identity of the game chunk. + * + * SHELL_KEEP_MARKER is load-bearing: `scripts/check-bundle.mjs` asserts that + * this string does NOT appear in the entry chunk the corporate view loads. The + * game is a lazily-imported route and it has to stay that way, so the build + * fails rather than quietly shipping a game engine to somebody who only wanted + * a session list. + * + * It is written onto the DOM in GameRoute rather than left in a constant a + * minifier could fold away, because a marker that gets tree-shaken makes the + * check pass for the wrong reason. + */ +export const SHELL_KEEP_MARKER = "__SHELL_KEEP__"; + +/** Shown in the pause menu, so a bug report can say which build it came from. */ +export const KEEP_BUILD = __SHELL_ONLINE_VERSION__; + +/** + * Where "Quit to boring UI" goes. + * + * The session list, not whatever route was visited last: the game can be + * entered from anywhere, and leaving it should land on the page the game is a + * skin over rather than back on, say, the terms page. + */ +export const BORING_UI = "/sessions"; + +/** The name on the tab and the banner. */ +export const KEEP_TITLE = "Shell Keep"; diff --git a/app/src/game/state/context.ts b/app/src/game/state/context.ts new file mode 100644 index 0000000..f3f7479 --- /dev/null +++ b/app/src/game/state/context.ts @@ -0,0 +1,35 @@ +import { createContext, useContext } from "react"; +import type { InputDevice } from "../engine/input"; +import { DEFAULT_OPTIONS, type GameOptions } from "./options"; + +/** + * The few things every screen in the keep needs to draw itself correctly: + * what the player is holding, how big they want the interface, and whether + * they want it to move. + * + * Deliberately small. Game state proper (heroes, the base, the purse) is + * fetched and owned separately; this is presentation, and presentation is + * needed by the boot screen before there is any game state at all. + */ +export interface GameShell { + options: GameOptions; + setOptions: (next: GameOptions) => void; + /** Resolved against the OS setting, so callers do not repeat that decision. */ + reducedMotion: boolean; + device: InputDevice; + paused: boolean; + setPaused: (paused: boolean) => void; +} + +export const GameShellContext = createContext({ + options: DEFAULT_OPTIONS, + setOptions: () => {}, + reducedMotion: false, + device: "keyboard", + paused: false, + setPaused: () => {}, +}); + +export function useGameShell(): GameShell { + return useContext(GameShellContext); +} diff --git a/app/src/game/state/options.test.ts b/app/src/game/state/options.test.ts new file mode 100644 index 0000000..c5db57b --- /dev/null +++ b/app/src/game/state/options.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + DEFAULT_OPTIONS, + motionReduced, + normaliseOptions, + optionsToStyle, + SAFE_ZONE_RANGE, + UI_SCALE_RANGE, +} from "./options"; + +describe("bringing stored options into range", () => { + it("keeps values that are already sensible", () => { + const options = { safeZone: 7, uiScale: 150, motion: "reduced", colour: "protanopia" }; + expect(normaliseOptions(options)).toEqual(options); + }); + + it("clamps a safe area beyond what the slider offers", () => { + /* + * A safe area of 80% is a game played through a letterbox. Whatever wrote + * it -- an old build, a hand-edited value -- the renderer has to be handed + * something it can draw. + */ + expect(normaliseOptions({ safeZone: 80 }).safeZone).toBe(SAFE_ZONE_RANGE.max); + expect(normaliseOptions({ safeZone: -20 }).safeZone).toBe(SAFE_ZONE_RANGE.min); + }); + + it("clamps an interface scale to something readable", () => { + expect(normaliseOptions({ uiScale: 5 }).uiScale).toBe(UI_SCALE_RANGE.min); + expect(normaliseOptions({ uiScale: 1000 }).uiScale).toBe(UI_SCALE_RANGE.max); + }); + + it("falls back for a setting it does not recognise", () => { + expect(normaliseOptions({ motion: "spinny" }).motion).toBe(DEFAULT_OPTIONS.motion); + expect(normaliseOptions({ colour: "beige" }).colour).toBe(DEFAULT_OPTIONS.colour); + }); + + it("survives anything at all, because localStorage can hold anything at all", () => { + expect(normaliseOptions(null)).toEqual(DEFAULT_OPTIONS); + expect(normaliseOptions("not an object")).toEqual(DEFAULT_OPTIONS); + expect(normaliseOptions(42)).toEqual(DEFAULT_OPTIONS); + expect(normaliseOptions({ safeZone: Number.NaN })).toEqual(DEFAULT_OPTIONS); + }); + + it("defaults to a safe area a television will not eat", () => { + /* + * The recoverable mistake is a little wasted margin on a monitor. The + * unrecoverable one is a player who cannot see the menu they would need in + * order to fix it, so the default errs towards the monitor's loss. + */ + expect(DEFAULT_OPTIONS.safeZone).toBeGreaterThan(0); + }); +}); + +describe("deciding whether to animate", () => { + it("follows the system when asked to", () => { + const options = { ...DEFAULT_OPTIONS, motion: "system" as const }; + expect(motionReduced(options, true)).toBe(true); + expect(motionReduced(options, false)).toBe(false); + }); + + it("lets an explicit choice override the system either way", () => { + expect(motionReduced({ ...DEFAULT_OPTIONS, motion: "full" }, true)).toBe(false); + expect(motionReduced({ ...DEFAULT_OPTIONS, motion: "reduced" }, false)).toBe(true); + }); +}); + +describe("handing the options to the stylesheet", () => { + it("writes the custom properties game.css reads", () => { + const style = optionsToStyle({ safeZone: 8, uiScale: 125, motion: "full", colour: "default" }, false); + expect(style["--keep-safe"]).toBe("8%"); + expect(style["--keep-scale"]).toBe("1.25"); + expect(style["--keep-motion"]).toBe("1"); + }); + + it("collapses every duration to nothing when motion is reduced", () => { + /* game.css multiplies its durations by this, so zero stops all of them. */ + const style = optionsToStyle({ ...DEFAULT_OPTIONS, motion: "reduced" }, false); + expect(style["--keep-motion"]).toBe("0"); + }); +}); diff --git a/app/src/game/state/options.ts b/app/src/game/state/options.ts new file mode 100644 index 0000000..8a818d4 --- /dev/null +++ b/app/src/game/state/options.ts @@ -0,0 +1,127 @@ +/** + * Display options for the game, kept per browser. + * + * These are not preferences in the ordinary product sense; every one of them + * exists because a game that ignores it is unplayable for somebody: + * + * safeZone a TV cuts 3-10% off every edge, so a HUD pinned to the corner + * is a HUD that person never sees. Adjustable, because how much + * is cut varies by set and nobody can detect it from script. + * uiScale the same layout is viewed from a phone at arm's length and a + * 4K display across a room. One pixel size cannot serve both. + * motion parallax, shake and particle drift trigger motion sickness. + * colour roughly 8% of men cannot separate the red and green that games + * lean on. Every state carries an icon and a label as well, but + * the palette should not fight them either. + * + * Stored locally rather than on the account, deliberately: which display you + * are sitting at is a property of the browser, not of who you are. Sign in on + * the TV and the phone and each keeps its own. + */ + +export type MotionSetting = "system" | "full" | "reduced"; +export type ColourSetting = "default" | "deuteranopia" | "protanopia" | "tritanopia"; + +export interface GameOptions { + /** Percent of each edge treated as unsafe. 0 for a monitor, 5+ for a TV. */ + safeZone: number; + /** Percent. 100 is the reference design. */ + uiScale: number; + motion: MotionSetting; + colour: ColourSetting; +} + +export const DEFAULT_OPTIONS: GameOptions = { + /* + * Conservative by default. A 5% inset on a monitor costs a little room; a + * 0% default on a television costs the player their health bar, and only one + * of those two mistakes is recoverable by someone who cannot see the menu + * they would need to fix it in. + */ + safeZone: 5, + uiScale: 100, + motion: "system", + colour: "default", +}; + +export const SAFE_ZONE_RANGE = { min: 0, max: 10 } as const; +export const UI_SCALE_RANGE = { min: 50, max: 200 } as const; + +const MOTION_VALUES: MotionSetting[] = ["system", "full", "reduced"]; +const COLOUR_VALUES: ColourSetting[] = ["default", "deuteranopia", "protanopia", "tritanopia"]; + +const STORAGE_KEY = "shell-online-keep-options"; + +function clampNumber(value: unknown, fallback: number, min: number, max: number): number { + const numeric = typeof value === "number" ? value : Number(value); + if (!Number.isFinite(numeric)) return fallback; + return Math.min(max, Math.max(min, Math.round(numeric))); +} + +function oneOf(value: unknown, allowed: T[], fallback: T): T { + return allowed.includes(value as T) ? (value as T) : fallback; +} + +/** + * Brings anything at all into the shape the renderer can trust. + * + * Separate from the storage read so it can be tested without a DOM, and so a + * value that arrives from the service later goes through the same gate as one + * that came out of localStorage. + */ +export function normaliseOptions(input: unknown): GameOptions { + const raw = (typeof input === "object" && input !== null ? input : {}) as Partial; + return { + safeZone: clampNumber(raw.safeZone, DEFAULT_OPTIONS.safeZone, SAFE_ZONE_RANGE.min, SAFE_ZONE_RANGE.max), + uiScale: clampNumber(raw.uiScale, DEFAULT_OPTIONS.uiScale, UI_SCALE_RANGE.min, UI_SCALE_RANGE.max), + motion: oneOf(raw.motion, MOTION_VALUES, DEFAULT_OPTIONS.motion), + colour: oneOf(raw.colour, COLOUR_VALUES, DEFAULT_OPTIONS.colour), + }; +} + +export function readOptions(): GameOptions { + try { + const stored = localStorage.getItem(STORAGE_KEY); + return normaliseOptions(stored ? JSON.parse(stored) : null); + } catch { + /* Private window, blocked storage, or something that is not JSON. */ + return { ...DEFAULT_OPTIONS }; + } +} + +export function writeOptions(options: GameOptions): void { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(normaliseOptions(options))); + } catch { + /* The choice is lost on reload, which is survivable; failing is not. */ + } +} + +/** + * Whether animation should be held back, resolving "system" against the OS. + * + * Taken as an argument rather than read here so the decision stays pure: the + * caller owns the media query and can re-run this when it changes. + */ +export function motionReduced(options: GameOptions, systemPrefersReduced: boolean): boolean { + if (options.motion === "reduced") return true; + if (options.motion === "full") return false; + return systemPrefersReduced; +} + +/** + * The CSS custom properties the game's stylesheet reads. + * + * Returned as a plain record so the caller can apply it to whichever element + * scopes the game, and so a test can assert on the values without a browser. + */ +export function optionsToStyle( + options: GameOptions, + systemPrefersReduced: boolean, +): Record { + return { + "--keep-safe": `${options.safeZone}%`, + "--keep-scale": String(options.uiScale / 100), + "--keep-motion": motionReduced(options, systemPrefersReduced) ? "0" : "1", + }; +} diff --git a/app/src/game/ui/Menu.tsx b/app/src/game/ui/Menu.tsx new file mode 100644 index 0000000..266b322 --- /dev/null +++ b/app/src/game/ui/Menu.tsx @@ -0,0 +1,147 @@ +import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { actionForKey, type GameAction } from "../engine/input"; +import { firstEnabled, nextIndex, restoreIndex } from "../engine/menu"; +import { useGamepadActions } from "../engine/use-gamepad"; + +export interface MenuItem { + id: string; + label: string; + /** A second line, for what the choice costs or what it will do. */ + detail?: string; + icon?: ReactNode; + disabled?: boolean; + /** Draws it as the way out, and never as the item focus lands on first. */ + danger?: boolean; + onSelect: () => void; +} + +/** + * A vertical menu that a keyboard, a pad and a thumb can all drive. + * + * Real ` + ))} + + ); +} diff --git a/app/src/game/ui/OptionsPanel.tsx b/app/src/game/ui/OptionsPanel.tsx new file mode 100644 index 0000000..9caf23a --- /dev/null +++ b/app/src/game/ui/OptionsPanel.tsx @@ -0,0 +1,138 @@ +import { useGameShell } from "../state/context"; +import { + SAFE_ZONE_RANGE, + UI_SCALE_RANGE, + type ColourSetting, + type MotionSetting, +} from "../state/options"; + +const MOTION_CHOICES: { value: MotionSetting; label: string; detail: string }[] = [ + { value: "system", label: "Match my system", detail: "Follows the setting on this device" }, + { value: "full", label: "Full", detail: "Shake, drift and particles" }, + { value: "reduced", label: "Reduced", detail: "Still frames, no shake, no particles" }, +]; + +const COLOUR_CHOICES: { value: ColourSetting; label: string }[] = [ + { value: "default", label: "Default" }, + { value: "deuteranopia", label: "Deuteranopia" }, + { value: "protanopia", label: "Protanopia" }, + { value: "tritanopia", label: "Tritanopia" }, +]; + +/** + * The settings that decide whether the game is playable at all on a given + * screen, for a given person. + * + * Grouped with the reason above each one rather than a bare label. "Safe area" + * means nothing to somebody whose television is eating their health bar; "if + * the corners of the board are cut off, raise this" tells them what to do. + */ +export function OptionsPanel({ onBack }: { onBack: () => void }) { + const { options, setOptions } = useGameShell(); + + return ( +
+
+ Safe area +

+ Televisions crop the edges of the picture. If the corners of the frame below are cut + off, raise this until all four are visible. +

+
+ setOptions({ ...options, safeZone: Number(event.target.value) })} + /> + {options.safeZone}% +
+ {/* + * A calibration target, not decoration. It sits exactly on the inset + * the slider sets, so "can you see all four corners" is a question + * somebody can answer by looking rather than by guessing at a number. + */} + +
+ +
+ Interface size +

+ Larger for a television across the room, smaller for a monitor at arm’s length. +

+
+ setOptions({ ...options, uiScale: Number(event.target.value) })} + /> + {options.uiScale}% +
+
+ +
+ Motion +

+ Drifting and shaking can cause nausea. Reduced keeps everything readable and still. +

+
+ {MOTION_CHOICES.map((choice) => ( + + ))} +
+
+ +
+ Colour +

+ Every state in the keep carries an icon and a word as well as a colour. These palettes + widen the gaps between the colours themselves. +

+
+ {COLOUR_CHOICES.map((choice) => ( + + ))} +
+
+ + +
+ ); +} diff --git a/app/src/game/ui/PauseMenu.tsx b/app/src/game/ui/PauseMenu.tsx new file mode 100644 index 0000000..50f70fb --- /dev/null +++ b/app/src/game/ui/PauseMenu.tsx @@ -0,0 +1,130 @@ +import { useEffect, useRef, useState } from "react"; +import { useNavigate } from "react-router-dom"; +import { BORING_UI, KEEP_BUILD } from "../keep"; +import { useGameShell } from "../state/context"; +import { Menu, type MenuItem } from "./Menu"; +import { OptionsPanel } from "./OptionsPanel"; +import { Prompt } from "./Prompt"; + +type Pane = "root" | "options"; + +/** + * The pause screen. + * + * Pausing is the one place a game is allowed to take the whole screen, so it + * does: the field dims, the simulation stops, and what is left is a short list + * of the things somebody who has just stopped playing actually wants. The way + * out is the last item and it says what it does in plain words rather than in + * character, because a person looking for the exit is no longer playing along. + */ +export function PauseMenu({ onResume }: { onResume: () => void }) { + const navigate = useNavigate(); + const [pane, setPane] = useState("root"); + const { options } = useGameShell(); + const panel = useRef(null); + /* Where the root menu was, so Options and back does not reset it. */ + const rootIndex = useRef(0); + /* Focus goes back where it came from when the menu closes. */ + const returnFocus = useRef(null); + + useEffect(() => { + returnFocus.current = document.activeElement as HTMLElement | null; + return () => returnFocus.current?.focus?.(); + }, []); + + /* + * A modal that does not hold focus is a modal a keyboard can walk out of + * while it is still covering the screen, which leaves somebody typing into + * a page they cannot see. + */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (event.key !== "Tab") return; + const focusable = panel.current?.querySelectorAll( + "button:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])", + ); + if (!focusable || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, []); + + const rootItems: MenuItem[] = [ + { + id: "resume", + label: "Resume", + detail: "Back to the field", + onSelect: onResume, + }, + { + id: "sheet", + label: "Character sheet", + detail: "Your class, level and holdings", + /* + * Honest rather than hidden: the sheet is not built yet, and a menu item + * that silently does nothing is worse than one that says why. + */ + disabled: true, + onSelect: () => {}, + }, + { + id: "options", + label: "Options", + detail: `Safe area ${options.safeZone}% · Interface ${options.uiScale}%`, + onSelect: () => setPane("options"), + }, + { + id: "quit", + label: "Quit to boring UI", + detail: "Back to the session list", + danger: true, + onSelect: () => navigate(BORING_UI), + }, + ]; + + return ( +
+
+
+ {/* Icon and word together: never the icon alone, never the colour alone. */} + +

{pane === "root" ? "Paused" : "Options"}

+
+ + {pane === "root" ? ( + { + rootIndex.current = index; + }} + /> + ) : ( + setPane("root")} /> + )} + +
+ + Build {KEEP_BUILD} +
+
+
+ ); +} diff --git a/app/src/game/ui/Prompt.tsx b/app/src/game/ui/Prompt.tsx new file mode 100644 index 0000000..9f7f01a --- /dev/null +++ b/app/src/game/ui/Prompt.tsx @@ -0,0 +1,27 @@ +import { promptFor, type GameAction } from "../engine/input"; +import { useGameShell } from "../state/context"; + +/** + * "Press + + ); +} diff --git a/app/src/styles/game.css b/app/src/styles/game.css index b92bc7e..48932d3 100644 --- a/app/src/styles/game.css +++ b/app/src/styles/game.css @@ -612,3 +612,214 @@ .keep-canvas.is-front { pointer-events: none; } + +/* ---- HUD bar ---------------------------------------------------------- */ + +/* + * The strip along the top: standing, purse, elixir, garrison. + * + * Wraps rather than shrinks. On a phone these become two rows of readable + * panels instead of four unreadable ones, which is the right trade when the + * alternative is text below the floor this file holds itself to. + */ +.keep-hud-bar { + display: flex; + flex-flow: row wrap; + align-items: stretch; + gap: var(--keep-space); + flex: 1; + min-width: 0; +} + +.keep-hud-bar > .keep-panel { + display: flex; + padding: var(--keep-space) var(--keep-space-2); + align-items: center; + gap: var(--keep-space-2); +} + +.keep-standing { + min-width: calc(260px * var(--keep-scale)); + flex-direction: column; + align-items: stretch !important; + gap: var(--keep-space) !important; +} + +.keep-standing-head { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +.keep-sigil { + display: inline-flex; + width: calc(34px * var(--keep-scale)); + height: calc(34px * var(--keep-scale)); + border: 3px solid var(--keep-gold); + background: var(--keep-stone-dark); + color: var(--keep-gold); + font-size: var(--keep-text-lg); + font-weight: 700; + align-items: center; + justify-content: center; + flex: none; +} + +.keep-standing-text, +.keep-purse-text, +.keep-roster-text, +.keep-elixir-text { + display: flex; + min-width: 0; + flex-direction: column; +} + +.keep-standing-class { + font-weight: 700; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-standing-level, +.keep-purse-label, +.keep-roster-detail, +.keep-elixir-value { + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +/* ---- meters ----------------------------------------------------------- */ + +.keep-meter { + display: flex; + flex-direction: column; + gap: calc(var(--keep-space) / 2); +} + +.keep-meter-head { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--keep-space); +} + +.keep-meter-label { + color: var(--keep-mist); + font-size: var(--keep-text-sm); + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.keep-meter-value { + font-size: var(--keep-text-sm); + font-weight: 700; +} + +.keep-meter-of { + color: var(--keep-mist); + font-weight: 400; +} + +.keep-meter-track { + height: calc(14px * var(--keep-scale)); + border: 3px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 35%); + overflow: hidden; +} + +.keep-meter-fill { + display: block; + height: 100%; + background: var(--keep-gold); + transition: width var(--keep-normal) linear; +} + +.keep-meter.is-elixir .keep-meter-fill { + background: var(--keep-elixir); +} + +.keep-meter-detail { + margin: 0; + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +/* ---- purse, elixir, roster -------------------------------------------- */ + +.keep-coin { + color: var(--keep-gold); + font-size: var(--keep-text-xl); +} + +.keep-purse-value, +.keep-roster-count, +.keep-elixir-label { + font-size: var(--keep-text-lg); + font-weight: 700; +} + +.keep-elixir { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +/* + * The vial. A pixel vessel rather than a bar, because this is the one gauge + * that is not a game resource: it is real money, and it should not look like + * the experience bar next to it. + */ +.keep-vial { + display: block; + position: relative; + width: calc(18px * var(--keep-scale)); + height: calc(34px * var(--keep-scale)); + border: 3px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 40%); + box-shadow: inset 0 3px 0 0 var(--keep-stone-lit); + overflow: hidden; + flex: none; +} + +.keep-vial-fill { + display: block; + position: absolute; + right: 0; + bottom: 0; + left: 0; + background: var(--keep-elixir); + transition: height var(--keep-normal) linear; +} + +.keep-elixir-label { + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-roster-button { + border: 4px solid var(--keep-stone-dark); + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} + +.keep-roster-button:hover { + background: var(--keep-stone-lit); +} + +.keep-roster-count { + color: var(--keep-gold); +} + +.keep-roster-label { + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +@media (width <= 900px) { + .keep-standing { + min-width: 100%; + } +} From 64845ad326d1d91b1ea94a4c78a16a485b555987 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Wed, 16 Sep 2026 00:53:16 -0700 Subject: [PATCH 08/49] Make the garrison real, and make it move Iterations eight and nine. The wrights on the field are now the account's live sessions: their class is the harness the session runs, and what they are doing is read from what it is called. `kindForCommand` is reused rather than reimplemented, so the game and the session list cannot start disagreeing about what things are -- including seeing through the `sh -c` wrapper a browser-started session arrives in. Reading a session's name for "fix" or "feat" is a guess, so it is a careful one: mending is checked before making, because "fix the new importer" is a fix and reading it as a feature for containing "new" is exactly backwards. Anything unrecognised is neither and shows as a wright going about the yard, because pretending to know is worse than showing that you do not. The field is not rebuilt when the list is polled. Wrights already there keep walking, new sessions march in through the gate, finished ones go home. Rebuilding would teleport the whole garrison back to the gate every four seconds, which looks like a rendering fault and is a data one. Then the animation. Swings, hammer blows, and a bolt for the Arcanist, which is the one class whose flavour is naming a fault from across the yard rather than hitting it. Attack and build frames change only the six rows the working arm lives in, so a swing reads as the same person swinging rather than as a second character appearing. The action is held for a few ticks rather than being derived from position, because an action that is true only on the tick the damage lands is one frame in eighteen -- a swing nobody ever sees. Bursts where blows land, cold for the Unmade and warm stone chips for a hammer. And the garrison rests a quarter as long between errands, because a yard of statues is not what a place with work going on in it looks like. The tests earned their keep three times over on one problem. A wright at the gate with a fault directly opposite, hall in between, breaks every local steering rule in turn: shoving out of the wall makes it vibrate in place; sliding along the wall makes it creep at a twentieth speed while jittering, because the building is axis-aligned and the travel is not; heading for the cheapest corner walks it into the wall, because the cheapest corner is the one diagonally across the building and the path to it goes through. It now routes via corners it can actually reach in a straight line, checked with a slab test. All three failures would have looked identical on screen: a hero having a fit against a building. --- app/src/game/GameRoute.tsx | 23 +- app/src/game/assets/heroes.ts | 122 ++++++++- app/src/game/scenes/fx.ts | 105 ++++++++ app/src/game/scenes/wrights.ts | 15 +- app/src/game/state/sessions.test.ts | 127 ++++++++++ app/src/game/state/sessions.ts | 98 +++++++ app/src/game/state/use-garrison.ts | 105 ++++++++ app/src/game/state/world.test.ts | 130 ++++++++++ app/src/game/state/world.ts | 381 ++++++++++++++++++++++++---- 9 files changed, 1046 insertions(+), 60 deletions(-) create mode 100644 app/src/game/scenes/fx.ts create mode 100644 app/src/game/state/sessions.test.ts create mode 100644 app/src/game/state/sessions.ts create mode 100644 app/src/game/state/use-garrison.ts diff --git a/app/src/game/GameRoute.tsx b/app/src/game/GameRoute.tsx index 918b9b9..cf7d542 100644 --- a/app/src/game/GameRoute.tsx +++ b/app/src/game/GameRoute.tsx @@ -10,7 +10,9 @@ import { drawField, HOLDING, layout, STARTING_BASE } from "./scenes/field"; import { drawLife } from "./scenes/life"; import { drawWrights } from "./scenes/wrights"; import { drawFoes, drawSites } from "./scenes/foes"; -import { createWorld, DEMO_GARRISON, muster, tickWorld, type Wright } from "./state/world"; +import { drawFx } from "./scenes/fx"; +import { createWorld, DEMO_GARRISON, tickWorld, type Wright } from "./state/world"; +import { useGarrison } from "./state/use-garrison"; import { experienceFrom, fortification, marksEarnedTo, standing } from "./state/progress"; import { Hud } from "./ui/Hud"; import { PauseMenu } from "./ui/PauseMenu"; @@ -110,7 +112,12 @@ export default function GameRoute() { * much ground is visible; until then there is nowhere to stand. */ const world = useRef(createWorld({ left: 0, top: 0, right: 0, bottom: 0 })); - const mustered = useRef(false); + + /* + * Who is on the field: the account's live sessions, polled, with the + * stand-in garrison when there are none or the service cannot be reached. + */ + const garrison = useGarrison(world.current, DEMO_GARRISON); /* * The game takes the window. The corporate shell scrolls; a field that @@ -186,9 +193,8 @@ export default function GameRoute() { drawFrame={(draw) => { /* * The courtyard is only known once the stage has measured the - * window, so the garrison is mustered on the first frame rather - * than on mount. One tile in from the wall on every side, which - * is the walkable yard. + * window. One tile in from the wall on every side, which is the + * part of the yard anybody can actually walk on. */ const { left, top } = layout(draw); world.current.bounds = { @@ -197,15 +203,12 @@ export default function GameRoute() { right: left + HOLDING.w - 2.5, bottom: top + HOLDING.h - 2, }; - if (!mustered.current) { - mustered.current = true; - for (const entry of DEMO_GARRISON) muster(world.current, entry); - } drawLife(draw); drawSites(draw, world.current); drawWrights(draw, world.current); drawFoes(draw, world.current); + drawFx(draw, world.current); }} /> @@ -224,7 +227,7 @@ export default function GameRoute() { gathering={false} characterClass="claude-code" wrights={tally.wrights} - demo + demo={garrison.demo} onOpenRoster={() => setPaused(true)} /> + + ); +} diff --git a/app/src/game/ui/PauseMenu.tsx b/app/src/game/ui/PauseMenu.tsx index 50f70fb..a2f416c 100644 --- a/app/src/game/ui/PauseMenu.tsx +++ b/app/src/game/ui/PauseMenu.tsx @@ -1,28 +1,52 @@ import { useEffect, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; import { BORING_UI, KEEP_BUILD } from "../keep"; +import { CLASS_LORE } from "../lore/world"; +import type { Purse } from "../state/shop"; import { useGameShell } from "../state/context"; +import { Codex } from "./Codex"; import { Menu, type MenuItem } from "./Menu"; import { OptionsPanel } from "./OptionsPanel"; import { Prompt } from "./Prompt"; +import { Shop } from "./Shop"; -type Pane = "root" | "options"; +type Pane = "root" | "options" | "shop" | "codex"; /** - * The pause screen. + * The pause screen, and everything reached from it. * - * Pausing is the one place a game is allowed to take the whole screen, so it - * does: the field dims, the simulation stops, and what is left is a short list - * of the things somebody who has just stopped playing actually wants. The way - * out is the last item and it says what it does in plain words rather than in - * character, because a person looking for the exit is no longer playing along. + * The field carries four numbers and nothing else; everything a player might + * want but does not need at a glance lives behind this. That is the whole + * division: a heads-up display is screen space borrowed from the game, and a + * pause menu is space that costs nothing because the game has stopped. + * + * The way out is the last item and it says what it does in plain words rather + * than in character, because a person looking for the exit has stopped playing + * along. */ -export function PauseMenu({ onResume }: { onResume: () => void }) { +export function PauseMenu({ + onResume, + purse, + characterClass, + wearing, + shopOpen, + onBuy, + onWear, +}: { + onResume: () => void; + purse: Purse; + characterClass: string; + wearing: string; + /** The pedlar starts calling at level two; before that the row says so. */ + shopOpen: boolean; + onBuy: (skinId: string) => void; + onWear: (skinId: string) => void; +}) { const navigate = useNavigate(); const [pane, setPane] = useState("root"); const { options } = useGameShell(); const panel = useRef(null); - /* Where the root menu was, so Options and back does not reset it. */ + /* Where the root menu was, so a side trip does not reset it. */ const rootIndex = useRef(0); /* Focus goes back where it came from when the menu closes. */ const returnFocus = useRef(null); @@ -58,6 +82,8 @@ export function PauseMenu({ onResume }: { onResume: () => void }) { return () => document.removeEventListener("keydown", onKey); }, []); + const lore = CLASS_LORE[characterClass] ?? CLASS_LORE.terminal; + const rootItems: MenuItem[] = [ { id: "resume", @@ -66,15 +92,19 @@ export function PauseMenu({ onResume }: { onResume: () => void }) { onSelect: onResume, }, { - id: "sheet", - label: "Character sheet", - detail: "Your class, level and holdings", - /* - * Honest rather than hidden: the sheet is not built yet, and a menu item - * that silently does nothing is worse than one that says why. - */ - disabled: true, - onSelect: () => {}, + id: "shop", + label: "The pedlar", + detail: shopOpen + ? `${purse.marks.toLocaleString()} marks to spend` + : "Starts calling at level 2", + disabled: !shopOpen, + onSelect: () => setPane("shop"), + }, + { + id: "codex", + label: "The Chronicle", + detail: "What everything here is a name for", + onSelect: () => setPane("codex"), }, { id: "options", @@ -91,22 +121,32 @@ export function PauseMenu({ onResume }: { onResume: () => void }) { }, ]; + const title = + pane === "root" + ? "Paused" + : pane === "options" + ? "Options" + : pane === "shop" + ? "The pedlar" + : "The Chronicle"; + return (
{/* Icon and word together: never the icon alone, never the colour alone. */} -

{pane === "root" ? "Paused" : "Options"}

+

{title}

+ {pane === "root" && {lore.title}}
- {pane === "root" ? ( + {pane === "root" && ( void }) { rootIndex.current = index; }} /> - ) : ( - setPane("root")} /> )} + {pane === "options" && setPane("root")} />} + {pane === "shop" && ( + setPane("root")} + /> + )} + {pane === "codex" && setPane("root")} />}
+ {picked && ( + { + setPicked(undefined); + handle.current.select(undefined); + }} + /> + )} + {paused && ( setPaused(false)} diff --git a/app/src/game/pixi/PixiStage.tsx b/app/src/game/pixi/PixiStage.tsx index f5d9892..66f1b3b 100644 --- a/app/src/game/pixi/PixiStage.tsx +++ b/app/src/game/pixi/PixiStage.tsx @@ -43,13 +43,11 @@ export interface Scene { */ export function PixiStage({ build, - onReady, paused = false, label, }: { /** Builds the scene once the renderer exists. */ build: (app: Application, viewport: Viewport) => Promise | Scene; - onReady?: (viewport: Viewport) => void; paused?: boolean; label: string; }) { @@ -114,7 +112,6 @@ export function PixiStage({ scene = await build(created, viewport); if (stopped) return; viewport.addChild(scene.world); - onReady?.(viewport); created.ticker.add((ticker) => { if (pausedRef.current) return; diff --git a/app/src/game/pixi/actors.ts b/app/src/game/pixi/actors.ts new file mode 100644 index 0000000..94e0e02 --- /dev/null +++ b/app/src/game/pixi/actors.ts @@ -0,0 +1,183 @@ +import { Container, Graphics, Sprite, Text, Texture } from "pixi.js"; +import { depthOf, TILE_H, toScreen } from "./iso"; +import type { Loaded } from "./scene"; +import type { Actor, Sim } from "../world/sim"; + +/** + * The people on the map, and the things that got in. + * + * Pixi keeps a display object per actor for as long as that actor exists, + * rather than rebuilding the scene each frame. That is the whole reason for + * using a scene graph: moving a sprite is setting two numbers, where drawing + * it again is uploading geometry. + */ + +/** + * Which unit sprite stands for which class. + * + * Kenney's pack has four colours of unit; they are used here to tell the + * classes apart at a glance, which is what a colour is for on a map where + * everything is the same size. + */ +const UNIT_FOR: Record = { + "claude-code": "Unit_05", + codex: "Unit_01", + hermes: "Unit_11", + openclaw: "Unit_07", + terminal: "Unit_17", + soldier: "Unit_19", + /* The Unmade get the darkest units, tinted below so they read as wrong. */ + mite: "Unit_21", + crawler: "Unit_23", + heisenbug: "Unit_09", +}; + +/** The Unmade are tinted cold; everything else on this map is warm. */ +const UNMADE_TINT = 0x6fd6c0; + +interface Piece { + root: Container; + sprite: Sprite; + shadow: Graphics; + bar: Graphics; + plate?: Text; + lastHp: number; +} + +export class ActorLayer { + private readonly pieces = new Map(); + + constructor( + private readonly art: Loaded, + private readonly parent: Container, + private readonly onPick: (id: string) => void, + ) {} + + private make(actor: Actor): Piece { + const root = new Container(); + + const shadow = new Graphics(); + shadow.ellipse(0, 0, 13, 6).fill({ color: 0x1a1008, alpha: 0.3 }); + root.addChild(shadow); + + const texture = this.art.frame(UNIT_FOR[actor.kind] ?? UNIT_FOR.terminal); + const sprite = new Sprite(texture); + sprite.anchor.set(0.5, 1); + sprite.scale.set(0.85); + sprite.position.set(0, TILE_H * 0.25); + if (actor.side === "unmade") sprite.tint = UNMADE_TINT; + root.addChild(sprite); + + /* Health, shown only once something has been taken off it. */ + const bar = new Graphics(); + bar.position.set(0, -sprite.height - 4); + bar.visible = false; + root.addChild(bar); + + /* + * Only real sessions carry a name and only real sessions can be clicked. + * The garrison's own soldiers are scenery; giving them plates would fill + * the map with labels that stand for nothing. + */ + let plate: Text | undefined; + if (actor.session) { + plate = new Text({ + text: actor.name.length > 22 ? `${actor.name.slice(0, 21)}…` : actor.name, + style: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 16, + fill: 0xf5e3c0, + stroke: { color: 0x1a1008, width: 4 }, + }, + }); + plate.anchor.set(0.5, 0); + plate.position.set(0, TILE_H * 0.35); + plate.scale.set(0.8); + root.addChild(plate); + + root.eventMode = "static"; + root.cursor = "pointer"; + /* A little larger than the sprite, because a 20px figure is a small target. */ + root.hitArea = { + contains: (x: number, y: number) => Math.abs(x) < 22 && y > -44 && y < 14, + }; + root.on("pointertap", () => this.onPick(actor.id)); + } + + this.parent.addChild(root); + return { root, sprite, shadow, bar, plate, lastHp: actor.hp }; + } + + /** Brings the display in line with the simulation. */ + sync(sim: Sim, selectedId?: string): void { + const seen = new Set(); + + for (const actor of sim.actors) { + seen.add(actor.id); + let piece = this.pieces.get(actor.id); + if (!piece) { + piece = this.make(actor); + this.pieces.set(actor.id, piece); + } + + const { x, y } = toScreen(actor.x, actor.y); + piece.root.position.set(x, y); + piece.root.zIndex = depthOf(actor.x, actor.y, 10); + + /* Facing, as a mirror rather than a second sprite. */ + piece.sprite.scale.x = actor.facing === 1 ? 0.85 : -0.85; + + /* + * A blow is a lunge rather than a different drawing. Kenney's units have + * no attack frame, and a small forward shove reads as a strike far + * better than a static figure with a number popping off it. + */ + const lunging = actor.action === "attack"; + piece.sprite.position.x = lunging ? actor.facing * 5 : 0; + piece.sprite.position.y = TILE_H * 0.25 + (actor.moving ? Math.sin(sim.clock / 3) * 1.5 : 0); + + /* White when struck. Never colour alone: a number flies off as well. */ + piece.sprite.tint = actor.hurt > 0 + ? 0xffffff + : actor.side === "unmade" + ? UNMADE_TINT + : 0xffffff; + piece.sprite.alpha = actor.hurt > 0 ? 0.75 : 1; + + if (actor.hp !== piece.lastHp) { + piece.lastHp = actor.hp; + const hurt = actor.hp < actor.maxHp; + piece.bar.visible = hurt; + if (hurt) { + piece.bar.clear(); + piece.bar.rect(-13, 0, 26, 4).fill({ color: 0x1a1008, alpha: 0.85 }); + piece.bar + .rect(-12, 1, 24 * Math.max(0, actor.hp / actor.maxHp), 2) + .fill({ color: actor.side === "unmade" ? 0x48d6c0 : 0x8fd05a }); + } + } + + if (piece.plate) { + const chosen = actor.id === selectedId; + piece.plate.style.fill = chosen ? 0xf0a03c : 0xf5e3c0; + piece.shadow.tint = chosen ? 0xf0a03c : 0xffffff; + piece.shadow.alpha = chosen ? 0.9 : 1; + } + } + + /* Anybody who has left the simulation leaves the scene with them. */ + for (const [id, piece] of this.pieces) { + if (seen.has(id)) continue; + piece.root.destroy({ children: true }); + this.pieces.delete(id); + } + } + + destroy(): void { + for (const piece of this.pieces.values()) piece.root.destroy({ children: true }); + this.pieces.clear(); + } +} + +export { UNIT_FOR }; +export type { Texture }; diff --git a/app/src/game/pixi/ambience.ts b/app/src/game/pixi/ambience.ts new file mode 100644 index 0000000..a402fc4 --- /dev/null +++ b/app/src/game/pixi/ambience.ts @@ -0,0 +1,244 @@ +import { Assets, Container, Graphics, Sprite, Texture } from "pixi.js"; +import { depthOf, toScreen } from "./iso"; +import { GARRISONS, MAP } from "../world/marches"; +import type { Effect, Mark, Sim } from "../world/sim"; + +/** + * Everything that is there to be looked at rather than played. + * + * Birds, smoke, the flash where a blow lands, the numbers that come off it. + * None of it is a mechanic and none of it can be interacted with. It is here + * because a map where the only thing moving is the thing you are watching + * reads as a diagram of a place rather than a place. + */ + +/** Particle textures, vendored from Kenney's CC0 pack. See the notices file. */ +const FX_TEXTURES = ["fx-smoke_01", "fx-star_04", "fx-flare_01", "fx-spark_04", "fx-magic_05"]; + +export async function loadEffects(): Promise> { + const loaded: Record = {}; + await Promise.all( + FX_TEXTURES.map(async (name) => { + loaded[name] = await Assets.load(`/game/${name}.png`); + }), + ); + return loaded; +} + +/* ---- birds --------------------------------------------------------------- */ + +interface Bird { + sprite: Graphics; + x: number; + y: number; + vx: number; + vy: number; + phase: number; +} + +/** + * Birds, drawn rather than sprited. + * + * A bird at this distance is two strokes that open and close. Kenney has no + * bird and a five-pixel drawing of one would be a smudge; two lines that flap + * read as a bird from any distance and cost a handful of vertices. + * + * They fly above everything, cast no shadow, and are pushed to a depth beyond + * anything on the ground, which is what makes them read as being in the air + * rather than walking about on it. + */ +export class Birds { + private readonly birds: Bird[] = []; + private readonly layer = new Container(); + + constructor(parent: Container, count = 14) { + parent.addChild(this.layer); + this.layer.zIndex = depthOf(MAP.width, MAP.height, 9_000); + + for (let index = 0; index < count; index += 1) { + const sprite = new Graphics(); + this.layer.addChild(sprite); + /* Spread over the whole map, drifting on roughly the same wind. */ + this.birds.push({ + sprite, + x: Math.random() * MAP.width, + y: Math.random() * MAP.height, + vx: 0.35 + Math.random() * 0.5, + vy: -0.12 + Math.random() * 0.24, + phase: Math.random() * Math.PI * 2, + }); + } + } + + tick(deltaMs: number): void { + const seconds = deltaMs / 1000; + for (const bird of this.birds) { + bird.x += bird.vx * seconds; + bird.y += bird.vy * seconds; + bird.phase += seconds * 9; + + /* Off one edge and back on the other, so the sky is never empty. */ + if (bird.x > MAP.width + 4) bird.x = -4; + if (bird.y < -4) bird.y = MAP.height + 4; + if (bird.y > MAP.height + 4) bird.y = -4; + + const { x, y } = toScreen(bird.x, bird.y); + /* Height above the ground, which is what the projection cannot give us. */ + const lift = 54 + Math.sin(bird.phase / 3) * 6; + const flap = Math.sin(bird.phase) * 4; + + bird.sprite.clear(); + bird.sprite + .moveTo(x - 6, y - lift) + .lineTo(x - 2, y - lift - flap) + .lineTo(x + 2, y - lift) + .stroke({ color: 0x2b1d16, width: 2, alpha: 0.75 }); + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} + +/* ---- chimney smoke ------------------------------------------------------- */ + +/** + * Smoke over the holdings, so the map looks inhabited from a distance. + * + * One drifting column per garrison, made of a handful of sprites recycled + * rather than created and destroyed — a particle system that allocates is a + * particle system that stutters. + */ +export class Smoke { + private readonly puffs: { sprite: Sprite; life: number; x: number; y: number; from: number }[] = []; + private readonly layer = new Container(); + + constructor(parent: Container, texture: Texture, perGarrison = 5) { + parent.addChild(this.layer); + this.layer.zIndex = depthOf(MAP.width, MAP.height, 8_000); + + GARRISONS.forEach((garrison, index) => { + for (let puff = 0; puff < perGarrison; puff += 1) { + const sprite = new Sprite(texture); + sprite.anchor.set(0.5); + sprite.alpha = 0; + this.layer.addChild(sprite); + this.puffs.push({ + sprite, + /* Staggered, so a chimney does not cough all its smoke at once. */ + life: (puff / perGarrison) * 100, + x: garrison.x, + y: garrison.y - 1, + from: index, + }); + } + }); + } + + tick(deltaMs: number): void { + const step = deltaMs / 1000; + for (const puff of this.puffs) { + puff.life += step * 22; + if (puff.life > 100) puff.life = 0; + + const progress = puff.life / 100; + const { x, y } = toScreen(puff.x, puff.y); + puff.sprite.position.set(x + progress * 26, y - 40 - progress * 46); + puff.sprite.scale.set(0.12 + progress * 0.3); + puff.sprite.alpha = Math.max(0, 0.38 * (1 - progress)); + puff.sprite.tint = 0xd9c9b0; + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} + +/* ---- blows, and the numbers that come off them --------------------------- */ + +/** + * The flash where something was struck, and the number that rises off it. + * + * Both are pooled: a fight can produce a dozen a second, and creating a Text + * object per hit is the fastest way to make a Pixi scene stutter, because each + * one uploads a new texture. + */ +export class Blows { + private readonly layer = new Container(); + private readonly flashes = new Map(); + private readonly numbers = new Map(); + + constructor( + parent: Container, + private readonly textures: Record, + private readonly makeNumber: (text: string, kind: Mark["kind"]) => Container, + ) { + parent.addChild(this.layer); + this.layer.zIndex = depthOf(MAP.width, MAP.height, 7_000); + } + + sync(sim: Sim): void { + const liveEffects = new Set(); + for (const effect of sim.effects) { + liveEffects.add(effect.id); + let sprite = this.flashes.get(effect.id); + if (!sprite) { + sprite = new Sprite(this.pick(effect)); + sprite.anchor.set(0.5); + sprite.blendMode = "add"; + this.layer.addChild(sprite); + this.flashes.set(effect.id, sprite); + } + const progress = 1 - effect.life / effect.maxLife; + const { x, y } = toScreen(effect.x, effect.y); + sprite.position.set(x, y - 18); + sprite.scale.set(0.16 + progress * 0.4); + sprite.alpha = 1 - progress; + sprite.rotation = progress * 1.2; + } + for (const [id, sprite] of this.flashes) { + if (liveEffects.has(id)) continue; + sprite.destroy(); + this.flashes.delete(id); + } + + const liveMarks = new Set(); + for (const mark of sim.marks) { + liveMarks.add(mark.id); + let node = this.numbers.get(mark.id); + if (!node) { + node = this.makeNumber(mark.text, mark.kind); + this.layer.addChild(node); + this.numbers.set(mark.id, node); + } + const progress = 1 - mark.life / mark.maxLife; + const { x, y } = toScreen(mark.x, mark.y); + node.position.set(x, y - 30 - progress * 34); + node.alpha = Math.min(1, (1 - progress) * 2.2); + } + for (const [id, node] of this.numbers) { + if (liveMarks.has(id)) continue; + node.destroy({ children: true }); + this.numbers.delete(id); + } + } + + private pick(effect: Effect): Texture { + switch (effect.kind) { + case "cast": + return this.textures["fx-magic_05"] ?? this.textures["fx-star_04"]; + case "fell": + return this.textures["fx-flare_01"]; + case "build": + return this.textures["fx-spark_04"]; + default: + return this.textures["fx-star_04"]; + } + } + + destroy(): void { + this.layer.destroy({ children: true }); + } +} diff --git a/app/src/game/pixi/keepScene.ts b/app/src/game/pixi/keepScene.ts index 82c910f..d96d2d1 100644 --- a/app/src/game/pixi/keepScene.ts +++ b/app/src/game/pixi/keepScene.ts @@ -1,39 +1,101 @@ +import { Container, Text } from "pixi.js"; import type { Application } from "pixi.js"; import type { Viewport } from "pixi-viewport"; -import { Container } from "pixi.js"; import { buildWorld, homePosition, loadArt } from "./scene"; +import { ActorLayer } from "./actors"; +import { Birds, Blows, loadEffects, Smoke } from "./ambience"; import type { Scene } from "./PixiStage"; +import { createSim, garrisonSoldiers, muster, tickSim, type Actor, type Mark, type Sim } from "../world/sim"; /** - * The Marches, assembled. + * The Marches, assembled and running. * - * Kept apart from PixiStage so the stage knows only "build me a scene" and - * this knows only what the world contains. The actors — wrights, the Unmade, - * birds — are added to `things` by the layers that own them, each of which can - * be worked on without touching this. + * The simulation and the scene are kept apart on purpose: `world/sim.ts` knows + * nothing about Pixi and can be run a thousand ticks deep in a test, and this + * file only ever reads it and draws what it finds. That split is what made the + * shaking findable — it was a question about numbers, not about pixels. */ -export async function buildKeepScene(_app: Application, viewport: Viewport): Promise { - const art = await loadArt(); + +export interface KeepHandle { + sim: Sim; + /** Called when a wright standing for a real session is clicked. */ + onPick?: (actor: Actor | undefined) => void; + select(id: string | undefined): void; + /** Centres the view on a garrison, for the map menu. */ + lookAt(x: number, y: number): void; +} + +/** The style of a floating number. Built here so Blows stays about pooling. */ +function numberFor(text: string, kind: Mark["kind"]): Container { + const node = new Text({ + text, + style: { + fontFamily: "ui-monospace, SFMono-Regular, Menlo, monospace", + fontSize: 20, + fontWeight: "700", + fill: kind === "damage" ? 0xff8fb0 : 0xe8c65a, + stroke: { color: 0x1a1008, width: 5 }, + }, + }); + node.anchor.set(0.5); + return node; +} + +export async function buildKeepScene( + _app: Application, + viewport: Viewport, + handle: KeepHandle, + roster: { id: string; name: string; kind: string; work: "bug" | "feature" | "idle"; session?: Actor["session"] }[], +): Promise { + const [art, fx] = await Promise.all([loadArt(), loadEffects()]); const { root, things, signs } = buildWorld(art); const world = new Container(); world.addChild(root); - /* Open on the Keep, at a zoom where a building is a building. */ + const sim = handle.sim; + garrisonSoldiers(sim); + for (const entry of roster) muster(sim, entry); + + let selected: string | undefined; + const actors = new ActorLayer(art, things, (id) => { + selected = id; + handle.onPick?.(sim.actors.find((actor) => actor.id === id)); + }); + + const birds = new Birds(things); + const smoke = new Smoke(things, fx["fx-smoke_01"]); + const blows = new Blows(things, fx, numberFor); + + handle.select = (id) => { + selected = id; + }; + handle.lookAt = (x, y) => { + viewport.animate({ position: { x, y }, scale: 1.1, time: 450, ease: "easeInOutSine" }); + }; + + /* Clicking bare ground clears the selection, which is what closes the panel. */ + viewport.eventMode = "static"; + const clearPick = (event: { target: unknown }) => { + if (event.target !== viewport) return; + selected = undefined; + handle.onPick?.(undefined); + }; + viewport.on("pointertap", clearPick); + const home = homePosition(); viewport.setZoom(0.9, true); viewport.moveCenter(home.x, home.y); /* - * The signs shrink as you zoom in and grow as you zoom out, so a name stays - * about the same size on screen at any zoom. Without this, a map zoomed out - * is a map covered in enormous words, and zoomed in they vanish. + * Signs keep roughly the same size on screen at any zoom, and the sentence + * under the name only appears close up. A map zoomed out is otherwise a map + * covered in enormous words, and zoomed in they disappear. */ const rescaleSigns = () => { const scale = 1 / viewport.scale.x; for (const sign of signs.children) { sign.scale.set(Math.min(1.6, Math.max(0.55, scale))); - /* The sentence under the name is only worth reading up close. */ const purpose = (sign as Container).getChildByLabel?.("purpose"); if (purpose) purpose.visible = viewport.scale.x > 0.75; } @@ -42,15 +104,38 @@ export async function buildKeepScene(_app: Application, viewport: Viewport): Pro viewport.on("zoomed", rescaleSigns); viewport.on("moved", rescaleSigns); + /* + * The simulation runs at a fixed thirty ticks a second whatever the display + * does, with a ceiling on catching up: a tab left in the background for ten + * minutes should resume, not replay ten minutes of battle in one frame. + */ + let owed = 0; + const TICK_MS = 1000 / 30; + return { world, - tick() { - /* Actors are added in the next layer; the ground does not move. */ - void things; + tick(deltaMs) { + owed = Math.min(owed + deltaMs, TICK_MS * 5); + while (owed >= TICK_MS) { + owed -= TICK_MS; + tickSim(sim); + } + actors.sync(sim, selected); + blows.sync(sim); + birds.tick(deltaMs); + smoke.tick(deltaMs); }, destroy() { viewport.off("zoomed", rescaleSigns); viewport.off("moved", rescaleSigns); + viewport.off("pointertap", clearPick); + actors.destroy(); + birds.destroy(); + smoke.destroy(); + blows.destroy(); }, }; } + +export { createSim }; +export type { Actor, Sim }; diff --git a/app/src/game/state/sessions.test.ts b/app/src/game/state/sessions.test.ts index d536d6b..07cfef6 100644 --- a/app/src/game/state/sessions.test.ts +++ b/app/src/game/state/sessions.test.ts @@ -97,13 +97,19 @@ describe("who is on the field", () => { }); }); +/** A roster entry, with the session facts the panel needs. */ +const muster = (id: string) => ({ + id, + name: id, + kind: "terminal", + work: "idle" as const, + session: { id, startedAt: 0, host: "laptop", command: "htop" }, +}); + describe("keeping the field in step with the list", () => { it("brings in the new and sends home the finished", () => { const present = [{ id: "a" }, { id: "b" }]; - const wanted = [ - { id: "b", name: "b", kind: "terminal", work: "idle" as const }, - { id: "c", name: "c", kind: "terminal", work: "idle" as const }, - ]; + const wanted = [muster("b"), muster("c")]; const { arrived, left } = difference(present, wanted); expect(arrived.map((entry) => entry.id)).toEqual(["c"]); expect(left).toEqual(["a"]); @@ -116,10 +122,7 @@ describe("keeping the field in step with the list", () => { * the gate at exactly that interval. */ const present = [{ id: "a" }, { id: "b" }]; - const wanted = [ - { id: "a", name: "a", kind: "terminal", work: "idle" as const }, - { id: "b", name: "b", kind: "terminal", work: "idle" as const }, - ]; + const wanted = [muster("a"), muster("b")]; const { arrived, left } = difference(present, wanted); expect(arrived).toHaveLength(0); expect(left).toHaveLength(0); diff --git a/app/src/game/state/sessions.ts b/app/src/game/state/sessions.ts index 84fe1b7..e280928 100644 --- a/app/src/game/state/sessions.ts +++ b/app/src/game/state/sessions.ts @@ -47,6 +47,14 @@ export interface Muster { name: string; kind: string; work: Work; + /** + * The facts the inspection panel shows when a wright is clicked. + * + * Carried through rather than looked up again later: by the time somebody + * clicks a figure on the map, the session list may have been polled a dozen + * times, and the answer should be about the session this wright *is*. + */ + session: { id: string; startedAt: number; host: string; command: string }; } /** Whether a session should be on the field at all. */ @@ -73,6 +81,12 @@ export function garrisonFrom(sessions: SessionRecord[]): Muster[] { name: label, kind: kindForCommand(session.command).id, work: workFor(`${session.name ?? ""} ${session.command}`), + session: { + id: session.id, + startedAt: session.startedAt, + host: session.host, + command: session.command, + }, }; }); } diff --git a/app/src/game/state/use-garrison.ts b/app/src/game/state/use-garrison.ts index eb7dc77..6062ba6 100644 --- a/app/src/game/state/use-garrison.ts +++ b/app/src/game/state/use-garrison.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import { fetchSessions } from "../../lib/api"; import { difference, garrisonFrom, type Muster } from "./sessions"; -import { dismiss, muster, type World } from "./world"; +import { muster, type Sim } from "../world/sim"; /** The same interval the session list polls on, so the two agree. */ const POLL_MS = 4000; @@ -34,7 +34,7 @@ export interface GarrisonState { * first impression: a walled yard with nobody in it and no way to tell whether * that is the point or a fault. */ -export function useGarrison(world: World, demoGarrison: Muster[]): GarrisonState { +export function useGarrison(sim: Sim, demoGarrison: Muster[]): GarrisonState { const [state, setState] = useState({ loading: true, error: "", @@ -53,13 +53,17 @@ export function useGarrison(world: World, demoGarrison: Muster[]): GarrisonState * first. Diffing across that boundary would leave demo wrights standing * among real ones, which is worse than either. */ + const sessions = sim.actors.filter((actor) => actor.session !== undefined); if (demo !== showingDemo.current) { - for (const wright of [...world.wrights]) dismiss(world, wright.id); + const ids = new Set(sessions.map((actor) => actor.id)); + sim.actors = sim.actors.filter((actor) => !ids.has(actor.id)); showingDemo.current = demo; } - const { arrived, left } = difference(world.wrights, wanted); - for (const id of left) dismiss(world, id); - for (const entry of arrived) muster(world, entry); + const present = sim.actors.filter((actor) => actor.session !== undefined); + const { arrived, left } = difference(present, wanted); + const going = new Set(left); + sim.actors = sim.actors.filter((actor) => !going.has(actor.id)); + for (const entry of arrived) muster(sim, entry); }; const load = async () => { diff --git a/app/src/game/state/world.ts b/app/src/game/state/world.ts index 92ab43a..c32cc5e 100644 --- a/app/src/game/state/world.ts +++ b/app/src/game/state/world.ts @@ -892,10 +892,23 @@ export function tickWorld(world: World): void { * the demo garrison stands in, and the interface says plainly that it is * standing in. */ -export const DEMO_GARRISON: { id: string; name: string; kind: string; work: Work }[] = [ - { id: "demo-1", name: "fix: audit seal", kind: "claude-code", work: "bug" }, - { id: "demo-2", name: "feat: session board", kind: "codex", work: "feature" }, - { id: "demo-3", name: "chore: rotate keys", kind: "hermes", work: "idle" }, - { id: "demo-4", name: "fix: relay reconnect", kind: "openclaw", work: "bug" }, - { id: "demo-5", name: "npm run dev", kind: "terminal", work: "idle" }, -]; +export const DEMO_GARRISON = [ + { id: "demo-1", name: "fix: audit seal", kind: "claude-code", work: "bug" as const }, + { id: "demo-2", name: "feat: session board", kind: "codex", work: "feature" as const }, + { id: "demo-3", name: "chore: rotate keys", kind: "hermes", work: "idle" as const }, + { id: "demo-4", name: "fix: relay reconnect", kind: "openclaw", work: "bug" as const }, + { id: "demo-5", name: "npm run dev", kind: "terminal", work: "idle" as const }, +].map((entry, index) => ({ + ...entry, + /* + * The stand-in garrison carries plausible session facts, so clicking one + * shows the same panel a real session would rather than a panel with holes + * in it. The interface says elsewhere, plainly, that these are an example. + */ + session: { + id: entry.id, + startedAt: Date.now() - (index + 1) * 11 * 60_000, + host: ["laptop", "workshop", "builder-01", "laptop", "workshop"][index], + command: entry.name, + }, +})); diff --git a/app/src/game/ui/Hud.tsx b/app/src/game/ui/Hud.tsx index 81beefe..2bed515 100644 --- a/app/src/game/ui/Hud.tsx +++ b/app/src/game/ui/Hud.tsx @@ -1,6 +1,6 @@ import { CLASS_LORE, WORLD } from "../lore/world"; import { nextUnlock, type Standing } from "../state/progress"; -import type { Wright } from "../state/world"; +import type { Actor } from "../world/sim"; /** * What the player needs to know without opening anything. @@ -23,7 +23,7 @@ export interface HudProps { /** Whether any stat-gathering has been agreed to at all. */ gathering: boolean; characterClass: string; - wrights: Wright[]; + wrights: Actor[]; /** True when the field is showing a stand-in garrison, not real sessions. */ demo: boolean; onOpenRoster: () => void; diff --git a/app/src/game/ui/WrightPanel.tsx b/app/src/game/ui/WrightPanel.tsx new file mode 100644 index 0000000..aea4cb1 --- /dev/null +++ b/app/src/game/ui/WrightPanel.tsx @@ -0,0 +1,119 @@ +import { CLASS_LORE } from "../lore/world"; +import { garrisonById } from "../world/marches"; +import type { Actor } from "../world/sim"; + +/** + * Everything known about one wright, shown when it is clicked. + * + * This is the answer to "the game is non-interactive". The map was a thing you + * watched; now the figures on it are the sessions in your account and clicking + * one tells you which, on what machine, running what, since when, and where it + * has been posted. + * + * It is deliberately a panel at the side rather than a modal over the middle. + * A modal would cover the thing you just clicked, which is the one part of the + * screen you were looking at. + */ + +function since(startedAt: number, now: number): string { + const seconds = Math.max(0, Math.round((now - startedAt) / 1000)); + if (seconds < 90) return `${seconds}s`; + const minutes = Math.round(seconds / 60); + if (minutes < 90) return `${minutes}m`; + const hours = Math.round(minutes / 60); + return hours < 48 ? `${hours}h` : `${Math.round(hours / 24)}d`; +} + +const WORK_WORDS: Record = { + bug: { title: "Mending", note: "Out against the Unmade." }, + feature: { title: "Making", note: "Raising something that was not there." }, + idle: { title: "Standing to", note: "No fault named, nothing being built." }, +}; + +export function WrightPanel({ + actor, + now, + onClose, + onOpenSession, +}: { + actor: Actor; + now: number; + onClose: () => void; + onOpenSession?: (sessionId: string) => void; +}) { + const lore = CLASS_LORE[actor.kind] ?? CLASS_LORE.terminal; + const posting = garrisonById(actor.home); + const work = WORK_WORDS[actor.work]; + + return ( + + ); +} diff --git a/app/src/game/world/sim.ts b/app/src/game/world/sim.ts new file mode 100644 index 0000000..af78f78 --- /dev/null +++ b/app/src/game/world/sim.ts @@ -0,0 +1,440 @@ +import { GARRISONS, garrisonFor, type Garrison } from "./marches"; + +/** + * Who is on the Marches, and what they are doing. + * + * A rewrite of the old yard simulation, and much simpler for one reason: the + * map is open country. The old one spent most of its code steering around the + * one building in the middle of the one yard, and every version of that + * steering shook — the wright re-decided which way to go thirty times a second + * and spent its time turning round rather than walking. + * + * Here there is nowhere that has to be gone around. A wright walks to the + * garrison its work belongs to and mills about inside it; the buildings sit in + * the middle and everybody else keeps to the apron. No avoidance, no corner + * routing, no oscillation. The fix for the shaking was mostly deleting the + * thing that shook. + * + * Everything here is a plain object advanced by pure functions, with no Pixi in + * it, so a thousand ticks can be run in a test and looked at. + */ + +export type Work = "bug" | "feature" | "idle"; +export type Side = "garrison" | "unmade"; + +export interface Actor { + id: string; + side: Side; + /** For a wright, the session kind. For the rest, which sprite to draw. */ + kind: string; + /** Shown on the plate, and in the panel when clicked. */ + name: string; + work: Work; + x: number; + y: number; + toX: number; + toY: number; + facing: 1 | -1; + moving: boolean; + hp: number; + maxHp: number; + /** Ticks of flinch left, so a hit is visible as well as counted. */ + hurt: number; + /** What the renderer draws: standing, walking, or mid-blow. */ + action: "stand" | "walk" | "attack"; + actionUntil: number; + /** Ticks to mill about before choosing somewhere new inside the garrison. */ + rest: number; + /** Which holding this actor belongs to. */ + home: string; + /** Who it is fighting, held until that one dies or wanders off. */ + targetId?: string; + /** + * A real session, rather than one of the garrison's own soldiers. + * + * Only these are worth clicking: they stand for something in the account. + * The rest are there so a battle looks like a battle. + */ + session?: { id: string; startedAt: number; host: string; command: string }; +} + +export interface Effect { + id: number; + kind: "hit" | "cast" | "build" | "fell"; + x: number; + y: number; + life: number; + maxLife: number; +} + +export interface Mark { + id: number; + text: string; + x: number; + y: number; + life: number; + maxLife: number; + kind: "damage" | "gain"; +} + +export interface Sim { + actors: Actor[]; + effects: Effect[]; + marks: Mark[]; + clock: number; + spawned: number; + felled: number; + raised: number; + /** Ticks until the next of the Unmade arrives. */ + nextSpawn: number; +} + +/** Tiles a second. */ +const WALK = 1.9; +const CHARGE = 2.4; +/** Thirty ticks a second, matching the renderer's fixed step. */ +const PER_TICK = 1 / 30; + +const REACH = 0.9; +const CAST_REACH = 3.2; +const SWING_EVERY = 20; +const SWING_ANIM = 12; +const HURT_TICKS = 6; +const MARK_TICKS = 45; +const EFFECT_TICKS = 18; +const SPAWN_EVERY = 90; +const MAX_UNMADE = 14; +/** Beyond this a fight is abandoned and another chosen. */ +const ABANDON_AT = 9; + +export function createSim(): Sim { + return { + actors: [], + effects: [], + marks: [], + clock: 0, + spawned: 0, + felled: 0, + raised: 0, + nextSpawn: 40, + }; +} + +/** Deterministic, so the map is the same every time it is opened. */ +function noise(seed: number): number { + let value = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b); + value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); + return ((value ^ (value >>> 16)) >>> 0) / 4_294_967_296; +} + +function hashId(id: string): number { + let value = 0; + for (let index = 0; index < id.length; index += 1) { + value = (Math.imul(value, 31) + id.charCodeAt(index)) | 0; + } + return Math.abs(value); +} + +/** + * Somewhere to stand inside a holding. + * + * On the apron, not the middle: the middle is where the buildings are, and a + * soldier standing inside a church looks like a bug even when it is only a + * missing collision. + */ +function spotIn(garrison: Garrison, seed: number): { x: number; y: number } { + const angle = noise(seed) * Math.PI * 2; + const distance = garrison.radius * (0.55 + noise(seed * 7) * 0.4); + return { + x: garrison.x + Math.cos(angle) * distance, + y: garrison.y + Math.sin(angle) * distance * 0.85, + }; +} + +let nextEffect = 1; + +function addEffect(sim: Sim, kind: Effect["kind"], x: number, y: number): void { + sim.effects.push({ id: nextEffect++, kind, x, y, life: EFFECT_TICKS, maxLife: EFFECT_TICKS }); +} + +function addMark(sim: Sim, text: string, x: number, y: number, kind: Mark["kind"]): void { + sim.marks.push({ id: nextEffect++, text, x, y, life: MARK_TICKS, maxLife: MARK_TICKS, kind }); +} + +/** Adds a wright for a real session, at the garrison its work belongs to. */ +export function muster( + sim: Sim, + input: { id: string; name: string; kind: string; work: Work; session?: Actor["session"] }, +): Actor { + const garrison = garrisonFor(input.work); + const spot = spotIn(garrison, hashId(input.id)); + const actor: Actor = { + ...input, + side: "garrison", + x: spot.x, + y: spot.y + 4, + toX: spot.x, + toY: spot.y, + facing: 1, + moving: true, + hp: 20, + maxHp: 20, + hurt: 0, + action: "walk", + actionUntil: 0, + rest: 0, + home: garrison.id, + }; + sim.actors.push(actor); + return actor; +} + +/** + * The garrison's own soldiers. + * + * They are not sessions and they never will be. They are here because a keep + * with five people in it does not look like a keep, and a fault met by one + * wright does not look like a battle. Clicking one says as much rather than + * pretending it stands for something. + */ +export function garrisonSoldiers(sim: Sim): void { + for (const garrison of GARRISONS) { + const count = garrison.draws === "bug" ? 6 : 3; + for (let index = 0; index < count; index += 1) { + const id = `${garrison.id}-soldier-${index}`; + const spot = spotIn(garrison, hashId(id)); + sim.actors.push({ + id, + side: "garrison", + kind: "soldier", + name: `${garrison.name} watch`, + work: garrison.draws === "bug" ? "bug" : "idle", + x: spot.x, + y: spot.y, + toX: spot.x, + toY: spot.y, + facing: 1, + moving: false, + hp: 14, + maxHp: 14, + hurt: 0, + action: "stand", + actionUntil: 0, + rest: Math.round(noise(hashId(id)) * 60), + home: garrison.id, + }); + } + } +} + +/** Whether anybody is working on a fault, which is what draws the Unmade. */ +function underAttack(sim: Sim): boolean { + return sim.actors.some((actor) => actor.side === "garrison" && actor.work === "bug"); +} + +const UNMADE_KINDS = [ + { kind: "mite", hp: 6, speed: 1.0 }, + { kind: "crawler", hp: 12, speed: 0.85 }, + { kind: "heisenbug", hp: 20, speed: 0.7 }, +]; + +function spawnUnmade(sim: Sim): void { + sim.spawned += 1; + const watch = GARRISONS.find((garrison) => garrison.draws === "bug") ?? GARRISONS[0]; + const roll = noise(sim.spawned * 977); + const choice = UNMADE_KINDS[Math.min(2, Math.floor(roll * UNMADE_KINDS.length))]; + + /* Out of the open country north-east of the Watch, never through a gate. */ + const angle = -Math.PI / 4 + (noise(sim.spawned * 31) - 0.5) * 1.4; + const distance = watch.radius + 5 + noise(sim.spawned * 53) * 5; + sim.actors.push({ + id: `unmade-${sim.spawned}`, + side: "unmade", + kind: choice.kind, + name: choice.kind, + work: "bug", + x: watch.x + Math.cos(angle) * distance, + y: watch.y + Math.sin(angle) * distance, + toX: watch.x, + toY: watch.y, + facing: -1, + moving: true, + hp: choice.hp, + maxHp: choice.hp, + hurt: 0, + action: "walk", + actionUntil: 0, + rest: 0, + home: watch.id, + }); +} + +function blow(kind: string): number { + switch (kind) { + case "codex": + return 4; + case "openclaw": + return 3; + case "soldier": + return 2; + case "hermes": + return 2; + default: + return 3; + } +} + +function ranged(kind: string): boolean { + return kind === "codex"; +} + +/** The nearest enemy, held once chosen. This is what stops the shaking. */ +function chooseEnemy(sim: Sim, actor: Actor): Actor | undefined { + const held = sim.actors.find((other) => other.id === actor.targetId); + if (held && held.hp > 0 && Math.hypot(held.x - actor.x, held.y - actor.y) < ABANDON_AT) { + return held; + } + + let best: Actor | undefined; + let bestDistance = Infinity; + for (const other of sim.actors) { + if (other.side === actor.side || other.hp <= 0) continue; + const distance = Math.hypot(other.x - actor.x, other.y - actor.y); + if (distance < bestDistance) { + bestDistance = distance; + best = other; + } + } + actor.targetId = best?.id; + return best; +} + +/** A step towards a point. No avoidance: the map is open country. */ +function stepTo(actor: Actor, toX: number, toY: number, speed: number): boolean { + const step = speed * PER_TICK; + const dx = toX - actor.x; + const dy = toY - actor.y; + const distance = Math.hypot(dx, dy); + if (distance <= step) { + actor.x = toX; + actor.y = toY; + return false; + } + actor.x += (dx / distance) * step; + actor.y += (dy / distance) * step; + /* Face the way travelled, with a deadband so a stopped actor does not spin. */ + if (Math.abs(dx) > 0.02) actor.facing = dx > 0 ? 1 : -1; + return true; +} + +export function tickSim(sim: Sim): void { + sim.clock += 1; + + if (underAttack(sim) && sim.actors.filter((a) => a.side === "unmade").length < MAX_UNMADE) { + sim.nextSpawn -= 1; + if (sim.nextSpawn <= 0) { + sim.nextSpawn = SPAWN_EVERY; + spawnUnmade(sim); + } + } + + for (const effect of sim.effects) effect.life -= 1; + sim.effects = sim.effects.filter((effect) => effect.life > 0); + for (const mark of sim.marks) mark.life -= 1; + sim.marks = sim.marks.filter((mark) => mark.life > 0); + + for (const actor of sim.actors) { + if (actor.hurt > 0) actor.hurt -= 1; + if (actor.action !== "stand" && sim.clock >= actor.actionUntil) { + actor.action = actor.moving ? "walk" : "stand"; + } + + /* Anyone who fights looks for someone to fight. */ + const fights = actor.side === "unmade" || actor.work === "bug"; + if (fights) { + const enemy = chooseEnemy(sim, actor); + if (enemy) { + const dx = enemy.x - actor.x; + const distance = Math.hypot(dx, enemy.y - actor.y); + const reach = ranged(actor.kind) ? CAST_REACH : REACH; + + if (distance > reach) { + actor.moving = stepTo(actor, enemy.x, enemy.y, CHARGE); + if (actor.action !== "attack") actor.action = "walk"; + } else { + actor.moving = false; + if (Math.abs(dx) > 0.02) actor.facing = dx > 0 ? 1 : -1; + if ((sim.clock + hashId(actor.id)) % SWING_EVERY === 0) { + const damage = blow(actor.kind); + actor.action = "attack"; + actor.actionUntil = sim.clock + SWING_ANIM; + addEffect(sim, ranged(actor.kind) ? "cast" : "hit", enemy.x, enemy.y); + enemy.hp -= damage; + enemy.hurt = HURT_TICKS; + addMark(sim, String(damage), enemy.x, enemy.y, "damage"); + if (enemy.hp <= 0) { + addEffect(sim, "fell", enemy.x, enemy.y); + if (enemy.side === "unmade") sim.felled += 1; + } + } + } + continue; + } + } + + /* Nobody to fight: mill about inside the holding. */ + if (!actor.moving) { + actor.rest -= 1; + if (actor.rest > 0) continue; + const garrison = GARRISONS.find((g) => g.id === actor.home) ?? GARRISONS[0]; + const spot = spotIn(garrison, hashId(actor.id) + sim.clock); + actor.toX = spot.x; + actor.toY = spot.y; + actor.moving = true; + actor.action = "walk"; + continue; + } + + if (!stepTo(actor, actor.toX, actor.toY, WALK)) { + actor.moving = false; + actor.action = "stand"; + actor.rest = Math.round(20 + noise(hashId(actor.id) + sim.clock) * 70); + } + } + + /* + * The dead are taken off after everybody has had their turn, so an actor + * removed mid-loop cannot leave somebody else holding a reference to it. + */ + const fallen = sim.actors.filter((actor) => actor.hp <= 0); + if (fallen.length > 0) { + const gone = new Set(fallen.map((actor) => actor.id)); + sim.actors = sim.actors.filter((actor) => !gone.has(actor.id)); + for (const actor of sim.actors) { + if (actor.targetId && gone.has(actor.targetId)) actor.targetId = undefined; + } + /* + * A garrison soldier who falls is back on watch shortly. They are scenery, + * and scenery that thins out over an afternoon leaves an empty map. + */ + for (const dead of fallen) { + if (dead.side === "garrison" && dead.kind === "soldier") { + const garrison = GARRISONS.find((g) => g.id === dead.home) ?? GARRISONS[0]; + const spot = spotIn(garrison, hashId(dead.id) + sim.clock); + sim.actors.push({ + ...dead, + x: spot.x, + y: spot.y, + toX: spot.x, + toY: spot.y, + hp: dead.maxHp, + hurt: 0, + action: "stand", + moving: false, + targetId: undefined, + rest: 90, + }); + } + } + } +} diff --git a/app/src/styles/game.css b/app/src/styles/game.css index 647d2fb..2752cba 100644 --- a/app/src/styles/game.css +++ b/app/src/styles/game.css @@ -1207,3 +1207,117 @@ text-align: center; place-content: center; } + +/* ---- the wright panel -------------------------------------------------- */ + +/* + * Clicking a figure on the map opens this. At the side rather than over the + * middle, because a modal in the middle covers the thing that was just + * clicked — the one part of the screen somebody was looking at. + */ +.keep-wright { + display: flex; + position: absolute; + top: var(--keep-safe); + right: var(--keep-safe); + bottom: var(--keep-safe); + z-index: var(--keep-z-modal); + width: min(340px, calc(100vw - var(--keep-space-4))); + padding: var(--keep-space-2); + flex-direction: column; + gap: var(--keep-space-2); + overflow-y: auto; + pointer-events: auto; + animation: keep-slide var(--keep-normal) ease-out; +} + +@keyframes keep-slide { + from { opacity: 0; transform: translateX(12px); } + to { opacity: 1; transform: none; } +} + +.keep-wright-head { + display: flex; + align-items: center; + gap: var(--keep-space); +} + +.keep-wright-title { + display: flex; + min-width: 0; + flex: 1; + flex-direction: column; +} + +.keep-wright-name { + overflow: hidden; + font-weight: 700; + text-overflow: ellipsis; + white-space: nowrap; +} + +.keep-wright-class { + color: var(--keep-gold); + font-size: var(--keep-text-sm); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-close { + min-width: var(--keep-hit); + min-height: var(--keep-hit); + border: 4px solid var(--keep-stone-dark); + background: var(--keep-stone); + color: var(--keep-parchment); + font: inherit; + cursor: pointer; + flex: none; +} + +.keep-close:hover { + background: var(--keep-stone-lit); +} + +.keep-wright-motto { + margin: 0; + padding: var(--keep-space); + border-left: 4px solid var(--keep-gold); + color: var(--keep-pale, var(--keep-mist)); + font-style: italic; +} + +.keep-wright-facts { + display: flex; + margin: 0; + flex-direction: column; + gap: var(--keep-space-2); +} + +.keep-wright-facts dt { + color: var(--keep-mist); + font-size: var(--keep-text-sm); + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.keep-wright-facts dd { + display: flex; + margin: 0; + flex-direction: column; +} + +.keep-wright-note { + color: var(--keep-mist); + font-size: var(--keep-text-sm); +} + +.keep-wright-command code { + display: block; + padding: var(--keep-space); + border: 3px solid var(--keep-stone-dark); + background: rgb(0 0 0 / 30%); + color: var(--keep-elixir); + font-family: inherit; + font-size: var(--keep-text-sm); + overflow-wrap: anywhere; +} From c99eebacc6eebb7bbfcfaea46d49703c2981875f Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Wed, 16 Sep 2026 03:19:11 -0700 Subject: [PATCH 15/49] Make a wright clickable, and stop the field filling with duplicates Clicking a figure opened nothing. Three faults, found in order: A Pixi container only hit-tests its children unless it is given a hit area of its own, so a tap on open grass -- which is most of the map -- reached nothing and the handler on the viewport never ran. The listener now sits on the stage with a hit area the size of the screen, so every click inside the canvas arrives and where it landed becomes a question about coordinates rather than about the display list. Picking then finds the nearest wright to the click rather than asking anybody to hit a twenty-pixel figure exactly, which at the far end of the zoom range is asking them to miss. Instrumenting that turned up the second fault: every session was on the map twice. `muster` and `garrisonSoldiers` pushed blindly, and both the roster poll and a remounted effect call them again with ids already on the field. Both are idempotent now, and the roster count fell from ten to five, which is how many there are. The third was in the premise. The Unmade arrived on a map where nothing was broken, because the garrison's own watch is drawn at a bug-facing holding and counted as somebody fighting. Only a real session summons them now. A quiet map is the correct picture of a quiet day, and it is the reason a loud one means anything. `sim.test.ts` covers all of it, including a count of direction reversals over nine hundred ticks -- the shaking was found by counting rather than by looking, and this is what keeps it gone. --- app/scripts/sprite-sheet.ts | 85 --- app/src/game/GameRoute.tsx | 11 +- app/src/game/assets/atlas.test.ts | 94 --- app/src/game/assets/compose.ts | 99 --- app/src/game/assets/foes.ts | 164 ----- app/src/game/assets/heroes.ts | 273 --------- app/src/game/assets/palette.ts | 154 ----- app/src/game/assets/props.ts | 306 ---------- app/src/game/assets/sprite.test.ts | 149 ----- app/src/game/assets/sprite.ts | 129 ---- app/src/game/assets/structures.ts | 476 --------------- app/src/game/assets/terrain.ts | 189 ------ app/src/game/engine/Stage.tsx | 170 ------ app/src/game/engine/atlas.ts | 145 ----- app/src/game/engine/loop.test.ts | 102 ---- app/src/game/engine/loop.ts | 147 ----- app/src/game/pixi/actors.ts | 26 +- app/src/game/pixi/keepScene.ts | 61 +- app/src/game/scenes/field.ts | 257 -------- app/src/game/scenes/foes.ts | 120 ---- app/src/game/scenes/fx.ts | 105 ---- app/src/game/scenes/life.ts | 133 ---- app/src/game/scenes/wrights.ts | 224 ------- app/src/game/state/demo-garrison.ts | 33 + app/src/game/state/sessions.ts | 2 +- app/src/game/state/shop.test.ts | 40 +- app/src/game/state/shop.ts | 47 +- app/src/game/state/world.test.ts | 265 -------- app/src/game/state/world.ts | 914 ---------------------------- app/src/game/ui/Codex.tsx | 19 +- app/src/game/ui/Shop.tsx | 19 +- app/src/game/world/sim.test.ts | 209 +++++++ app/src/game/world/sim.ts | 28 +- 33 files changed, 406 insertions(+), 4789 deletions(-) delete mode 100644 app/scripts/sprite-sheet.ts delete mode 100644 app/src/game/assets/atlas.test.ts delete mode 100644 app/src/game/assets/compose.ts delete mode 100644 app/src/game/assets/foes.ts delete mode 100644 app/src/game/assets/heroes.ts delete mode 100644 app/src/game/assets/palette.ts delete mode 100644 app/src/game/assets/props.ts delete mode 100644 app/src/game/assets/sprite.test.ts delete mode 100644 app/src/game/assets/sprite.ts delete mode 100644 app/src/game/assets/structures.ts delete mode 100644 app/src/game/assets/terrain.ts delete mode 100644 app/src/game/engine/Stage.tsx delete mode 100644 app/src/game/engine/atlas.ts delete mode 100644 app/src/game/engine/loop.test.ts delete mode 100644 app/src/game/engine/loop.ts delete mode 100644 app/src/game/scenes/field.ts delete mode 100644 app/src/game/scenes/foes.ts delete mode 100644 app/src/game/scenes/fx.ts delete mode 100644 app/src/game/scenes/life.ts delete mode 100644 app/src/game/scenes/wrights.ts create mode 100644 app/src/game/state/demo-garrison.ts delete mode 100644 app/src/game/state/world.test.ts delete mode 100644 app/src/game/state/world.ts create mode 100644 app/src/game/world/sim.test.ts diff --git a/app/scripts/sprite-sheet.ts b/app/scripts/sprite-sheet.ts deleted file mode 100644 index 45dce3e..0000000 --- a/app/scripts/sprite-sheet.ts +++ /dev/null @@ -1,85 +0,0 @@ -/* - * Renders every sprite in the game to one HTML page, big enough to see. - * - * Authoring pixel art as text has one real weakness: you cannot look at it. - * The atlas test will tell you a row is the wrong width, but it has no opinion - * about whether the watchtower looks like a watchtower. This closes that gap - * without a build step or an image pipeline -- each sprite becomes a grid of - * divs, magnified, next to its name and size. - * - * npx tsx scripts/sprite-sheet.ts [outfile] - * - * It writes a standalone file with no assets and no script, so it can be - * opened straight from disk. - */ -import { writeFile } from "node:fs/promises"; -import { PALETTES } from "../src/game/assets/palette"; -import { slotOf, type Sprite } from "../src/game/assets/sprite"; -import { STRUCTURES } from "../src/game/assets/structures"; -import { TERRAIN } from "../src/game/assets/terrain"; - -const SCALE = 6; - -function renderSprite(name: string, sprite: Sprite): string { - const palette = PALETTES[sprite.palette]; - const cells: string[] = []; - for (let y = 0; y < sprite.h; y += 1) { - const row = sprite.rows[y] ?? ""; - for (let x = 0; x < sprite.w; x += 1) { - const slot = slotOf(row[x] ?? "."); - const colour = slot < 0 ? "transparent" : palette[slot]; - cells.push(``); - } - } - return ` -
-
${cells.join("")}
-
${name}${sprite.w}×${sprite.h} · ${sprite.palette}
-
`; -} - -function section(title: string, sprites: [string, Sprite][]): string { - return `

${title}

${sprites - .map(([name, sprite]) => renderSprite(name, sprite)) - .join("")}
`; -} - -const terrain: [string, Sprite][] = Object.entries(TERRAIN); -const structures: [string, Sprite][] = Object.entries(STRUCTURES).flatMap(([name, art]) => - art.tiers.map((sprite, index): [string, Sprite] => [`${name} ${"I".repeat(index + 1)}`, sprite]), -); - -const html = ` - -Shell Keep — sprite sheet - -

Shell Keep — sprite sheet

-${section("Terrain", terrain)} -${section("Structures", structures)} -`; - -const out = process.argv[2] ?? "sprite-sheet.html"; -await writeFile(out, html, "utf8"); -console.log(`sprite-sheet: wrote ${out}`); diff --git a/app/src/game/GameRoute.tsx b/app/src/game/GameRoute.tsx index b7a1c96..474cdb7 100644 --- a/app/src/game/GameRoute.tsx +++ b/app/src/game/GameRoute.tsx @@ -9,9 +9,9 @@ import { useGamepadActions } from "./engine/use-gamepad"; import { KEEP_TITLE, SHELL_KEEP_MARKER } from "./keep"; import { GameShellContext, type GameShell } from "./state/context"; import { motionReduced, optionsToStyle, readOptions, writeOptions, type GameOptions } from "./state/options"; -import { DEMO_GARRISON } from "./state/world"; +import { DEMO_GARRISON } from "./state/demo-garrison"; import { useGarrison } from "./state/use-garrison"; -import { buy } from "./state/shop"; +import { buy, tintFor } from "./state/shop"; import { experienceFrom, marksEarnedTo, standing } from "./state/progress"; import { hasChosen, marksLeft, readSave, writeSave, type Save } from "./state/save"; import { loadSave, reconcile, storeSave } from "./state/remote"; @@ -173,6 +173,7 @@ export default function GameRoute() { sim: sim.current, select: () => {}, lookAt: () => {}, + wear: () => {}, }); handle.current.onPick = setPicked; @@ -321,7 +322,11 @@ export default function GameRoute() { skinId: save.skinId || skinId, }); }} - onWear={(skinId) => setSave({ ...save, skinId })} + onWear={(skinId) => { + setSave({ ...save, skinId }); + /* The field shows it at once, rather than on the next reload. */ + handle.current.wear(tintFor(skinId)); + }} /> )} diff --git a/app/src/game/assets/atlas.test.ts b/app/src/game/assets/atlas.test.ts deleted file mode 100644 index aad0bc1..0000000 --- a/app/src/game/assets/atlas.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { PROPS } from "./props"; -import { STRUCTURES } from "./structures"; -import { MEADOW, PAVING, ROAD, TERRAIN } from "./terrain"; -import { spriteProblems, TRANSPARENT, type Sprite } from "./sprite"; - -/** - * Every sprite in the game, checked for the mistakes hand-authoring makes. - * - * This is the test that makes text-as-pixel-art safe to work in. A row one - * character short shifts every pixel after it and looks, on screen, like the - * artwork was simply drawn badly -- there is no error, nothing throws, it just - * comes out wrong. Here it is a line number and a count. - */ -const everySprite = (): [string, Sprite][] => { - const entries: [string, Sprite][] = []; - for (const [name, sprite] of Object.entries(TERRAIN)) { - entries.push([`terrain.${name}`, sprite]); - } - MEADOW.forEach((sprite, index) => entries.push([`meadow.${index}`, sprite])); - PAVING.forEach((sprite, index) => entries.push([`paving.${index}`, sprite])); - ROAD.forEach((sprite, index) => entries.push([`road.${index}`, sprite])); - for (const [name, prop] of Object.entries(PROPS)) { - entries.push([`prop.${name}`, prop.sprite]); - } - for (const [name, art] of Object.entries(STRUCTURES)) { - art.tiers.forEach((sprite, tier) => entries.push([`${name}.tier${tier + 1}`, sprite])); - } - return entries; -}; - -describe("the atlas", () => { - it("has no miscounted rows anywhere", () => { - const problems = everySprite().flatMap(([name, sprite]) => spriteProblems(name, sprite)); - expect(problems).toEqual([]); - }); - - it("gives every structure the same footprint at every tier", () => { - /* - * An upgrade that changed size would jump out of its plot on the field, or - * overlap the thing next to it. Taller is allowed and is most of how an - * upgrade reads; wider is not. - */ - for (const [name, art] of Object.entries(STRUCTURES)) { - const widths = new Set(art.tiers.map((tier) => tier.w)); - expect(widths, `${name} changes width between tiers`).toHaveProperty("size", 1); - } - }); - - it("gives every structure a name and a description", () => { - /* Colour and silhouette are not enough on their own; see the colour rules. */ - for (const art of Object.values(STRUCTURES)) { - expect(art.name).toBeTruthy(); - expect(art.blurb).toBeTruthy(); - expect(art.tiers.length).toBeGreaterThan(0); - } - }); - - it("keeps the ground tiles square and tileable", () => { - for (const [name, sprite] of Object.entries(TERRAIN)) { - expect(sprite.w, `${name} is not square`).toBe(sprite.h); - } - for (const set of [MEADOW, PAVING, ROAD]) { - for (const sprite of set) expect(sprite.w).toBe(sprite.h); - } - }); - - it("leaves no hole in a ground tile", () => { - /* - * The ground is the bottom layer: a transparent pixel in it is a hole - * through to the page behind, which shows up as a single stray dark dot - * somewhere in a field of grass and is very hard to find by looking. - */ - for (const [name, sprite] of Object.entries(TERRAIN)) { - const holes = sprite.rows.some((row) => row.includes(TRANSPARENT)); - expect(holes, `${name} has a transparent pixel`).toBe(false); - } - }); - - it("gives every prop somewhere to stand", () => { - /* - * Props sit on whatever ground they land on, so they must have transparent - * edges. One drawn to the edge of its tile carries a square of the wrong - * surface around with it. - */ - for (const [name, prop] of Object.entries(PROPS)) { - const { rows, h } = prop.sprite; - const solidEdge = - !rows[0]?.includes(TRANSPARENT) && !rows[h - 1]?.includes(TRANSPARENT); - expect(solidEdge, `${name} fills its whole tile`).toBe(false); - expect(prop.name).toBeTruthy(); - } - }); -}); diff --git a/app/src/game/assets/compose.ts b/app/src/game/assets/compose.ts deleted file mode 100644 index d7d9355..0000000 --- a/app/src/game/assets/compose.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { TRANSPARENT, type Animation, type Sprite } from "./sprite"; - -/** - * Building animation strips out of single frames. - * - * An animation is stored as its frames laid side by side in one sprite, which - * is what lets the renderer blit a sub-rectangle instead of juggling a list of - * images. Typing those rows out by hand means counting to forty-eight, sixteen - * times, without slipping -- and a single miscounted row shifts every pixel - * after it. - * - * So frames are authored one at a time, at their own width, and joined here. - * The helpers below are the only reason the artwork in this directory is worth - * trusting. - */ - -/** One frame: rows of palette characters, all the same length. */ -export type Frame = string[]; - -/** Lays frames side by side into the strip an Animation expects. */ -export function strip(frames: Frame[], palette: Sprite["palette"]): Sprite { - const height = frames[0]?.length ?? 0; - const width = frames[0]?.[0]?.length ?? 0; - const rows: string[] = []; - for (let y = 0; y < height; y += 1) { - rows.push(frames.map((frame) => frame[y] ?? "".padEnd(width, TRANSPARENT)).join("")); - } - return { w: width * frames.length, h: height, palette, rows }; -} - -export function animation( - frames: Frame[], - palette: Sprite["palette"], - fps: number, -): Animation { - return { sprite: strip(frames, palette), frames: frames.length, fps }; -} - -/** - * The same frame moved down by a row or two. - * - * Most of the idle animation in this game is a one-pixel bob. Drawing it twice - * would be two chances to make a mistake for a picture that is the same. - */ -export function bob(frame: Frame, by = 1): Frame { - const width = frame[0]?.length ?? 0; - const blank = TRANSPARENT.repeat(width); - return [...Array.from({ length: by }, () => blank), ...frame.slice(0, frame.length - by)]; -} - -/** A frame with one palette slot swapped for another, for a flash on damage. */ -export function recolour(frame: Frame, from: string, to: string): Frame { - return frame.map((row) => row.split(from).join(to)); -} - -/** A single frame as a still sprite. */ -export function still(frame: Frame, palette: Sprite["palette"]): Sprite { - return { - w: frame[0]?.length ?? 0, - h: frame.length, - palette, - rows: [...frame], - }; -} - -/** - * A frame turned a quarter turn clockwise. - * - * A map seen from above needs the same wall running north to south as well as - * east to west, and the same corner at all four orientations. Authoring each - * one separately is four chances to draw a slightly different wall; deriving - * them means the rampart is the same masonry whichever way it turns. - * - * Only correct for square frames, which is what every tile here is, so it says - * so rather than silently producing a sheared picture. - */ -export function rotate(frame: Frame): Frame { - const height = frame.length; - const width = frame[0]?.length ?? 0; - if (width !== height) { - throw new Error(`rotate needs a square frame, got ${width}x${height}`); - } - const rows: string[] = []; - for (let y = 0; y < height; y += 1) { - let row = ""; - /* Reading up the source columns turns the picture clockwise. */ - for (let x = 0; x < width; x += 1) row += frame[height - 1 - x]?.[y] ?? TRANSPARENT; - rows.push(row); - } - return rows; -} - -/** The same frame at all four orientations, clockwise from the one given. */ -export function turns(frame: Frame): [Frame, Frame, Frame, Frame] { - const east = rotate(frame); - const south = rotate(east); - const west = rotate(south); - return [frame, east, south, west]; -} diff --git a/app/src/game/assets/foes.ts b/app/src/game/assets/foes.ts deleted file mode 100644 index 3919a59..0000000 --- a/app/src/game/assets/foes.ts +++ /dev/null @@ -1,164 +0,0 @@ -import { animation, type Frame } from "./compose"; -import type { Animation } from "./sprite"; - -/** - * The Unmade: what comes out of rotted code. - * - * Everything else on this map is warm — sandstone, terracotta, timber, amber. - * These are cold and faintly luminous, drawn from the one palette that does - * not belong to the world, so that a foe on the field reads as something that - * got *in* rather than something that lives here. That contrast does the job - * an outline would do, and costs no pixels. - * - * Three kinds, and each is a rename of a real thing a session fights: - * - * Glitch-mite small, many, never the actual problem - * Null-crawler went for the thing nobody checked - * Heisenbug not there while you are looking at it - * - * Glitch palette slots: 0-4 dark to lit body, 5-7 pale, 8-11 the cold ramp, - * 12-14 violet, 15 the hot pink that only ever means damage. - */ - -/* ---- Glitch-mite -------------------------------------------------------- */ - -const MITE_A: Frame = [ - "............", - "...2....2...", - "....2..2....", - "..22366322..", - ".2366aa6632.", - "236aabbaa632", - "236abffba632", - "236aabbaa632", - ".2366aa6632.", - "..22366322..", - "...2....2...", - "............", -]; - -const MITE_B: Frame = [ - "...2....2...", - "....2..2....", - "............", - "..22366322..", - ".2366aa6632.", - "236aabbaa632", - "236abffba632", - "236aabbaa632", - ".2366aa6632.", - "..22366322..", - "....2..2....", - "...2....2...", -]; - -/* ---- Null-crawler ------------------------------------------------------- */ - -/* - * Longer and segmented, so a crawler reads as a different silhouette from a - * mite at a glance rather than as a bigger one. Its head is at the west end; - * the renderer flips it when it is walking the other way. - */ -const CRAWLER_A: Frame = [ - "................", - "..2..2..2..2....", - ".223663663662...", - "2366aabaabaa62..", - "36abffbaabaab632", - "2366aabaabaa6632", - ".22366366366322.", - "..2..2..2..2.22.", - "................", - "................", - "................", - "................", -]; - -const CRAWLER_B: Frame = [ - "..2..2..2..2....", - "................", - ".223663663662...", - "2366aabaabaa62..", - "36abffbaabaab632", - "2366aabaabaa6632", - ".22366366366322.", - "................", - "..2..2..2..2.22.", - "................", - "................", - "................", -]; - -/* ---- Heisenbug ---------------------------------------------------------- */ - -/* - * Half there. The second frame is nearly empty on purpose, so it blinks out of - * existence as it walks — the joke and the warning at the same time. The - * renderer does not fade it; the artwork does, which means reduced motion - * leaves it solid rather than leaving it invisible. - */ -const HEISEN_A: Frame = [ - "................", - "....22cccc22....", - "...2c366663c2...", - "..2c36aabaa63c..", - "..c36abffba63c..", - "..c36aabbaa63c..", - "..2c366aa663c2..", - "...2c3666663c...", - "....22cccc22....", - "......2..2......", - ".....2....2.....", - "................", -]; - -const HEISEN_B: Frame = [ - "................", - "....2......2....", - "...2........2...", - "..2..3....3..2..", - ".....3affa3.....", - "..2..3....3..2..", - "...2........2...", - "....2......2....", - "................", - "......2..2......", - "................", - "................", -]; - -export interface FoeArt { - walk: Animation; - name: string; - /** How much of a beating it takes. Small, many, or awkward. */ - hp: number; - /** Tiles per second. */ - speed: number; -} - -export const FOES: Record = { - mite: { - walk: animation([MITE_A, MITE_B], "glitch", 6), - name: "Glitch-mite", - hp: 3, - speed: 1.1, - }, - crawler: { - walk: animation([CRAWLER_A, CRAWLER_B], "glitch", 5), - name: "Null-crawler", - hp: 6, - speed: 0.8, - }, - heisenbug: { - walk: animation([HEISEN_A, HEISEN_B], "glitch", 3), - name: "Heisenbug", - hp: 9, - speed: 0.6, - }, -}; - -export type FoeKind = keyof typeof FOES; - -export function foeArt(kind: string): FoeArt { - return FOES[kind] ?? FOES.mite; -} diff --git a/app/src/game/assets/heroes.ts b/app/src/game/assets/heroes.ts deleted file mode 100644 index e8dda7e..0000000 --- a/app/src/game/assets/heroes.ts +++ /dev/null @@ -1,273 +0,0 @@ -import { animation, bob, still, type Frame } from "./compose"; -import { reskin, STONE, type Palette } from "./palette"; -import type { Animation, Sprite } from "./sprite"; - -/** - * The wrights: one per live session, drawn from one figure. - * - * Five classes with eight frames each is forty pictures of a person, and forty - * hand-drawn people at sixteen pixels come out as five slightly different - * people with inconsistent proportions — the sort of thing nobody can name but - * everybody sees. So there is one figure, and a class is three things laid - * over it: - * - * a crest four pixels above the helm, which is the silhouette difference - * a palette the tunic ramp swapped, which is the colour difference - * a name from the lore, which is the difference that actually matters - * - * That also makes a shop skin exactly the same kind of thing as a class: a - * different palette over the same figure. Nothing in the renderer has to know - * which of the two it is holding. - */ - -/** The body, without legs: helm, face, shoulders, tunic. */ -const TORSO: string[] = [ - ".....4444.......", - "....445544......", - "...44566544.....", - "...45677654.....", - "...45677654.....", - "...44566544.....", - "....455554......", - "...9aaaaaa9.....", - "..9aaaaaaaa9....", - "..9abbbbbba9....", - "..9abbbbbba9....", - "..9aaaaaaaa9....", - "...9aaaaaa9.....", - "....4a44a4......", -]; - -/** - * The torso again, with the working arm in four positions. - * - * Only rows seven to twelve differ: the head, the shoulders and the belt are - * the same pixels in every frame. That is what makes a swing read as the same - * person swinging rather than as a second character appearing — and it is also - * why these are written out rather than generated, because the six rows that - * change are exactly the drawing and the eight that do not are exactly the - * noise. - * - * The arm is always on the east side. The renderer flips the whole sprite when - * a wright is facing west, so one set of frames serves both directions. - */ -const SWING: string[][] = [ - /* Wind up: weapon raised. */ - [ - "...9aaaaaa9.e...", - "..9aaaaaaaa9e...", - "..9abbbbbba944..", - "..9abbbbbba9....", - "..9aaaaaaaa9....", - "...9aaaaaa9.....", - ], - /* Strike: arm out, weapon level. */ - [ - "...9aaaaaa9.....", - "..9aaaaaaaa9....", - "..9abbbbbba94eee", - "..9abbbbbba9....", - "..9aaaaaaaa9....", - "...9aaaaaa9.....", - ], - /* Follow through: weapon down past the knee. */ - [ - "...9aaaaaa9.....", - "..9aaaaaaaa9....", - "..9abbbbbba9....", - "..9abbbbbba944..", - "..9aaaaaaaa9.e..", - "...9aaaaaa9..e..", - ], - /* Recover. */ - [ - "...9aaaaaa9.....", - "..9aaaaaaaa9....", - "..9abbbbbba9....", - "..9abbbbbba9....", - "..9aaaaaaaa9....", - "...9aaaaaa9.....", - ], -]; - -/** The same six rows for a hammer, which goes up and down rather than across. */ -const HAMMER: string[][] = [ - [ - "...9aaaaaa9.d...", - "..9aaaaaaaa9d...", - "..9abbbbbba944..", - "..9abbbbbba9....", - "..9aaaaaaaa9....", - "...9aaaaaa9.....", - ], - [ - "...9aaaaaa9dd...", - "..9aaaaaaaa9d...", - "..9abbbbbba944..", - "..9abbbbbba9....", - "..9aaaaaaaa9....", - "...9aaaaaa9.....", - ], - [ - "...9aaaaaa9.....", - "..9aaaaaaaa9....", - "..9abbbbbba944..", - "..9abbbbbba9.d..", - "..9aaaaaaaa9dd..", - "...9aaaaaa9.....", - ], - [ - "...9aaaaaa9.....", - "..9aaaaaaaa9....", - "..9abbbbbba9....", - "..9abbbbbba944..", - "..9aaaaaaaa9dd..", - "...9aaaaaa9.dd..", - ], -]; - -/** Legs, as the four positions a walk cycles through. */ -const LEGS: Record<"stand" | "left" | "pass" | "right", string[]> = { - stand: [ - "....c4..4c......", - "....c4..4c......", - "....c4..4c......", - "...cc4..4cc.....", - ], - left: [ - "...cc4..4c......", - "...c44..4c......", - "..cc4...4cc.....", - "..cc.....4c.....", - ], - pass: [ - "....c4..4c......", - "....c44.4c......", - "....c4..4c......", - "....cc..cc......", - ], - right: [ - "....c4..4cc.....", - "....c4..44c.....", - "....cc...4cc....", - "....c4.....cc...", - ], -}; - -/** A crest, four pixels wide, sitting above the helm. */ -const CRESTS: Record = { - /* An anvil: the Artificer builds. */ - "claude-code": "..ee..", - /* A reading eye: the Arcanist names the fault. */ - codex: ".e77e.", - /* Wings: the Herald is fast. */ - hermes: "e.ee.e", - /* A claw: the Beastmaster holds on. */ - openclaw: ".e..e.", - /* Nothing at all. Someone has to. */ - terminal: "......", -}; - -function figure(kind: string, legs: string[], arms?: string[]): Frame { - const crest = CRESTS[kind] ?? CRESTS.terminal; - /* Rows seven to twelve are the working arm; everything else never moves. */ - const torso = arms ? [...TORSO.slice(0, 7), ...arms, TORSO[13]] : TORSO; - return [ - /* The crest is centred over the helm: five in, six wide, five out. */ - `.....${crest}.....`.slice(0, 16).padEnd(16, "."), - ...torso, - ...legs, - ]; -} - -/** - * Class colours, as swaps of the tunic ramp on the stone palette. - * - * Only the accent slots move. The helm, the face and the boots stay the same - * across every class, which is what keeps five wrights standing together - * looking like one garrison rather than five different games. - */ -const TUNICS: Record>> = { - /* Artificer: forge iron and hot metal. */ - "claude-code": { 8: "#6b3a1c", 9: "#a35c22", 10: "#d98b34", 11: "#f0b74c" }, - /* Arcanist: the one cold class, and the only blue on the field. */ - codex: { 8: "#20305e", 9: "#33498f", 10: "#4c6fc4", 11: "#7f9ae8" }, - /* Herald: road dust and a bright sash. */ - hermes: { 8: "#4a4a2a", 9: "#7a7539", 10: "#a8a04c", 11: "#ded36a" }, - /* Beastmaster: hide and dried blood. */ - openclaw: { 8: "#4a2320", 9: "#7d3a2e", 10: "#a85643", 11: "#c97e63" }, - /* Footman: undyed wool. */ - terminal: { 8: "#3b352c", 9: "#5f584a", 10: "#867d6b", 11: "#b0a692" }, -}; - -export function tunicFor(kind: string): Palette { - return reskin(STONE, TUNICS[kind] ?? TUNICS.terminal); -} - -export interface HeroArt { - idle: Animation; - walk: Animation; - /** Swinging at a fault. */ - attack: Animation; - /** Raising a structure. */ - build: Animation; - /** A single frame, for a portrait or a roster row. */ - portrait: Sprite; - palette: Palette; - /** - * Whether this class fights at a distance. - * - * Only the Arcanist does. It is the one class whose flavour is naming a - * fault from across the yard rather than hitting it, and one ranged class - * among five is enough to make the field read as having variety without - * anybody having to learn a system. - */ - ranged: boolean; -} - -function build(kind: string): HeroArt { - const stand = figure(kind, LEGS.stand); - return { - /* - * Idle is the same figure a pixel lower on alternate frames. A person - * standing still is not motionless, and one pixel of breath is the - * difference between a character and a game piece. - */ - idle: animation([stand, bob(stand, 1)], "stone", 2), - walk: animation( - [figure(kind, LEGS.left), figure(kind, LEGS.pass), figure(kind, LEGS.right), figure(kind, LEGS.pass)], - "stone", - 8, - ), - /* - * Faster than the walk. A swing that plays at walking speed reads as - * someone waving; the snap is most of what makes it land. - */ - attack: animation( - SWING.map((arms) => figure(kind, LEGS.stand, arms)), - "stone", - 12, - ), - build: animation( - HAMMER.map((arms) => figure(kind, LEGS.stand, arms)), - "stone", - 8, - ), - portrait: still(stand, "stone"), - palette: tunicFor(kind), - ranged: kind === "codex", - }; -} - -/** Every class, keyed by the session kind it is drawn from. */ -export const HEROES: Record = { - "claude-code": build("claude-code"), - codex: build("codex"), - hermes: build("hermes"), - openclaw: build("openclaw"), - terminal: build("terminal"), -}; - -export function heroArt(kind: string): HeroArt { - return HEROES[kind] ?? HEROES.terminal; -} diff --git a/app/src/game/assets/palette.ts b/app/src/game/assets/palette.ts deleted file mode 100644 index 424c16f..0000000 --- a/app/src/game/assets/palette.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Sixteen colours, and why there are only sixteen. - * - * The restriction is the style. Hardware from the era this borrows from could - * hold a handful of colours at once, and the look everyone remembers — flat - * fills, hard edges, dithering where a gradient would go — is a consequence of - * that limit rather than a filter applied afterwards. Give an artist an - * unlimited palette and the result stops reading as 8-bit no matter how few - * pixels it has. - * - * It also buys two practical things: - * - * Skins are free. A sprite stores palette *indices*, not colours, so a skin - * is a different sixteen-entry array — about forty bytes — rather than a - * second copy of the artwork. Twelve skins in the shop cost twelve arrays. - * - * Colourblind palettes work everywhere at once. Widening the gap between two - * hues is an edit to one table, not to every sprite that used them. - * - * The world is warm: sandstone, terracotta, timber and amber, lit as if late - * in the afternoon. The screens set into the stone are amber CRT rather than - * green, which is both the warmer choice and the more period-accurate one — - * amber monochrome monitors were the other half of that history, and they let - * the keep stay a terminal without a cold green cast over everything. - */ - -/** Sixteen CSS colours. Index 0 is the darkest; see the slot map below. */ -export type Palette = readonly [ - string, string, string, string, - string, string, string, string, - string, string, string, string, - string, string, string, string, -]; - -/* - * What each slot is for. Sprites are authored against these meanings, so a - * palette swap keeps a structure looking like a structure: slot 9 is "the - * accent, mid tone" in every palette, whatever colour that happens to be. - * - * 0-4 shadow through to lit, the body of a thing - * 5-7 pale, for highlights and parchment - * 8-11 the accent ramp, dark to brightest - * 12-14 timber and metal, dark to gold - * 15 alarm: damage, danger, a thing that has gone wrong - */ -export const SLOT = { - shadow: 0, - bodyDark: 1, - body: 2, - bodyLit: 3, - bodyHigh: 4, - mist: 5, - pale: 6, - paper: 7, - accentDark: 8, - accent: 9, - accentLit: 10, - accentBright: 11, - timberDark: 12, - timber: 13, - gold: 14, - alarm: 15, -} as const; - -/** - * Sandstone and terracotta: the keep and everything built of it. - * - * Five steps of stone rather than three, because detail at this size is - * shading. A wall with one highlight and one shadow reads as a rectangle; a - * wall with five steps reads as masonry. - */ -export const STONE: Palette = [ - "#160f0c", "#2b1d16", "#463024", "#6a4a33", - "#8f6a45", "#b58d5f", "#d9b88a", "#f5e3c0", - "#5e2a16", "#94441f", "#c46b2a", "#f0a03c", - "#4a3a1e", "#7d6430", "#e8c65a", "#c0392b", -]; - -/** Grass, dirt and the worn path between them. */ -export const FIELD: Palette = [ - "#141208", "#241f0e", "#343017", "#474620", - "#5b5c28", "#757334", "#938d48", "#bdb271", - "#4a3418", "#6b4a22", "#8d652f", "#b0854a", - "#3a2a14", "#5c4420", "#e8c65a", "#c0392b", -]; - -/** - * What the bugs are made of. - * - * The one palette that does not belong here. Everything else in the keep is - * warm; a bug is cold, sickly and slightly luminous, so it reads as something - * that got in rather than something that lives here. That contrast is doing - * the same job an outline would, without costing a pixel. - */ -export const GLITCH: Palette = [ - "#04100c", "#0b2019", "#133228", "#1c4838", - "#266048", "#37805c", "#57a877", "#9fe0b4", - "#123b4a", "#1a5e6b", "#2a8f92", "#48d6c0", - "#2b1a3a", "#4a2d5e", "#7b4a9c", "#ff5e7a", -]; - -export const PALETTES = { stone: STONE, field: FIELD, glitch: GLITCH } as const; -export type PaletteName = keyof typeof PALETTES; - -/** - * A palette with some slots replaced. This is the whole of what a skin is. - * - * Kept as a function rather than as pre-built tables so a shop skin is stored - * as the handful of slots it changes, which is what makes one cheap enough to - * hand out on a level-up. - */ -export function reskin(base: Palette, changes: Partial>): Palette { - return base.map((colour, slot) => changes[slot] ?? colour) as unknown as Palette; -} - -/** - * Every slot the same colour, which turns any sprite into its own silhouette. - * - * This is how things get shadows. Drawing the sprite again in flat dark, a few - * pixels down and to the right and at low opacity, costs one more blit and no - * new artwork — and it is the single biggest thing that stops a building - * looking like a sticker laid on the grass. Objects without shadows read as - * floating no matter how well they are drawn. - */ -export function silhouette(colour: string): Palette { - return Array.from({ length: 16 }, () => colour) as unknown as Palette; -} - -/** - * The one shadow palette, shared. - * - * A module constant rather than a fresh array each call, because the renderer - * caches decoded sprites by palette identity: a new array every frame would - * decode every sprite on screen every frame and defeat the cache entirely. - */ -export const SHADOW = silhouette("#100a06"); - -/** - * How far apart two colours are, roughly as an eye sees it. - * - * Used by the palette tests to hold the colourblind variants to their promise: - * if alarm and accent are not far enough apart in a palette meant to separate - * them, the palette is not doing its job and the test says so. Weighted - * towards green because that is where human vision has the most resolution. - */ -export function contrast(a: string, b: string): number { - const parse = (hex: string) => { - const value = Number.parseInt(hex.replace("#", ""), 16); - return [(value >> 16) & 255, (value >> 8) & 255, value & 255]; - }; - const [r1, g1, b1] = parse(a); - const [r2, g2, b2] = parse(b); - return Math.sqrt(2 * (r1 - r2) ** 2 + 4 * (g1 - g2) ** 2 + 3 * (b1 - b2) ** 2); -} diff --git a/app/src/game/assets/props.ts b/app/src/game/assets/props.ts deleted file mode 100644 index 2877d3a..0000000 --- a/app/src/game/assets/props.ts +++ /dev/null @@ -1,306 +0,0 @@ -import { still, type Frame } from "./compose"; -import type { Sprite } from "./sprite"; - -/** - * The things lying about that make a place look lived in. - * - * Structures say what a holding *is*. Props are what say somebody is using it: - * a cart left by the gate, barrels stacked against a wall, a well somebody has - * to walk to. None of them do anything, and leaving them out is the difference - * between a diagram of a fort and a place. - * - * All of them are drawn on transparent ground so they can sit on grass, on - * paving or on the road without carrying a square of the wrong surface with - * them, and all of them get a shadow from the renderer for the same reason. - * - * Palette slots: 0-4 dark to lit body, 5-7 pale, 8-11 the accent ramp, - * 12-14 timber and gold, 15 alarm. - */ - -/* ---- Outside the walls -------------------------------------------------- */ - -/* - * A broadleaf, twenty-four across, which is half again the width of a tile. - * - * The first trees were drawn inside a single tile and read as cabbages: at - * sixteen pixels a canopy has no room for both a silhouette and any structure - * inside it, so it comes out as a green blob. Bigger, with lobes broken into - * the outline and a trunk showing at the south side where the light does not - * reach, it reads as a tree from across the room. - * - * Props are allowed to be larger than the tile they stand on; the field - * centres them and anchors them to the bottom of it, so a tree overhangs its - * neighbours the way a tree does. - */ -const TREE: Frame = [ - ".......2222.............", - ".....22333322...........", - "...223344443322.........", - "..23344555544332........", - ".2334455665544332.......", - ".2334556676554433.......", - "233455667776554332......", - "23345566777665543322....", - "2334556677766554332 2...".replace(" ", "3"), - "23345566776655443322....", - "233455666665544332......", - ".23345555555443322......", - ".22334444444433222......", - "..2233333333322.........", - "...22222222222..........", - ".....2211222............", - "......cddc..............", - "......cddc..............", - "......cddc..............", - ".....ccddcc.............", - ".....cdddc..............", - "....ccddccc.............", - "....2cccc22.............", - ".....22222..............", -]; - -/** A younger, rounder tree, so a stand of them is not one shape repeated. */ -const TREE_B: Frame = [ - "........222.............", - "......2233322...........", - ".....223444332..........", - "....22345554332.........", - "...2334556654332........", - "...2345566765433........", - "..23455667765433........", - "..23455677766433........", - "..23455667765433........", - "...2345566654332........", - "...2334555554332........", - "....223444443322........", - ".....2233333322.........", - "......222222222.........", - ".......2112222..........", - ".......cddc.............", - ".......cddc.............", - "......ccddcc............", - "......cdddc.............", - ".....ccddcc.............", - ".....2cccc2.............", - "......2222..............", - "........................", - "........................", -]; - -const BUSH: Frame = [ - "................", - "................", - "....2233322.....", - "..22334443322...", - ".2334455544322..", - ".2345556655432..", - "2334555665554332", - "2334555555554332", - ".23345555544322.", - "..223344443322..", - "...2233333222...", - ".....222222.....", - "................", - "................", - "................", - "................", -]; - -/** A weathered boulder. Stone palette, so it reads as the same rock as the walls. */ -const ROCK: Frame = [ - "................", - "................", - "................", - ".....334444.....", - "...3344554433...", - "..334455564433..", - ".23344555544332.", - ".23344455543322.", - ".22334444333222.", - "..223333333222..", - "...2222222222...", - "................", - "................", - "................", - "................", - "................", -]; - -/* ---- Inside the walls --------------------------------------------------- */ - -/* - * The well: a stone ring, dark water, and a timber winch across it. The one - * thing in the yard that says people live here rather than store things here. - */ -const WELL: Frame = [ - "....cc........cc....", - "....cd........dc....", - "....cddddddddddc....", - "....cd11111111dc....", - "....cc........cc....", - "...33444444444433...", - "..334555555555433 ..".replace(" ", "."), - ".33455111111554433..", - ".34551100001155443..", - ".34510000000015543..", - ".34510000000015543..", - ".34551100001155443..", - ".33455111111554433..", - "..33455555555544 3..".replace(" ", "3"), - "...334444444444 3...".replace(" ", "3"), - "....333333333333....", - ".....2222222222.....", - "....................", - "....................", - "....................", -]; - -const BARRELS: Frame = [ - "....................", - "...4444......4444...", - "..455554....455554..", - "..4c11c4....4c11c4..", - "..4d55d4....4d55d4..", - "..4c11c4....4c11c4..", - "..4d55d4....4d55d4..", - "..4c11c4....4c11c4..", - "..455554....455554..", - "...4444......4444...", - "......4444..........", - ".....455554.........", - ".....4c11c4.........", - ".....4d55d4.........", - ".....4c11c4.........", - ".....455554.........", - "......4444..........", - "....................", - "....................", - "....................", -]; - -const CRATES: Frame = [ - "....................", - "...cccccccccc.......", - "...cdddddddddc......", - "...cd11dd11ddc......", - "...cdddddddddc......", - "...cd11dd11ddc......", - "...cdddddddddc......", - "...cccccccccc.......", - "......cccccccccc....", - "......cdddddddddc...", - "......cd11dd11ddc...", - "......cdddddddddc...", - "......cd11dd11ddc...", - "......cdddddddddc...", - "......cccccccccc....", - "....................", - "....................", - "....................", - "....................", - "....................", -]; - -/* - * A banner on a pole. The one place the accent colour is allowed to be large: - * it is cloth, it is meant to be seen from the other side of the field, and it - * is what tells you the holding belongs to somebody. - */ -const BANNER: Frame = [ - "......55........", - "......54........", - "....9999994.....", - "...999aaa994....", - "...99aaaaa994...", - "...9aabbbaa94...", - "...9aabbbaa94...", - "...99aaaaa994...", - "...999aaa9994...", - "....99999994....", - ".....999994.....", - "......5494......", - "......54........", - "......54........", - "......54........", - ".....c54c.......", -]; - -/** A torch on a post, for the wall walk and the gate. */ -const TORCH: Frame = [ - "................", - "................", - "................", - "......bb........", - ".....babb.......", - ".....abba.......", - "......aa........", - "......cc........", - "......cd........", - "......cd........", - "......cd........", - "......cd........", - ".....ccdc.......", - "................", - "................", - "................", -]; - -/** A handcart left by the gate. */ -const CART: Frame = [ - "................", - "................", - "................", - "..cccccccccc....", - "..cdddddddddc...", - "..cd11111111c...", - "..cdddddddddc...", - "..cccccccccc....", - "...c......c.....", - "..ccc....ccc....", - ".cd1dc..cd1dc...", - ".c111c..c111c...", - ".cd1dc..cd1dc...", - "..ccc....ccc....", - "................", - "................", -]; - -export interface Prop { - sprite: Sprite; - /** Named, because colour and silhouette alone do not describe a scene. */ - name: string; -} - -const field = (frame: Frame, name: string): Prop => ({ sprite: still(frame, "field"), name }); -const stone = (frame: Frame, name: string): Prop => ({ sprite: still(frame, "stone"), name }); - -/** What grows outside the walls. */ -export const WILD: Prop[] = [ - field(TREE, "a broadleaf tree"), - field(TREE_B, "a young tree"), - field(BUSH, "a bush"), - stone(ROCK, "a boulder"), -]; - -/** What is kept inside them. */ -export const YARD: Prop[] = [ - stone(WELL, "the well"), - stone(BARRELS, "stacked barrels"), - stone(CRATES, "stacked crates"), - stone(CART, "a handcart"), -]; - -export const PROPS = { - tree: field(TREE, "a broadleaf tree"), - treeSmall: field(TREE_B, "a young tree"), - bush: field(BUSH, "a bush"), - rock: stone(ROCK, "a boulder"), - well: stone(WELL, "the well"), - barrels: stone(BARRELS, "stacked barrels"), - crates: stone(CRATES, "stacked crates"), - cart: stone(CART, "a handcart"), - banner: stone(BANNER, "the holding's banner"), - torch: stone(TORCH, "a lit torch"), -} as const; - -export type PropName = keyof typeof PROPS; diff --git a/app/src/game/assets/sprite.test.ts b/app/src/game/assets/sprite.test.ts deleted file mode 100644 index 0ec0e1c..0000000 --- a/app/src/game/assets/sprite.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { STONE, contrast, reskin, SLOT } from "./palette"; -import { - decodeSprite, - frameAt, - frameOffset, - slotOf, - spriteProblems, - type Animation, - type Sprite, -} from "./sprite"; - -const square: Sprite = { - w: 2, - h: 2, - palette: "stone", - rows: [ - "0f", - ".7", - ], -}; - -describe("reading an authored sprite", () => { - it("maps hex digits to palette slots and dots to nothing", () => { - expect(slotOf("0")).toBe(0); - expect(slotOf("f")).toBe(15); - expect(slotOf("a")).toBe(10); - expect(slotOf(".")).toBe(-1); - }); - - it("treats anything it does not understand as transparent", () => { - /* Better a hole than a wrong colour; the validator below names it anyway. */ - expect(slotOf("z")).toBe(-1); - expect(slotOf(" ")).toBe(-1); - }); -}); - -describe("decoding to pixels", () => { - it("writes the palette colour for each slot", () => { - const pixels = decodeSprite(square, STONE); - const red = Number.parseInt(STONE[0].slice(1, 3), 16); - expect(pixels[0]).toBe(red); - expect(pixels[3]).toBe(255); - }); - - it("leaves transparent pixels fully clear", () => { - const pixels = decodeSprite(square, STONE); - /* Row 1, column 0 is the '.' — its alpha byte is the fourth of that pixel. */ - const at = (1 * square.w + 0) * 4; - expect(pixels[at + 3]).toBe(0); - }); - - it("produces exactly four bytes per pixel", () => { - expect(decodeSprite(square, STONE)).toHaveLength(square.w * square.h * 4); - }); - - it("draws the same shape in a different palette, which is what a skin is", () => { - const skin = reskin(STONE, { [SLOT.shadow]: "#ff0000" }); - const original = decodeSprite(square, STONE); - const reskinned = decodeSprite(square, skin); - /* The silhouette is identical... */ - expect(reskinned[3]).toBe(original[3]); - /* ...and only the colour moved. */ - expect(reskinned[0]).toBe(255); - expect(reskinned[1]).toBe(0); - }); -}); - -describe("catching an authoring mistake", () => { - it("passes a sprite that counts up", () => { - expect(spriteProblems("square", square)).toEqual([]); - }); - - it("notices a row of the wrong width", () => { - /* - * The mistake this exists for. One character short shifts every pixel after - * it, which is obvious in a test and baffling on screen. - */ - const problems = spriteProblems("short", { ...square, rows: ["0f", "7"] }); - expect(problems).toHaveLength(1); - expect(problems[0]).toContain("row 1 is 1 wide, expected 2"); - }); - - it("notices the wrong number of rows", () => { - const problems = spriteProblems("tall", { ...square, rows: ["0f"] }); - expect(problems.join(" ")).toContain("2 rows tall but has 1"); - }); - - it("notices a character that is not a palette slot", () => { - const problems = spriteProblems("odd", { ...square, rows: ["0z", ".7"] }); - expect(problems.join(" ")).toContain("'z'"); - }); - - it("reports every mistake at once rather than only the first", () => { - const problems = spriteProblems("bad", { ...square, rows: ["0", "7f7"] }); - expect(problems.length).toBeGreaterThan(1); - }); - - it("refuses a sprite with no size at all", () => { - expect(spriteProblems("empty", { w: 0, h: 0, palette: "stone", rows: [] })).toHaveLength(1); - }); -}); - -describe("running an animation", () => { - const walk: Animation = { - sprite: { w: 8, h: 2, palette: "stone", rows: ["01234567", "01234567"] }, - frames: 4, - fps: 8, - }; - - it("advances through the frames and loops", () => { - expect(frameAt(walk, 0)).toBe(0); - expect(frameAt(walk, 125)).toBe(1); - expect(frameAt(walk, 500)).toBe(0); - }); - - it("holds on the first frame when motion is turned down", () => { - /* - * This is the whole of how reduced motion reaches the field: the caller - * passes the flag and every animation in the game stops, without any of - * them knowing the setting exists. - */ - expect(frameAt(walk, 375, true)).toBe(0); - }); - - it("holds still for a single-frame sprite", () => { - expect(frameAt({ ...walk, frames: 1 }, 9999)).toBe(0); - }); - - it("finds each frame's column", () => { - expect(frameOffset(walk, 0)).toBe(0); - expect(frameOffset(walk, 2)).toBe(4); - }); -}); - -describe("the colourblind palettes", () => { - it("keeps alarm clearly apart from the accent it must not be confused with", () => { - /* - * Colour is never the only signal in the keep -- every state carries an - * icon and a word. This is the second line of defence, and a palette that - * quietly stopped separating them would otherwise go unnoticed. - */ - expect(contrast(STONE[SLOT.alarm], STONE[SLOT.accentLit])).toBeGreaterThan(100); - }); - - it("measures no distance between a colour and itself", () => { - expect(contrast("#c8ff4d", "#c8ff4d")).toBe(0); - }); -}); diff --git a/app/src/game/assets/sprite.ts b/app/src/game/assets/sprite.ts deleted file mode 100644 index 8412caa..0000000 --- a/app/src/game/assets/sprite.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { PALETTES, type Palette, type PaletteName } from "./palette"; - -/** - * Pixel art as text. - * - * A sprite is rows of characters, one per pixel: '.' is transparent and the - * hex digits 0-f are slots in a sixteen-colour palette. A 32x32 sprite is - * about a kilobyte of source that gzips to a couple of hundred bytes. - * - * Why not PNGs. This repository has no pipeline for binary assets, a build - * that checks what ends up in the bundle, and a review culture that reads - * diffs. A folder of images would be none of those things: a change to a - * watchtower would show up in review as "binary file differs", the atlas would - * need fetching and caching and cache-busting, and a skin would mean a second - * copy of every image rather than a different palette. Written this way, a - * sprite is reviewable, the whole atlas travels inside the game's chunk, and - * recolouring is free. - * - * The cost is honest: authoring by hand is slower than drawing, and nothing - * here is going to render a photograph. For chunky 16- and 32-pixel artwork - * with a strict palette, that is not the constraint that binds. - */ -export interface Sprite { - w: number; - h: number; - palette: PaletteName; - /** One string per row, `w` characters each. '.' is transparent. */ - rows: string[]; -} - -/** A sprite drawn as several frames laid out left to right in one row set. */ -export interface Animation { - sprite: Sprite; - /** How many frames sit side by side inside `sprite`. */ - frames: number; - /** Frames per second. Kept low on purpose; this is not a cartoon. */ - fps: number; -} - -export const TRANSPARENT = "."; - -/** Turns one authored character into a palette slot, or -1 for transparent. */ -export function slotOf(character: string): number { - if (character === TRANSPARENT) return -1; - const slot = Number.parseInt(character, 16); - return Number.isNaN(slot) ? -1 : slot; -} - -/** - * Everything wrong with a sprite, as sentences. - * - * Authoring by hand means miscounting a row, and a sprite one character short - * is a column of pixels silently shifted for the rest of the image -- the kind - * of thing that is obvious in a test and baffling on screen. Returned as a - * list rather than thrown so one test can report every mistake in the atlas at - * once instead of stopping at the first. - */ -export function spriteProblems(name: string, sprite: Sprite): string[] { - const problems: string[] = []; - if (sprite.w <= 0 || sprite.h <= 0) { - problems.push(`${name}: size ${sprite.w}x${sprite.h} is not a picture`); - return problems; - } - if (sprite.rows.length !== sprite.h) { - problems.push(`${name}: says it is ${sprite.h} rows tall but has ${sprite.rows.length}`); - } - sprite.rows.forEach((row, index) => { - if (row.length !== sprite.w) { - problems.push(`${name}: row ${index} is ${row.length} wide, expected ${sprite.w}`); - } - for (const character of row) { - if (character === TRANSPARENT) continue; - const slot = slotOf(character); - if (slot < 0 || slot > 15) { - problems.push(`${name}: row ${index} has '${character}', which is not a palette slot`); - break; - } - } - }); - return problems; -} - -/** - * A sprite as raw RGBA bytes. - * - * Kept separate from anything that touches a canvas so the decoder is testable - * in the node environment the rest of this project's tests run in. - */ -export function decodeSprite(sprite: Sprite, palette: Palette): Uint8ClampedArray { - const pixels = new Uint8ClampedArray(sprite.w * sprite.h * 4); - for (let y = 0; y < sprite.h; y += 1) { - const row = sprite.rows[y] ?? ""; - for (let x = 0; x < sprite.w; x += 1) { - const slot = slotOf(row[x] ?? TRANSPARENT); - const at = (y * sprite.w + x) * 4; - if (slot < 0) continue; - const hex = palette[slot] ?? "#000000"; - const value = Number.parseInt(hex.slice(1), 16); - pixels[at] = (value >> 16) & 255; - pixels[at + 1] = (value >> 8) & 255; - pixels[at + 2] = value & 255; - pixels[at + 3] = 255; - } - } - return pixels; -} - -/** The palette a sprite asks for, unless something is overriding it for a skin. */ -export function paletteFor(sprite: Sprite, override?: Palette): Palette { - return override ?? PALETTES[sprite.palette]; -} - -/** - * Which frame of an animation to show at a given moment. - * - * Takes the time rather than reading a clock, so a paused game simply stops - * passing time and every animation in it holds still without any of them - * needing to know that pausing exists. - */ -export function frameAt(animation: Animation, elapsedMs: number, motionless = false): number { - if (motionless || animation.frames <= 1) return 0; - const index = Math.floor((elapsedMs / 1000) * animation.fps); - return ((index % animation.frames) + animation.frames) % animation.frames; -} - -/** The pixel column an animation's frame starts at. */ -export function frameOffset(animation: Animation, frame: number): number { - return frame * (animation.sprite.w / animation.frames); -} diff --git a/app/src/game/assets/structures.ts b/app/src/game/assets/structures.ts deleted file mode 100644 index 17830a3..0000000 --- a/app/src/game/assets/structures.ts +++ /dev/null @@ -1,476 +0,0 @@ -import { still, turns, type Frame } from "./compose"; -import type { Sprite } from "./sprite"; - -/** - * The keep and the things you put up around it, seen from above. - * - * Terminal-punk rather than medieval: the lights set into the stone are amber - * cathode screens, and the keep's roof opens onto one. The vocabulary is - * borrowed from every base-builder there has ever been — ramparts, towers, a - * gate — and the material is ours. - * - * Detail at this size is shading, not more shapes. A wall with one highlight - * and one shadow reads as a rectangle; the same wall with five steps of stone, - * a lit outer edge, a shadowed inner one and courses picked out along the - * walkway reads as masonry. That is why the palette gives five stone tones - * rather than three, and why nearly every sprite here uses all of them. - * - * Palette slots, from palette.ts: 0-4 stone dark to lit, 5-7 pale, 8-11 the - * terracotta and amber ramp, 12-14 timber and gold, 15 alarm. - */ - -/* ---- Rampart ----------------------------------------------------------- */ - -/* - * A stretch of wall running east to west, tiling seamlessly with itself. - * - * Read from the top: merlons along the outer edge, the parapet they stand on, - * the walkway with its flagging, the inner parapet, and merlons again. Both - * edges are crenellated because from above you can see both of them. - */ -const RAMPART_1: Frame = [ - "444.444.444.444.", - "333.333.333.333.", - "222.222.222.222.", - "4444444444444444", - "3333333333333333", - "2222222222222222", - "2111111111111111", - "2122222222222221", - "2122222222222221", - "2111111111111111", - "2222222222222222", - "3333333333333333", - "4444444444444444", - "222.222.222.222.", - "333.333.333.333.", - "444.444.444.444.", -]; - -/* Tier II: the walkway is flagged and the merlons capped in dressed stone. */ -const RAMPART_2: Frame = [ - "555.555.555.555.", - "444.444.444.444.", - "222.222.222.222.", - "5555555555555555", - "4444444444444444", - "2222222222222222", - "2133133133133131", - "2133133133133131", - "2111111111111111", - "2133133133133131", - "2222222222222222", - "4444444444444444", - "5555555555555555", - "222.222.222.222.", - "444.444.444.444.", - "555.555.555.555.", -]; - -/* Tier III: braziers burning along the walk, so the wall is lit at night. */ -const RAMPART_3: Frame = [ - "666.666.666.666.", - "555.555.555.555.", - "222.222.222.222.", - "6666666666666666", - "5555555555555555", - "2222222222222222", - "2133133133133131", - "21b3313313b31331", - "21a3313313a31331", - "2111111111111111", - "2222222222222222", - "5555555555555555", - "6666666666666666", - "222.222.222.222.", - "555.555.555.555.", - "666.666.666.666.", -]; - -/* - * Where two stretches meet. Merlons wrap the outside of the turn and the - * inside is walkway, so a run of wall turns a corner without a seam. - */ -const CORNER_1: Frame = [ - "444.444.444.4444", - "333.333.333.3334", - "222.222.222.2224", - "4444444444442224", - "3333333333332224", - "2222222222222224", - "2111111111111224", - "2122222222211224", - "2122222222211224", - "2122222222211224", - "2122222222211224", - "2122222222211224", - "2122222222211224", - "4432222222211224", - "4432222222211224", - "4442222222222224", -]; - -/* - * The way in. A timber gate under a stone arch with the road running through - * it, so the courtyard has somewhere a hero can actually walk out of. - */ -const GATE_1: Frame = [ - "444.4444444.444.", - "333.4444444.333.", - "222.4444444.222.", - "4444444444444444", - "3334333333343333", - "2224dddddddd4222", - "2114dccccccd4111", - "2124dcaaaacd4222", - "2124dcaaaacd4222", - "2124dcaaaacd4222", - "2114dccccccd4111", - "2224dddddddd4222", - "3334333333343333", - "4444444444444444", - "222.4444444.222.", - "444.4444444.444.", -]; - -/* ---- Watchtower -------------------------------------------------------- */ - -/* - * A round tower from above: a ring of merlons, a walk inside it, and an amber - * screen at the middle that is the thing actually keeping watch. - * - * The tiers grow outward rather than upward, because upward is the one - * direction this camera cannot show. Tier II widens the base and adds a - * dressed rim; tier III mounts a turret on it. - */ -const TOWER_1: Frame = [ - ".....444444.....", - "...4433333344...", - "..443222222344..", - ".44322111122344.", - ".43211122211234.", - "4432112222112344", - "4321122ab2211234", - "4321122bb2211234", - "4321122bb2211234", - "4321122ab2211234", - "4432112222112344", - ".43211122211234.", - ".44322111122344.", - "..443222222344..", - "...4433333344...", - ".....444444.....", -]; - -/* Tier II: a dressed rim, a wider walk, and a brighter lamp. */ -const TOWER_2: Frame = [ - "....55555555....", - "..554433334455..", - ".55443222234455.", - "5544322111223445", - "5443211222112344", - "5432112222112234", - "4321129ab9211234", - "432112abba211234", - "432112abba211234", - "4321129ab9211234", - "5432112222112234", - "5443211222112344", - "5544322111223445", - ".55443222234455.", - "..554433334455..", - "....55555555....", -]; - -/* Tier III: a turret mounted on the rim, and gold on the merlons. */ -const TOWER_3: Frame = [ - "...66555555 66..".replace(" ", "5"), - ".66554444445566.", - "6655e33333e35566", - "6544322111223456", - "5443211222112345", - "5432112eee211234", - "432112eabae211 4".replace(" ", "3"), - "43211eabbbae1234", - "43211eabbbae1234", - "432112eabae211 4".replace(" ", "3"), - "5432112eee211234", - "5443211222112345", - "6544322111223456", - "6655e33333e35566", - ".66554444445566.", - "...665555556 66.".replace(" ", "5"), -]; - -/* ---- The keep itself --------------------------------------------------- */ - -/* - * The hall at the middle of the holding, and the biggest thing on the map. - * - * A terracotta roof with a ridge running east to west, one lantern of amber - * glass opening out of the middle of it, and a timber porch on the south side. - * It reads as a building rather than as another tower because its roof has a - * direction, where the towers are radially symmetrical. - * - * Built by rule rather than typed out. Thirty-two rows of thirty-two - * characters is past what anyone can proofread, and the atlas test can only - * tell you that a row is wrong, not which pixel you meant. - */ -/* - * Three tiles square. The first version was two, and on the field it read as - * one more tower rather than as the hall the whole holding is arranged around; - * the thing the eye should land on first has to be the biggest thing there. - */ -const KEEP_W = 48; - -/** - * The hall, built by rule. - * - * Forty-eight rows of forty-eight characters is far past what anyone can - * proofread, and the atlas test can only tell you that a row is the wrong - * width, not that you meant the ridge to be somewhere else. - * - * What makes it read as a building rather than a patterned rectangle is the - * roof having a *direction*. There is a ridge across the middle; the slope - * above it faces the light and is a step brighter, the slope below faces away - * and is a step darker, and both are laid in courses with the joints staggered - * between them. The eaves overhang into shadow on all four sides, and the - * south wall shows below the roofline with the door in it, so there is a front - * to the building and it faces the gate. - */ -function roofCourse( - y: number, - slope: "north" | "south", - width: number, -): string { - /* Three-pixel courses: two of tile, one of the shadow under its lip. */ - const step = y % 3; - const lit = slope === "north" ? "a" : "9"; - const mid = slope === "north" ? "9" : "9"; - const lip = slope === "north" ? "9" : "8"; - const shift = (Math.floor(y / 3) % 2) * 2; - - let row = ""; - for (let x = 0; x < width; x += 1) { - if (step === 2) row += lip; - else if ((x + shift) % 4 === 0) row += mid; - else row += step === 0 ? lit : lit; - } - return row; -} - -function buildKeep(gold: boolean, lit: boolean): Frame { - const rows: string[] = []; - const W = KEEP_W; - /* The roof overhangs the walls by two pixels on each side. */ - const roofW = W - 4; - const roof = (body: string) => "12" + body + "21"; - - /* Eaves: the dark lip of the roof, and the shadow it throws. */ - rows.push("." + "1".repeat(W - 2) + "."); - rows.push(roof("8".repeat(roofW))); - rows.push(roof("8".repeat(roofW))); - - /* The north slope, facing the light. */ - const northRows = 17; - for (let y = 0; y < northRows; y += 1) { - let body = roofCourse(y, "north", roofW); - /* - * A chimney standing off the north slope, and a dormer with a light in it. - * Both are here rather than in a separate sprite because they have to sit - * inside the courses rather than on top of them. - */ - if (y >= 3 && y <= 9) { - const chimney = y === 3 ? "3443" : y === 9 ? "1221" : "3223"; - body = body.slice(0, 6) + chimney + body.slice(10); - } - if (y >= 8 && y <= 14) { - const glass = lit ? "e" : "b"; - const dormer = - y === 8 ? "1111111111" - : y === 14 ? "1222222221" - : `12${glass.repeat(6)}21`; - const at = Math.floor((roofW - 10) / 2); - body = body.slice(0, at) + dormer + body.slice(at + 10); - } - rows.push(roof(body)); - } - - /* The ridge: capped tiles along the top of the roof. */ - const cap = gold ? "e" : "4"; - rows.push(roof("8".repeat(roofW))); - rows.push(roof(cap.repeat(roofW))); - rows.push(roof((gold ? "d" : "3").repeat(roofW))); - rows.push(roof("8".repeat(roofW))); - - /* The south slope, facing away. */ - const southRows = 14; - for (let y = 0; y < southRows; y += 1) { - rows.push(roof(roofCourse(y, "south", roofW))); - } - - /* The eaves again, then the wall below them. */ - rows.push(roof("8".repeat(roofW))); - rows.push("1" + "1".repeat(W - 2) + "1"); - - /* - * The south face: dressed stone, two lit windows, and the door, so the hall - * has a front and the front faces the gate. - */ - const glass = lit ? "e" : "b"; - const wall = (body: string) => "1" + body + "1"; - const face = (middle: string) => { - const side = "4433".repeat(3); - return wall(side + middle + side.split("").reverse().join("")); - }; - rows.push(wall("4".repeat(W - 2))); - const window = glass + glass; - const door = "cddc"; - rows.push(face("3333" + window + "333" + door + "333" + window + "3333")); - rows.push(face("3333" + window + "333" + door + "333" + window + "3333")); - rows.push(face("3".repeat(9) + door + "3".repeat(9))); - rows.push(wall("3".repeat(W - 2))); - rows.push("1" + "2".repeat(W - 2) + "1"); - rows.push("." + "1".repeat(W - 2) + "."); - return rows; -} - -const KEEP_1: Frame = buildKeep(false, false); -/* Tier II: a gilded ridge and every light in the place burning. */ -const KEEP_2: Frame = buildKeep(true, true); - -export interface StructureArt { - /** One sprite per tier, lowest first. */ - tiers: Sprite[]; - /** What it is called in the shop and on the field. */ - name: string; - /** Read aloud, and shown when colour alone would not say which this is. */ - blurb: string; -} - -const stone = (frames: Frame[]): Sprite[] => frames.map((frame) => still(frame, "stone")); - -export const RAMPART: StructureArt = { - name: "Rampart", - blurb: "Slows what comes over the wall.", - tiers: stone([RAMPART_1, RAMPART_2, RAMPART_3]), -}; - -export const CORNER: StructureArt = { - name: "Corner", - blurb: "Where two stretches of wall meet.", - tiers: stone([CORNER_1]), -}; - -export const GATE: StructureArt = { - name: "Gate", - blurb: "The only way in, and the first thing they try.", - tiers: stone([GATE_1]), -}; - -export const WATCHTOWER: StructureArt = { - name: "Watchtower", - blurb: "Sees a wave coming before it arrives.", - tiers: stone([TOWER_1, TOWER_2, TOWER_3]), -}; - -export const KEEP_CORE: StructureArt = { - name: "The Keep", - blurb: "Where the garrison musters.", - tiers: stone([KEEP_1, KEEP_2]), -}; - -/** - * The corner at all four orientations, clockwise from the one authored above. - * - * Derived rather than drawn four times, so every corner of a holding is the - * same masonry. - */ -export const CORNER_TURNS: Sprite[] = turns(CORNER_1).map((frame) => still(frame, "stone")); - -/** The rampart running north to south, from the east-to-west one. */ -export const RAMPART_VERTICAL: Sprite[] = [RAMPART_1, RAMPART_2, RAMPART_3].map((frame) => - still(turns(frame)[1], "stone"), -); - -export const GATE_VERTICAL: Sprite = still(turns(GATE_1)[1], "stone"); - -export const STRUCTURES = { - rampart: RAMPART, - corner: CORNER, - gate: GATE, - watchtower: WATCHTOWER, - keep: KEEP_CORE, -} as const; - -export type StructureName = keyof typeof STRUCTURES; - -/* ---- Under construction ------------------------------------------------- */ - -/* - * What a feature looks like while a wright is raising it. - * - * Three stages: pegged out, framed, and roofed. The stages matter more than - * the artwork does — a structure that appears finished in one step gives the - * player nothing to watch, and watching something you already did turn into - * something standing is the whole of what this game offers. - */ -const SITE_1: Frame = [ - "................", - "................", - "................", - "..c..........c..", - "..cc........cc..", - "................", - "................", - "................", - "................", - "................", - "..cc........cc..", - "..c..........c..", - "................", - "..2222222222222.", - ".22222222222222.", - "................", -]; - -const SITE_2: Frame = [ - "................", - "..cccccccccccc..", - "..c1dddddddd1c..", - "..c1........1c..", - "..cc........cc..", - "..c1........1c..", - "..c1........1c..", - "..cc........cc..", - "..c1........1c..", - "..c1........1c..", - "..cc........cc..", - "..c1dddddddd1c..", - "..cccccccccccc..", - "..2222222222222.", - ".22222222222222.", - "................", -]; - -const SITE_3: Frame = [ - "................", - "..999999999999..", - "..9aaaaaaaaaa9..", - "..9a88888888a9..", - "..9aaaaaaaaaa9..", - "..9a88888888a9..", - "..999999999999..", - "..cccccccccccc..", - "..c4444444444c..", - "..c433bb3334cc..", - "..c433bb333 cc..".replace(" ", "4"), - "..c4444444444c..", - "..cccccccccccc..", - "..2222222222222.", - ".22222222222222.", - "................", -]; - -/** The three stages of a build, in order. */ -export const BUILD_SITE: Sprite[] = [SITE_1, SITE_2, SITE_3].map((frame) => still(frame, "stone")); diff --git a/app/src/game/assets/terrain.ts b/app/src/game/assets/terrain.ts deleted file mode 100644 index d033ca5..0000000 --- a/app/src/game/assets/terrain.ts +++ /dev/null @@ -1,189 +0,0 @@ -import type { Sprite } from "./sprite"; - -/** - * The ground, seen from above. - * - * The camera looks down at the holding rather than across at it, so these are - * a floor rather than a backdrop. That change is what removed the sky: half - * the frame was empty air, and there is no air in a view from above. - * - * Generated rather than drawn, but not random. The scatter comes from a hash - * of the coordinate, so a tile is the same every time the page loads — a field - * that reshuffled itself on reload would be unsettling in exactly the way a - * base you are fortifying should not be. - */ - -export const TILE = 16; - -/** A small, fast, well-mixed integer hash. Deterministic is the requirement. */ -export function hash(x: number, y: number, seed: number): number { - let value = (x * 374_761_393 + y * 668_265_263 + seed * 2_246_822_519) | 0; - value = (value ^ (value >>> 13)) * 1_274_126_177; - return ((value ^ (value >>> 16)) >>> 0) / 4_294_967_296; -} - -export function generateTile( - palette: Sprite["palette"], - seed: number, - pick: (noise: number, x: number, y: number) => string, -): Sprite { - const rows: string[] = []; - for (let y = 0; y < TILE; y += 1) { - let row = ""; - for (let x = 0; x < TILE; x += 1) row += pick(hash(x, y, seed), x, y); - rows.push(row); - } - return { w: TILE, h: TILE, palette, rows }; -} - -/** - * Meadow, in four cuts. - * - * One grass tile repeated across a whole window is a chequerboard: the eye - * finds the period within a second and the ground stops being ground. Four - * variants chosen by position break the rhythm, and because the choice comes - * from a hash of the tile's place in the world, the field is still the same - * field every time it is drawn. - * - * Tufts rather than speckle. Per-pixel noise reads as static; a tuft is a - * short vertical run, which at this size is the smallest mark the eye will - * accept as a plant. - */ -function meadow(seed: number, density: number): Sprite { - return generateTile("field", seed, (noise, x, y) => { - const tuft = hash(x, Math.floor(y / 3), seed * 7 + 1); - if (tuft > 1 - density && y % 3 !== 0) return "6"; - if (tuft > 1 - density * 2 && y % 3 === 1) return "5"; - if (noise > 0.88) return "4"; - if (noise > 0.55) return "3"; - return "2"; - }); -} - -export const GRASS: Sprite = meadow(11, 0.07); -export const GRASS_B: Sprite = meadow(12, 0.04); -export const GRASS_C: Sprite = meadow(13, 0.1); - -/** The same meadow with a few heads of warm flower in it. */ -export const GRASS_FLOWER: Sprite = generateTile("field", 14, (noise, x, y) => { - const bloom = hash(Math.floor(x / 4), Math.floor(y / 4), 99); - if (bloom > 0.93 && x % 4 === 1 && y % 4 === 1) return "e"; - if (bloom > 0.93 && x % 4 === 1 && y % 4 === 2) return "5"; - const tuft = hash(x, Math.floor(y / 3), 78); - if (tuft > 0.93 && y % 3 !== 0) return "6"; - if (noise > 0.88) return "4"; - if (noise > 0.55) return "3"; - return "2"; -}); - -/** Every cut of meadow, for a caller picking one by position. */ -export const MEADOW: Sprite[] = [GRASS, GRASS_B, GRASS_C, GRASS_FLOWER]; - -/** - * Trodden earth: the yard immediately around the keep, and the roads out. - * - * Ruts run along the road so it reads as a direction rather than as a patch, - * with stones pressed into it where it has worn through. - */ -export const DIRT: Sprite = generateTile("field", 21, (noise, x, y) => { - const rut = hash(Math.floor(x / 4), y, 31); - if (rut > 0.9) return "8"; - if (noise > 0.94) return "b"; - if (noise > 0.78) return "a"; - if (noise > 0.4) return "9"; - return "8"; -}); - -/** The same road, more worn, so a long run of it is not one sprite repeated. */ -export const DIRT_B: Sprite = generateTile("field", 22, (noise, x, y) => { - const rut = hash(Math.floor(x / 3), y, 33); - if (rut > 0.88) return "8"; - if (noise > 0.9) return "b"; - if (noise > 0.7) return "a"; - if (noise > 0.35) return "9"; - return "8"; -}); - -export const ROAD: Sprite[] = [DIRT, DIRT_B]; - -/** - * Laid flagstone, for the courtyard inside the walls. - * - * The joints are offset course by course, like real paving. An earlier version - * lined them up in both directions and the ground read as graph paper. Each - * stone gets a lit top edge and a shadowed bottom one, which is what makes it - * look laid rather than printed. - */ -export const FLAGSTONE: Sprite = generateTile("stone", 31, (noise, x, y) => { - const course = Math.floor(y / 8); - const shifted = (x + course * 4) % 8; - /* - * Joints one step either side of the stone, not four. - * - * The first version lit the top of every course with the palest colour in - * the palette. Laid out across a courtyard that is a bright line every eight - * pixels all the way across, and the yard read as decking rather than as - * paving. Real stone has joints you have to look for. - */ - if (y % 8 === 0) return "5"; - if (y % 8 === 7) return "3"; - if (shifted === 0) return "3"; - if (noise > 0.92) return "5"; - return "4"; -}); - -/** A second cut, with a cracked stone or two in it. */ -export const FLAGSTONE_B: Sprite = generateTile("stone", 32, (noise, x, y) => { - const course = Math.floor(y / 8); - const shifted = (x + course * 4) % 8; - const cracked = hash(Math.floor(x / 8), course, 51) > 0.7; - if (cracked && shifted === 4 && y % 8 > 1 && y % 8 < 7) return "3"; - if (y % 8 === 0) return "5"; - if (y % 8 === 7) return "3"; - if (shifted === 0) return "3"; - if (noise > 0.9) return "5"; - return "4"; -}); - -export const PAVING: Sprite[] = [FLAGSTONE, FLAGSTONE_B]; - -/** - * The kerb where the paving meets the grass. - * - * A hard edge between two ground textures is the thing that most makes a map - * look assembled out of tiles. A course of dressed stone along the join is - * what a real yard would have, and it hides the seam at the same time. - */ -export const KERB: Sprite = generateTile("stone", 41, (noise, x, y) => { - if (y < 2) return "2"; - if (y < 4) return "3"; - if (y < 6) return "5"; - /* A dressed course, wider than the yard's, so the edge reads as an edge. */ - if (y % 8 === 6 || (x + Math.floor(y / 8) * 6) % 12 === 0) return "3"; - if (noise > 0.92) return "5"; - return "4"; -}); - -/** - * Where something was knocked down and not yet rebuilt. - * - * Chunks rather than static: the noise is quantised into two-pixel blocks so - * the eye can resolve them as broken stone. - */ -export const RUBBLE: Sprite = generateTile("stone", 14, (_noise, x, y) => { - const lump = hash(Math.floor(x / 2), Math.floor(y / 2), 41); - if (lump > 0.88) return "4"; - if (lump > 0.64) return "3"; - if (lump > 0.3) return "2"; - return "1"; -}); - -export const TERRAIN = { - grass: GRASS, - dirt: DIRT, - flagstone: FLAGSTONE, - rubble: RUBBLE, - kerb: KERB, -} as const; - -export type TerrainName = keyof typeof TERRAIN; diff --git a/app/src/game/engine/Stage.tsx b/app/src/game/engine/Stage.tsx deleted file mode 100644 index 399139c..0000000 --- a/app/src/game/engine/Stage.tsx +++ /dev/null @@ -1,170 +0,0 @@ -import { useEffect, useRef } from "react"; -import { startLoop } from "./loop"; - -export interface DrawContext { - context: CanvasRenderingContext2D; - /** Whole-number magnification from design pixels to screen pixels. */ - scale: number; - /** The visible field in design pixels — as much as this window can show. */ - width: number; - height: number; - /** Milliseconds since the stage was mounted; the clock animations read. */ - elapsedMs: number; - /** True when the player has asked for stillness. Animations must obey it. */ - motionless: boolean; -} - -/** - * How big a design pixel is drawn, chosen from the window. - * - * The camera looks down at the holding, so there is no fixed frame to fit - * inside: a wider window sees more ground rather than the same ground larger. - * What has to be decided is only how chunky a pixel should be, and that is a - * question about viewing distance — a phone held close wants a smaller factor - * than a monitor across a desk. - * - * Whole numbers only. At 2.37x some pixels are two screen pixels wide and some - * are three, and the eye reads that unevenness as blur however careful the art - * was. - */ -export function pixelScale(viewportWidth: number, viewportHeight: number): number { - const shortest = Math.min(viewportWidth, viewportHeight); - if (shortest <= 0) return 3; - /* Aim for roughly 22 tiles across the short side, then round to a whole number. */ - const wanted = shortest / (16 * 22); - return Math.max(2, Math.min(6, Math.round(wanted))); -} - -/** - * Two stacked canvases, filling the window, and a loop that draws them. - * - * The split is the whole optimisation. The ground and the buildings change - * when somebody puts up a watchtower and at no other time, so they are drawn - * once and left alone; only the heroes, the bugs and the sparks are redrawn - * thirty times a second. On a field of forty actors that is most of the - * per-frame cost removed for the price of one extra element. - */ -export function Stage({ - drawStatic, - drawFrame, - onTick, - /** Change this to have the static layer redrawn. */ - staticKey, - motionless = false, - label, -}: { - drawStatic: (draw: DrawContext) => void; - drawFrame: (draw: DrawContext) => void; - /** - * Advance the world by one fixed step. Separate from drawing on purpose: - * see loop.ts for why the simulation must not run at the display's rate. - */ - onTick?: (tick: number) => void; - staticKey: string; - motionless?: boolean; - label: string; -}) { - const host = useRef(null); - const staticCanvas = useRef(null); - const frameCanvas = useRef(null); - const view = useRef({ scale: 3, width: 0, height: 0 }); - /* - * Through refs so a re-render with a new closure does not tear down the - * loop. The loop is started once; what it calls is looked up each frame. - */ - const drawStaticRef = useRef(drawStatic); - const drawFrameRef = useRef(drawFrame); - const onTickRef = useRef(onTick); - const motionlessRef = useRef(motionless); - drawStaticRef.current = drawStatic; - drawFrameRef.current = drawFrame; - onTickRef.current = onTick; - motionlessRef.current = motionless; - - /* Redraws the static layer. Called on resize and when staticKey changes. */ - const paintStatic = useRef(() => {}); - - useEffect(() => { - const element = host.current; - const behind = staticCanvas.current; - const front = frameCanvas.current; - if (!element || !behind || !front) return; - - const size = () => { - const bounds = element.getBoundingClientRect(); - const scale = pixelScale(bounds.width, bounds.height); - /* - * The backing store is a whole number of design pixels, so nothing is - * ever drawn on a half. The element is then stretched by at most one - * scale factor of a pixel to cover the last sliver of the window, which - * is invisible and keeps the field edge-to-edge. - */ - const width = Math.ceil(bounds.width / scale); - const height = Math.ceil(bounds.height / scale); - view.current = { scale, width, height }; - - for (const canvas of [behind, front]) { - canvas.width = width * scale; - canvas.height = height * scale; - const context = canvas.getContext("2d"); - if (context) context.imageSmoothingEnabled = false; - } - paintStatic.current(); - }; - - paintStatic.current = () => { - const context = behind.getContext("2d"); - if (!context) return; - context.imageSmoothingEnabled = false; - context.clearRect(0, 0, behind.width, behind.height); - drawStaticRef.current({ - context, - ...view.current, - elapsedMs: 0, - motionless: motionlessRef.current, - }); - }; - - size(); - const observer = new ResizeObserver(size); - observer.observe(element); - - const started = performance.now(); - const stop = startLoop({ - tick: (tick) => onTickRef.current?.(tick), - draw: () => { - const context = front.getContext("2d"); - if (!context) return; - context.clearRect(0, 0, front.width, front.height); - drawFrameRef.current({ - context, - ...view.current, - elapsedMs: performance.now() - started, - motionless: motionlessRef.current, - }); - }, - }); - - return () => { - stop(); - observer.disconnect(); - }; - }, []); - - /* A change to the world behind the actors: redraw it, once. */ - useEffect(() => { - paintStatic.current(); - }, [staticKey, motionless]); - - return ( -
- {/* - * One accessible name for the pair. Two canvases is an implementation - * detail; announcing them separately would describe the same scene - * twice. - */} - -
- ); -} diff --git a/app/src/game/engine/atlas.ts b/app/src/game/engine/atlas.ts deleted file mode 100644 index e4d68e3..0000000 --- a/app/src/game/engine/atlas.ts +++ /dev/null @@ -1,145 +0,0 @@ -import { decodeSprite, frameAt, frameOffset, paletteFor, type Animation, type Sprite } from "../assets/sprite"; -import { SHADOW, type Palette } from "../assets/palette"; - -/** - * Decoded sprites, kept for as long as the sprite itself is alive. - * - * Decoding is cheap but not free, and a field of forty actors redrawing thirty - * times a second would do it twelve hundred times a second for pictures that - * never change. So each sprite is turned into a canvas once per palette it is - * asked for, and after that drawing is a blit. - * - * A WeakMap on the sprite, so an atlas that stops being referenced -- a skin - * nobody has equipped since -- is collectable rather than pinned for the life - * of the page. - */ -const cache = new WeakMap>(); - -/** The sprite as a canvas, decoded on first use and reused afterwards. */ -export function spriteCanvas(sprite: Sprite, override?: Palette): HTMLCanvasElement { - const palette = paletteFor(sprite, override); - let byPalette = cache.get(sprite); - if (!byPalette) { - byPalette = new Map(); - cache.set(sprite, byPalette); - } - const existing = byPalette.get(palette); - if (existing) return existing; - - const canvas = document.createElement("canvas"); - canvas.width = sprite.w; - canvas.height = sprite.h; - const context = canvas.getContext("2d"); - if (context) { - const image = context.createImageData(sprite.w, sprite.h); - image.data.set(decodeSprite(sprite, palette)); - context.putImageData(image, 0, 0); - } - byPalette.set(palette, canvas); - return canvas; -} - -export interface DrawOptions { - /** Whole-number magnification. See pixelScale in loop.ts for why. */ - scale: number; - palette?: Palette; - /** Draw mirrored, so one sprite serves both directions of travel. */ - flip?: boolean; - alpha?: number; -} - -/** - * Blits a sprite with its pixels kept square. - * - * `x` and `y` are rounded because half a pixel of offset is what turns a hard - * edge into a soft one -- the single most effective way to make pixel art look - * like a mistake. - */ -export function drawSprite( - context: CanvasRenderingContext2D, - sprite: Sprite, - x: number, - y: number, - options: DrawOptions, -): void { - const { scale, palette, flip = false, alpha = 1 } = options; - const canvas = spriteCanvas(sprite, palette); - const width = sprite.w * scale; - const height = sprite.h * scale; - const left = Math.round(x); - const top = Math.round(y); - - context.save(); - context.imageSmoothingEnabled = false; - if (alpha !== 1) context.globalAlpha = alpha; - if (flip) { - context.translate(left + width, top); - context.scale(-1, 1); - context.drawImage(canvas, 0, 0, width, height); - } else { - context.drawImage(canvas, left, top, width, height); - } - context.restore(); -} - -/** - * The same sprite as a flat dark shape, offset, under the thing itself. - * - * This is the cheapest large improvement available to a view from above. A - * building drawn straight onto the grass reads as a sticker; the same building - * with a shadow under it reads as standing on the ground, and the difference - * costs one extra blit of artwork that already exists. - * - * The offset is down and to the right for everything, so one light source is - * implied across the whole map. Shadows that disagree about where the sun is - * look worse than no shadows at all. - */ -export function drawShadow( - context: CanvasRenderingContext2D, - sprite: Sprite, - x: number, - y: number, - scale: number, - spread = 2, -): void { - drawSprite(context, sprite, x + spread * scale, y + spread * scale, { - scale, - palette: SHADOW, - alpha: 0.32, - }); -} - -/** One frame of an animation, chosen from the time the caller is holding. */ -export function drawAnimation( - context: CanvasRenderingContext2D, - animation: Animation, - x: number, - y: number, - elapsedMs: number, - options: DrawOptions & { motionless?: boolean }, -): void { - const { scale, palette, flip = false, alpha = 1, motionless = false } = options; - const frame = frameAt(animation, elapsedMs, motionless); - const canvas = spriteCanvas(animation.sprite, palette); - const frameWidth = animation.sprite.w / animation.frames; - const source = frameOffset(animation, frame); - const width = frameWidth * scale; - const height = animation.sprite.h * scale; - const left = Math.round(x); - const top = Math.round(y); - - context.save(); - context.imageSmoothingEnabled = false; - if (alpha !== 1) context.globalAlpha = alpha; - if (flip) { - context.translate(left + width, top); - context.scale(-1, 1); - context.drawImage(canvas, source, 0, frameWidth, animation.sprite.h, 0, 0, width, height); - } else { - context.drawImage( - canvas, source, 0, frameWidth, animation.sprite.h, - left, top, width, height, - ); - } - context.restore(); -} diff --git a/app/src/game/engine/loop.test.ts b/app/src/game/engine/loop.test.ts deleted file mode 100644 index 20ab927..0000000 --- a/app/src/game/engine/loop.test.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { MAX_CATCHUP_TICKS, TICK_MS, newAccumulator, pixelScale, ticksFor } from "./loop"; - -describe("keeping the simulation at a fixed rate", () => { - it("runs one tick per tick's worth of time", () => { - const { ticks } = ticksFor(newAccumulator(), TICK_MS); - expect(ticks).toBe(1); - }); - - it("runs nothing for a frame shorter than a tick, and remembers the time", () => { - const first = ticksFor(newAccumulator(), TICK_MS / 2); - expect(first.ticks).toBe(0); - /* The other half arrives next frame and the tick happens then. */ - expect(ticksFor(first.next, TICK_MS / 2).ticks).toBe(1); - }); - - it("runs the same number of ticks whatever the display rate", () => { - /* - * The point of the whole module: a second of wall clock is 30 ticks on a - * 30Hz panel and on a 144Hz one, so the game is not slower on one and - * faster on the other. - */ - const run = (frameMs: number, frames: number) => { - let accumulator = newAccumulator(); - let total = 0; - for (let index = 0; index < frames; index += 1) { - const result = ticksFor(accumulator, frameMs); - total += result.ticks; - accumulator = result.next; - } - return total; - }; - /* - * Within a tick of 30, not exactly 30: a 144Hz frame is 6.944ms, and 144 - * of those add up to a hair under a second in binary floating point. The - * guarantee worth having is that the rates agree with each other, not that - * they agree with arithmetic that cannot be done exactly. - */ - for (const total of [run(1000 / 60, 60), run(1000 / 144, 144), run(1000 / 30, 30)]) { - expect(total).toBeGreaterThanOrEqual(29); - expect(total).toBeLessThanOrEqual(30); - } - }); - - it("refuses to replay a battle nobody watched", () => { - /* Ten minutes in a background tab is not ten minutes of simulation owed. */ - const { ticks } = ticksFor(newAccumulator(), 600_000); - expect(ticks).toBe(MAX_CATCHUP_TICKS); - }); - - it("drops the time it did not run, rather than spiralling", () => { - /* - * Carrying the unrun debt forward would mean a game that fell behind could - * never catch up: every frame would ask for more than the last. - */ - let { next } = ticksFor(newAccumulator(), 600_000); - expect(next.debt).toBeLessThan(TICK_MS * (MAX_CATCHUP_TICKS + 1)); - ({ next } = ticksFor(next, TICK_MS)); - expect(next.debt).toBeLessThan(TICK_MS * (MAX_CATCHUP_TICKS + 1)); - }); - - it("ignores a clock that moved backwards", () => { - /* A suspended laptop or a stepped system clock. */ - expect(ticksFor(newAccumulator(), -500).ticks).toBe(0); - expect(ticksFor(newAccumulator(), Number.NaN).ticks).toBe(0); - expect(ticksFor(newAccumulator(), Number.POSITIVE_INFINITY).ticks).toBe(0); - }); -}); - -describe("choosing how big to draw the pixels", () => { - it("picks whole numbers only", () => { - /* - * 2.37x would make some pixels two screen pixels wide and some three, and - * the unevenness reads as blur however good the art is. - */ - const scale = pixelScale(1000, 800, 320, 180); - expect(Number.isInteger(scale)).toBe(true); - }); - - it("fills as much of the viewport as a whole number allows", () => { - expect(pixelScale(640, 360, 320, 180)).toBe(2); - expect(pixelScale(1280, 720, 320, 180)).toBe(4); - }); - - it("fits the tighter of the two dimensions", () => { - /* A wide, short window is bounded by its height. */ - expect(pixelScale(3000, 400, 320, 180)).toBe(2); - }); - - it("never disappears on a small window", () => { - expect(pixelScale(100, 60, 320, 180)).toBe(1); - expect(pixelScale(0, 0, 320, 180)).toBe(1); - }); - - it("stops growing, so a 4K display is not all thumbs", () => { - expect(pixelScale(7680, 4320, 320, 180, 6)).toBe(6); - }); - - it("survives a design size of nothing", () => { - expect(pixelScale(1920, 1080, 0, 0)).toBe(1); - }); -}); diff --git a/app/src/game/engine/loop.ts b/app/src/game/engine/loop.ts deleted file mode 100644 index fe7a2ea..0000000 --- a/app/src/game/engine/loop.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * A fixed simulation step, drawn at whatever rate the display runs. - * - * Simulating in step with the monitor sounds simpler and is a trap: the same - * game then runs at half speed on a 30Hz panel and double on a 120Hz one, and - * a tab that was in the background comes back with one enormous frame that - * teleports everything. So the simulation advances in fixed ticks and the - * renderer draws whatever the latest tick produced. - */ - -/** 30 ticks a second. Plenty for marching, swinging and building. */ -export const TICK_MS = 1000 / 30; - -/** - * The most simulation one frame may run. - * - * Without a ceiling, a tab restored after ten minutes asks for eighteen - * thousand ticks in a single frame, which locks the page up solid while it - * catches up on a battle nobody watched. Time is dropped instead: the world - * resumes where it is rather than replaying where it was. - */ -export const MAX_CATCHUP_TICKS = 5; - -export interface Accumulator { - /** Simulation time owed but not yet run. */ - debt: number; -} - -export function newAccumulator(): Accumulator { - return { debt: 0 }; -} - -/** - * How many ticks to run for a frame of `deltaMs`, and what is left over. - * - * Pure, and returns the new accumulator rather than mutating it, so the - * catch-up rules can be tested without a browser or a clock. - */ -export function ticksFor( - accumulator: Accumulator, - deltaMs: number, -): { ticks: number; next: Accumulator } { - /* - * A negative or absurd delta means the clock moved oddly -- a suspended - * laptop, a stepped clock, a test passing something silly. Nothing good - * comes of simulating it. - */ - const delta = Number.isFinite(deltaMs) && deltaMs > 0 ? deltaMs : 0; - const debt = accumulator.debt + delta; - const wanted = Math.floor(debt / TICK_MS); - const ticks = Math.min(wanted, MAX_CATCHUP_TICKS); - /* - * Every whole tick is paid off, including the ones refused above. Only the - * part-tick remainder is carried. - * - * Paying off just the ticks that ran instead is the spiral: a tab restored - * after ten minutes would keep ten minutes of debt, run its five ticks, and - * arrive at the next frame still owing ten minutes -- for ever. The time - * that was not simulated is gone, and the world resumes where it is rather - * than replaying where it was. - */ - return { ticks, next: { debt: debt - wanted * TICK_MS } }; -} - -export interface LoopHandlers { - /** Advance the world by exactly one TICK_MS. */ - tick: (tickIndex: number) => void; - /** - * Draw. `blend` is how far between the last tick and the next this frame - * falls, 0 to 1, for smoothing positions between them. - */ - draw: (blend: number) => void; -} - -/** - * Runs the loop until stopped. - * - * Nothing runs while the document is hidden. A game loop in a background tab - * burns a laptop's battery to animate pixels nobody is looking at, and the - * browser will throttle it into exactly the enormous-delta problem the - * accumulator's ceiling exists to survive. - */ -export function startLoop(handlers: LoopHandlers): () => void { - let frame = 0; - let last = performance.now(); - let accumulator = newAccumulator(); - let tickIndex = 0; - let stopped = false; - - const onVisibility = () => { - /* - * Coming back, time starts again from now. The wall-clock gap while the - * tab was hidden is not simulation anybody is owed. - */ - if (!document.hidden) last = performance.now(); - }; - document.addEventListener("visibilitychange", onVisibility); - - const frameStep = (now: number) => { - if (stopped) return; - frame = requestAnimationFrame(frameStep); - if (document.hidden) { - last = now; - return; - } - - const { ticks, next } = ticksFor(accumulator, now - last); - accumulator = next; - last = now; - - for (let index = 0; index < ticks; index += 1) { - tickIndex += 1; - handlers.tick(tickIndex); - } - handlers.draw(Math.min(1, accumulator.debt / TICK_MS)); - }; - - frame = requestAnimationFrame(frameStep); - - return () => { - stopped = true; - cancelAnimationFrame(frame); - document.removeEventListener("visibilitychange", onVisibility); - }; -} - -/** - * The integer factor to draw pixel art at, for a given viewport. - * - * Integer, always. A sprite drawn at 2.37x has some pixels two screen pixels - * wide and some three, and the eye reads that unevenness as a blurry mistake - * however carefully the art was made. Better a slightly smaller picture that - * is crisp. - * - * At least 1, so a very small window still gets something rather than nothing. - */ -export function pixelScale( - viewportWidth: number, - viewportHeight: number, - designWidth: number, - designHeight: number, - max = 6, -): number { - if (designWidth <= 0 || designHeight <= 0) return 1; - const fit = Math.min(viewportWidth / designWidth, viewportHeight / designHeight); - return Math.max(1, Math.min(max, Math.floor(fit))); -} diff --git a/app/src/game/pixi/actors.ts b/app/src/game/pixi/actors.ts index 94e0e02..5576b8b 100644 --- a/app/src/game/pixi/actors.ts +++ b/app/src/game/pixi/actors.ts @@ -47,12 +47,19 @@ interface Piece { export class ActorLayer { private readonly pieces = new Map(); + /** The skin the player is wearing, washed over their own sessions' wrights. */ + private skinTint = 0xffffff; + constructor( private readonly art: Loaded, private readonly parent: Container, - private readonly onPick: (id: string) => void, ) {} + /** Called when a skin is bought or changed in the shop. */ + wear(tint: number): void { + this.skinTint = tint; + } + private make(actor: Actor): Piece { const root = new Container(); @@ -95,13 +102,6 @@ export class ActorLayer { plate.scale.set(0.8); root.addChild(plate); - root.eventMode = "static"; - root.cursor = "pointer"; - /* A little larger than the sprite, because a 20px figure is a small target. */ - root.hitArea = { - contains: (x: number, y: number) => Math.abs(x) < 22 && y > -44 && y < 14, - }; - root.on("pointertap", () => this.onPick(actor.id)); } this.parent.addChild(root); @@ -136,12 +136,18 @@ export class ActorLayer { piece.sprite.position.x = lunging ? actor.facing * 5 : 0; piece.sprite.position.y = TILE_H * 0.25 + (actor.moving ? Math.sin(sim.clock / 3) * 1.5 : 0); - /* White when struck. Never colour alone: a number flies off as well. */ + /* + * White when struck. Never colour alone: a number flies off as well. + * Otherwise the Unmade are cold, a real session wears whatever skin has + * been bought, and the garrison's own soldiers are as drawn. + */ piece.sprite.tint = actor.hurt > 0 ? 0xffffff : actor.side === "unmade" ? UNMADE_TINT - : 0xffffff; + : actor.session + ? this.skinTint + : 0xffffff; piece.sprite.alpha = actor.hurt > 0 ? 0.75 : 1; if (actor.hp !== piece.lastHp) { diff --git a/app/src/game/pixi/keepScene.ts b/app/src/game/pixi/keepScene.ts index d96d2d1..4d0a554 100644 --- a/app/src/game/pixi/keepScene.ts +++ b/app/src/game/pixi/keepScene.ts @@ -2,6 +2,7 @@ import { Container, Text } from "pixi.js"; import type { Application } from "pixi.js"; import type { Viewport } from "pixi-viewport"; import { buildWorld, homePosition, loadArt } from "./scene"; +import { toTile } from "./iso"; import { ActorLayer } from "./actors"; import { Birds, Blows, loadEffects, Smoke } from "./ambience"; import type { Scene } from "./PixiStage"; @@ -21,6 +22,8 @@ export interface KeepHandle { /** Called when a wright standing for a real session is clicked. */ onPick?: (actor: Actor | undefined) => void; select(id: string | undefined): void; + /** The skin worn by this player's own wrights. */ + wear(tint: number): void; /** Centres the view on a garrison, for the map menu. */ lookAt(x: number, y: number): void; } @@ -42,7 +45,7 @@ function numberFor(text: string, kind: Mark["kind"]): Container { } export async function buildKeepScene( - _app: Application, + app: Application, viewport: Viewport, handle: KeepHandle, roster: { id: string; name: string; kind: string; work: "bug" | "feature" | "idle"; session?: Actor["session"] }[], @@ -58,10 +61,7 @@ export async function buildKeepScene( for (const entry of roster) muster(sim, entry); let selected: string | undefined; - const actors = new ActorLayer(art, things, (id) => { - selected = id; - handle.onPick?.(sim.actors.find((actor) => actor.id === id)); - }); + const actors = new ActorLayer(art, things); const birds = new Birds(things); const smoke = new Smoke(things, fx["fx-smoke_01"]); @@ -70,18 +70,53 @@ export async function buildKeepScene( handle.select = (id) => { selected = id; }; + handle.wear = (tint) => actors.wear(tint); handle.lookAt = (x, y) => { viewport.animate({ position: { x, y }, scale: 1.1, time: 450, ease: "easeInOutSine" }); }; - /* Clicking bare ground clears the selection, which is what closes the panel. */ - viewport.eventMode = "static"; - const clearPick = (event: { target: unknown }) => { - if (event.target !== viewport) return; - selected = undefined; - handle.onPick?.(undefined); + /* + * Picking is done by finding the nearest wright to where the map was + * clicked, rather than by giving every figure its own hit area. + * + * Two reasons. A wright is about twenty pixels tall and zooms down to seven, + * and asking somebody to hit that exactly is asking them to miss; a generous + * radius around the click is what makes small figures selectable at all. + * And it means one hit test against the world instead of one display object + * per actor in the interaction tree, which is cheaper and, unlike per-sprite + * hit areas, actually worked. + */ + /* + * The listener goes on the stage, with a hit area the size of the screen. + * + * A Pixi container only hit-tests its children unless it is given one of its + * own, so taps on open grass -- which is most of the map -- reached nothing + * and the handler on the viewport never fired. A stage-wide hit area means + * every click inside the canvas arrives, and where it landed is then a + * question about coordinates rather than about the display list. + */ + app.stage.eventMode = "static"; + app.stage.hitArea = app.screen; + const PICK_RADIUS = 1.4; + const onTap = (event: { global: { x: number; y: number } }) => { + const world = viewport.toWorld(event.global.x, event.global.y); + const tile = toTile(world.x, world.y); + + let nearest: Actor | undefined; + let nearestDistance = PICK_RADIUS; + for (const actor of sim.actors) { + if (!actor.session) continue; + const distance = Math.hypot(actor.x - tile.x, actor.y - tile.y); + if (distance < nearestDistance) { + nearestDistance = distance; + nearest = actor; + } + } + + selected = nearest?.id; + handle.onPick?.(nearest); }; - viewport.on("pointertap", clearPick); + app.stage.on("pointertap", onTap); const home = homePosition(); viewport.setZoom(0.9, true); @@ -128,7 +163,7 @@ export async function buildKeepScene( destroy() { viewport.off("zoomed", rescaleSigns); viewport.off("moved", rescaleSigns); - viewport.off("pointertap", clearPick); + app.stage.off("pointertap", onTap); actors.destroy(); birds.destroy(); smoke.destroy(); diff --git a/app/src/game/scenes/field.ts b/app/src/game/scenes/field.ts deleted file mode 100644 index 313b568..0000000 --- a/app/src/game/scenes/field.ts +++ /dev/null @@ -1,257 +0,0 @@ -import { drawShadow, drawSprite } from "../engine/atlas"; -import type { DrawContext } from "../engine/Stage"; -import { - CORNER_TURNS, - GATE, - GATE_VERTICAL, - KEEP_CORE, - RAMPART, - RAMPART_VERTICAL, - WATCHTOWER, -} from "../assets/structures"; -import { PROPS, WILD } from "../assets/props"; -import { KERB, MEADOW, PAVING, ROAD, TILE, hash, type TerrainName } from "../assets/terrain"; -import type { Sprite } from "../assets/sprite"; - -/** - * The holding, seen from above and filling the window. - * - * There is no fixed frame here and no sky. A wider window sees more meadow - * rather than the same picture stretched, which is what "the camera is above - * it" actually means once you stop drawing a horizon. - * - * Everything in this module is static — it changes when somebody finishes a - * structure and at no other time — so it is drawn onto the back canvas and - * left alone. The heroes and the bugs that move across it are drawn separately - * every frame, over the top. - * - * Three things do most of the work of making this look like a place rather - * than a diagram, and all three live here rather than in the artwork: - * - * Variants. One grass tile repeated is a chequerboard the eye solves in a - * second. Four cuts, chosen by world position, break the period. - * Shadows. Everything standing up casts one, down and to the right, so the - * map has a single light source and nothing floats. - * Props. A cart by the gate and barrels against a wall are what say - * somebody actually uses this place. - */ - -/** The walled holding, in tiles. Odd numbers, so there is a middle to stand in. */ -const HOLDING_W = 13; -const HOLDING_H = 9; - -export interface Base { - ground: TerrainName; - /** Ramparts and towers grow with the level; the keep has its own tier. */ - wallTier: number; - keepTier: number; - towerTier: number; -} - -export const STARTING_BASE: Base = { - ground: "grass", - /* - * Deliberately not an empty plot. Somebody arriving at bare ground has - * nothing to be proud of and nothing to improve; arriving at a small holding - * that is visibly theirs gives the first upgrade something to be an upgrade - * to. - */ - wallTier: 1, - keepTier: 1, - towerTier: 1, -}; - -/** Where the holding sits, in tiles, given how much ground is visible. */ -export function layout(draw: DrawContext) { - const cols = Math.ceil(draw.width / TILE); - const rows = Math.ceil(draw.height / TILE); - return { - cols, - rows, - /* Centred, and snapped to the tile grid so nothing lands on a half. */ - left: Math.floor((cols - HOLDING_W) / 2), - top: Math.floor((rows - HOLDING_H) / 2), - }; -} - -type Holding = ReturnType; - -/** Screen position of a tile, in device pixels. */ -function at(draw: DrawContext, tileX: number, tileY: number) { - return { x: tileX * TILE * draw.scale, y: tileY * TILE * draw.scale }; -} - -function put(draw: DrawContext, sprite: Sprite, tileX: number, tileY: number): void { - const { x, y } = at(draw, tileX, tileY); - drawSprite(draw.context, sprite, x, y, { scale: draw.scale }); -} - -/** - * Something standing on the ground, with its shadow under it. - * - * A sprite larger than a tile is centred on it and stood on its bottom edge, - * rather than hung from its top-left corner. That is what lets a tree be - * twenty-four pixels wide and still be *at* a particular tile: its trunk is on - * the tile and its canopy overhangs whatever is next to it, which is how a - * tree behaves. - */ -function stand(draw: DrawContext, sprite: Sprite, tileX: number, tileY: number, spread = 2): void { - const { x, y } = at(draw, tileX, tileY); - const offsetX = Math.round((TILE - sprite.w) / 2) * draw.scale; - const offsetY = (TILE - sprite.h) * draw.scale; - drawShadow(draw.context, sprite, x + offsetX, y + offsetY, draw.scale, spread); - drawSprite(draw.context, sprite, x + offsetX, y + offsetY, { scale: draw.scale }); -} - -/** - * A building that covers several tiles, drawn from the tile at its corner. - * - * Distinct from `stand` on purpose. A prop is a thing standing *at* a spot, so - * it is centred on its tile and stood on the bottom edge of it; a building - * *occupies* a block of tiles, and anchoring it the same way lifted the hall - * two tiles clear of the courtyard it is supposed to be sitting in. - */ -function place(draw: DrawContext, sprite: Sprite, tileX: number, tileY: number, spread = 3): void { - const { x, y } = at(draw, tileX, tileY); - drawShadow(draw.context, sprite, x, y, draw.scale, spread); - drawSprite(draw.context, sprite, x, y, { scale: draw.scale }); -} - -/** One of a set, chosen from where the tile is in the world. */ -function pick(options: T[], worldX: number, worldY: number, seed: number): T { - const index = Math.floor(hash(worldX, worldY, seed) * options.length); - return options[Math.min(index, options.length - 1)]; -} - -/** - * The meadow, everywhere. - * - * Drawn across the whole visible area rather than a fixed rectangle: this is - * what fills the window when somebody opens the game on an ultrawide. - */ -function drawGround(draw: DrawContext, holding: Holding): void { - const { cols, rows, left, top } = holding; - - for (let y = -1; y < rows + 1; y += 1) { - for (let x = -1; x < cols + 1; x += 1) { - /* - * Keyed on the tile's position relative to the holding rather than on - * screen, so the field does not reshuffle itself when the window is - * resized. - */ - put(draw, pick(MEADOW, x - left, y - top, 77), x, y); - } - } -} - -/** The road in through the gate, running south to the edge of the map. */ -function drawRoad(draw: DrawContext, holding: Holding): void { - const { rows, left, top } = holding; - const gateX = left + Math.floor(HOLDING_W / 2); - for (let y = top + HOLDING_H - 1; y < rows + 1; y += 1) { - put(draw, pick(ROAD, 0, y - top, 55), gateX, y); - } -} - -/** Trees, rocks and bushes outside the walls, kept clear of the road. */ -function drawWild(draw: DrawContext, holding: Holding): void { - const { cols, rows, left, top } = holding; - const right = left + HOLDING_W - 1; - const bottom = top + HOLDING_H - 1; - const gateX = left + Math.floor(HOLDING_W / 2); - - for (let y = -1; y < rows + 1; y += 1) { - for (let x = -1; x < cols + 1; x += 1) { - /* Nothing grows inside the walls, on the road, or right up against them. */ - const nearHolding = x >= left - 1 && x <= right + 1 && y >= top - 1 && y <= bottom + 1; - if (nearHolding || x === gateX) continue; - - const roll = hash(x - left, y - top, 303); - if (roll < 0.84) continue; - stand(draw, pick(WILD, x - left, y - top, 404).sprite, x, y, roll > 0.96 ? 3 : 2); - } - } -} - -/** The courtyard inside the walls, and the walls themselves. */ -function drawHolding(draw: DrawContext, base: Base, holding: Holding): void { - const { left, top } = holding; - const right = left + HOLDING_W - 1; - const bottom = top + HOLDING_H - 1; - const tier = Math.max(1, base.wallTier) - 1; - const rampart = RAMPART.tiers[Math.min(tier, RAMPART.tiers.length - 1)]; - const rampartV = RAMPART_VERTICAL[Math.min(tier, RAMPART_VERTICAL.length - 1)]; - const gateX = left + Math.floor(HOLDING_W / 2); - - /* Paving, in two cuts so a yard this size is not one stone repeated. */ - for (let y = top + 1; y < bottom; y += 1) { - for (let x = left + 1; x < right; x += 1) { - put(draw, pick(PAVING, x - left, y - top, 66), x, y); - } - } - /* A kerb along the inside of the north wall, where the join would show. */ - for (let x = left + 1; x < right; x += 1) put(draw, KERB, x, top + 1); - - /* Ramparts along the four sides, with the gate set into the south wall. */ - for (let x = left + 1; x < right; x += 1) { - stand(draw, rampart, x, top, 1); - stand(draw, x === gateX ? GATE.tiers[0] : rampart, x, bottom, 1); - } - for (let y = top + 1; y < bottom; y += 1) { - stand(draw, rampartV, left, y, 1); - stand(draw, rampartV, right, y, 1); - } - - /* - * Corners, each at the orientation its turn needs. CORNER_TURNS is the one - * authored corner rotated, so all four are the same masonry. - */ - stand(draw, CORNER_TURNS[0], left, top, 1); - stand(draw, CORNER_TURNS[1], right, top, 1); - stand(draw, CORNER_TURNS[2], right, bottom, 1); - stand(draw, CORNER_TURNS[3], left, bottom, 1); - - /* A watchtower inside each corner of the yard. */ - const tower = - WATCHTOWER.tiers[Math.min(Math.max(1, base.towerTier) - 1, WATCHTOWER.tiers.length - 1)]; - stand(draw, tower, left + 1, top + 1); - stand(draw, tower, right - 1, top + 1); - stand(draw, tower, left + 1, bottom - 1); - stand(draw, tower, right - 1, bottom - 1); - - /* The hall, three tiles square, in the middle of the yard. */ - const keep = KEEP_CORE.tiers[Math.min(Math.max(1, base.keepTier) - 1, KEEP_CORE.tiers.length - 1)]; - const keepX = left + Math.floor((HOLDING_W - 3) / 2); - const keepY = top + Math.floor((HOLDING_H - 3) / 2); - place(draw, keep, keepX, keepY, 3); - - /* - * The yard's clutter, placed rather than scattered: stores against the side - * walls, a well somebody has to walk to, a cart left by the gate, torches - * either side of it and the holding's banner over the road. - */ - stand(draw, PROPS.barrels.sprite, left + 2, top + 3, 1); - stand(draw, PROPS.well.sprite, left + 2, top + 5); - stand(draw, PROPS.crates.sprite, right - 2, top + 3, 1); - stand(draw, PROPS.cart.sprite, right - 2, top + 5, 1); - stand(draw, PROPS.torch.sprite, gateX - 1, bottom - 1, 1); - stand(draw, PROPS.torch.sprite, gateX + 1, bottom - 1, 1); - stand(draw, PROPS.banner.sprite, gateX, top + 2, 1); -} - -export function drawField(draw: DrawContext, base: Base): void { - const holding = layout(draw); - drawGround(draw, holding); - drawRoad(draw, holding); - drawWild(draw, holding); - drawHolding(draw, base, holding); -} - -/** Named so the vertical gate is not dropped before the second gate uses it. */ -export const GATE_NORTH_SOUTH = GATE_VERTICAL; - -/** - * The size of the holding, in tiles, for anything that needs to know where its - * parts are without redrawing them. - */ -export const HOLDING = { w: HOLDING_W, h: HOLDING_H } as const; diff --git a/app/src/game/scenes/foes.ts b/app/src/game/scenes/foes.ts deleted file mode 100644 index 1e90935..0000000 --- a/app/src/game/scenes/foes.ts +++ /dev/null @@ -1,120 +0,0 @@ -import { drawAnimation, drawShadow, drawSprite } from "../engine/atlas"; -import type { DrawContext } from "../engine/Stage"; -import { foeArt } from "../assets/foes"; -import { BUILD_SITE } from "../assets/structures"; -import { SHADOW, silhouette } from "../assets/palette"; -import { TILE } from "../assets/terrain"; -import type { Foe, Mark, World } from "../state/world"; - -/** - * The Unmade, and the numbers that come off them. - * - * Drawn after the wrights so a fault is always in front of whoever is hitting - * it — you should be able to see the thing being fought. - */ - -/** - * The flash a foe shows when it has just been hit. - * - * A module constant, not built per hit: the sprite cache is keyed on palette - * identity, so a fresh array each frame would re-decode every foe on screen - * every frame and quietly undo the whole point of the cache. - */ -const FLASH = silhouette("#ffd9e2"); - -function drawHealth(draw: DrawContext, foe: Foe, x: number, y: number, width: number): void { - if (foe.hp >= foe.maxHp) return; - const { context, scale } = draw; - const height = Math.max(2, scale); - const top = y - height * 2; - - context.save(); - context.fillStyle = "#160f0c"; - context.fillRect(x, top, width, height); - context.fillStyle = "#48d6c0"; - context.fillRect(x, top, Math.max(0, (width * foe.hp) / foe.maxHp), height); - context.restore(); -} - -/** - * A number rising off something that was just hit. - * - * Floats up and fades over its life. Outlined, like every other piece of text - * on the field, because it appears over grass, over paving and over a fire. - */ -function drawMark(draw: DrawContext, mark: Mark): void { - const { context, scale } = draw; - const progress = 1 - mark.life / mark.maxLife; - const size = Math.max(12, 5 * scale); - const x = (mark.x * TILE + TILE / 2) * scale; - const y = (mark.y * TILE) * scale - progress * 12 * scale; - - context.save(); - /* Holds full strength for the first half, then goes. */ - context.globalAlpha = Math.min(1, (1 - progress) * 2); - context.font = `bold ${size}px ui-monospace, SFMono-Regular, Menlo, monospace`; - context.textAlign = "center"; - context.textBaseline = "middle"; - context.lineWidth = Math.max(2, scale / 2); - context.strokeStyle = "#160f0c"; - context.strokeText(mark.text, x, y); - context.fillStyle = mark.kind === "damage" ? "#ff6fd8" : "#e8c65a"; - context.fillText(mark.text, x, y); - context.restore(); -} - -/** - * The plots being built on, at whichever stage they have reached. - * - * Drawn before the wrights rather than after: a builder stands in front of - * what they are raising, which is the only arrangement that reads as working - * on it rather than hiding behind it. - */ -export function drawSites(draw: DrawContext, world: World): void { - for (const site of world.sites) { - const stage = Math.min( - BUILD_SITE.length - 1, - Math.floor((site.progress / site.total) * BUILD_SITE.length), - ); - const sprite = BUILD_SITE[stage]; - const x = (site.x * TILE + TILE / 2 - sprite.w / 2) * draw.scale; - const y = (site.y * TILE + TILE - sprite.h) * draw.scale; - drawShadow(draw.context, sprite, x, y, draw.scale, 2); - drawSprite(draw.context, sprite, x, y, { scale: draw.scale }); - } -} - -export function drawFoes(draw: DrawContext, world: World): void { - const ordered = [...world.foes].sort((a, b) => a.y - b.y); - const elapsed = (world.clock / 30) * 1000; - - for (const foe of ordered) { - const art = foeArt(foe.kind); - const frameWidth = art.walk.sprite.w / art.walk.frames; - const x = (foe.x * TILE + TILE / 2) * draw.scale - (frameWidth / 2) * draw.scale; - const y = (foe.y * TILE + TILE - art.walk.sprite.h) * draw.scale; - - drawAnimation(draw.context, art.walk, x + draw.scale, y + draw.scale, elapsed, { - scale: draw.scale, - palette: SHADOW, - alpha: 0.3, - flip: foe.facing === -1, - motionless: draw.motionless, - }); - drawAnimation(draw.context, art.walk, x, y, elapsed, { - scale: draw.scale, - /* - * White while flinching. Colour alone is never the only signal in this - * game, and here it is not: the number coming off it says the same thing - * in figures, and the bar below says it a third time. - */ - palette: foe.hurt > 0 ? FLASH : undefined, - flip: foe.facing === -1, - motionless: draw.motionless, - }); - - drawHealth(draw, foe, x, y, frameWidth * draw.scale); - } - - for (const mark of world.marks) drawMark(draw, mark); -} diff --git a/app/src/game/scenes/fx.ts b/app/src/game/scenes/fx.ts deleted file mode 100644 index 79e50ba..0000000 --- a/app/src/game/scenes/fx.ts +++ /dev/null @@ -1,105 +0,0 @@ -import type { DrawContext } from "../engine/Stage"; -import { TILE } from "../assets/terrain"; -import type { Bolt, Spark, World } from "../state/world"; - -/** - * Bolts in the air and bursts where something was struck. - * - * Drawn with fills rather than sprites. These are three or four pixels that - * exist for a third of a second, and a sprite for each would be artwork - * nobody can see well enough to appreciate — the shape that reads at this size - * and duration is a bright block and a couple of chips flying off it. - * - * All of it is a pure function of the world's clock, so a paused game freezes - * mid-burst rather than swallowing the effect. - */ - -function block( - draw: DrawContext, - x: number, - y: number, - size: number, - colour: string, - alpha = 1, -): void { - const { context, scale } = draw; - context.save(); - context.globalAlpha = alpha; - context.fillStyle = colour; - context.fillRect(Math.round(x) * scale, Math.round(y) * scale, size * scale, size * scale); - context.restore(); -} - -/** - * A bolt, drawn as a head with a short tail behind it. - * - * The tail is three blocks along the path it has already covered, which at - * this speed reads as a streak without needing motion blur or a gradient — - * both of which would look wrong beside hard-edged pixel art. - */ -function drawBolt(draw: DrawContext, bolt: Bolt): void { - const progress = 1 - bolt.life / bolt.maxLife; - const x = bolt.x + (bolt.toX - bolt.x) * progress; - const y = bolt.y + (bolt.toY - bolt.y) * progress; - - for (let step = 0; step < 3; step += 1) { - const back = Math.max(0, progress - step * 0.08); - const tailX = bolt.x + (bolt.toX - bolt.x) * back; - const tailY = bolt.y + (bolt.toY - bolt.y) * back; - block( - draw, - tailX * TILE + TILE / 2 - 1, - tailY * TILE + TILE / 2 - 5, - 2, - step === 0 ? "#b4bdd8" : "#4267f5", - 1 - step * 0.28, - ); - } - /* The head, brightest, on top of its own tail. */ - block(draw, x * TILE + TILE / 2 - 1, y * TILE + TILE / 2 - 5, 3, "#eef1fb", 0.95); -} - -/** - * A burst. - * - * Four chips thrown out from the middle, further and fainter as it ages. Hits - * throw cold colours because that is what the Unmade are made of; a hammer - * throws warm stone chips, because that is what it is hitting. - */ -function drawSpark(draw: DrawContext, spark: Spark): void { - const progress = 1 - spark.life / spark.maxLife; - const spread = 1 + progress * 5; - const alpha = 1 - progress; - const colours = - spark.kind === "hit" ? ["#ff6fd8", "#48d6c0"] : ["#d9b88a", "#8f6a45"]; - - const centreX = spark.x * TILE + TILE / 2; - const centreY = spark.y * TILE + TILE / 2; - - /* Fixed diagonals rather than random ones, so it is the same burst twice. */ - const chips = [ - [-1, -1], - [1, -1], - [-1, 1], - [1, 1], - ]; - chips.forEach(([dx, dy], index) => { - block( - draw, - centreX + dx * spread - 1, - centreY + dy * spread - 1, - 2, - colours[index % colours.length], - alpha, - ); - }); - /* A bright core for the first half, so the moment of impact has weight. */ - if (progress < 0.5) { - block(draw, centreX - 1, centreY - 1, 3, colours[0], 1 - progress * 2); - } -} - -export function drawFx(draw: DrawContext, world: World): void { - for (const bolt of world.bolts) drawBolt(draw, bolt); - for (const spark of world.sparks) drawSpark(draw, spark); -} diff --git a/app/src/game/scenes/life.ts b/app/src/game/scenes/life.ts deleted file mode 100644 index b5a0878..0000000 --- a/app/src/game/scenes/life.ts +++ /dev/null @@ -1,133 +0,0 @@ -import type { DrawContext } from "../engine/Stage"; -import { TILE } from "../assets/terrain"; -import { HOLDING, layout } from "./field"; - -/** - * The things that move. - * - * Everything drawn here goes on the front canvas, cleared and redrawn thirty - * times a second, while the ground and the buildings sit still underneath. It - * is a short list on purpose: smoke from the chimney, the torches at the gate, - * and the banner shifting on its pole. None of it is a game mechanic and none - * of it can be interacted with. - * - * It earns its place because a still picture of a fort reads as a diagram no - * matter how well it is drawn, and three moving things are enough to make the - * same picture read as a place where something is happening. This is also the - * cheapest possible proof that the loop, the clear, and the layering all work, - * which is worth having before anything that matters is drawn on it. - * - * Every one of these is a pure function of the time passed in, with no state - * of its own. That is what makes `motionless` a single early return rather - * than a flag each effect has to remember to honour: hand it the same instant - * every frame and the whole scene simply stops. - */ - -/** Warm greys for smoke, palest first as it thins out. */ -const SMOKE = ["#b58d5f", "#8f6a45", "#6a4a33"]; - -/** Pixel-aligned fill, so nothing here is drawn on a half pixel. */ -function block( - draw: DrawContext, - x: number, - y: number, - w: number, - h: number, - colour: string, - alpha = 1, -): void { - const { context, scale } = draw; - context.save(); - context.globalAlpha = alpha; - context.fillStyle = colour; - context.fillRect(Math.round(x) * scale, Math.round(y) * scale, w * scale, h * scale); - context.restore(); -} - -/** - * Smoke from the hall's chimney. - * - * Four puffs on the same path at different points along it, each rising, - * spreading and fading. Drawn as squares rather than circles because a circle - * at this size is a square with the corners guessed at. - */ -function drawSmoke(draw: DrawContext, elapsedMs: number, originX: number, originY: number): void { - const puffs = 4; - const climb = 26; - const period = 3200; - - for (let index = 0; index < puffs; index += 1) { - const phase = ((elapsedMs / period) + index / puffs) % 1; - const y = originY - phase * climb; - /* Drifts east as it rises, the way smoke does when there is any wind. */ - const x = originX + phase * 5; - const size = 1 + Math.floor(phase * 3); - const colour = SMOKE[Math.min(SMOKE.length - 1, Math.floor(phase * SMOKE.length))]; - /* Fades out over the top half of the climb rather than all the way up. */ - const alpha = Math.max(0, 0.55 * (1 - Math.max(0, phase - 0.4) / 0.6)); - block(draw, x, y, size, size, colour, alpha); - } -} - -/** - * A torch flame. - * - * Two shapes alternating on a period that is deliberately not a round number, - * so two torches side by side do not flicker in step — that synchrony is the - * thing that makes a row of torches read as a string of fairy lights. - */ -function drawFlame(draw: DrawContext, elapsedMs: number, x: number, y: number, seed: number): void { - const phase = Math.floor((elapsedMs + seed * 137) / 110) % 2; - const tall = phase === 0; - - /* The glow it throws, under everything else, so the flame sits inside it. */ - block(draw, x - 2, y - 1, 6, 6, "#f0a03c", 0.12); - block(draw, x - 1, y, 4, 4, "#f0a03c", 0.14); - - if (tall) { - block(draw, x, y - 2, 2, 2, "#f0a03c", 0.9); - block(draw, x, y, 2, 2, "#c46b2a", 0.9); - } else { - block(draw, x, y - 1, 2, 2, "#f0a03c", 0.9); - block(draw, x, y + 1, 2, 1, "#c46b2a", 0.9); - } -} - -/** - * The banner, shifting on its pole. - * - * One pixel, twice: enough to read as cloth in the air and not enough to draw - * the eye away from whatever is happening on the field. - */ -function drawBanner(draw: DrawContext, elapsedMs: number, x: number, y: number): void { - const sway = Math.round(Math.sin(elapsedMs / 900) * 1.4); - block(draw, x + sway, y, 7, 8, "#94441f", 0.85); - block(draw, x + sway + 1, y + 2, 5, 4, "#c46b2a", 0.9); -} - -export function drawLife(draw: DrawContext): void { - /* - * Stillness is honoured once, here. Every effect below is a function of the - * clock, so not advancing the clock stops all of them, and no individual - * effect has to remember that the setting exists. - */ - if (draw.motionless) return; - - const { left, top } = layout(draw); - const gateX = left + Math.floor(HOLDING.w / 2); - const bottom = top + HOLDING.h - 1; - - /* - * The chimney is six pixels in from the hall's north-west corner; see - * buildKeep in assets/structures.ts, which puts it there. - */ - const keepX = left + Math.floor((HOLDING.w - 3) / 2); - const keepY = top + Math.floor((HOLDING.h - 3) / 2); - drawSmoke(draw, draw.elapsedMs, keepX * TILE + 9, keepY * TILE + 6); - - /* The two torches either side of the gate. */ - drawFlame(draw, draw.elapsedMs, (gateX - 1) * TILE + 7, (bottom - 1) * TILE + 4, 0); - drawFlame(draw, draw.elapsedMs, (gateX + 1) * TILE + 7, (bottom - 1) * TILE + 4, 3); - - drawBanner(draw, draw.elapsedMs, gateX * TILE + 4, (top + 2) * TILE + 2); -} diff --git a/app/src/game/scenes/wrights.ts b/app/src/game/scenes/wrights.ts deleted file mode 100644 index 194f7ab..0000000 --- a/app/src/game/scenes/wrights.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { drawAnimation } from "../engine/atlas"; -import type { DrawContext } from "../engine/Stage"; -import { heroArt } from "../assets/heroes"; -import { SHADOW } from "../assets/palette"; -import { TILE } from "../assets/terrain"; -import type { Wright, World } from "../state/world"; - -/** - * The garrison, drawn over the field. - * - * Wrights are the only things on screen that move under their own steam, so - * they get the whole front canvas to themselves and are redrawn every frame. - * Everything here reads the world and draws it; nothing here changes it, which - * is what keeps "what is happening" and "what it looks like" separable. - */ - -/** Wrights are drawn standing on the middle of their tile, not its corner. */ -function screenPosition(draw: DrawContext, wright: Wright, height: number) { - return { - x: (wright.x * TILE + TILE / 2 - 8) * draw.scale, - /* Feet on the tile, so a taller sprite grows upwards. */ - y: (wright.y * TILE + TILE - height) * draw.scale, - }; -} - -/** - * The plate under a wright with their session's name on it. - * - * Drawn on the canvas rather than in the DOM: there may be forty of these, one - * per session, and forty absolutely-positioned elements tracking moving canvas - * coordinates is a layout thrash the browser does not deserve. Canvas text is - * the right tool the moment a label has to follow something that moves. - * - * A dark plate behind it and a hard outline on it, because it sits over grass, - * over paving and over a fire, and it has to stay readable on all three. - */ -function drawPlate(draw: DrawContext, label: string, x: number, y: number): void { - const { context, scale } = draw; - /* - * Deliberately not scaled with the pixel art. Names are read, not looked at, - * and shrinking them to four pixels tall for authenticity would make the one - * piece of real information on the field the least legible thing on it. - */ - const size = Math.max(11, 4 * scale); - - context.save(); - context.font = `${size}px ui-monospace, SFMono-Regular, Menlo, monospace`; - context.textAlign = "center"; - context.textBaseline = "top"; - - const width = context.measureText(label).width; - const padding = Math.round(scale * 1.5); - const plateX = x - width / 2 - padding; - const plateY = y + padding; - - /* - * Lighter than it was. Five plates at full strength dominated a field they - * are only meant to annotate; the roster in the HUD is where the whole list - * is read, and this is a label on a moving thing. - */ - context.globalAlpha = 0.5; - context.fillStyle = "#160f0c"; - context.fillRect(plateX, plateY, width + padding * 2, size + padding); - context.globalAlpha = 1; - - context.lineWidth = Math.max(2, scale / 2); - context.strokeStyle = "#160f0c"; - context.strokeText(label, x, plateY + padding / 2); - context.globalAlpha = 0.85; - context.fillStyle = "#f5e3c0"; - context.fillText(label, x, plateY + padding / 2); - context.restore(); -} - -/** - * A mark over the wright's head saying what the session is doing. - * - * Shape and colour together, never colour alone: a cross for a fault being - * fought, a square for something being built. Somebody who cannot separate the - * two colours can still separate the two shapes. - */ -function drawTask(draw: DrawContext, wright: Wright, x: number, y: number): void { - if (wright.work === "idle") return; - const { context, scale } = draw; - const size = 3 * scale; - const top = y - size - scale; - - context.save(); - context.fillStyle = "#160f0c"; - context.fillRect(x - size / 2 - scale, top - scale, size + scale * 2, size + scale * 2); - - if (wright.work === "bug") { - /* A cross: something is being struck. */ - context.fillStyle = "#c0392b"; - context.fillRect(x - size / 2, top + size / 3, size, size / 3); - context.fillRect(x - size / 6, top, size / 3, size); - } else { - /* A square: something is being raised. */ - context.fillStyle = "#f0a03c"; - context.fillRect(x - size / 2, top, size, size); - context.fillStyle = "#160f0c"; - context.fillRect(x - size / 4, top + size / 3, size / 2, size / 3); - } - context.restore(); -} - -/** A plate waiting to be drawn, once it is known what else is near it. */ -interface Plate { - label: string; - x: number; - y: number; - halfWidth: number; -} - -/** - * Moves plates apart so none is drawn on top of another. - * - * Five wrights standing near each other produced five labels in the same - * place, and a stack of overlapping text is worse than no text at all: it - * hides the one piece of real information on the field behind itself. Each - * plate that would land on one already placed is pushed down until it clears, - * which keeps every name readable and keeps it near whoever it belongs to. - * - * Exported so the rule can be tested without a canvas. - */ -export function spreadPlates(plates: Plate[], lineHeight: number): Plate[] { - const placed: Plate[] = []; - /* Higher up the field first, so the pushing goes downwards and stays stable. */ - for (const plate of [...plates].sort((a, b) => a.y - b.y)) { - let { y } = plate; - let moved = true; - /* Bounded: with N plates the worst case is N pushes, never a loop. */ - for (let guard = 0; moved && guard <= placed.length; guard += 1) { - moved = false; - for (const other of placed) { - const overlapsX = Math.abs(other.x - plate.x) < other.halfWidth + plate.halfWidth; - const overlapsY = Math.abs(other.y - y) < lineHeight; - if (overlapsX && overlapsY) { - y = other.y + lineHeight; - moved = true; - } - } - } - placed.push({ ...plate, y }); - } - return placed; -} - -export function drawWrights(draw: DrawContext, world: World): void { - /* - * Back to front, so somebody standing lower on the map overlaps somebody - * standing higher. Without this, two wrights crossing pass through each - * other in whichever order the array happens to hold them. - */ - const ordered = [...world.wrights].sort((a, b) => a.y - b.y); - const plates: Plate[] = []; - - for (const wright of ordered) { - const art = heroArt(wright.kind); - /* - * The action decides the animation, not the position. Deriving "is - * swinging" from being next to a fault would play the swing continuously - * while standing there; the world says when a blow is actually being - * struck and holds it long enough to be seen. - */ - const animation = - wright.action === "attack" - ? art.attack - : wright.action === "build" - ? art.build - : wright.moving - ? art.walk - : art.idle; - const height = animation.sprite.h; - const { x, y } = screenPosition(draw, wright, height); - - /* - * The clock comes from the world rather than from the wall, so pausing the - * game stops the animation: a paused world stops ticking, the clock stops - * advancing, and every wright holds their frame. - */ - const elapsed = (world.clock / 30) * 1000; - - /* - * The shadow is the same call with a flat palette, not drawShadow: that - * takes a whole sprite, and an animation's sprite is the entire strip, so - * a wright would have cast a shadow four frames wide. - */ - drawAnimation(draw.context, animation, x + draw.scale, y + draw.scale, elapsed, { - scale: draw.scale, - palette: SHADOW, - alpha: 0.32, - flip: wright.facing === -1, - motionless: draw.motionless, - }); - drawAnimation(draw.context, animation, x, y, elapsed, { - scale: draw.scale, - palette: art.palette, - flip: wright.facing === -1, - motionless: draw.motionless, - }); - - const centreX = x + 8 * draw.scale; - drawTask(draw, wright, centreX, y); - - const label = wright.name.length > 18 ? `${wright.name.slice(0, 17)}…` : wright.name; - plates.push({ - label, - x: centreX, - y: y + height * draw.scale, - /* Close enough for an overlap test without measuring every frame. */ - halfWidth: (label.length * Math.max(11, 4 * draw.scale) * 0.62) / 2, - }); - } - - /* - * Plates last, and all together, so a name is never drawn underneath the - * next wright along and no two land on top of each other. - */ - const lineHeight = Math.max(11, 4 * draw.scale) + draw.scale * 3; - for (const plate of spreadPlates(plates, lineHeight)) { - drawPlate(draw, plate.label, plate.x, plate.y); - } -} diff --git a/app/src/game/state/demo-garrison.ts b/app/src/game/state/demo-garrison.ts new file mode 100644 index 0000000..c23d0d3 --- /dev/null +++ b/app/src/game/state/demo-garrison.ts @@ -0,0 +1,33 @@ +import type { Work } from "../world/sim"; + +/** + * A garrison to show when there are no live sessions. + * + * An empty map is the correct picture of an account with nothing running, and + * it is also a terrible first impression: a keep with nobody in it and no way + * to tell whether that is the point or a fault. So this stands in, and the HUD + * says plainly that it is standing in. + * + * It carries plausible session facts so that clicking one shows the same panel + * a real session would, rather than a panel with holes in it. + */ +export const DEMO_GARRISON = [ + { id: "demo-1", name: "fix: audit seal", kind: "claude-code", work: "bug" as Work }, + { id: "demo-2", name: "feat: session board", kind: "codex", work: "feature" as Work }, + { id: "demo-3", name: "chore: rotate keys", kind: "hermes", work: "idle" as Work }, + { id: "demo-4", name: "fix: relay reconnect", kind: "openclaw", work: "bug" as Work }, + { id: "demo-5", name: "npm run dev", kind: "terminal", work: "idle" as Work }, +].map((entry, index) => ({ + ...entry, + /* + * The stand-in garrison carries plausible session facts, so clicking one + * shows the same panel a real session would rather than a panel with holes + * in it. The interface says elsewhere, plainly, that these are an example. + */ + session: { + id: entry.id, + startedAt: Date.now() - (index + 1) * 11 * 60_000, + host: ["laptop", "workshop", "builder-01", "laptop", "workshop"][index], + command: entry.name, + }, +})); diff --git a/app/src/game/state/sessions.ts b/app/src/game/state/sessions.ts index e280928..f1dce0c 100644 --- a/app/src/game/state/sessions.ts +++ b/app/src/game/state/sessions.ts @@ -1,6 +1,6 @@ import { kindForCommand } from "../../lib/session-kinds"; import type { SessionRecord } from "../../lib/api"; -import type { Work } from "./world"; +import type { Work } from "../world/sim"; /** * Turning real sessions into a garrison. diff --git a/app/src/game/state/shop.test.ts b/app/src/game/state/shop.test.ts index c5244cd..5d9dcdf 100644 --- a/app/src/game/state/shop.test.ts +++ b/app/src/game/state/shop.test.ts @@ -1,6 +1,5 @@ import { describe, expect, it } from "vitest"; -import { STONE } from "../assets/palette"; -import { buy, fitsClass, paletteFor, SKINS, skinById, type Purse } from "./shop"; +import { buy, fitsClass, SKINS, skinById, swatchFor, tintFor, type Purse } from "./shop"; const purse = (marks: number, owned: string[] = []): Purse => ({ marks, owned }); @@ -14,14 +13,11 @@ describe("the catalogue", () => { */ for (const skin of SKINS) { expect(Object.keys(skin)).toEqual( - expect.arrayContaining(["id", "name", "note", "cost", "fits", "changes"]), + expect.arrayContaining(["id", "name", "note", "cost", "fits", "tint"]), ); - const slots = Object.keys(skin.changes).map(Number); - expect(slots.length).toBeGreaterThan(0); - for (const slot of slots) { - expect(slot).toBeGreaterThanOrEqual(0); - expect(slot).toBeLessThanOrEqual(15); - } + /* A colour and nothing else: no stats, no reach, no damage. */ + expect(skin.tint).toBeGreaterThanOrEqual(0); + expect(skin.tint).toBeLessThanOrEqual(0xffffff); } }); @@ -88,17 +84,23 @@ describe("buying", () => { }); describe("wearing it", () => { - it("changes the colours it says it changes, and nothing else", () => { - const skin = skinById("gilt")!; - const worn = paletteFor(STONE, "gilt"); - worn.forEach((colour, slot) => { - if (slot in skin.changes) expect(colour).toBe(skin.changes[slot]); - else expect(colour).toBe(STONE[slot]); - }); + it("washes the figure in the colour it advertises", () => { + expect(tintFor("gilt")).toBe(skinById("gilt")!.tint); }); - it("wears the class colours when nothing is equipped", () => { - expect(paletteFor(STONE, undefined)).toEqual(STONE); - expect(paletteFor(STONE, "nonsense")).toEqual(STONE); + it("leaves the art as drawn when nothing is equipped", () => { + /* White multiplied over a sprite is the sprite. */ + expect(tintFor(undefined)).toBe(0xffffff); + expect(tintFor("nonsense")).toBe(0xffffff); + }); + + it("shows the same colour in the shop as on the field", () => { + /* + * The swatch is not a decorative approximation of the skin: it is the + * skin. A preview that drifts from the thing being sold is a small lie. + */ + for (const skin of SKINS) { + expect(swatchFor(skin.id)).toBe(`#${skin.tint.toString(16).padStart(6, "0")}`); + } }); }); diff --git a/app/src/game/state/shop.ts b/app/src/game/state/shop.ts index 9b87307..aa91fa4 100644 --- a/app/src/game/state/shop.ts +++ b/app/src/game/state/shop.ts @@ -1,5 +1,3 @@ -import { reskin, STONE, type Palette } from "../assets/palette"; - /** * What marks can be spent on. * @@ -22,15 +20,23 @@ export interface Skin { cost: number; /** Which class it dresses, or "any" for the whole garrison. */ fits: string | "any"; - /** The slots it changes. A skin is a handful of colours, nothing more. */ - changes: Partial>; + /** + * The colour the wright is washed in. + * + * A skin used to be a sixteen-slot palette swap, because the artwork was + * palette-indexed text authored in this repository. The artwork is now + * Kenney's, which is ordinary PNG, so a skin is a tint: one number + * multiplied over the sprite by the renderer. Simpler, and it survives the + * artwork being replaced again. + */ + tint: number; } /** * The catalogue. * * Costs rise with how loud the skin is, which is the only balancing this - * needs: the quiet ones are affordable early, and the one that makes your + * needs: the quiet ones are affordable early, and the one that turns your * whole garrison gold is a thing you save for. */ export const SKINS: Skin[] = [ @@ -40,7 +46,7 @@ export const SKINS: Skin[] = [ note: "Whatever it was before, it has been through a fire since.", cost: 40, fits: "any", - changes: { 8: "#2b2b2b", 9: "#4a4a4a", 10: "#6e6e6e", 11: "#9a9a9a" }, + tint: 0x9a9a9a, }, { id: "moss", @@ -48,7 +54,7 @@ export const SKINS: Skin[] = [ note: "Issued to whoever was last through the gate.", cost: 40, fits: "any", - changes: { 8: "#25401c", 9: "#3d6b2c", 10: "#5c963f", 11: "#8bc45c" }, + tint: 0x76b055, }, { id: "wine", @@ -56,7 +62,7 @@ export const SKINS: Skin[] = [ note: "Dyed properly, once, by somebody who was owed a favour.", cost: 80, fits: "any", - changes: { 8: "#3d1226", 9: "#6b2040", 10: "#9c3560", 11: "#c96a92" }, + tint: 0xc96a92, }, { id: "frost", @@ -64,7 +70,7 @@ export const SKINS: Skin[] = [ note: "Cold colours on a warm march. It does not help.", cost: 80, fits: "codex", - changes: { 8: "#123244", 9: "#1d5570", 10: "#2f86a8", 11: "#63c2dd" }, + tint: 0x7fd0e8, }, { id: "forge", @@ -72,7 +78,7 @@ export const SKINS: Skin[] = [ note: "The Artificers had these made. Nobody asked for them.", cost: 120, fits: "claude-code", - changes: { 8: "#5e1c08", 9: "#953210", 10: "#d15a1c", 11: "#ffa03c" }, + tint: 0xff9a3c, }, { id: "gilt", @@ -80,7 +86,7 @@ export const SKINS: Skin[] = [ note: "The Castellan's own colours. Wear them and mean it.", cost: 260, fits: "any", - changes: { 8: "#5a4208", 9: "#8f6a12", 10: "#c79a24", 11: "#f2d357" }, + tint: 0xf2d357, }, ]; @@ -131,19 +137,12 @@ export const REFUSALS: Record = { poor: "Not enough marks.", }; -/** - * The palette a wright wears. - * - * A skin is the same kind of thing as a class: a handful of slots swapped on - * the one figure. That is why the renderer never has to know which of the two - * it has been handed. - */ -export function paletteFor(base: Palette, skinId?: string): Palette { - const skin = skinId ? skinById(skinId) : undefined; - return skin ? reskin(base, skin.changes) : base; +/** The tint a wright wears. White is "as drawn", which is no skin at all. */ +export function tintFor(skinId?: string): number { + return (skinId ? skinById(skinId)?.tint : undefined) ?? 0xffffff; } -/** A palette for a preview swatch, without needing the class it belongs to. */ -export function previewPalette(skinId: string): Palette { - return paletteFor(STONE, skinId); +/** The same number as a CSS colour, for the swatch in the shop. */ +export function swatchFor(skinId: string): string { + return `#${tintFor(skinId).toString(16).padStart(6, "0")}`; } diff --git a/app/src/game/state/world.test.ts b/app/src/game/state/world.test.ts deleted file mode 100644 index 37aebbf..0000000 --- a/app/src/game/state/world.test.ts +++ /dev/null @@ -1,265 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { createWorld, dismiss, muster, tickWorld, type World } from "./world"; - -const YARD = { left: 2, top: 2, right: 10, bottom: 8 }; - -function garrison(count: number): World { - const world = createWorld(YARD); - for (let index = 0; index < count; index += 1) { - muster(world, { - id: `w${index}`, - name: `session ${index}`, - kind: "terminal", - work: "idle", - }); - } - return world; -} - -function run(world: World, ticks: number): void { - for (let index = 0; index < ticks; index += 1) tickWorld(world); -} - -describe("mustering", () => { - it("brings a wright in at the gate rather than dropping them in the yard", () => { - const world = garrison(1); - const [wright] = world.wrights; - expect(wright.y).toBe(YARD.bottom); - expect(wright.moving).toBe(true); - }); - - it("removes one that has finished", () => { - const world = garrison(3); - dismiss(world, "w1"); - expect(world.wrights.map((wright) => wright.id)).toEqual(["w0", "w2"]); - }); - - it("ignores a dismissal for somebody who is not there", () => { - const world = garrison(2); - dismiss(world, "nobody"); - expect(world.wrights).toHaveLength(2); - }); -}); - -describe("walking about the yard", () => { - it("never leaves the courtyard, however long it runs", () => { - /* - * The failure this guards against is a wright wandering out through a wall - * and off across the meadow, which looks like a bug in the walls rather - * than in the pathing and is exactly the sort of thing nobody sees until a - * screenshot goes out. - */ - const world = garrison(6); - for (let step = 0; step < 4000; step += 1) { - tickWorld(world); - for (const wright of world.wrights) { - expect(wright.x).toBeGreaterThanOrEqual(YARD.left - 0.01); - expect(wright.x).toBeLessThanOrEqual(YARD.right + 0.01); - expect(wright.y).toBeGreaterThanOrEqual(YARD.top - 0.01); - expect(wright.y).toBeLessThanOrEqual(YARD.bottom + 0.01); - } - } - }); - - it("never stands on the hall, even walking from one side to the other", () => { - /* - * Choosing targets outside the building is not enough on its own: the walk - * between two points on opposite sides goes straight across it, and a - * wright strolling over the roof makes the whole map read as flat. This - * asserts the stronger thing -- not one frame inside it, ever. - */ - const world = garrison(6); - const midX = (YARD.left + YARD.right) / 2; - const midY = (YARD.top + YARD.bottom) / 2; - - for (let step = 0; step < 3000; step += 1) { - tickWorld(world); - for (const wright of world.wrights) { - const inside = - Math.abs(wright.x - midX) < 1.79 && Math.abs(wright.y - midY) < 1.79; - expect(inside, `${wright.id} was on the hall at ${wright.x},${wright.y}`).toBe(false); - } - } - }); - - it("keeps moving rather than settling into a stack", () => { - /* - * A wander that always picked the same target would look like a crowd - * frozen in one corner within a minute. - */ - const world = garrison(5); - run(world, 600); - const places = new Set( - world.wrights.map((wright) => `${Math.round(wright.x)}:${Math.round(wright.y)}`), - ); - expect(places.size).toBeGreaterThan(1); - }); - - it("moves when ticked and stops dead when it is not", () => { - /* - * This is the whole of how pausing works: the route stops calling tick. - * Both halves are asserted, because "nothing moved" on its own is also - * what a world that never moves at all looks like. - */ - const where = (world: World) => world.wrights.map((w) => `${w.x}:${w.y}`); - const world = garrison(3); - - const atStart = where(world); - run(world, 40); - const afterTicks = where(world); - expect(afterTicks).not.toEqual(atStart); - - const withoutTicks = where(world); - expect(withoutTicks).toEqual(afterTicks); - }); - - it("gives the same world for the same run, every time", () => { - /* - * Deterministic on purpose: a field that differs on every reload cannot be - * screenshotted in review and cannot be tested at all. - */ - const a = garrison(4); - const b = garrison(4); - run(a, 500); - run(b, 500); - expect(a.wrights.map((w) => [w.x, w.y])).toEqual(b.wrights.map((w) => [w.x, w.y])); - }); - - it("faces the way it is walking", () => { - const world = garrison(4); - run(world, 120); - for (const wright of world.wrights) { - expect([1, -1]).toContain(wright.facing); - } - }); -}); - -describe("fighting, and showing that it is fighting", () => { - /** A yard with one fault already in it and one wright sent to deal with it. */ - function skirmish(kind: string): World { - const world = createWorld(YARD); - muster(world, { id: "w", name: "fix: a thing", kind, work: "bug" }); - world.foes.push({ - id: "f1", - kind: "mite", - x: (YARD.left + YARD.right) / 2, - y: YARD.top, - hp: 50, - maxHp: 50, - speed: 0, - facing: 1, - hurt: 0, - }); - return world; - } - - it("gets round the hall to a fault on the far side of it", () => { - /* - * The regression test for three separate attempts at this. - * - * A wright at the gate and a fault directly opposite, with the hall - * between them, is the worst case for anything that steers by local rules: - * shoving out of the wall makes it vibrate, sliding along the wall makes - * it creep at a twentieth speed, and heading for the nearest corner walks - * it into the wall and stops. All three look, on screen, like a hero - * having a fit against a building. - * - * What is asserted is only that it arrives. How it gets there is allowed - * to change. - */ - const world = skirmish("openclaw"); - const foe = world.foes[0]; - let closest = Infinity; - for (let step = 0; step < 400; step += 1) { - tickWorld(world); - const wright = world.wrights[0]; - closest = Math.min(closest, Math.hypot(foe.x - wright.x, foe.y - wright.y)); - } - expect(closest).toBeLessThan(1); - }); - - it("swings, and holds the swing long enough to be seen", () => { - /* - * The failure this guards against: an action that is true only on the tick - * the damage lands is one frame in eighteen, which is a swing nobody ever - * sees on screen. - */ - const world = skirmish("openclaw"); - let swinging = 0; - for (let step = 0; step < 300; step += 1) { - tickWorld(world); - if (world.wrights[0].action === "attack") swinging += 1; - } - expect(swinging).toBeGreaterThan(20); - }); - - it("takes the fault down and counts it", () => { - /* - * The planted fault is gone; the field is not empty, because more keep - * arriving for as long as somebody is working on one. Asserting an empty - * field would be asserting that the waves stop, which is not the design. - */ - const world = skirmish("openclaw"); - for (let step = 0; step < 2000; step += 1) tickWorld(world); - expect(world.foes.some((foe) => foe.id === "f1")).toBe(false); - expect(world.felled).toBeGreaterThan(0); - }); - - it("throws a bolt for the one class that fights at a distance", () => { - const world = skirmish("codex"); - let sawBolt = false; - for (let step = 0; step < 300; step += 1) { - tickWorld(world); - if (world.bolts.length > 0) sawBolt = true; - } - expect(sawBolt).toBe(true); - }); - - it("strikes in reach for everybody else, with no bolt", () => { - const world = skirmish("openclaw"); - let sawBolt = false; - let sawSpark = false; - for (let step = 0; step < 300; step += 1) { - tickWorld(world); - if (world.bolts.length > 0) sawBolt = true; - if (world.sparks.some((spark) => spark.kind === "hit")) sawSpark = true; - } - expect(sawBolt).toBe(false); - expect(sawSpark).toBe(true); - }); - - it("clears its own effects rather than piling them up for ever", () => { - /* - * Every burst, bolt and number is short-lived. If any of them failed to be - * removed, a keep left open all afternoon would slow to a crawl, and the - * only symptom would be that it got gradually worse. - */ - const world = skirmish("codex"); - for (let step = 0; step < 4000; step += 1) tickWorld(world); - expect(world.bolts.length).toBeLessThan(20); - expect(world.sparks.length).toBeLessThan(20); - expect(world.marks.length).toBeLessThan(20); - }); -}); - -describe("building, and showing that it is building", () => { - it("raises a structure through its stages and counts it", () => { - const world = createWorld(YARD); - muster(world, { id: "b", name: "feat: a thing", kind: "claude-code", work: "feature" }); - - let hammering = 0; - const stagesSeen = new Set(); - for (let step = 0; step < 3000; step += 1) { - tickWorld(world); - if (world.wrights[0].action === "build") hammering += 1; - for (const site of world.sites) { - stagesSeen.add(Math.floor((site.progress / site.total) * 3)); - } - } - - expect(hammering).toBeGreaterThan(20); - /* It passed through more than one stage rather than jumping to finished. */ - expect(stagesSeen.size).toBeGreaterThan(1); - expect(world.raised).toBeGreaterThan(0); - }); -}); diff --git a/app/src/game/state/world.ts b/app/src/game/state/world.ts deleted file mode 100644 index c32cc5e..0000000 --- a/app/src/game/state/world.ts +++ /dev/null @@ -1,914 +0,0 @@ -import { TICK_MS } from "../engine/loop"; - -/** - * What is on the field, and how it moves. - * - * The world is a plain object advanced by pure functions, with no reference to - * a canvas, a sprite or the clock. Everything here can be run in a test by - * calling `tickWorld` a few hundred times and looking at the result, which is - * the only practical way to find out whether a wright can get stuck, whether a - * wave ever ends, or whether two of them can stand in the same place. - * - * Positions are in tiles, as floats. Tiles rather than pixels because the map - * is a grid and the interesting questions are about squares; floats because a - * wright walking one tile per second should be somewhere sensible in between. - */ - -/** What a session is doing, in the game's terms. */ -export type Work = "bug" | "feature" | "idle"; - -export interface Wright { - id: string; - /** The session's name, shown on the plate under them. */ - name: string; - /** The session kind, which is the class. See assets/heroes.ts. */ - kind: string; - work: Work; - x: number; - y: number; - /** Where they are heading. */ - toX: number; - toY: number; - /** Which way they are facing, for the sprite flip. */ - facing: 1 | -1; - /** True while actually moving, so idle and walk can differ. */ - moving: boolean; - /** Ticks to stand still before choosing somewhere new to be. */ - rest: number; - /** - * What they are doing right now, which is what the renderer draws. - * - * Held for a few ticks after the blow lands rather than being derived from - * position each frame. A swing that is only true on the tick the damage - * applies is a swing nobody ever sees: at thirty ticks a second it would be - * one frame in eighteen. - */ - action: "stand" | "walk" | "attack" | "build"; - /** The clock tick the current action stops being true at. */ - actionUntil: number; - /** - * Which way round the hall they committed to going, once blocked. - * - * 0 means not going around anything. This is memory rather than geometry - * because the geometry alone oscillates: at the middle of a wall, "step - * sideways to get past" and "step towards the target" point opposite ways, - * and an actor that re-decides every tick alternates between them for ever. - * Once a wright starts going round a building, it keeps going round the same - * way until it is clear. - */ - detour: 0 | 1 | -1; - /** - * The corner being walked to, while going around something. - * - * Committed rather than recomputed. Choosing the best corner afresh every - * tick looks correct and is the cause of the shaking: as the actor moves, - * which corner is cheapest flips between two candidates, so it turns round, - * which makes the other one cheapest, so it turns round again. Thirty times - * a second that is not a detour, it is a seizure. - */ - waypointX?: number; - waypointY?: number; - /** - * The fault being fought, held until it dies or gets far away. - * - * Same disease, different cause: two faults roughly equidistant swap places - * as "nearest" whenever either of them moves, and the wright walks between - * them for ever without reaching either. - */ - targetId?: string; -} - -/** A bolt in the air, from an Arcanist to whatever it named. */ -export interface Bolt { - id: number; - x: number; - y: number; - toX: number; - toY: number; - life: number; - maxLife: number; -} - -/** A short-lived burst where something was struck. */ -export interface Spark { - id: number; - x: number; - y: number; - life: number; - maxLife: number; - kind: "hit" | "build"; -} - -/** One of the Unmade, on its way in. */ -export interface Foe { - id: string; - kind: string; - x: number; - y: number; - hp: number; - maxHp: number; - speed: number; - facing: 1 | -1; - /** Ticks of flinch left, so a hit is visible as well as counted. */ - hurt: number; -} - -/** A number floating up off something that was just hit. */ -export interface Mark { - id: number; - text: string; - x: number; - y: number; - /** Counts down; the renderer uses it for the rise and the fade. */ - life: number; - maxLife: number; - kind: "damage" | "gain"; -} - -/** Something being raised in the yard, by whoever is building a feature. */ -export interface Site { - id: string; - x: number; - y: number; - /** 0 to `total`; the stage drawn is derived from it. */ - progress: number; - total: number; -} - -export interface World { - wrights: Wright[]; - foes: Foe[]; - sites: Site[]; - marks: Mark[]; - bolts: Bolt[]; - sparks: Spark[]; - /** The courtyard they may walk in, in tiles. */ - bounds: { left: number; top: number; right: number; bottom: number }; - /** Advances once per tick; animations read it so a pause freezes them. */ - clock: number; - /** Ticks until the next of the Unmade wanders in. */ - nextSpawn: number; - /** Counts up, for ids that do not repeat. */ - spawned: number; - /** Faults put down since the keep was opened. Feeds the stats. */ - felled: number; - /** Structures finished since the keep was opened. */ - raised: number; -} - -/** Tiles per second. Slow: this is a garrison at work, not a race. */ -const SPEED = 1.6; - -/** - * How long a wright stands about before picking somewhere new, in ticks. - * - * Shortened from one-to-five seconds. At the longer figure most of the - * garrison was standing still most of the time, and a yard of statues is not - * what a place with work going on in it looks like. - */ -const REST_MIN = 12; -const REST_MAX = 60; - -export function createWorld(bounds: World["bounds"]): World { - return { - wrights: [], - foes: [], - sites: [], - marks: [], - bolts: [], - sparks: [], - bounds, - clock: 0, - nextSpawn: SPAWN_EVERY, - spawned: 0, - felled: 0, - raised: 0, - }; -} - -/** - * Deterministic enough to be repeatable, random enough not to look it. - * - * Math.random would make the world different every reload and impossible to - * test; a hash of the wright and the clock gives a wander that is varied, - * reproducible, and the same on every machine. - */ -function noise(seed: number): number { - let value = Math.imul(seed ^ 0x9e3779b9, 0x85ebca6b); - value = Math.imul(value ^ (value >>> 13), 0xc2b2ae35); - return ((value ^ (value >>> 16)) >>> 0) / 4_294_967_296; -} - -/** Somewhere inside the courtyard, avoiding the block the keep stands on. */ -function wander(world: World, wright: Wright, salt: number): { x: number; y: number } { - const { left, top, right, bottom } = world.bounds; - const width = right - left; - const height = bottom - top; - const seed = hashId(wright.id) + world.clock + salt; - - for (let attempt = 0; attempt < 8; attempt += 1) { - const x = left + noise(seed + attempt * 31) * width; - const y = top + noise(seed + attempt * 67) * height; - if (!insideKeep(world, x, y)) return { x, y }; - } - /* Give up and stand where they are rather than walking into a wall. */ - return { x: wright.x, y: wright.y }; -} - -/** Half the width of the block the hall stands on, in tiles. */ -const KEEP_HALF = 1.8; - -/** The hall occupies the middle of the yard; nobody walks through it. */ -function insideKeep(world: World, x: number, y: number): boolean { - const { left, top, right, bottom } = world.bounds; - const midX = (left + right) / 2; - const midY = (top + bottom) / 2; - return Math.abs(x - midX) < KEEP_HALF && Math.abs(y - midY) < KEEP_HALF; -} - -/** - * Whether the straight line from one point to another passes through the hall. - * - * The usual slab test. It is here so that a detour can be checked before it is - * committed to: the corner on the far side of a building looks like the - * shortest way round right up until you notice that walking at it goes through - * the building, which is how the second attempt at this got stuck. - * - * The box is shrunk by a hair, so a path that grazes the wall counts as clear. - * Without that, walking along the edge is forever "blocked" by the edge being - * walked along. - */ -function pathCrossesKeep( - world: World, - fromX: number, - fromY: number, - toX: number, - toY: number, -): boolean { - const { left, top, right, bottom } = world.bounds; - const midX = (left + right) / 2; - const midY = (top + bottom) / 2; - const nudge = 0.001; - const minX = midX - KEEP_HALF + nudge; - const maxX = midX + KEEP_HALF - nudge; - const minY = midY - KEEP_HALF + nudge; - const maxY = midY + KEEP_HALF - nudge; - - const dx = toX - fromX; - const dy = toY - fromY; - let enter = 0; - let leave = 1; - - /* One slab per axis: clip the travelled fraction to the overlap of both. */ - for (const [origin, delta, low, high] of [ - [fromX, dx, minX, maxX], - [fromY, dy, minY, maxY], - ] as const) { - if (Math.abs(delta) < 1e-9) { - /* Parallel to this slab: either always within it, or never touching. */ - if (origin < low || origin > high) return false; - continue; - } - const first = (low - origin) / delta; - const second = (high - origin) / delta; - enter = Math.max(enter, Math.min(first, second)); - leave = Math.min(leave, Math.max(first, second)); - if (enter > leave) return false; - } - return enter <= leave; -} - -/** - * A step towards a target, going around the hall rather than into it. - * - * Returns whether the actor actually moved. - * - * The first version of this walked straight at the target and shoved anybody - * who ended up inside the building back out by the shortest way. That works - * until the target is directly on the other side of the hall: the wright steps - * in, gets shoved back, steps in again, and stands there vibrating against the - * wall for ever. The tests found it; it would have shown up on screen as a - * hero having a fit against the keep. - * - * So the step is tried in order — straight at it, along each axis alone, then - * tangentially either way — and the first one that does not end up inside the - * building is taken. That is wall-sliding, and what it looks like is somebody - * walking round a building, which is what they should have been doing. - */ -function stepToward( - world: World, - actor: Wright, - toX: number, - toY: number, - speed: number, -): boolean { - const step = speed / (1000 / TICK_MS); - const dx = toX - actor.x; - const dy = toY - actor.y; - const distance = Math.hypot(dx, dy); - if (distance <= step) { - actor.x = toX; - actor.y = toY; - actor.detour = 0; - return false; - } - - const ux = dx / distance; - const uy = dy / distance; - - const take = (mx: number, my: number): boolean => { - /* A candidate with no length is not a move; it reports progress and makes none. */ - if (Math.abs(mx) + Math.abs(my) < 0.01) return false; - const nextX = actor.x + mx * step; - const nextY = actor.y + my * step; - if (insideKeep(world, nextX, nextY)) return false; - actor.x = nextX; - actor.y = nextY; - return true; - }; - - /* Straight at it, whenever that is possible. Nothing is being gone around. */ - if (take(ux, uy)) { - actor.detour = 0; - actor.waypointX = undefined; - actor.waypointY = undefined; - return true; - } - - /* - * Already going around something: keep going to the same corner. - * - * This is the whole fix for the shaking. The corner is chosen once, when the - * way is first found to be blocked, and held until it is reached or the way - * ahead opens up. Re-choosing it every tick is what made the garrison - * vibrate: the cheapest corner flips as the actor moves, so it turns round, - * which makes the other one cheapest, so it turns round again. - */ - if (actor.waypointX !== undefined && actor.waypointY !== undefined) { - const toCorner = Math.hypot(actor.waypointX - actor.x, actor.waypointY - actor.y); - if (toCorner > step) { - if (take((actor.waypointX - actor.x) / toCorner, (actor.waypointY - actor.y) / toCorner)) { - return true; - } - } - /* Reached it, or it turned out to be no use. Choose again below. */ - actor.waypointX = undefined; - actor.waypointY = undefined; - } - - /* - * Blocked, so go round by way of a corner. - * - * Sliding along the wall was the obvious approach and it does not work here. - * The building is axis-aligned and the direction of travel is not, so a step - * along the wall always has some component into it; the actor drifts over - * the edge, gets refused, steps back out, and creeps along at a twentieth of - * its speed while jittering. Both earlier attempts died this way. - * - * Corners do not have that problem. They sit outside the building by a - * margin, so walking at one is an ordinary unobstructed walk, and the choice - * of which corner is made from geometry that does not change between ticks. - */ - const midX = (world.bounds.left + world.bounds.right) / 2; - const midY = (world.bounds.top + world.bounds.bottom) / 2; - const out = KEEP_HALF + 0.35; - - const corners: [number, number][] = [ - [midX - out, midY - out], - [midX + out, midY - out], - [midX + out, midY + out], - [midX - out, midY + out], - ]; - - /* - * The corner on the shortest way round: nearest to here, and from there - * nearest to where we are going. Recomputed each tick, but from position - * alone, so it is stable while the actor walks towards it. - */ - let best: [number, number] | undefined; - let bestCost = Infinity; - for (const [cx, cy] of corners) { - /* Skip the one we are effectively standing on, or we would never leave. */ - const here = Math.hypot(cx - actor.x, cy - actor.y); - if (here < step) continue; - /* - * And skip any corner we cannot walk straight at. The corner diagonally - * across the building always looks cheapest and is never reachable; taking - * it means walking into the wall and stopping there. - */ - if (pathCrossesKeep(world, actor.x, actor.y, cx, cy)) continue; - const cost = here + Math.hypot(toX - cx, toY - cy); - if (cost < bestCost) { - bestCost = cost; - best = [cx, cy]; - } - } - - if (best) { - const [cx, cy] = best; - const cornerDistance = Math.hypot(cx - actor.x, cy - actor.y); - if (take((cx - actor.x) / cornerDistance, (cy - actor.y) / cornerDistance)) { - actor.detour = 1; - /* Committed. See the note on waypointX. */ - actor.waypointX = cx; - actor.waypointY = cy; - return true; - } - } - - /* - * Last resort: a step that is inside the building at least gets out of it. - * Reached only if the actor somehow starts inside, which nothing above can - * cause but a resize of the courtyard underneath them can. - */ - if (take(ux, 0)) return true; - if (take(0, uy)) return true; - return false; -} - - -function hashId(id: string): number { - let value = 0; - for (let index = 0; index < id.length; index += 1) { - value = (Math.imul(value, 31) + id.charCodeAt(index)) | 0; - } - return Math.abs(value); -} - -/** Adds a wright at the gate, which is where somebody arriving would come in. */ -export function muster( - world: World, - input: Omit< - Wright, - "x" | "y" | "toX" | "toY" | "facing" | "moving" | "rest" | "action" | "actionUntil" | "detour" - >, -): Wright { - const gateX = (world.bounds.left + world.bounds.right) / 2; - const wright: Wright = { - ...input, - x: gateX, - y: world.bounds.bottom, - toX: gateX, - toY: world.bounds.bottom, - facing: 1, - moving: false, - rest: 0, - action: "stand", - actionUntil: 0, - detour: 0, - }; - world.wrights.push(wright); - /* Send them somewhere immediately, so they walk in rather than appearing. */ - const target = wander(world, wright, 7); - wright.toX = target.x; - wright.toY = target.y; - wright.moving = true; - return wright; -} - -/** Removes a wright, for a session that has ended. */ -export function dismiss(world: World, id: string): void { - const at = world.wrights.findIndex((wright) => wright.id === id); - if (at >= 0) world.wrights.splice(at, 1); -} - -/* ---- the Unmade --------------------------------------------------------- */ - -/** Ticks between arrivals, while anybody is working on a fault. */ -const SPAWN_EVERY = 150; -/** More than this on the field at once is a crowd nobody can read. */ -const MAX_FOES = 6; -/** How close a wright has to be to swing, in tiles. */ -const REACH = 0.9; -/** Ticks between blows. */ -const SWING_EVERY = 18; -/** How long a hit shows, and how long a number floats. */ -const HURT_TICKS = 6; -const MARK_TICKS = 40; -/** - * How long a swing or a hammer blow is held on screen. - * - * Long enough to be seen, and shorter than the gap between blows, so the - * animation plays out and the wright settles before the next one begins. A - * swing that were only true on the tick the damage lands would be one frame in - * eighteen, which is a swing nobody ever sees. - */ -const SWING_ANIM = 12; -const HAMMER_ANIM = 16; -/** How long a bolt is in the air, and how long a burst burns. */ -const BOLT_TICKS = 9; -const SPARK_TICKS = 10; -/** How far an Arcanist can name a fault from. */ -const CAST_REACH = 3.4; - -const FOE_KINDS = [ - { kind: "mite", hp: 3, speed: 1.1 }, - { kind: "crawler", hp: 6, speed: 0.8 }, - { kind: "heisenbug", hp: 9, speed: 0.6 }, -]; - -/** - * Whether anything should be coming in at all. - * - * The Unmade arrive because somebody is fixing a fault, not on a timer of - * their own. A keep with nothing broken in it is a quiet keep, and that is the - * correct picture of an account whose sessions are all building. - */ -function underAttack(world: World): boolean { - return world.wrights.some((wright) => wright.work === "bug"); -} - -function spawnFoe(world: World): void { - const { left, top, right, bottom } = world.bounds; - world.spawned += 1; - const roll = noise(world.spawned * 977 + world.clock); - const choice = FOE_KINDS[Math.min(FOE_KINDS.length - 1, Math.floor(roll * FOE_KINDS.length))]; - - /* - * In over a wall rather than through the gate. The gate is the way the - * garrison comes and goes; things that are not supposed to be here should - * not be using the door. - */ - const side = Math.floor(noise(world.spawned * 31) * 4); - const along = noise(world.spawned * 53); - const x = side === 0 ? left : side === 1 ? right : left + along * (right - left); - const y = side === 2 ? top : side === 3 ? bottom : top + along * (bottom - top); - - world.foes.push({ - id: `foe-${world.spawned}`, - kind: choice.kind, - x, - y, - hp: choice.hp, - maxHp: choice.hp, - speed: choice.speed, - facing: 1, - hurt: 0, - }); -} - -function addSpark(world: World, x: number, y: number, kind: Spark["kind"]): void { - world.sparks.push({ - id: world.clock * 1000 + world.sparks.length, - x, - y, - life: SPARK_TICKS, - maxLife: SPARK_TICKS, - kind, - }); -} - -function addBolt(world: World, from: Wright, to: Foe): void { - world.bolts.push({ - id: world.clock * 1000 + world.bolts.length, - x: from.x, - y: from.y, - toX: to.x, - toY: to.y, - life: BOLT_TICKS, - maxLife: BOLT_TICKS, - }); -} - -function addMark(world: World, text: string, x: number, y: number, kind: Mark["kind"]): void { - world.marks.push({ - id: world.clock * 1000 + world.marks.length, - text, - x, - y, - life: MARK_TICKS, - maxLife: MARK_TICKS, - kind, - }); -} - -/** Ticks between hammer blows. Slower than a sword; a wall takes a while. */ -const HAMMER_EVERY = 24; -/** How many blows a structure takes, over the three stages. */ -const BUILD_EFFORT = 18; - -/** - * The plot a wright is working on, claimed on first need. - * - * One site per builder rather than one shared site, because two sessions - * building different features are doing two different things and the field - * should say so. The plot is picked from the wright's own id, so the same - * session always returns to the same corner of the yard. - */ -function siteFor(world: World, wright: Wright): Site { - const existing = world.sites.find((site) => site.id === wright.id); - if (existing) return existing; - - const { left, top, right, bottom } = world.bounds; - const seed = hashId(wright.id); - let x = left + noise(seed) * (right - left); - let y = top + noise(seed * 3) * (bottom - top); - /* Not on the hall, and not so close to it that the two overlap. */ - for (let attempt = 0; attempt < 8 && insideKeep(world, x, y); attempt += 1) { - x = left + noise(seed + attempt * 41) * (right - left); - y = top + noise(seed * 3 + attempt * 59) * (bottom - top); - } - - const site: Site = { id: wright.id, x, y, progress: 0, total: BUILD_EFFORT }; - world.sites.push(site); - return site; -} - -/** - * Turns a wright to face the way it actually moved. - * - * Not the way the target lies, which was the old rule and the third cause of - * the shaking: a wright going *around* something walks away from its target - * for several seconds, and facing the target the whole time made the sprite - * flip back and forth against its own travel. The deadband is there so that a - * wright that has stopped does not spin on the spot. - */ -function faceTravel(wright: Wright, fromX: number): void { - const moved = wright.x - fromX; - if (Math.abs(moved) > 0.004) wright.facing = moved > 0 ? 1 : -1; -} - -/** - * How far a fault can wander before a wright gives up on it and picks another. - * - * Generous on purpose: switching is the expensive thing, not walking. - */ -const ABANDON_AT = 7; - -/** - * The fault a wright is fighting. - * - * Sticky. A wright keeps the one it chose until that fault dies or gets a long - * way off, and only then looks for another. Picking the nearest every tick is - * the obvious implementation and it shakes: two faults at roughly equal - * distance swap places as "nearest" whenever either of them moves, and the - * wright walks back and forth between them without ever arriving. - */ -function chooseFoe(world: World, wright: Wright): Foe | undefined { - const held = world.foes.find((foe) => foe.id === wright.targetId); - if (held && Math.hypot(held.x - wright.x, held.y - wright.y) < ABANDON_AT) return held; - - let best: Foe | undefined; - let bestDistance = Infinity; - for (const foe of world.foes) { - const distance = Math.hypot(foe.x - wright.x, foe.y - wright.y); - if (distance < bestDistance) { - bestDistance = distance; - best = foe; - } - } - wright.targetId = best?.id; - return best; -} - -/** - * How hard a class hits. - * - * The differences are small and are there to make the classes legible on the - * field, not to make one of them correct to pick. Nobody chooses which session - * kind they are running to win a fight in a browser game, and a balance patch - * for something nobody chooses would be a strange thing to write. - */ -/** - * Whether a class fights at a distance. - * - * Named here rather than imported from the artwork, so the simulation does not - * depend on the sprites. The two agree because they are both short lists - * saying the same thing about the same five classes, and the test below says - * so if they stop agreeing. - */ -function ranged(kind: string): boolean { - return kind === "codex"; -} - -function blow(kind: string): number { - switch (kind) { - case "codex": - return 3; - case "openclaw": - return 2; - case "hermes": - return 1; - default: - return 2; - } -} - -/** - * One fixed step of the world. - * - * Mutates, deliberately: this runs thirty times a second over every actor on - * the field, and rebuilding the array each time would make the garbage - * collector the most expensive thing in the game. - */ -export function tickWorld(world: World): void { - world.clock += 1; - - /* Arrivals, while there is a fault being worked on. */ - if (underAttack(world) && world.foes.length < MAX_FOES) { - world.nextSpawn -= 1; - if (world.nextSpawn <= 0) { - world.nextSpawn = SPAWN_EVERY; - spawnFoe(world); - } - } - - /* The Unmade make for the hall. */ - const midX = (world.bounds.left + world.bounds.right) / 2; - const midY = (world.bounds.top + world.bounds.bottom) / 2; - for (const foe of world.foes) { - if (foe.hurt > 0) foe.hurt -= 1; - const dx = midX - foe.x; - const dy = midY - foe.y; - const distance = Math.hypot(dx, dy); - /* - * They stop at the hall rather than entering it, and nothing happens when - * they arrive. There is no losing here on purpose: the game is a picture of - * work that has already happened, and a keep that falls over because - * somebody closed their laptop would be a punishment for nothing. - */ - if (distance > KEEP_HALF + 0.4) { - const step = foe.speed / (1000 / TICK_MS); - foe.x += (dx / distance) * step; - foe.y += (dy / distance) * step; - if (Math.abs(dx) > 0.05) foe.facing = dx > 0 ? 1 : -1; - } - } - - /* Numbers rise and fade; bolts fly; bursts burn out. */ - for (const mark of world.marks) mark.life -= 1; - world.marks = world.marks.filter((mark) => mark.life > 0); - - for (const bolt of world.bolts) { - bolt.life -= 1; - /* A bolt that lands leaves a burst where it landed. */ - if (bolt.life === 0) addSpark(world, bolt.toX, bolt.toY, "hit"); - } - world.bolts = world.bolts.filter((bolt) => bolt.life > 0); - - for (const spark of world.sparks) spark.life -= 1; - world.sparks = world.sparks.filter((spark) => spark.life > 0); - - /* An action that has run its course goes back to standing. */ - for (const wright of world.wrights) { - if (wright.action !== "stand" && world.clock >= wright.actionUntil) { - wright.action = wright.moving ? "walk" : "stand"; - } - } - - for (const wright of world.wrights) { - /* - * A wright working a fault goes to the nearest one and swings at it. - * Everybody else wanders, which is what the yard looks like when the - * sessions running are building things rather than fixing them. - */ - /* - * A wright building a feature claims a plot in the yard and works on it - * until it is standing. The stages are what make it worth watching; a - * structure that appeared finished in one step would be a number going up - * with a picture next to it. - */ - if (wright.work === "feature") { - const site = siteFor(world, wright); - const dx = site.x - wright.x; - const dy = site.y - wright.y; - const distance = Math.hypot(dx, dy); - - if (distance > REACH) { - const from = wright.x; - wright.moving = stepToward(world, wright, site.x, site.y, SPEED); - wright.action = "walk"; - faceTravel(wright, from); - } else { - wright.moving = false; - if (Math.abs(dx) > 0.05) wright.facing = dx > 0 ? 1 : -1; - if ((world.clock + hashId(wright.id)) % HAMMER_EVERY === 0) { - wright.action = "build"; - wright.actionUntil = world.clock + HAMMER_ANIM; - /* Chips fly where the hammer lands, not where the wright stands. */ - addSpark(world, site.x, site.y - 0.2, "build"); - site.progress += 1; - if (site.progress >= site.total) { - world.raised += 1; - addMark(world, "RAISED", site.x, site.y, "gain"); - world.sites = world.sites.filter((other) => other.id !== site.id); - } - } - } - continue; - } - - if (wright.work === "bug") { - const foe = chooseFoe(world, wright); - if (foe) { - const dx = foe.x - wright.x; - const dy = foe.y - wright.y; - const distance = Math.hypot(dx, dy); - - /* - * An Arcanist stops further out and throws. It is the one class whose - * flavour is naming a fault from across the yard rather than hitting - * it, and one ranged class among five gives the field variety without - * anybody having to learn a system. - */ - const reach = ranged(wright.kind) ? CAST_REACH : REACH; - - if (distance > reach) { - const from = wright.x; - wright.moving = stepToward(world, wright, foe.x, foe.y, SPEED); - wright.action = "walk"; - faceTravel(wright, from); - } else { - wright.moving = false; - if (Math.abs(dx) > 0.05) wright.facing = dx > 0 ? 1 : -1; - /* - * Staggered by who is swinging, so two wrights on one fault do not - * land every blow on the same tick and read as one attacker. - */ - if ((world.clock + hashId(wright.id)) % SWING_EVERY === 0) { - const damage = blow(wright.kind); - wright.action = "attack"; - wright.actionUntil = world.clock + SWING_ANIM; - - if (ranged(wright.kind)) { - /* - * The bolt is the flourish, not the mechanism: the damage lands - * now, and the burst appears where it arrives. Applying it on - * arrival instead would mean a fault could die to a bolt thrown - * by a wright who has since been dismissed, which is a whole - * class of bug for no visible gain. - */ - addBolt(world, wright, foe); - } else { - addSpark(world, foe.x, foe.y, "hit"); - } - - foe.hp -= damage; - foe.hurt = HURT_TICKS; - addMark(world, String(damage), foe.x, foe.y, "damage"); - if (foe.hp <= 0) { - world.felled += 1; - world.foes = world.foes.filter((other) => other.id !== foe.id); - } - } - } - continue; - } - /* Nothing to fight; fall through and wander like everyone else. */ - } - - if (!wright.moving) { - wright.rest -= 1; - if (wright.rest > 0) continue; - const target = wander(world, wright, 13); - wright.toX = target.x; - wright.toY = target.y; - wright.moving = true; - wright.action = "walk"; - continue; - } - - const from = wright.x; - - if (!stepToward(world, wright, wright.toX, wright.toY, SPEED)) { - wright.moving = false; - wright.action = "stand"; - /* A spread of rests, so a garrison does not move in lockstep. */ - const roll = noise(hashId(wright.id) + world.clock); - wright.rest = Math.round(REST_MIN + roll * (REST_MAX - REST_MIN)); - continue; - } - - faceTravel(wright, from); - } -} - -/** - * A garrison to show when there are no live sessions. - * - * An empty keep is the correct picture of an account with nothing running, and - * it is also a terrible first impression: the game would open on a walled yard - * with nobody in it and no way to tell whether that was the point or a bug. So - * the demo garrison stands in, and the interface says plainly that it is - * standing in. - */ -export const DEMO_GARRISON = [ - { id: "demo-1", name: "fix: audit seal", kind: "claude-code", work: "bug" as const }, - { id: "demo-2", name: "feat: session board", kind: "codex", work: "feature" as const }, - { id: "demo-3", name: "chore: rotate keys", kind: "hermes", work: "idle" as const }, - { id: "demo-4", name: "fix: relay reconnect", kind: "openclaw", work: "bug" as const }, - { id: "demo-5", name: "npm run dev", kind: "terminal", work: "idle" as const }, -].map((entry, index) => ({ - ...entry, - /* - * The stand-in garrison carries plausible session facts, so clicking one - * shows the same panel a real session would rather than a panel with holes - * in it. The interface says elsewhere, plainly, that these are an example. - */ - session: { - id: entry.id, - startedAt: Date.now() - (index + 1) * 11 * 60_000, - host: ["laptop", "workshop", "builder-01", "laptop", "workshop"][index], - command: entry.name, - }, -})); diff --git a/app/src/game/ui/Codex.tsx b/app/src/game/ui/Codex.tsx index 896388b..06a16f2 100644 --- a/app/src/game/ui/Codex.tsx +++ b/app/src/game/ui/Codex.tsx @@ -1,4 +1,4 @@ -import { STRUCTURES } from "../assets/structures"; +import { GARRISONS } from "../world/marches"; import { CLASS_LORE, FOE_LORE, WORLD } from "../lore/world"; /** @@ -80,12 +80,19 @@ export function Codex({ onBack }: { onBack: () => void }) {
-

The holding

+

The holdings

+

+ Every one of these is somewhere on the map, with its name and its purpose on a + board outside it. This is the index, not the source: the Marches are meant to be + walked. +

- {Object.entries(STRUCTURES).map(([id, art]) => ( -
-
{art.name}
-
{art.blurb}
+ {GARRISONS.map((garrison) => ( +
+
{garrison.name}
+
+ {garrison.purpose} {garrison.truth} +
))}
diff --git a/app/src/game/ui/Shop.tsx b/app/src/game/ui/Shop.tsx index b54dfed..f618bea 100644 --- a/app/src/game/ui/Shop.tsx +++ b/app/src/game/ui/Shop.tsx @@ -1,7 +1,6 @@ import { useState } from "react"; import { CLASS_LORE, WORLD } from "../lore/world"; -import { buy, fitsClass, previewPalette, REFUSALS, SKINS, type Purse } from "../state/shop"; -import { SLOT } from "../assets/palette"; +import { buy, fitsClass, REFUSALS, SKINS, swatchFor, type Purse } from "../state/shop"; /** * The pedlar at the gate. @@ -55,20 +54,18 @@ export function Shop({ const owned = purse.owned.includes(skin.id); const worn = wearing === skin.id; const wearable = fitsClass(skin, characterClass); - const palette = previewPalette(skin.id); return (
  • {/* - * The swatch shows the four colours the skin actually changes, - * which is a more honest preview than a picture of a hero would - * be: those four colours are the entire product. + * The swatch is the colour itself, which is the whole product: + * a skin washes the figure in this and changes nothing else. */} -
    -
    -
    - - - {lore.title} - Level {standing.level} - + <> +
    +
    +
    + + + {lore.title} + Level {standing.level} + +
    +
    -
    -
    - - - {marks.toLocaleString()} - {WORLD.coin} - -
    +
    +
    + + + {marks.toLocaleString()} + {WORLD.coin} + +
    - {/* - * The vial is a way in, not an ornament. A figure that stands for money - * somebody's machine has spent should be one press from the account of - * what spent it -- and while it is off, one press from the notice - * explaining what turning it on would read. - */} - + {/* + * The vial is a way in, not an ornament. A figure that stands for + * money somebody's machine has spent should be one press from the + * account of what spent it -- and while it is off, one press from the + * notice explaining what turning it on would read. + */} + +
    - -
    + +
    + ); } diff --git a/app/src/styles/game.css b/app/src/styles/game.css index d1cd0d6..1ffeca5 100644 --- a/app/src/styles/game.css +++ b/app/src/styles/game.css @@ -139,14 +139,9 @@ * does not land on an invisible box. */ .keep-safe { - display: flex; position: absolute; inset: 0; z-index: var(--keep-z-hud); - padding: var(--keep-safe); - flex-direction: column; - justify-content: space-between; - gap: var(--keep-space-2); pointer-events: none; } @@ -203,24 +198,6 @@ box-shadow: 0 calc(6px * var(--keep-scale)) 0 0 rgb(0 0 0 / 50%); } -/* ---- HUD -------------------------------------------------------------- */ - -.keep-hud { - display: flex; - align-items: flex-start; - justify-content: flex-end; - gap: var(--keep-space-2); - flex: none; -} - -.keep-foot { - display: flex; - align-items: flex-end; - justify-content: flex-start; - gap: var(--keep-space-2); - flex: none; -} - /* ---- buttons ---------------------------------------------------------- */ /* @@ -914,70 +891,98 @@ pointer-events: none; } -/* ---- HUD bar ---------------------------------------------------------- */ +/* ---- the HUD, in the corners ------------------------------------------- */ /* - * The strip along the top: standing, purse, elixir, garrison. + * Four corners rather than a bar across the top. * - * One framed strip divided by brass rules, not four floating panels. Four - * panels over a map read as four things that happen to be near each other, and - * at the sizes their contents want they collided; a single bar reads as the - * instrument panel it is, and a divider costs two pixels where a frame costs - * sixteen on each side. + * A strip was one panel wide enough to reach edge to edge and tall enough for + * three rows, and what it mostly did was cover the map. Everything on it is + * glanced at rather than read, and things that are glanced at belong at the + * edges of the eye. * - * It wraps rather than shrinks. On a narrow window these become two rows of - * readable cells instead of four unreadable ones, which is the right trade - * when the alternative is text below the floor this file holds itself to. + * Each corner is pinned inside the safe area, so a television's crop takes the + * same bite out of all four. They do not take pointer events; their contents + * do, so a click on the gap between two panels reaches the map underneath. */ -.keep-hud-bar { +.keep-corner { display: flex; - flex-flow: row wrap; - align-items: stretch; - /* Hugs its contents. A bar stretched to the window is a bar with a hole in it. */ - flex: 0 1 auto; - min-width: 0; - margin-right: auto; - padding: 0; + position: absolute; + z-index: var(--keep-z-hud); + gap: var(--keep-space); + pointer-events: none; } -.keep-hud-cell { - display: flex; - padding: var(--keep-space) var(--keep-space-2); - align-items: center; - gap: var(--keep-space-2); - /* Reset, because one of these is a button and buttons bring their own. */ - border: 0; - background: none; - color: inherit; - font: inherit; - text-align: left; +.keep-corner > * { + pointer-events: auto; +} + +.keep-corner.is-top-left { + top: var(--keep-safe); + left: var(--keep-safe); + flex-direction: column; + align-items: flex-start; +} + +.keep-corner.is-top-right { + top: var(--keep-safe); + right: var(--keep-safe); + flex-direction: column; + align-items: flex-end; +} + +.keep-corner.is-bottom-left { + bottom: var(--keep-safe); + left: var(--keep-safe); + align-items: flex-end; +} + +.keep-corner.is-bottom-right { + right: var(--keep-safe); + bottom: var(--keep-safe); + align-items: flex-end; +} + +.keep-corner.is-bottom-centre { + bottom: var(--keep-safe); + left: 50%; + transform: translateX(-50%); } /* - * A hairline between cells rather than around each one. + * A crest rather than a square. * - * `:not(:first-child)` and not a `+` rule, so a cell that has been hidden on a - * narrow window does not leave the one after it without its divider. + * The rest of the interface is brass on stone; this is the one piece that says + * whose interface it is, so it takes the shape a device takes -- a shield, + * pointed at the foot. A clip path rather than an image, because it is four + * straight lines and a point. */ -.keep-hud-cell:not(:first-child) { - border-left: 2px solid rgb(232 180 74 / 35%); -} - -/* The one cell you can press says so when pressed, like every other control. */ -.keep-roster-button { - cursor: pointer; - transition: background var(--keep-fast) linear; +.keep-crest { + display: inline-flex; + width: calc(38px * var(--keep-scale)); + height: calc(42px * var(--keep-scale)); + background: var(--keep-gold); + align-items: center; + justify-content: center; + flex: none; + clip-path: polygon(0 0, 100% 0, 100% 62%, 50% 100%, 0 62%); } -.keep-roster-button:hover { - background: rgb(232 180 74 / 12%); +.keep-crest-letter { + color: var(--keep-stone-dark); + font-size: var(--keep-text-lg); + font-weight: 700; + line-height: 1; + /* Off the geometric middle, because the point below drags the eye down. */ + transform: translateY(calc(-3px * var(--keep-scale))); } .keep-standing { - min-width: calc(260px * var(--keep-scale)); + display: flex; + min-width: calc(240px * var(--keep-scale)); + max-width: calc(320px * var(--keep-scale)); flex-direction: column; - align-items: stretch !important; - gap: var(--keep-space) !important; + gap: var(--keep-space); } .keep-standing-head { @@ -986,20 +991,6 @@ gap: var(--keep-space-2); } -.keep-sigil { - display: inline-flex; - width: calc(34px * var(--keep-scale)); - height: calc(34px * var(--keep-scale)); - border: 3px solid var(--keep-gold); - background: var(--keep-stone-dark); - color: var(--keep-gold); - font-size: var(--keep-text-lg); - font-weight: 700; - align-items: center; - justify-content: center; - flex: none; -} - .keep-standing-text, .keep-purse-text, .keep-roster-text, @@ -1015,12 +1006,34 @@ text-transform: uppercase; } +.keep-purse, +.keep-elixir-panel, +.keep-roster-button { + display: flex; + align-items: center; + gap: var(--keep-space-2); +} + +.keep-roster-button, +.keep-elixir-panel { + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; + transition: background var(--keep-fast) linear; +} + +.keep-roster-button:hover, +.keep-elixir-panel:hover { + background: var(--keep-stone-lit); +} + .keep-standing-level, .keep-purse-label, .keep-roster-detail, .keep-elixir-value { color: var(--keep-mist); - font-size: var(--keep-text-sm); + font-size: var(--keep-text); } /* ---- meters ----------------------------------------------------------- */ @@ -1157,15 +1170,11 @@ * they carried is a tap away in the pause menu. */ @media (width <= 640px) { - .keep-hud-bar { - flex-wrap: nowrap; - } - /* - * Four panels cannot fit 390 pixels at a 16px floor, and squeezing them - * clipped every one of them. So three of them leave: the purse, the vial and - * the garrison count are all repeated in the pause menu, which is one tap - * away and is the right home for a number you look up rather than glance at. + * The corners still do not fit 390 pixels at a 16px floor, so three of them + * leave: the purse, the vial and the garrison count are all repeated in the + * pause menu, which is one tap away and is the right home for a number you + * look up rather than glance at. */ .keep-purse, .keep-elixir-panel, @@ -1173,15 +1182,9 @@ display: none; } - .keep-hud-cell { - min-width: 0; - padding: calc(var(--keep-space) / 2) var(--keep-space); - gap: var(--keep-space); - } - .keep-standing { min-width: 0; - flex: 1; + max-width: calc(100vw - var(--keep-space-4)); } /* The explanations, which are the first thing that can go. */ @@ -1202,9 +1205,9 @@ white-space: nowrap; } - .keep-sigil { + .keep-crest { width: calc(28px * var(--keep-scale)); - height: calc(28px * var(--keep-scale)); + height: calc(32px * var(--keep-scale)); } .keep-vial { From a29449d684f96c06c6b434bfd2821489d22b16e6 Mon Sep 17 00:00:00 2001 From: Alexgodoroja Date: Wed, 16 Sep 2026 13:30:11 -0700 Subject: [PATCH 35/49] Stand the inspect card beside the figure, and walk it along with them It was pinned to the right edge, which meant reading about somebody while looking at a box a screen's width away from them. It now stands next to whoever was clicked and follows them as they walk. Its position is written straight onto the node by the scene rather than kept in React state. It moves sixty times a second, and re-rendering the route at frame rate to move one box is paying a component tree for arithmetic. It is clamped to the window, because a card about somebody standing at the edge of the view is otherwise a card half off the screen. Every fact carries a mark as well as a word. The mark is the faster read and the word is the unambiguous one; a card skimmed wants the first and a card read wants the second. Never the mark alone -- a glyph nobody has been taught is decoration. The class is the harness's own logo in a brass disc, which is the mark the session list uses and the mark over that figure's head on the map. A letter in a box would have been a third way of saying the same thing. And the facts now depend on what was clicked. A hero shows the company they command and what it is doing; a soldier shows whose company it is in. Both used to show "Posted to", which for a hero read "the field" -- a sentence that tells nobody anything. --- app/src/game/GameRoute.tsx | 33 ++++++++++++ app/src/game/pixi/keepScene.ts | 28 ++++++++++ app/src/game/ui/WrightPanel.tsx | 96 ++++++++++++++++++++++++++++----- app/src/styles/game.css | 53 +++++++++++++++--- 4 files changed, 189 insertions(+), 21 deletions(-) diff --git a/app/src/game/GameRoute.tsx b/app/src/game/GameRoute.tsx index 60c79f5..0bc89f0 100644 --- a/app/src/game/GameRoute.tsx +++ b/app/src/game/GameRoute.tsx @@ -190,6 +190,7 @@ export default function GameRoute() { * cost; everything else opens the menu where it was left. */ const [pauseAt, setPauseAt] = useState<"gathering" | undefined>(); + const handle = useRef({ sim: sim.current, select: () => {}, @@ -198,6 +199,36 @@ export default function GameRoute() { still: () => {}, }); handle.current.onPick = setPicked; + /* + * The inspect card follows the figure it is about. + * + * Its position is written straight onto the node by the scene, every frame, + * rather than kept in state: a card that re-rendered the route sixty times a + * second to move one box would be paying a component tree for arithmetic. + * It is also clamped to the window here, because a card about somebody + * standing at the edge of the view is a card half off the screen. + */ + const cardRef = useRef(null); + handle.current.onTrack = (at) => { + const card = cardRef.current; + if (!card) return; + if (!at) { + card.style.visibility = "hidden"; + return; + } + const box = card.getBoundingClientRect(); + const GAP = 28; + const left = Math.min( + Math.max(12, at.x + GAP), + Math.max(12, window.innerWidth - box.width - 12), + ); + const top = Math.min( + Math.max(12, at.y - box.height / 2), + Math.max(12, window.innerHeight - box.height - 12), + ); + card.style.visibility = "visible"; + card.style.transform = `translate(${Math.round(left)}px, ${Math.round(top)}px)`; + }; /* * Who is on the field: the account's live sessions, polled, with the @@ -325,7 +356,9 @@ export default function GameRoute() { {picked && ( { setPicked(undefined); diff --git a/app/src/game/pixi/keepScene.ts b/app/src/game/pixi/keepScene.ts index 08240f5..b4577f8 100644 --- a/app/src/game/pixi/keepScene.ts +++ b/app/src/game/pixi/keepScene.ts @@ -29,6 +29,15 @@ export interface KeepHandle { onPick?: (actor: Actor | undefined) => void; /** Called when the ground is clicked and the player's hero was sent there. */ onOrder?: (x: number, y: number) => void; + /** + * Where the inspected figure is on the canvas, every frame. + * + * Called rather than returned because the card follows a figure that walks: + * it has to be told sixty times a second, and routing that through React + * state would re-render the whole route at frame rate to move one box. + * `undefined` means nothing is inspected. + */ + onTrack?: (at: { x: number; y: number } | undefined) => void; select(id: string | undefined): void; /** What the player's own hero and their soldiers are drawn in. */ wear(skin: number, livery: number): void; @@ -204,6 +213,24 @@ export async function buildKeepScene( held = new Set([...sim.camps.values()].map((camp) => `${camp.x},${camp.y}`)); }; + /* + * Where the inspected figure is, in canvas pixels, reported every frame. + * + * `toScreen` here is pixi-viewport's, which is the world transform -- not the + * projection's `toScreen`, which turns tiles into world units. The figure's + * position has to go through both, in that order. + */ + const track = () => { + if (!handle.onTrack) return; + const chosen = selected ? sim.actors.find((actor) => actor.id === selected) : undefined; + if (!chosen) { + handle.onTrack(undefined); + return; + } + const world = toScreen(chosen.x, chosen.y); + handle.onTrack(viewport.toScreen(world.x, world.y)); + }; + let found = false; const findYou = () => { if (found) return; @@ -266,6 +293,7 @@ export async function buildKeepScene( } findYou(); heldCamps(); + track(); /* Only the camps somebody is actually holding are standing. */ for (const [key, camp] of camps) camp.visible = held.has(key); companies.sync(sim); diff --git a/app/src/game/ui/WrightPanel.tsx b/app/src/game/ui/WrightPanel.tsx index aea4cb1..c242daf 100644 --- a/app/src/game/ui/WrightPanel.tsx +++ b/app/src/game/ui/WrightPanel.tsx @@ -1,3 +1,5 @@ +import type React from "react"; +import { kindById } from "../../lib/session-kinds"; import { CLASS_LORE } from "../lore/world"; import { garrisonById } from "../world/marches"; import type { Actor } from "../world/sim"; @@ -32,23 +34,60 @@ const WORK_WORDS: Record = { export function WrightPanel({ actor, + field, now, onClose, onOpenSession, + cardRef, }: { actor: Actor; + /** Everybody on the field, for "whose company is this". */ + field: Actor[]; now: number; onClose: () => void; onOpenSession?: (sessionId: string) => void; + /** + * The card's own element, which the scene moves. + * + * It follows the figure it is about, sixty times a second, so its position is + * written straight onto the node rather than held in React state -- a card + * that re-rendered the route at frame rate to move one box would be paying a + * component tree for an arithmetic problem. + */ + cardRef?: React.Ref; }) { const lore = CLASS_LORE[actor.kind] ?? CLASS_LORE.terminal; const posting = garrisonById(actor.home); const work = WORK_WORDS[actor.work]; + const icon = kindById(actor.kind)?.icon; + /* + * Everybody on the field is handed in rather than looked up from a store, + * because the card is about one figure and this is the only thing it needs + * the rest of them for. + */ + const company = field.filter( + (one) => one.role === "soldier" && one.heroUid === actor.heroUid, + ); + const captain = field.find( + (one) => one.role === "hero" && one.heroUid === actor.heroUid, + ); return ( -