From 28a6198cc348c826d134421d48aa4c4fd5b9deae Mon Sep 17 00:00:00 2001 From: Teddy Date: Fri, 6 Nov 2026 22:12:25 +0100 Subject: [PATCH] feat: host bear war on the home page and restyle the site as a game console --- .github/workflows/gh-pages.yml | 8 + AGENTS.md | 2 +- gh-pages/.gitignore | 2 + gh-pages/README.md | 8 + gh-pages/bear-war/.dockerignore | 6 + gh-pages/bear-war/.editorconfig | 9 + gh-pages/bear-war/.gitignore | 8 + gh-pages/bear-war/CONTRIBUTING.md | 32 + gh-pages/bear-war/Dockerfile | 19 + gh-pages/bear-war/LICENSE | 21 + gh-pages/bear-war/README.md | 178 ++ gh-pages/bear-war/docs/ARCHITECTURE.md | 83 + gh-pages/bear-war/docs/UNITS.md | 227 +++ gh-pages/bear-war/docs/images/base-horde.jpg | Bin 0 -> 210967 bytes .../bear-war/docs/images/base-ironpaw.jpg | Bin 0 -> 167729 bytes gh-pages/bear-war/docs/images/battle.jpg | Bin 0 -> 124230 bytes gh-pages/bear-war/gallery.html | 18 + gh-pages/bear-war/index.html | 21 + gh-pages/bear-war/package-lock.json | 1547 +++++++++++++++++ gh-pages/bear-war/package.json | 46 + gh-pages/bear-war/public/apple-touch-icon.png | Bin 0 -> 12023 bytes gh-pages/bear-war/public/icon-192.png | Bin 0 -> 12786 bytes gh-pages/bear-war/public/icon-512.png | Bin 0 -> 35855 bytes .../bear-war/public/icon-maskable-512.png | Bin 0 -> 36906 bytes gh-pages/bear-war/public/icon.svg | 25 + gh-pages/bear-war/public/manifest.webmanifest | 20 + gh-pages/bear-war/scripts/battle.ts | 110 ++ gh-pages/bear-war/scripts/gen-docs.ts | 69 + gh-pages/bear-war/scripts/make-icons.ts | 125 ++ gh-pages/bear-war/scripts/mapview.ts | 34 + gh-pages/bear-war/scripts/simtest.ts | 66 + gh-pages/bear-war/server/index.ts | 309 ++++ gh-pages/bear-war/server/simhash.ts | 28 + gh-pages/bear-war/src/app/app.ts | 477 +++++ gh-pages/bear-war/src/app/gamescreen.ts | 348 ++++ gh-pages/bear-war/src/app/lobby.ts | 292 ++++ gh-pages/bear-war/src/app/settings.ts | 49 + gh-pages/bear-war/src/app/ui.ts | 85 + gh-pages/bear-war/src/audio/audio.ts | 595 +++++++ gh-pages/bear-war/src/dev/gallery.ts | 51 + gh-pages/bear-war/src/env.d.ts | 2 + gh-pages/bear-war/src/game/ai/ai.ts | 1274 ++++++++++++++ gh-pages/bear-war/src/game/ai/plans.ts | 189 ++ gh-pages/bear-war/src/game/commands.ts | 474 +++++ gh-pages/bear-war/src/game/constants.ts | 60 + gh-pages/bear-war/src/game/data/abilities.ts | 85 + gh-pages/bear-war/src/game/data/horde.ts | 269 +++ gh-pages/bear-war/src/game/data/index.ts | 114 ++ gh-pages/bear-war/src/game/data/ironpaw.ts | 296 ++++ gh-pages/bear-war/src/game/data/neutral.ts | 12 + gh-pages/bear-war/src/game/data/research.ts | 78 + gh-pages/bear-war/src/game/data/starborn.ts | 221 +++ gh-pages/bear-war/src/game/data/types.ts | 197 +++ gh-pages/bear-war/src/game/entity.ts | 197 +++ gh-pages/bear-war/src/game/game.ts | 617 +++++++ gh-pages/bear-war/src/game/grid.ts | 413 +++++ gh-pages/bear-war/src/game/map/builder.ts | 417 +++++ gh-pages/bear-war/src/game/map/gamemap.ts | 76 + gh-pages/bear-war/src/game/map/maps.ts | 104 ++ gh-pages/bear-war/src/game/math.ts | 96 + gh-pages/bear-war/src/game/player.ts | 87 + gh-pages/bear-war/src/game/rng.ts | 31 + gh-pages/bear-war/src/game/spatial.ts | 62 + gh-pages/bear-war/src/game/stats.ts | 108 ++ .../bear-war/src/game/systems/abilities.ts | 368 ++++ gh-pages/bear-war/src/game/systems/cargo.ts | 252 +++ gh-pages/bear-war/src/game/systems/comb.ts | 125 ++ gh-pages/bear-war/src/game/systems/combat.ts | 315 ++++ .../bear-war/src/game/systems/movement.ts | 167 ++ .../bear-war/src/game/systems/placement.ts | 78 + .../bear-war/src/game/systems/structures.ts | 368 ++++ gh-pages/bear-war/src/game/systems/units.ts | 778 +++++++++ gh-pages/bear-war/src/game/systems/vision.ts | 131 ++ gh-pages/bear-war/src/game/types.ts | 96 + gh-pages/bear-war/src/main.ts | 87 + gh-pages/bear-war/src/net/client.ts | 114 ++ gh-pages/bear-war/src/net/netsession.ts | 201 +++ gh-pages/bear-war/src/net/protocol.ts | 81 + gh-pages/bear-war/src/net/replay.ts | 113 ++ gh-pages/bear-war/src/net/room.ts | 412 +++++ gh-pages/bear-war/src/net/session.ts | 79 + gh-pages/bear-war/src/render/art/bear.ts | 98 ++ gh-pages/bear-war/src/render/art/horde.ts | 1078 ++++++++++++ gh-pages/bear-war/src/render/art/index.ts | 89 + gh-pages/bear-war/src/render/art/ironpaw.ts | 1217 +++++++++++++ gh-pages/bear-war/src/render/art/kit.ts | 196 +++ gh-pages/bear-war/src/render/art/neutral.ts | 62 + gh-pages/bear-war/src/render/art/starborn.ts | 821 +++++++++ gh-pages/bear-war/src/render/camera.ts | 80 + gh-pages/bear-war/src/render/effects.ts | 536 ++++++ gh-pages/bear-war/src/render/minimap.ts | 142 ++ gh-pages/bear-war/src/render/noise.ts | 72 + gh-pages/bear-war/src/render/renderer.ts | 1057 +++++++++++ gh-pages/bear-war/src/render/sprites.ts | 98 ++ gh-pages/bear-war/src/render/terrain.ts | 510 ++++++ gh-pages/bear-war/src/style.css | 1263 ++++++++++++++ gh-pages/bear-war/src/ui/commandcard.ts | 297 ++++ gh-pages/bear-war/src/ui/controller.ts | 469 +++++ gh-pages/bear-war/src/ui/hud.ts | 710 ++++++++ gh-pages/bear-war/src/ui/icons.ts | 77 + gh-pages/bear-war/src/ui/input.ts | 491 ++++++ gh-pages/bear-war/src/ui/selection.ts | 106 ++ gh-pages/bear-war/tests/abilities.test.ts | 260 +++ gh-pages/bear-war/tests/cargo.test.ts | 233 +++ gh-pages/bear-war/tests/room.test.ts | 166 ++ gh-pages/bear-war/tests/sim.test.ts | 186 ++ gh-pages/bear-war/tests/units.test.ts | 171 ++ gh-pages/bear-war/tsconfig.json | 20 + gh-pages/bear-war/vite.config.ts | 90 + gh-pages/docusaurus.config.ts | 36 +- gh-pages/package.json | 2 +- .../src/client-modules/force-home-dark.ts | 8 - gh-pages/src/components/audience-chart.tsx | 22 - gh-pages/src/components/home-chart-toggle.tsx | 38 - .../components/home-dark-mode-enforcer.tsx | 7 - gh-pages/src/components/icon-chart.tsx | 12 - gh-pages/src/components/latest-news.tsx | 81 +- gh-pages/src/css/custom.css | 846 ++++----- gh-pages/src/hooks/use-chart-interactive.ts | 33 - gh-pages/src/hooks/use-home-dark-mode.ts | 47 - gh-pages/src/pages/index.tsx | 150 +- gh-pages/src/pages/support-us.tsx | 2 + gh-pages/src/theme/Footer/index.tsx | 24 + .../theme/Navbar/ColorModeToggle/index.tsx | 20 - gh-pages/src/theme/Navbar/Content/index.tsx | 65 + gh-pages/tsconfig.json | 2 +- 126 files changed, 25259 insertions(+), 893 deletions(-) create mode 100644 gh-pages/bear-war/.dockerignore create mode 100644 gh-pages/bear-war/.editorconfig create mode 100644 gh-pages/bear-war/.gitignore create mode 100644 gh-pages/bear-war/CONTRIBUTING.md create mode 100644 gh-pages/bear-war/Dockerfile create mode 100644 gh-pages/bear-war/LICENSE create mode 100644 gh-pages/bear-war/README.md create mode 100644 gh-pages/bear-war/docs/ARCHITECTURE.md create mode 100644 gh-pages/bear-war/docs/UNITS.md create mode 100644 gh-pages/bear-war/docs/images/base-horde.jpg create mode 100644 gh-pages/bear-war/docs/images/base-ironpaw.jpg create mode 100644 gh-pages/bear-war/docs/images/battle.jpg create mode 100644 gh-pages/bear-war/gallery.html create mode 100644 gh-pages/bear-war/index.html create mode 100644 gh-pages/bear-war/package-lock.json create mode 100644 gh-pages/bear-war/package.json create mode 100644 gh-pages/bear-war/public/apple-touch-icon.png create mode 100644 gh-pages/bear-war/public/icon-192.png create mode 100644 gh-pages/bear-war/public/icon-512.png create mode 100644 gh-pages/bear-war/public/icon-maskable-512.png create mode 100644 gh-pages/bear-war/public/icon.svg create mode 100644 gh-pages/bear-war/public/manifest.webmanifest create mode 100644 gh-pages/bear-war/scripts/battle.ts create mode 100644 gh-pages/bear-war/scripts/gen-docs.ts create mode 100644 gh-pages/bear-war/scripts/make-icons.ts create mode 100644 gh-pages/bear-war/scripts/mapview.ts create mode 100644 gh-pages/bear-war/scripts/simtest.ts create mode 100644 gh-pages/bear-war/server/index.ts create mode 100644 gh-pages/bear-war/server/simhash.ts create mode 100644 gh-pages/bear-war/src/app/app.ts create mode 100644 gh-pages/bear-war/src/app/gamescreen.ts create mode 100644 gh-pages/bear-war/src/app/lobby.ts create mode 100644 gh-pages/bear-war/src/app/settings.ts create mode 100644 gh-pages/bear-war/src/app/ui.ts create mode 100644 gh-pages/bear-war/src/audio/audio.ts create mode 100644 gh-pages/bear-war/src/dev/gallery.ts create mode 100644 gh-pages/bear-war/src/env.d.ts create mode 100644 gh-pages/bear-war/src/game/ai/ai.ts create mode 100644 gh-pages/bear-war/src/game/ai/plans.ts create mode 100644 gh-pages/bear-war/src/game/commands.ts create mode 100644 gh-pages/bear-war/src/game/constants.ts create mode 100644 gh-pages/bear-war/src/game/data/abilities.ts create mode 100644 gh-pages/bear-war/src/game/data/horde.ts create mode 100644 gh-pages/bear-war/src/game/data/index.ts create mode 100644 gh-pages/bear-war/src/game/data/ironpaw.ts create mode 100644 gh-pages/bear-war/src/game/data/neutral.ts create mode 100644 gh-pages/bear-war/src/game/data/research.ts create mode 100644 gh-pages/bear-war/src/game/data/starborn.ts create mode 100644 gh-pages/bear-war/src/game/data/types.ts create mode 100644 gh-pages/bear-war/src/game/entity.ts create mode 100644 gh-pages/bear-war/src/game/game.ts create mode 100644 gh-pages/bear-war/src/game/grid.ts create mode 100644 gh-pages/bear-war/src/game/map/builder.ts create mode 100644 gh-pages/bear-war/src/game/map/gamemap.ts create mode 100644 gh-pages/bear-war/src/game/map/maps.ts create mode 100644 gh-pages/bear-war/src/game/math.ts create mode 100644 gh-pages/bear-war/src/game/player.ts create mode 100644 gh-pages/bear-war/src/game/rng.ts create mode 100644 gh-pages/bear-war/src/game/spatial.ts create mode 100644 gh-pages/bear-war/src/game/stats.ts create mode 100644 gh-pages/bear-war/src/game/systems/abilities.ts create mode 100644 gh-pages/bear-war/src/game/systems/cargo.ts create mode 100644 gh-pages/bear-war/src/game/systems/comb.ts create mode 100644 gh-pages/bear-war/src/game/systems/combat.ts create mode 100644 gh-pages/bear-war/src/game/systems/movement.ts create mode 100644 gh-pages/bear-war/src/game/systems/placement.ts create mode 100644 gh-pages/bear-war/src/game/systems/structures.ts create mode 100644 gh-pages/bear-war/src/game/systems/units.ts create mode 100644 gh-pages/bear-war/src/game/systems/vision.ts create mode 100644 gh-pages/bear-war/src/game/types.ts create mode 100644 gh-pages/bear-war/src/main.ts create mode 100644 gh-pages/bear-war/src/net/client.ts create mode 100644 gh-pages/bear-war/src/net/netsession.ts create mode 100644 gh-pages/bear-war/src/net/protocol.ts create mode 100644 gh-pages/bear-war/src/net/replay.ts create mode 100644 gh-pages/bear-war/src/net/room.ts create mode 100644 gh-pages/bear-war/src/net/session.ts create mode 100644 gh-pages/bear-war/src/render/art/bear.ts create mode 100644 gh-pages/bear-war/src/render/art/horde.ts create mode 100644 gh-pages/bear-war/src/render/art/index.ts create mode 100644 gh-pages/bear-war/src/render/art/ironpaw.ts create mode 100644 gh-pages/bear-war/src/render/art/kit.ts create mode 100644 gh-pages/bear-war/src/render/art/neutral.ts create mode 100644 gh-pages/bear-war/src/render/art/starborn.ts create mode 100644 gh-pages/bear-war/src/render/camera.ts create mode 100644 gh-pages/bear-war/src/render/effects.ts create mode 100644 gh-pages/bear-war/src/render/minimap.ts create mode 100644 gh-pages/bear-war/src/render/noise.ts create mode 100644 gh-pages/bear-war/src/render/renderer.ts create mode 100644 gh-pages/bear-war/src/render/sprites.ts create mode 100644 gh-pages/bear-war/src/render/terrain.ts create mode 100644 gh-pages/bear-war/src/style.css create mode 100644 gh-pages/bear-war/src/ui/commandcard.ts create mode 100644 gh-pages/bear-war/src/ui/controller.ts create mode 100644 gh-pages/bear-war/src/ui/hud.ts create mode 100644 gh-pages/bear-war/src/ui/icons.ts create mode 100644 gh-pages/bear-war/src/ui/input.ts create mode 100644 gh-pages/bear-war/src/ui/selection.ts create mode 100644 gh-pages/bear-war/tests/abilities.test.ts create mode 100644 gh-pages/bear-war/tests/cargo.test.ts create mode 100644 gh-pages/bear-war/tests/room.test.ts create mode 100644 gh-pages/bear-war/tests/sim.test.ts create mode 100644 gh-pages/bear-war/tests/units.test.ts create mode 100644 gh-pages/bear-war/tsconfig.json create mode 100644 gh-pages/bear-war/vite.config.ts delete mode 100644 gh-pages/src/client-modules/force-home-dark.ts delete mode 100644 gh-pages/src/components/audience-chart.tsx delete mode 100644 gh-pages/src/components/home-chart-toggle.tsx delete mode 100644 gh-pages/src/components/home-dark-mode-enforcer.tsx delete mode 100644 gh-pages/src/components/icon-chart.tsx delete mode 100644 gh-pages/src/hooks/use-chart-interactive.ts delete mode 100644 gh-pages/src/hooks/use-home-dark-mode.ts create mode 100644 gh-pages/src/theme/Footer/index.tsx delete mode 100644 gh-pages/src/theme/Navbar/ColorModeToggle/index.tsx create mode 100644 gh-pages/src/theme/Navbar/Content/index.tsx diff --git a/.github/workflows/gh-pages.yml b/.github/workflows/gh-pages.yml index d9e4d33d..93b52156 100644 --- a/.github/workflows/gh-pages.yml +++ b/.github/workflows/gh-pages.yml @@ -45,6 +45,14 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile + - name: Build Bear War + working-directory: gh-pages/bear-war + run: | + npm ci + npm run typecheck + npm test + npm run build -- --outDir ../static/bear-war --emptyOutDir + - name: Build Docusaurus site run: pnpm run build diff --git a/AGENTS.md b/AGENTS.md index 671e5fd5..72e862d1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,7 @@ Local Desktop is an Android app, written in Rust, that runs Arch Linux ARM in pr | `src/bin/` | `build_apk` (on-device builder), `build_docs` (PDF manuals), the PipeWire AAudio sink | | `manifest.yaml` | Android manifest/config for xbuild. **The package name comes from here**; don't hard-code it | | `patches/` | Vendored, patched xbuild, smithay, winit, proot and libxkbcommon builds, plus the guest's GPU Mesa (`build-mesa`) | -| `gh-pages/` | Docusaurus website: `blog/`, `docs/user/`, `docs/developer/` | +| `gh-pages/` | Docusaurus website: `blog/`, `docs/user/`, `docs/developer/`, plus `bear-war/`, the game shown on the home page (CI builds it into `static/bear-war/`) | | `vscode-extension/` | The "Local Desktop Dev" editor extension and the device tools described below | ## Build and run diff --git a/gh-pages/.gitignore b/gh-pages/.gitignore index b2d6de30..36417f5b 100644 --- a/gh-pages/.gitignore +++ b/gh-pages/.gitignore @@ -3,9 +3,11 @@ # Production /build +/static/bear-war # Generated files .docusaurus +.docusaurus-build .cache-loader # Misc diff --git a/gh-pages/README.md b/gh-pages/README.md index 00baa43c..f4ba9c17 100644 --- a/gh-pages/README.md +++ b/gh-pages/README.md @@ -16,6 +16,14 @@ $ pnpm start This command starts a local development server and opens up a browser window. Most changes are reflected live without having to restart the server. +### Bear War + +The home page embeds the game from `bear-war/`. Its build lives in `static/bear-war/` (gitignored), so build it once, and again after changing the game: + +``` +$ cd bear-war && npm ci && npm run build -- --outDir ../static/bear-war --emptyOutDir +``` + ### Build ``` diff --git a/gh-pages/bear-war/.dockerignore b/gh-pages/bear-war/.dockerignore new file mode 100644 index 00000000..6a768c93 --- /dev/null +++ b/gh-pages/bear-war/.dockerignore @@ -0,0 +1,6 @@ +node_modules +dist +.git +.claude +coverage +*.log diff --git a/gh-pages/bear-war/.editorconfig b/gh-pages/bear-war/.editorconfig new file mode 100644 index 00000000..86a63dc0 --- /dev/null +++ b/gh-pages/bear-war/.editorconfig @@ -0,0 +1,9 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +indent_size = 2 +insert_final_newline = true +trim_trailing_whitespace = true diff --git a/gh-pages/bear-war/.gitignore b/gh-pages/bear-war/.gitignore new file mode 100644 index 00000000..ccfca8f1 --- /dev/null +++ b/gh-pages/bear-war/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +coverage/ +*.log +.DS_Store +.vite/ +.env +.env.* diff --git a/gh-pages/bear-war/CONTRIBUTING.md b/gh-pages/bear-war/CONTRIBUTING.md new file mode 100644 index 00000000..e9832030 --- /dev/null +++ b/gh-pages/bear-war/CONTRIBUTING.md @@ -0,0 +1,32 @@ +# Contributing to Bear War + +Thanks for helping! A few rules keep the game open, legal and in sync across players. + +## Ground rules + +1. **No third-party or copyrighted assets.** Art is drawn procedurally in `src/render/art/*`, sounds are synthesized in `src/audio/audio.ts`. If you really need an external asset it must be CC0 / public domain and listed in the README's *Assets & licensing* section. Never use names, art, sounds or data from commercial games. +2. **Original names only.** New clans, bears, abilities and maps need original names. +3. **The simulation must stay deterministic** (see below). `npm test` enforces part of this. +4. Run `npm run typecheck && npm test` before opening a pull request. For gameplay changes also run a few AI games (`npm run simtest`) and, for balance changes, `node scripts/battle.ts`. + +## Determinism rules for `src/game` + +Every client runs the same simulation from the same commands (lockstep). Anything that can differ between machines will desync multiplayer games. + +- No `Math.random()`: use `game.rng` (or a dedicated seeded `Rng`). +- No `Math.sin/cos/tan/atan/atan2/exp/log/pow/hypot/cbrt`: their last bits differ between browsers. Use `dsin`, `dcos`, `datan2`, `dist` from `src/game/math.ts`. (`+ - * /`, `Math.sqrt`, `Math.floor/round/abs/min/max` are exact and fine.) +- No `Date`, `performance.now()`, timers, DOM, or anything from `src/render`/`src/ui`. +- Iterate collections in a deterministic order (arrays by id/creation order; `Map`/`Set` insertion order is fine if insertions are deterministic). Sort comparators must break ties (usually by `id`). +- Commands arrive from the network: validate everything in `src/game/commands.ts` (ownership, types, ranges). New command fields also need adding to `sanitizeCommand` in `src/net/room.ts`, which drops unknown fields before relaying. +- Any change under `src/game` changes the simulation fingerprint, so servers turn away clients built from other code. You don't need to do anything for that. Bump `PROTOCOL_VERSION` in `src/net/protocol.ts` only when the wire messages change. + +## Adding content + +- **Units/structures:** add a definition to `src/game/data/.ts`. Data validation (`validateData`) runs at startup and in tests. Add art to `src/render/art/.ts` (shared bear bodies live in `bear.ts`; check it in `/gallery.html`), regenerate `docs/UNITS.md` with `node scripts/gen-docs.ts`. Unique behaviour goes in `src/game/systems/*`. +- **Abilities:** define in `src/game/data/abilities.ts`, implement in `src/game/systems/abilities.ts`, optionally teach the AI in `src/game/ai/ai.ts` (`micro`). +- **Maps:** write a builder function in `src/game/map/maps.ts` using the symmetric DSL (`setElev`, `addRamp`, `addRock`, `addWater`, `base`, …) and iterate with `node scripts/mapview.ts ` until there are no warnings. Tests check every map. +- **AI:** clan strategies (openings, compositions, production) live in `src/game/ai/plans.ts`. + +## Code style + +TypeScript, ES modules with explicit `.ts` import extensions (Node runs the server and scripts directly). Keep the simulation free of allocations in hot loops where practical, it has to run on phones. diff --git a/gh-pages/bear-war/Dockerfile b/gh-pages/bear-war/Dockerfile new file mode 100644 index 00000000..f1146e1c --- /dev/null +++ b/gh-pages/bear-war/Dockerfile @@ -0,0 +1,19 @@ +# Bear War: game client + multiplayer relay server in one small image. +FROM node:24-alpine AS build +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM node:24-alpine +WORKDIR /app +ENV NODE_ENV=production PORT=8787 +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev +COPY --from=build /app/dist ./dist +COPY server ./server +COPY src ./src +EXPOSE 8787 +USER node +CMD ["node", "server/index.ts"] diff --git a/gh-pages/bear-war/LICENSE b/gh-pages/bear-war/LICENSE new file mode 100644 index 00000000..19bcceae --- /dev/null +++ b/gh-pages/bear-war/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Astral Front contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/gh-pages/bear-war/README.md b/gh-pages/bear-war/README.md new file mode 100644 index 00000000..fc337a96 --- /dev/null +++ b/gh-pages/bear-war/README.md @@ -0,0 +1,178 @@ +# Bear War + +**An open-source real-time strategy game for desktop and mobile browsers.** Three clans of bears, skirmish against computer opponents (works offline), and online multiplayer. Play in a browser with nothing to install, or install it as an app. + +Bear War follows the classic competitive RTS formula: harvest two resources, manage supply, climb a tech tree, scout through fog of war, fight for high ground and expansions, and out-macro and out-micro your opponent. Every clan, bear, name, graphic and sound in the game is original. Graphics are drawn procedurally in code and audio is synthesized at runtime, so **the project ships no third-party art, audio, or fonts**. + +

+ A Honey Horde base on honeycomb + An Ironpaw base built by the AI + Starborn bears clash with Ironpaw infantry and Kodiaks +

+ +> Bear War is an independent project, inspired by the gameplay of classic RTS games such as StarCraft. It is not affiliated with, endorsed by, or connected to Blizzard Entertainment. StarCraft is a trademark of Blizzard Entertainment, Inc. No Blizzard assets, names, code or data are used. + +--- + +## Features + +- **Three asymmetric clans**, from 8 px cubs to Cave Titans that fill a ramp + - **Ironpaw**: hard-working brown bears of the timber country. Hardhats construct buildings and must stay until they're finished; they can repair machines. Damaged buildings burn down. Infantry can garrison Bunkers. Rangers with Honey Rush, Grizzlies, Honey Medics, Blaze Riders that hop off to fight as Blaze Brutes, Sappers that dig in and wait, Kodiaks that brace into artillery, Trackers that lock on, Hoverbears that anchor over a zone, Bearloon drop balloons and the Iron Mama flagship with its Grand Cannon. + - **Honey Horde**: feral bears living alongside giant bees. Hives raise cubs that grow into any bear, and Foragers are used up to grow structures on spreading honeycomb. Every Horde bear heals over time. Scrappers grow into Buzzbombs, Stingers into Bombardiers (Scalding Honey) or Burrowers (hibernate and erupt spines in a line), Den Mothers release Wild Cubs, Longtongues yank enemies and blind gunners with pollen, and Bumblebears become transports once Cargo Pouch is researched. + - **Starborn**: mystic polar, spirit and moon bears. Snowcubs call buildings down from the sky and are free immediately. Structures need a Star Totem's glow. Every bear wears a regenerating aurora shield. Spirit Bears, Moon Bears with Moonstep, Glacier Bears with Spirit Walk, Panda Sages with Starfall, hidden Shadowpaws, Seers with a Sunlance, Snowballers, Polar Colossi and the Great Bear constellation. +- **Classic macro and micro** + - Mining with saturation and gas extraction, supply, production queues, rally points (rallying to minerals makes new workers mine automatically), and weapon/armor upgrades. + - Royal Jelly, Moonlight Haste, Big Sniff. + - High-ground vision, hiding and detection, burrowing, mode switches, splash and line damage, bonus damage vs. light/armored, shields, energy and spells. + - **Drops and garrisons**: transports carry 8 cargo slots of ground units (small units take 1, medium 2, large 4) over cliffs and water. Infantry inside a Bunker fire out with +1 range and can't be targeted. +- **Controls designed for both touch and mouse/keyboard**: one-finger pan, pinch zoom, tap-to-command, long-press box select, quick-select army and idle workers, control groups. On desktop you get full hotkeys, shift-queuing, edge scrolling and the usual mouse controls. +- **Skirmish vs AI** at four difficulty levels (Easy to Brutal), on 1v1, 2v2 or free-for-all maps. The AI scouts, expands, techs, builds counters, attacks in waves, defends, digs in, anchors and casts spells. +- **Online multiplayer** using deterministic lockstep networking. A tiny relay server handles lobbies, invite links, chat, lag pausing, disconnect handling and desync detection. +- **Works offline / installable (PWA).** After the first visit everything is cached. +- **Replays.** Save any game from the end screen or pause menu and watch it later with full vision or one team's vision, at 0.5x to 32x speed or on Auto, which fast-forwards quiet stretches and slows down for fights. Replays work because the simulation is deterministic: a replay is just the seed plus the command log. +- **Three maps** (Twin Craters, Frostline, Four Corners) with symmetric layouts, ramps, cliffs, chokes and expansions, plus a small DSL for making more. + +## Quick start + +Requires **Node.js 22.18+** (the server runs TypeScript directly). + +```bash +npm install +``` + +```bash +npm run dev +``` + +Open http://localhost:5173 to play. For multiplayer while developing, also run the relay server in a second terminal: + +```bash +npm run dev:server +``` + +To run the production build (the server serves the game and the multiplayer lobby on one port, default 8787): + +```bash +npm run build +``` + +```bash +npm start +``` + +Developer shortcut: `http://localhost:5173/?quick&race=horde&vs=starborn&ai=hard&map=frostline` jumps straight into a skirmish, and `?attract` runs the muted AI-vs-AI spectator demo shown on the Local Desktop home page. + +## How to play + +Destroy every enemy structure. Workers mine **minerals** and **gas** (gas needs a refinery-type building on a geyser); two to three workers per mineral field is ideal. Units cost **supply**, so stock up ahead of time (Larders, Bumblebears, Star Totems). + +| Touch | | +|---|---| +| Tap own unit/building | Select (double-tap: all of that type on screen) | +| Tap ground / enemy / minerals with units selected | Move / attack / gather | +| Tap own transport or Bunker with units selected | Load them in (the transport's **Unload** button drops them) | +| One-finger drag | Pan the camera | +| Pinch | Zoom | +| Long-press + drag, or the ▢ button | Box select | +| Long-press a command button | Show details & costs | +| Group buttons 1-5 | Tap to select, long-press to assign | +| Army / worker buttons | Select whole army / idle worker (long-press: all idle workers) | +| Building placement | Drag the ghost, then tap **✓ Build** | + +| Mouse & keyboard | | +|---|---| +| Left-click / drag | Select / box select (Shift adds, Ctrl or double-click selects all of a type) | +| Right-click | Smart command: move, attack, gather, repair, load into a transport or Bunker, set rally (Shift queues) | +| Command card | The character on each button. Keys follow the left-hand block, so they match your keyboard layout (QWERTY: `Q W E R` / `A S D F` / `Z X C V`) | +| Transport | Load and unload are on the command card (click a passenger's icon to drop just that unit) | +| Ctrl+`1-9` / `1-9` | Set / recall control group (press twice to jump to it) | +| `F1` / `F2` | Idle worker / select army | +| `Tab`, `Space`, `Backspace` | Cycle subgroup, jump to last alert, cycle bases | +| Arrow keys, screen edges, middle-drag, wheel | Scroll and zoom | +| `Esc` | Cancel targeting / placement, or the last queued item | + +The full stats for every unit, structure and upgrade are in **[docs/UNITS.md](docs/UNITS.md)**. + +## Multiplayer + +Online games use **deterministic lockstep** networking. The server never simulates the game. It collects each player's commands, groups them into 100 ms *turns*, and broadcasts every turn to every client. Each client runs the identical simulation, which keeps bandwidth tiny (a few hundred bytes per second) and makes cheating by editing game state impossible. Clients exchange state hashes so any desync is detected. + +Because every client must run *exactly* the same simulation, the build embeds a fingerprint of the simulation source (`src/game`). The server computes the same fingerprint from its own copy and turns away clients that differ, so a browser tab left open across a deploy gets a "please reload" message instead of a desynced game. Deploy the client and the server from the same commit. + +**Hosting a server.** `npm run build && npm start` serves the game and the lobby (WebSocket at `/ws`) on `PORT` (default 8787). Any Node host works: a VPS, Fly.io, Render, Railway, or a Raspberry Pi. A [Dockerfile](Dockerfile) is included: + +```bash +docker build -t bear-war . +``` + +```bash +docker run -p 8787:8787 bear-war +``` + +**Static hosting + separate server.** The client is fully static and can be hosted anywhere, such as GitHub Pages. Point it at your server at build time with `VITE_SERVER_URL=wss://your-server.example/ws npm run build`. Players can also type a server address in the Multiplayer screen, or open an invite link with `?server=...`. + +Lobby features: +- Public game list, or 5-letter join codes and shareable invite links. +- Clan, colour and team selection, and AI slots in online games. +- Chat, kicking, host migration. +- Automatic pausing when a player lags. +- **Automatic reconnection** when a phone drops its connection mid-game (the game waits up to 45 s, then resumes exactly where the player left off). + +## Development + +```text +src/ + game/ deterministic simulation (no DOM): data, map, pathfinding, systems, AI, commands + data/ bears, structures, research and abilities for all clans (pure data) + systems/ combat, movement, units (orders), structures (production), vision, honeycomb, abilities + ai/ computer opponent (runs inside the simulation) + map/ map format, symmetric map builder DSL, map definitions + render/ canvas renderer, procedural terrain, sprites (art/*), effects, minimap + ui/ HUD, command card, selection, input (mouse/keyboard/touch) + audio/ Web Audio synthesizer and generative music + net/ lockstep sessions, WebSocket client, protocol, room/turn clock + app/ menus, lobby, game screen +server/ Node relay + static file server +scripts/ dev tools (see below) +tests/ vitest suites +``` + +| Command | What it does | +|---|---| +| `npm run dev` / `npm run dev:server` | Dev client with hot reload / multiplayer server with auto-restart | +| `npm test` | Unit tests: data integrity, deterministic math, determinism, pathfinding, maps, abilities, transports, lobby/turn protocol | +| `npm run typecheck` | TypeScript type check (client, server, scripts, tests) | +| `npm run simtest -- 24000 7 horde starborn hard` | Headless AI-vs-AI game (`VERBOSE=1` for a timeline, `MAP=frostline` for another map) | +| `node scripts/battle.ts` | Balance lab: equal-cost armies fight on an open arena | +| `node scripts/mapview.ts frostline` | Print a map as ASCII while designing it | +| `node scripts/gen-docs.ts` | Regenerate [docs/UNITS.md](docs/UNITS.md) from the data | +| `npm run icons` | Regenerate the PNG app icons (procedurally) | +| `/gallery.html` (dev server) | Sprite gallery of every unit and structure | + +**The simulation must stay deterministic.** Everything in `src/game` runs identically on every client. No `Math.random`, `Math.sin/cos/atan2/pow/hypot/...`, or wall-clock time is allowed there; use `src/game/math.ts` and the seeded RNG instead. A test enforces this. See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for how the lockstep model, pathfinding, fog of war and the AI work, and [CONTRIBUTING.md](CONTRIBUTING.md) before sending changes. + +## Assets & licensing + +- **Code:** MIT (see [LICENSE](LICENSE)). +- **Graphics:** every sprite, terrain texture, icon and effect is generated by code in `src/render` (and `scripts/make-icons.ts` for the app icons). They're covered by the same MIT license. +- **Audio:** all sound effects and music are synthesized at runtime by `src/audio/audio.ts`. No audio files exist. +- **Spoken alerts** are optional and use the browser's built-in speech synthesis. No voice recordings are shipped. +- **Fonts:** the system UI font stack. No font files are bundled. +- **Dependencies:** + - Runtime: [`ws`](https://github.com/websockets/ws) (MIT, server only). + - Build/dev tools: [Vite](https://vite.dev) (MIT), [TypeScript](https://www.typescriptlang.org) (Apache-2.0), and [Vitest](https://vitest.dev) (MIT). Their transitive dependencies are MIT, ISC, BSD and MPL-2.0 (`lightningcss`, build-time only). None are bundled into the game except what Vite emits from this repository's own code. +- **Names:** all clan, unit, structure, ability and map names are original to this project. + +Contributions must keep this property: no third-party assets unless they're CC0/public domain and recorded in this section. + +## Roadmap + +- Observer slots in online lobbies, and rejoining after a full page reload (today: reconnects while the page stays open). +- Peer-to-peer (WebRTC) games without a relay server. +- More maps and a map editor. Lifting off buildings. +- Teach the computer opponent drops and Bunker play. +- Continued balance tuning (`scripts/battle.ts` and AI-vs-AI runs are the starting points). + +## License + +[MIT](LICENSE) © Bear War contributors diff --git a/gh-pages/bear-war/docs/ARCHITECTURE.md b/gh-pages/bear-war/docs/ARCHITECTURE.md new file mode 100644 index 00000000..3d92933a --- /dev/null +++ b/gh-pages/bear-war/docs/ARCHITECTURE.md @@ -0,0 +1,83 @@ +# Architecture + +Bear War is a single TypeScript codebase: a deterministic simulation (`src/game`) plus a browser client (rendering, UI, audio, networking) and a tiny Node relay server. + +``` + ┌──────────── browser ────────────────────────────────┐ + input ───▶ │ ui/Input → ui/Controller ──Command──▶ net/Session │ + │ │ (Local or │ + │ │ Net) │ + │ game/Game.step(commands) ◀─┘ │ + │ │ events │ + │ render/Renderer, render/Effects, audio/AudioEngine │ + └────────────────────────────────────────────────────┘ + ▲ turns │ commands + │ ▼ + server/index.ts + net/Room (turn clock, lobby) +``` + +## Simulation (`src/game`) + +- **Fixed timestep**: 20 ticks per game second (`TICK_RATE`). The renderer interpolates positions between ticks (`px/py → x/y`). +- **Entities** (`entity.ts`) are plain objects for units, structures and resources. `Game.entities` is kept in creation order, so iteration order is deterministic everywhere. +- **Tick order** (`Game.step`): apply commands → AI → structures (production, construction, cubs, regen, timers) → units (orders, targeting, firing) → movement & collision → projectiles → area effects → cleanup → vision (every 4 ticks) → honeycomb (every 5) → victory check. +- **Commands** (`types.ts`, `commands.ts`) are high-level player intents ("smart" right-click, train, build, ability…). They're the only input to the simulation and the only thing sent over the network. All validation happens inside the simulation. +- **Determinism**: only exactly-specified floating point operations are used (`+ - * / sqrt`). Trigonometry uses polynomial implementations in `math.ts`. Randomness comes from a seeded PRNG. `Game.hash()` summarises the state for desync detection. + +### Pathfinding and movement + +- The walk grid (`grid.ts`) uses 16 px cells (2×2 per tile) with a **clearance map** (Euclidean distance transform to the nearest obstacle), so big bears avoid gaps they don't fit through and still fit down diagonal ramps. +- **Weighted A\*** (octile, 8-way, no corner cutting), followed by line-of-sight **string pulling** for natural any-angle paths. If the goal is unreachable, the unit walks to the closest reachable point. +- **Path sharing**: units heading to the same region reuse a recent path when they can see onto it, and a per-tick node budget prevents frame spikes when 200 units are ordered at once. +- **Local movement**: units steer toward waypoints, then a pairwise separation pass pushes overlapping units apart. Idle units yield to moving ones, units holding position stand firm, and bigger units push harder. Mining workers phase through other units. Groups use "magic box" formations and stop when they bump into an arrived group-mate. + +### Combat, vision and clan mechanics + +- Weapons have damage, bonus vs. attributes (light / armored / bio / mech / massive / structure), multi-hit, splash rings, bounces, slows, line hits, units-only targeting and anchor zones. Armor reduces each hit (minimum 0.5); shields absorb first and regenerate after 10 s out of combat. +- **Fog of war** is computed per team at tile resolution. Ground units can't see onto higher elevation, so high ground matters. Detection is a separate grid that reveals hidden units. +- **Modes** (`base` in the data, `mode` on abilities): a Kodiak bracing, a Sapper digging in or a Hoverbear anchoring switches its def over a few seconds. Modes share selection groups and command cards. Burrowed modes are hidden and other units walk over them. +- **Honey Horde**: cubs (3 per Hive, more with Royal Jelly), cocoons, honeycomb that grows and recedes, building-on-honeycomb rules, healing over time, unit growth (Scrapper to Buzzbomb, Stinger to Bombardier or Burrower), timed Wild Cubs. +- **Starborn**: Star Totem glow fields, called-down construction, aurora shields, Moonlight Haste, travelling effects (spirits, snowballs) that follow a path and resolve when they expire. +- **Ironpaw**: hardhats that stay to construct (and resume construction), repair, burning buildings, lock-on damage over time. +- **Area effects** (`systems/abilities.ts`) cover Big Sniff, Bee Cloud, Sting Swarm, Starfall, Pollen Cloud, Hornet Bomb, Scalding Honey, Spirit Walk and the Star Snowball. +- **Transports and bunkers** (`systems/cargo.ts`): a carried unit stays in the entity list with `inside` set (like a worker inside a refinery), so it still counts for supply, but nothing can see, hit or collide with it, and it follows its carrier's position. Units walk to a transport while an idle transport flies to meet them. Transports drop one unit every few ticks at the nearest walkable point. A destroyed transport kills its cargo; a destroyed bunker lets its units out. Units in a bunker pick targets and fire from its walls with bonus range. + +### AI (`src/game/ai`) + +The computer opponent runs inside the simulation on every client, so AI games need no extra network traffic. Each think cycle (every 8-40 ticks depending on difficulty) it: + +1. Observes what its team can see. It remembers enemy structures and gauges enemy air/light/armored counts. +2. Runs the economy: assigns idle workers, balances gas, trains workers up to a saturation target, and resumes abandoned construction. +3. Keeps supply ahead of demand. +4. Executes a clan-specific **opening build order** (`plans.ts`) that tolerates failures, then macros: more production, expansions (ranked by ground distance), extra refineries, detection when hidden units appear, and upgrades. +5. Produces units by filling the most under-represented slot of a counter-weighted **composition**. +6. Controls the army through gather → attack → retreat/defend states. It re-targets the nearest known enemy structure and sweeps base locations when nothing is known. +7. Micros on Normal and above: bracing, digging in, anchoring, hibernating, Lock On, Honey Rush, Starfall/Sting Swarm/Star Snowball/Scalding Honey on clusters, Bee Cloud, Pollen Cloud, Yank, Hornet Bomb, Shield Bubble, the Grand Cannon, Sunlance, Big Sniff versus hidden units. It also grows Buzzbombs, Bombardiers and Burrowers. + +## Client + +- **Renderer** (`render/`): Canvas 2D. + - Terrain is hill-shaded per pixel from a smoothed elevation field and cached in 256 px chunks (with honeycomb overlays). + - Sprites are drawn procedurally once per (type, team colour, animation frame) and then blitted with rotation. + - Fog is a 1-pixel-per-tile alpha texture scaled up with smoothing. + - Particles use cached glow sprites. +- **UI** (`ui/`): a DOM HUD (resources, minimap, selection panel, 4×3 command card, control groups) driven by `Controller`, which turns intents into commands. `Input` handles mouse, keyboard and touch gestures. +- **Audio** (`audio/`): every sound is a small Web Audio graph (oscillators, filtered noise, envelopes), positioned by stereo pan and distance from the camera, with per-sound rate limiting. Music is a generative ambient chord progression. + +## Networking + +- `LocalSession` (offline) steps the game in real time and applies the local player's commands on the next tick. It supports pause and speed changes. +- `NetSession` (online) only advances through **turns** received from the server. A turn is 2 ticks (100 ms) and carries every player's commands. It catches up smoothly after stalls, acks progress every 5 turns, and sends a state hash every 50 turns. A watchdog keeps it ticking when the browser stops delivering animation frames (background tab, app switch). +- `Room` (`net/room.ts`, pure TS) runs lobbies and the **turn clock**: + - Emits turns on a fixed schedule and pauses the game when a client falls more than 3 s behind. + - Drops clients after 45 s of waiting. + - Injects `leave` commands on disconnect and compares hashes to detect desyncs. +- **Reconnection**: each player gets a secret token at game start. An unexpected disconnect isn't treated as leaving: the room pauses as for any lagging player, the client retries for up to 40 s, and on `rejoin` the room sends every turn since the client's last executed turn. The client still holds its simulation state, so it simply resumes. +- **Replays** (`net/replay.ts`) are the setup plus the command log. `ReplaySession` re-simulates them with full vision and speed control. +- `server/index.ts` hosts rooms over WebSocket (`ws`) with rate limiting and heartbeats, and serves the built client. +- **Version check**: Vite bakes a fingerprint of `src/game` into the client (`__SIM_HASH__`, from `server/simhash.ts`); the server computes the same fingerprint at startup and refuses clients whose simulation differs. Development builds report `dev` and are always accepted. +- **Untrusted input**: the room rebuilds every command from known fields before relaying it (at most 64 per player per turn). It keeps only the last two minutes of turns for reconnects and rate-limits rejoins. The simulation validates every command again (ownership, targets, costs, tech). + +## Offline / PWA + +`vite.config.ts` contains a small plugin that emits `sw.js` with a precache list of every build artifact. The service worker serves the app shell network-first (so updates arrive) and assets cache-first, so the game runs fully offline after the first load and can be installed to the home screen (`public/manifest.webmanifest`). diff --git a/gh-pages/bear-war/docs/UNITS.md b/gh-pages/bear-war/docs/UNITS.md new file mode 100644 index 00000000..0179a41c --- /dev/null +++ b/gh-pages/bear-war/docs/UNITS.md @@ -0,0 +1,227 @@ +# Units & structures reference + +_Generated from `src/game/data` by `node scripts/gen-docs.ts` - do not edit by hand._ + +Costs are minerals / gas; times are in game seconds; ranges and speeds in tiles. + +## Ironpaw + +Hard-working brown bears of the timber country. Hardhats build and repair; rugged infantry, steam-plated war bears and armored airships. + +### Units + +| Unit | Cost | Supply | Time | HP | Armor | Speed | Weapons | Produced at | Requires | +|---|---|---|---|---|---|---|---|---|---| +| **Hardhat** _(worker, radius 7)_ | 50 | 1 | 12 | 45 | 0 | 3.94 | Blowtorch: 5, range melee, 1.07s, ground | Grand Lodge | - | +| **Ranger** _(radius 8)_ | 50 | 1 | 18 | 45 | 0 | 3.15 | Bramble Rifle: 6, range 5, 0.61s, both | Bunkhouse | - | +| **Grizzly** _(radius 12)_ | 100 / 25 | 2 | 21 | 125 | 1 | 3.15 | Sap Launcher: 10 (+10 armored), range 6, 1.07s, ground | Bunkhouse | Academy | +| **Honey Medic** _(radius 8)_ | 50 / 25 | 1 | 21 | 60 | 1 | 3.15 | - | Bunkhouse | Academy | +| **Blaze Rider** _(radius 11)_ | 100 | 2 | 21 | 90 | 0 | 5.95 | Steam Flamer: 8 (+6 light), range 5, 1.79s, ground, splash | Steamworks | - | +| **Blaze Brute** _(radius 11)_ | 100 | 2 | 21 | 135 | 0 | 3.15 | Inferno Paws: 18 (+12 light), range 2, 1.43s, ground, splash | mode of Blaze Rider | - | +| **Sapper** _(radius 7)_ | 75 / 25 | 2 | 21 | 90 | 0 | 3.94 | - | Steamworks | - | +| **Sapper (Dug In)** _(hidden, radius 7)_ | 75 / 25 | 2 | 21 | 90 | 0 | 0 | Powder Keg: 125, range 5, 20s, both, splash | mode of Sapper | - | +| **Kodiak** _(radius 18)_ | 150 / 125 | 3 | 32 | 175 | 1 | 3.15 | Back Cannons: 15 (+10 armored), range 7, 1.04s, ground | Steamworks | - | +| **Kodiak (Braced)** _(radius 18)_ | 150 / 125 | 3 | 32 | 175 | 1 | 0 | Boulder Shells: 40 (+30 armored), range 13, 2.14s, ground, splash | mode of Kodiak | - | +| **Tracker** _(radius 13)_ | 150 / 100 | 3 | 32 | 160 | 1 | 4.13 | Paw Cannon: 14, range 5, 0.8s, both | Steamworks | - | +| **Ironhide** _(radius 16)_ | 125 / 50 | 2 | 30 | 150 | 1 | 3.15 | Steam Autocannons: 12, range 6, 1s, ground
Skyhook Missiles: 10×2 (+4 armored), range 9, 1.6s, air | Steamworks | Armory | +| **Barnstormer** _(air, radius 13)_ | 150 / 75 | 2 | 30 | 135 | 0 | 3.85 | Prop Rockets: 10×2 (+4 armored), range 9, 2s, air
Strafing Guns: 12, range 5, 1.4s, ground | Airfield | - | +| **Hoverbear** _(air, radius 14)_ | 150 / 125 | 3 | 43 | 180 | 0 | 4.72 | Flak Pods: 5×2, range 5, 1.29s, air, splash | Airfield | - | +| **Hoverbear (Anchored)** _(air, radius 14)_ | 150 / 125 | 3 | 43 | 180 | 0 | 0 | Anchor Cannon: 75, range 10, 1.14s, ground | mode of Hoverbear | - | +| **Bearloon** _(air, radius 18)_ | 100 / 100 | 2 | 36 | 150 | 1 | 3.5 | - | Airfield | - | +| **Lookout** _(air, detector, radius 15)_ | 100 / 150 | 2 | 36 | 140 | 1 | 3.85 | - | Airfield | Science Lab | +| **Iron Mama** _(air, radius 36)_ | 400 / 300 | 6 | 64 | 550 | 3 | 2.62 | Broadside Batteries: 8, range 6, 0.16s, both | Airfield | Science Lab | + +- **Hardhat** - Worker. A young brown bear in a hard hat: gathers resources, constructs buildings and repairs machines. +- **Ranger** - Black bear rifleman. Hits ground and air. Can use Honey Rush once researched. Abilities: *Honey Rush* (Costs health. +50% attack speed and movement speed for 11 seconds.) +- **Grizzly** - Armored grizzly. Sticky sap shells slow their targets; strong against armored ground. Abilities: *Honey Rush* (Costs health. +50% attack speed and movement speed for 11 seconds.) +- **Honey Medic** - Sun bear field medic. Patches up nearby biological units with honey bandages, using energy. Abilities: *Honey Bandage* (Automatically heals nearby biological units (costs energy).) +- **Blaze Rider** - Sloth bear on a steam bike. Its flamer scorches every enemy in a short line, deadly to light units. Can hop off to fight as a Blaze Brute. Abilities: *Hop Off* (Park the bike and fight on foot as a Blaze Brute.) +- **Blaze Brute** - The Blaze Rider on foot in steam armor. Short-range flame paws roast clumps of light units; Honey Medics can heal it. Abilities: *Hop On* (Climb back on the bike.) +- **Sapper** - Sun bear with a powder keg. Digs in to hide, then lobs the keg at the first enemy unit that comes close. Abilities: *Dig In* (Dig in and hide. Dug-in Sappers lob their keg at enemy units that come close.) +- **Sapper (Dug In)** - Hidden under the dirt until detected. Its keg hits the target for 125 and splashes everything around it, friends included. Reloads in 20 seconds. Abilities: *Dig Out* (Climb out of the hole.) +- **Kodiak** - A huge steam-plated Kodiak with a cannon on its back. Braces for devastating long-range splash artillery. Abilities: *Brace* (Lie down and brace for long-range artillery.) +- **Kodiak (Braced)** - Dug in and braced. Long range splash artillery that also hurts friendly units. Cannot move. Abilities: *Get Up* (Get back on four paws and move again.) +- **Tracker** - Brown bear in a steam buggy. Locks onto a target and keeps pounding it while kiting at speed. Abilities: *Lock On* (Locks onto an enemy unit: 300 damage over 12 seconds while it stays within 14 range. The Tracker can keep moving.) +- **Ironhide** - Spectacled bear in a steam battle suit. Autocannons for ground targets and long-range missiles against air. +- **Barnstormer** - Bear ace in a biplane. Long-range rockets against air; strafes ground targets. +- **Hoverbear** - Twin-rotor gunship. Flak pods shred groups of flyers; anchors over a zone to pound any ground unit inside it. Abilities: *Anchor Down* (Anchor over a spot. Ground units inside the 5-wide circle below get pounded.) +- **Hoverbear (Anchored)** - Anchored over a zone. Its cannon hits any ground unit inside the circle hard. Cannot move or attack air. Abilities: *Lift Anchor* (Haul up the anchor and fly again.) +- **Bearloon** - Bear-shaped hot-air balloon with a roomy basket. Carries 8 slots: infantry take 1, riders and battle suits 2, Kodiaks 4. +- **Lookout** - Gyrocopter with a spyglass. Detects hidden units, wraps allies in a Shield Bubble and drains shields and energy with Static Burst. Abilities: *Shield Bubble* (Wraps a friendly unit in a bubble that absorbs 100 damage for 11 seconds.); *Static Burst* (Removes 100 shields and 100 energy from all units in the area.) +- **Iron Mama** - Armored flagship with a mama bear figurehead. Broadside batteries hit ground and air; the Grand Cannon obliterates a single target. Abilities: *Grand Cannon* (After a brief charge, deals 240 damage to the target.) + +### Structures + +| Structure | Cost | Time | HP | Size | Provides | Requires | Notes | +|---|---|---|---|---|---|---|---| +| **Grand Lodge** | 400 | 71 | 1500 | 5×5 | 15 supply | - | trains Hardhat | +| **Larder** | 100 | 21 | 400 | 2×2 | 8 supply | - | - | +| **Refinery** | 75 | 21 | 500 | 3×3 | - | - | - | +| **Bunkhouse** | 150 | 46 | 1000 | 3×3 | - | Larder | trains Ranger, Grizzly, Honey Medic | +| **Workshop** | 125 | 25 | 850 | 3×3 | - | Grand Lodge | researches Infantry Weapons, Infantry Armor | +| **Gun Tower** | 125 | 25 | 350 | 2×2 | - | Bunkhouse | Heavy Autogun: 12, range 7, 0.9s, ground | +| **Bunker** | 100 | 21 | 400 | 3×2 | - | Bunkhouse | - | +| **Flak Turret** | 100 | 18 | 250 | 2×2 | - | Workshop | Flak Rockets: 12×2, range 7, 0.86s, air; detector | +| **Academy** | 150 | 25 | 600 | 3×3 | - | Bunkhouse | researches Honey Rush, Thick Fur | +| **Steamworks** | 150 / 100 | 43 | 1250 | 3×3 | - | Bunkhouse | trains Blaze Rider, Sapper, Tracker, Kodiak, Ironhide | +| **Armory** | 150 / 50 | 46 | 750 | 3×3 | - | Steamworks | researches Vehicle Weapons, Steam Plating, Aircraft Weapons | +| **Airfield** | 150 / 100 | 36 | 1300 | 3×3 | - | Steamworks | trains Barnstormer, Hoverbear, Bearloon, Lookout, Iron Mama | +| **Science Lab** | 150 / 150 | 46 | 750 | 3×3 | - | Airfield | researches Static Burst, Grand Cannon | + +### Research + +| Research | Levels | Cost (per level) | Time | At | +|---|---|---|---|---| +| **Infantry Weapons** - Increases the damage of infantry weapons. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Workshop | +| **Infantry Armor** - Increases the armor of infantry. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Workshop | +| **Vehicle Weapons** - Increases the damage of steam vehicles and war bears. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Armory | +| **Steam Plating** - Increases the armor of steam vehicles, war bears and aircraft. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Armory | +| **Aircraft Weapons** - Increases the damage of aircraft weapons. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Armory | +| **Honey Rush** - Rangers and Grizzlies can trade health for +50% attack and movement speed. | 1 | 100 / 100 | 70 | Academy | +| **Thick Fur** - Rangers gain +10 maximum health. | 1 | 100 / 100 | 57 | Academy | +| **Static Burst** - Lookouts can drain shields and energy in an area. | 1 | 150 / 150 | 79 | Science Lab | +| **Grand Cannon** - Iron Mamas can fire a 240-damage Grand Cannon. | 1 | 150 / 150 | 79 | Science Lab | + +## Honey Horde + +Feral bears living alongside giant bees. Cubs grow into any bear; dens stand on honeycomb; everyone heals over time. + +### Units + +| Unit | Cost | Supply | Time | HP | Armor | Speed | Weapons | Produced at | Requires | +|---|---|---|---|---|---|---|---|---|---| +| **Forager** _(worker, radius 7)_ | 50 | 1 | 12 | 40 | 0 | 3.94 | Little Claws: 5, range melee, 1.07s, ground | Great Hive | - | +| **Bumblebear** _(air, detector, radius 20)_ | 100 | 0 | 18 | 200 | 0 | 0.9 | - | Great Hive | - | +| **Scrapper** _(radius 5)_ | 50 | 1 (×2) | 17 | 35 | 0 | 4.13 | Tiny Claws: 5, range melee, 0.5s, ground | Great Hive | Cub Den | +| **Buzzbomb** _(radius 7)_ | 25 / 25 | 0.5 | 14 | 30 | 0 | 3.5 | Hive Smash: 16 (+19 light, +64 structure), range melee, 0.8s, ground, splash | grown from Scrapper | Apiary | +| **Stinger** _(radius 10)_ | 100 / 50 | 2 | 24 | 100 | 0 | 3.15 | Stinger Volley: 12, range 5, 0.59s, both | Great Hive | Stinger Den | +| **Bombardier** _(radius 13)_ | 25 / 75 | 3 | 9 | 120 | 1 | 3.85 | Honey Spit: 16, range 6, 1.14s, ground | grown from Stinger | Stinger Den | +| **Burrower** _(radius 14)_ | 50 / 100 | 3 | 18 | 190 | 1 | 4.13 | - | grown from Stinger | Burrow Den | +| **Burrower (Hibernating)** _(hidden, radius 14)_ | 50 / 100 | 3 | 18 | 190 | 1 | 0 | Spine Eruption: 20 (+10 armored), range 8, 1.43s, ground | mode of Burrower | - | +| **Hornet** _(air, radius 12)_ | 100 / 100 | 2 | 24 | 120 | 0 | 5.6 | Bouncing Stinger: 9, range 3, 1.52s, both, bounces | Great Hive | Hornet Nest | +| **Den Mother** _(radius 16)_ | 100 / 75 | 3 | 29 | 160 | 1 | 3.15 | - | Great Hive | Swarm Pit | +| **Wild Cub** _(radius 5)_ | 0 | 0 | 0 | 50 | 0 | 4.13 | Feral Bite: 10, range melee, 0.6s, ground | released by Den Mothers | - | +| **Longtongue** _(air, radius 14)_ | 100 / 200 | 3 | 29 | 150 | 1 | 4.13 | - | Great Hive | Elder Hive | +| **Cave Titan** _(radius 34)_ | 275 / 200 | 6 | 39 | 500 | 2 | 4.13 | Titan Claws: 35, range 1, 0.61s, ground, splash | Great Hive | Titan Cavern | +| **Swarmcaller** _(radius 12)_ | 100 / 150 | 2 | 36 | 90 | 0 | 3.15 | - | Great Hive | Swarm Pit | + +- **Forager** - Worker. Gathers resources and settles into Horde structures (it is used up). +- **Bumblebear** - A fuzzy striped bear buzzing along on tiny wings. Provides 8 supply and detects hidden units. With Cargo Pouch it carries 8 slots of ground units. +- **Scrapper** - Fast, cheap feral cubs. Come in pairs. Can grow into Buzzbombs. +- **Buzzbomb** - A cub hugging a stolen beehive. Rushes into enemies and smashes it: the furious swarm devastates light units and buildings. Abilities: *Smash* (Smash the hive right now.) +- **Stinger** - Shaggy bear that flings barbed stingers at ground and air. Can grow into a Bombardier or a Burrower. +- **Bombardier** - Stocky bear with bulging honey sacs. Spits honey, and lobs Scalding Honey that lands on a spot moments later. Abilities: *Scalding Honey* (Lobs boiling honey at a spot. 2.5 seconds later it deals 60 damage to every enemy there, ground or air.) +- **Burrower** - Spiny armored bear. Hibernates underground, hidden, and erupts spines through every enemy in a line. Abilities: *Hibernate* (Dig in and hide. Only hibernating Burrowers can attack.) +- **Burrower (Hibernating)** - Hidden under the comb until detected. Spines erupt in a long line, hitting every enemy ground unit and structure along it. Abilities: *Wake Up* (Climb out and move again.) +- **Hornet** - Wasp-winged bear. Its stinger bounces between up to three targets. +- **Den Mother** - Big she-bear carrying a den on her back. Releases Wild Cubs that rush the enemy and fade after 18 seconds. Abilities: *Release Cubs* (Two Wild Cubs burst out of the den and rush the target area. They fade after 18 seconds.) +- **Wild Cub** - Released by a Den Mother. Free, fierce, and gone after 18 seconds. +- **Longtongue** - Winged sun bear with an absurdly long tongue. Yanks enemies close, blinds gunners with Pollen Cloud and sets Hornet Bombs on flyers. Abilities: *Yank* (Grabs a unit with an absurdly long tongue and pulls it right next to the Longtongue.); *Pollen Cloud* (Ground units inside sneeze uncontrollably: their ranged attacks only reach melee range.); *Hornet Bomb* (Sets a hornet swarm on an enemy flyer: 120 damage over 7 seconds to every enemy flyer near it.) +- **Cave Titan** - Colossal prehistoric cave bear in honeycomb armor. Its claws cleave through groups of ground units. +- **Swarmcaller** - Bear shaman wreathed in bees. Bee Cloud shields ground units from ranged attacks; Sting Swarm wears down everything in an area. Abilities: *Bee Cloud* (A buzzing bee cloud that blocks all ranged attacks against ground units inside it.); *Sting Swarm* (Deals 120 damage over 8 seconds to all enemy units and structures in the area. Cannot kill.) + +### Structures + +| Structure | Cost | Time | HP | Size | Provides | Requires | Notes | +|---|---|---|---|---|---|---|---| +| **Great Hive** | 300 | 71 | 1500 | 5×5 | 6 supply | - | cubs grow into Forager, Bumblebear, Scrapper, Stinger, Hornet, Den Mother, Longtongue, Cave Titan, Swarmcaller | +| **Nectar Tap** | 25 | 21 | 500 | 3×3 | - | - | - | +| **Cub Den** | 200 | 46 | 1000 | 3×3 | - | Great Hive | researches Zoomies | +| **Honey Cellar** | 75 | 25 | 750 | 3×3 | - | Great Hive | researches Sharper Claws, Barbed Stings, Thick Hide, Bigger Wings, Cargo Pouch | +| **Thorn Stump** | 100 | 36 | 300 | 2×2 | - | Cub Den | Thorn Spike: 25 (+5 armored), range 7, 1.85s, ground | +| **Sting Tower** | 75 | 21 | 400 | 2×2 | - | Cub Den | Sting Burst: 15 (+15 bio), range 7, 0.61s, air; detector | +| **Apiary** | 100 / 50 | 43 | 850 | 3×3 | - | Cub Den | researches Busy Bees | +| **Stinger Den** | 100 / 100 | 29 | 850 | 3×3 | - | Royal Hive | researches Long Stingers, Bounding Gait | +| **Burrow Den** | 100 / 150 | 57 | 850 | 3×3 | - | Royal Hive, Stinger Den | - | +| **Hornet Nest** | 200 / 200 | 71 | 850 | 3×3 | - | Royal Hive | researches Flyer Stings, Flyer Hide | +| **Swarm Pit** | 100 / 100 | 36 | 850 | 3×3 | - | Royal Hive | researches Sting Swarm | +| **Titan Cavern** | 150 / 200 | 46 | 850 | 3×3 | - | Elder Hive | researches Honeycomb Armor | +| **Royal Hive** | 150 / 100 | 57 | 2000 | 5×5 | 6 supply | Cub Den | cubs grow into Forager, Bumblebear, Scrapper, Stinger, Hornet, Den Mother, Longtongue, Cave Titan, Swarmcaller; evolved from Great Hive | +| **Elder Hive** | 200 / 150 | 71 | 2500 | 5×5 | 6 supply | Swarm Pit | cubs grow into Forager, Bumblebear, Scrapper, Stinger, Hornet, Den Mother, Longtongue, Cave Titan, Swarmcaller; evolved from Royal Hive | + +### Research + +| Research | Levels | Cost (per level) | Time | At | +|---|---|---|---|---| +| **Sharper Claws** - Increases the damage of Horde melee attacks. | 3 | 100 / 100, 150 / 150, 200 / 200 | 90, 110, 130 | Honey Cellar | +| **Barbed Stings** - Increases the damage of Horde ranged attacks. | 3 | 100 / 100, 150 / 150, 200 / 200 | 90, 110, 130 | Honey Cellar | +| **Thick Hide** - Increases the armor of Horde ground units. | 3 | 150 / 150, 225 / 225, 300 / 300 | 90, 110, 130 | Honey Cellar | +| **Flyer Stings** - Increases the damage of Horde flyers. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Hornet Nest | +| **Flyer Hide** - Increases the armor of Horde flyers. | 3 | 150 / 150, 225 / 225, 300 / 300 | 90, 110, 130 | Hornet Nest | +| **Zoomies** - Scrappers move 60% faster. | 1 | 100 / 100 | 79 | Cub Den | +| **Bigger Wings** - Bumblebears fly almost three times faster. | 1 | 100 / 100 | 43 | Honey Cellar | +| **Cargo Pouch** - Bumblebears can carry ground units (8 slots). | 1 | 200 / 200 | 79 | Honey Cellar | +| **Busy Bees** - Buzzbombs move faster. | 1 | 100 / 100 | 79 | Apiary | +| **Long Stingers** - Stingers gain +1 attack range. | 1 | 100 / 100 | 71 | Stinger Den | +| **Bounding Gait** - Stingers move 25% faster. | 1 | 100 / 100 | 71 | Stinger Den | +| **Honeycomb Armor** - Cave Titans gain +2 armor. | 1 | 150 / 150 | 79 | Titan Cavern | +| **Sting Swarm** - Swarmcallers can cast Sting Swarm. | 1 | 150 / 150 | 79 | Swarm Pit | + +## Starborn + +Panda Sage polar, spirit and moon bears. Structures are called down by starlight and need a Star Totem; every bear wears an aurora shield. + +### Units + +| Unit | Cost | Supply | Time | HP | Armor | Speed | Weapons | Produced at | Requires | +|---|---|---|---|---|---|---|---|---|---| +| **Snowcub** _(worker, radius 7)_ | 50 | 1 | 12 | 20 + 20 shields | 0 | 3.94 | Star Spark: 5, range melee, 1.07s, ground | Star Temple | - | +| **Spirit Bear** _(radius 11)_ | 100 | 2 | 27 | 100 + 50 shields | 1 | 3.15 | Aurora Claws: 8×2, range melee, 0.86s, ground | Moon Gate | - | +| **Moon Bear** _(radius 14)_ | 125 / 50 | 2 | 30 | 90 + 80 shields | 1 | 4.13 | Moon Orbs: 14 (+5 armored), range 6, 1.34s, both | Moon Gate | Spirit Lodge | +| **Glacier Bear** _(radius 10)_ | 100 / 25 | 2 | 30 | 70 + 70 shields | 1 | 3.5 | Frost Glaive: 10 (+12 light), range 4, 1.61s, ground | Moon Gate | Spirit Lodge | +| **Panda Sage** _(radius 10)_ | 50 / 150 | 2 | 39 | 40 + 40 shields | 0 | 2.62 | - | Moon Gate | Sage Library | +| **Shadowpaw** _(hidden, radius 10)_ | 125 / 125 | 2 | 39 | 40 + 80 shields | 1 | 3.94 | Shadow Scythe: 45, range melee, 1.21s, ground | Moon Gate | Sage Library | +| **Little Dipper** _(air, detector, hidden, radius 8)_ | 25 / 75 | 1 | 21 | 40 + 20 shields | 0 | 2.63 | - | Frost Works | - | +| **Floe Bear** _(air, radius 17)_ | 200 | 2 | 36 | 80 + 60 shields | 1 | 3.5 | - | Frost Works | - | +| **Snowballer** _(radius 14)_ | 150 / 150 | 3 | 36 | 100 + 100 shields | 1 | 3.15 | - | Frost Works | Sage Library | +| **Polar Colossus** _(radius 30)_ | 300 / 200 | 6 | 54 | 200 + 150 shields | 1 | 3.15 | Eye Beams: 12×2 (+5 light), range 7, 1.07s, ground, splash | Frost Works | - | +| **Starwing** _(air, radius 12)_ | 150 / 100 | 2 | 25 | 120 + 60 shields | 0 | 5.95 | Prism Lances: 5×2 (+5 light), range 5, 0.79s, air | Sky Altar | - | +| **Seer** _(air, radius 12)_ | 150 / 150 | 3 | 37 | 100 + 60 shields | 0 | 5.6 | - | Sky Altar | - | +| **Seer (Sunlance)** _(air, radius 12)_ | 150 / 150 | 3 | 37 | 100 + 60 shields | 0 | 5.6 | Sunlance: 15 (+7 light), range 4, 0.61s, ground | mode of Seer | - | +| **Great Bear** _(air, radius 38)_ | 350 / 250 | 6 | 64 | 300 + 150 shields | 2 | 2.62 | Comet Lance: 30 (+22 massive), range 14, 2.6s, air
Star Bolt: 30 (+15 massive), range 10, 1.9s, ground | Sky Altar | Great Beacon | + +- **Snowcub** - Worker. A little polar cub with a star on its brow: gathers resources and calls structures down from the sky, then is free to go. +- **Spirit Bear** - White spirit bear with blazing aurora claws. Learns to Pounce once researched. Abilities: *Pounce* (Spirit Bears move faster and pounce onto nearby targets.) +- **Moon Bear** - Agile bear with a glowing crescent that fires moon orbs at ground and air. Can Moonstep short distances. Abilities: *Moonstep* (Instantly teleports a short distance.) +- **Glacier Bear** - Blue-grey raider whose ice glaives shred light units. Its spirit runs ahead, and the bear follows in a flash. Abilities: *Spirit Walk* (The bear's spirit runs to a spot. 7 seconds later the bear appears wherever its spirit is.) +- **Panda Sage** - Floating panda mystic. Starfall tears apart everything caught beneath it, friend or foe. Abilities: *Starfall* (Deals 80 damage over 3 seconds to ALL units in the area, including your own.) +- **Shadowpaw** - Assassin wrapped in shadow. Always hidden unless detected. +- **Little Dipper** - A tiny bear of stars, nearly invisible. Scouts and detects hidden units. +- **Floe Bear** - Polar bear steering a flying ice floe. Carries 8 slots: Snowcubs take 1, warriors 2, Polar Colossi 4. +- **Snowballer** - Polar bear with a crackling star-snowball. Rolls it into the enemy, where it bursts and hurts every ground unit nearby, friend or foe. Abilities: *Star Snowball* (Rolls a crackling snowball toward a spot. It bursts after 2 seconds: 145 damage (+55 to shields) to ALL ground units nearby, including your own.) +- **Polar Colossus** - Towering polar bear in starmetal armor. Its eye beams scorch groups of ground units. +- **Starwing** - Spirit bear on wings of light. Fast air superiority; attacks air units only. +- **Seer** - Spectacled bear on a floating star disc. Fast raider: lights its Sunlance to burn light ground units, and casts Revelation to expose an area. Abilities: *Light Sunlance* (Light a searing beam that hits ground targets. Burns 2 energy per second.); *Revelation* (Reveals and detects an area for 12 seconds.) +- **Seer (Sunlance)** - Sunlance lit: a searing beam against ground targets that burns energy every second. Abilities: *Douse Sunlance* (Put the beam out to save energy.); *Revelation* (Reveals and detects an area for 12 seconds.) +- **Great Bear** - The Great Bear constellation come down to fight. Star bolts at long range against ground; comet lances outrange every flyer. + +### Structures + +| Structure | Cost | Time | HP | Size | Provides | Requires | Notes | +|---|---|---|---|---|---|---|---| +| **Star Temple** | 400 | 71 | 1000 + 1000 | 5×5 | 15 supply | - | trains Snowcub | +| **Star Totem** | 100 | 18 | 200 + 200 | 2×2 | 8 supply | - | - | +| **Dew Shrine** | 75 | 21 | 450 + 450 | 3×3 | - | - | - | +| **Moon Gate** | 150 | 46 | 500 + 500 | 3×3 | - | Star Temple | trains Spirit Bear, Moon Bear, Glacier Bear, Panda Sage, Shadowpaw | +| **Star Forge** | 150 | 32 | 400 + 400 | 3×3 | - | Star Temple | researches Ground Weapons, Ground Armor, Aurora Shields | +| **Star Lantern** | 150 | 29 | 150 + 150 | 2×2 | - | Star Forge | Lantern Bolt: 20, range 7, 0.89s, both; detector | +| **Spirit Lodge** | 150 | 36 | 550 + 550 | 3×3 | - | Moon Gate | researches Air Weapons, Air Armor | +| **Sky Altar** | 150 / 150 | 43 | 600 + 600 | 3×3 | - | Spirit Lodge | trains Starwing, Seer, Great Bear | +| **Frost Works** | 150 / 100 | 46 | 450 + 450 | 3×3 | - | Spirit Lodge | trains Little Dipper, Floe Bear, Snowballer, Polar Colossus; researches Long Gaze | +| **Sage Library** | 150 / 150 | 36 | 500 + 500 | 3×3 | - | Spirit Lodge | researches Pounce, Moonstep, Starfall | +| **Great Beacon** | 300 / 200 | 43 | 500 + 500 | 3×3 | - | Sky Altar | - | + +### Research + +| Research | Levels | Cost (per level) | Time | At | +|---|---|---|---|---| +| **Ground Weapons** - Increases the damage of ground units. | 3 | 100 / 100, 150 / 150, 200 / 200 | 90, 110, 130 | Star Forge | +| **Ground Armor** - Increases the armor of ground units. | 3 | 100 / 100, 150 / 150, 200 / 200 | 90, 110, 130 | Star Forge | +| **Aurora Shields** - Increases the shield armor of all units and structures. | 3 | 150 / 150, 225 / 225, 300 / 300 | 90, 110, 130 | Star Forge | +| **Air Weapons** - Increases the damage of flyers. | 3 | 100 / 100, 175 / 175, 250 / 250 | 90, 110, 130 | Spirit Lodge | +| **Air Armor** - Increases the armor of flyers. | 3 | 150 / 150, 225 / 225, 300 / 300 | 90, 110, 130 | Spirit Lodge | +| **Pounce** - Spirit Bears move faster and pounce onto targets. | 1 | 100 / 100 | 100 | Sage Library | +| **Moonstep** - Moon Bears can teleport a short distance. | 1 | 150 / 150 | 90 | Sage Library | +| **Starfall** - Panda Sages can cast Starfall. | 1 | 200 / 200 | 79 | Sage Library | +| **Long Gaze** - Polar Colossi gain +2 attack range. | 1 | 150 / 150 | 71 | Frost Works | + diff --git a/gh-pages/bear-war/docs/images/base-horde.jpg b/gh-pages/bear-war/docs/images/base-horde.jpg new file mode 100644 index 0000000000000000000000000000000000000000..56682ba70f6c7177ede1543f34f1ecd13122e0ac GIT binary patch literal 210967 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νe-!>lmzix728t-JqCAkOW$pOX)eODA8mMke_93E*D`OC% ziYNpB)A*}!Z1z5tMo8SAm1#jRtdf2F41v=<>UhgI>0cwU^t+j&+%LH`AdII@wW8MS zysaw2OKyIYa|MWD-;PB&l}X&k2COrLA1zUpjE6nx4)!ES!%5cyx*L0OE&;A?+!HR- zU3G$-g*~ZB6$WiVWi&`0)S*4Y1IYHl;-Z?$>TB38Wr2gE0hM6LtK9S$&2HLxajs-I z7^~1+L2#v`=~*W4p{p|us_#}Nwc8*s9Ac>4n4$UIvEHkuu>^z50H38***Y3{$;jzd z-Lo)78K!-yNZTHFRhwIRJi#k>tlpzFEtyp?$~9%~Ot(lskRPB~|03L8AvvT8G}vKM>-Shtf~+(MH>g4NMZ ztgmO(=bbgVzquYI1+Yk{e$UjC#b?BlG9w%vh2JMOSFN^Z@l03QZF&PFSmb(mMC4qqiv@de+>QtH1-NT6;wb$AQ+X zoQ_kLnkf%%DUz=$xvkldk+nrrXd=%f=B-GEhzx2H93Gbz=l=jBC4FZ-)!v_ZVXat1 zE-o~?sP57N!93ugq>+X^D>Q7uN#Go3jMvH&M9CaSu$~Y78nyK$)R(SewbJeW*L`;? zTiU>1%U6~|h2G>Hs5xhB94I6Lct2Oe+R~}&csa87;plsPhsB?U*OtvYYSx-OuH~k- z)Y)|T#Ha6NE;esD{xsaDsA4c`mB+*{h9(1OJWHUM3Y0PZzSCh-)NS94A4<-T;LH7* zGpcxo@ZA+2Xta*rI0N?)tdYq3eK#>RLVttyi_>4#KPzAUF<(NDC6ZnILHKnld2QlN z5tI-@o9j{mQ_8&0uKaoM`d3LC#5yyyM6wfmY)X;{B~Jbh2^@B$pW#Kaf6RMR z{H=fZ#beL##ue$V>OYmY{{R`S70GrhUU=icuKsA(w3cwCLkr7tz#d4>{+tTs?z~~( zz3(J?)|b8Uxt8h-wmM|)=N+qB=i%bV(_7R(D{uZYSQkDDc=L^B`2AMf{{W28@6;8H zE1wnkOa}6`eLLnQj@y`dC#g7YNv=-w#C{oA3{c+MN`Xp!grYojg*XF1KYpnULaC+BZh=~%k)2| zzK$6BSSOoeYiyMz_qp}xcnpUNn)A(k{{Y27WXGVkm-Ue==x>Vp2A$!b5J{%ne(LZ6 zCH1sxi)(ph{o+S%#O0$1%Yu3ObRRZ&jzYTKraRlo{{W*Bjv0fFNm>u2-~QTo{ySn} zUyCrc>8&L~5B~rrsr@I_9ZYf|+Ck0-O3)i+;G9+kq;ANv*2s*qf-8z!pPVLa4W=Ro z99BH>lpD8JgltwlvT%D=O!7ICXJb(nsZHo?_z%qg0EF5W0H5?bo=5x9*RBIE{3JK0 zV}Hk`uVeoJJtwag(wgcmS|zh-7Dj?jBP1~Zu72-071n*LSo2yxHs{P$1($Q~3Q5{X z&TvRL`Wo>wEN>-VUPkmkU*N9}=;rxsansBpNx3!3(!QF1GT+ebExg_L-y?s=!*)O1 zTIOf-f8i7>vA^TX9S{8Ts$%a|CAQ6QcAPdH_&$f8wGq@Wgg$Axm&)6_cbMCD`sX;t zE6sU__ZjvJ!VEwC4XORh{{WLO>V9r*en$TQUcHa^TCp!yR@SX^8z1y*j-a38imkSz zdbr!m2 z1cF4T83&&Jy=YjwFM)&bj}QL!pBLJE@LAnQnY?2FZUZNddi&MO3uQ?6NybHeUIMqY!o}L#{%7JmWsyfI&8lFb zT~$d%^DVVW_j`Jpx>DKy0BS*TsKFMYZEWcz))JNx+%8YaF-BJM+XP?{oDgy{r}TqK z_`#xRejb~}*HhS!4c|Og+7^qaMn9@B2T^xphXmlx!0b$|nb64uBE?KBpPZ208tee{S5J;QDqu zak#vKzYkEQMvI0voSJQG8O7b+O}|8v+tKOH{9f=!{45_8-}tsRw$bnPd8C@=#uPU& zD!Ud%b0#;)0r{T*6ZcU@F~@v5@SlgRG?^erd(@IyOZ}w`aT5!eUu*@LKvq($PEJ72 z8$k8dkx4R=No<_=B=@fwvD9?04r}_ihIQHQlFm6-RI|7c$#J;d49X=js}qTqM%YFe zH#~7)OOQ~fI^2~TX=;~C{{X<~uVdwNej&w24Ba?IRFdVDM4vm2t!?>L(zB9^lj`+% z_PY%^F5ydic*GV801AU=n7x95jj{g#=wD8~W38T(nBE@j9DUq#*z~S4`tI+y?zOT%!h1_M z_oO4B=!1@=*U;zOK|?H~d22Vce^=QP;n#-QhZW;FDK1J8e*I31$#;|dH+`EgudaMc zD0z{f($IqyyKvKxavpE?d#ZHqbKj`7(+#DvYPy6h&~5=CPGoRL3=_Ea^vUQs=roTG z3p9%NREWuODgOXRvmbenT)ui^sm}^J^TlgL;x7qX>g%Up>sBqR$sxB8`OET4o^8`7 zhxJp3W62!=`OKdnua2&>JmXgW7(c@6w`KSCKT+e45;E=|z&;ed#%&uQgpV>rsX~9W8B)R<{x(kpz(}i!^N@^Cywy$_G+#K?4|Sx1IW3ZXE6d>J4VX3r05aM6)b`ii41BjGQ#W zMfs5r`y?fQ{~b)7EjK-FNDXr+!eH;m)WGO0TrCIq@XL;|ObXM#@b{iZts z3{5u)Z_)NYoUqu*QJ*^b`JP;|?2lrM)1$TXK7O6+leE<4(e+9ECE_`d>XwccDTWnq zVQl$xB8~%biBxc}=m-_sBbA5|>0h4WXjGv$PHN2i3N@ipDsXxaB~mg%@+&k-eBMVD zGF(XQi8@r`2;_6+@@oyZG#;fGb*XX=2WqI$IyV&}hB7LzN&t@L-+0DwD-EH`F!Zjr zXuev)Xu`X0s)UJgGb7$k(l9epOzRrp4AzK_FD$UA1X0YmU%gk%Pa%>bYlDzFS7)Vo zhr}9QnI@TIaAJmB@w5`Vy6q$!X0mLdRAvNLszShSBbu4XJs9H~YEb($kjA+OrUrWP zQ$rcSQ^slEYMSo(V3W&5fk|anEX*5)BoHt-KD9l=s(-8NQrSri5QI`QlUVl!-b?|| z)wQ_-Ov-R-11R$e>`f84Gji8s#eg}*URGV6Rxo{QGACH0EQd7HatiLxH4R;iNu-h{ zATDVmlPn93wJdWirwxjN_2Wkw;CoeHp)D7nwrlI8wzt7~4ip|a`kKYQ-5kva8>3>a zgOQQJ=A!#_QL!q=HBJZK@33KeRmLxJ8L4xr>KE45_P4iFOAV@(f+liNGBLpVa4RTB zxZv?l^8+2nwM8uB7+$%mT5Lnta?DNTlisX}_bKBZ^-q%;k<`?sWHFqMXk$o)r(|rC z#aV`BIXTX2HI$KurYG)m$>*g7BJI)0+PvnVNbrEE=hBOP=0LJ^HLfky8*rrEv@Jw+ zo3=3PjwxNwm;&)u7{(L?xb zaC+vngl!A?eAA&jqfBuTWb-^HqcOKp4IvuV(P3NXG7!V zcJ;G7eiVidS>^^gg}sZb4twp(%&&hdZ{&OT!%qqLE5QB(wDJD{ilAL%UDnwiGcZ}@ zGTddOh5-BrE0>YU$N`y%RW)m0)4Un+OHaMiue7nGYWC2Hk={>%|#SYvqdPm>k7d0XcsZ!GQ5bOOFh2~KvPsm7bYx_6s@7d4&! zcemr#$KG)kH$7ZQh~}MDm@2lV7~b5s_f%HgmfNFN>2v0-KH^)g3jY8?)$r44G8hXn z1Z*U((obEbNFKxr`LDoAbbU)+@VX!Ldkfe5I_K_2$9kytRb>O|UqR{0A4=47K`QyP-e`@P~BJm^uwXN0bq%vdWb&nD52hfBBdl6ql!;+}- z>Mo`BcE0wz<-f|uf#RGn(c+A9!z7j0H!s8OIb-cD{{V40k4ko|`^k)U^2j=(GY`OK zrgy4R;nk745JCDH_s`D`v-Wnr8gIhy5$g7fBzmr|sL2eMK}ZP_%x!`R2Sy5=I{d`f z-@X<2C-CCS!B#hynn2WU@1jSzn&nu!w28doas+C<0UY$?lV6rU34Cv(_-9qtz8CAa zK1HUdIk>X81Z)#F3m{@~yKoK%a#?sIpRKfii60d_Ii;?RXK@CnXBgWAv$%Fd>;W#P z1F&Fm&>UCD=2X=@a>QaNI*{eEw0XH*-TmJ_{{SQQJ`}H06~ofYa?HjUO6see`RaQw zX%`ft)bzh2Pu;&;oS%zy`!5Fg+gai+k2u|w zJa(eYE?`pQUn_GqWGv^r}KYcw@~qPU}$SH!bHomm}{Uoqr0} zx{J$DBnC2h8Sh-wbF5C1M0rvVKBBGO>UUa{@y&6vIN<~;p(L?ANhA--haGMt$u9zn zSx$d}zmV-Q8V+ zyA*f#P~4$FaCdhP?o!;X{CD1W&+Pe5zS%i^GLy-3a?e`#`mO7-H^o(vfC>C>@j0`? z{lyEdcsKK7PZKc~HtF}(GgyWre1VB48qf)2Tw4OGxtPiNK9@Je%m98f&I0WpKk z>`NCehFxFxx98gxA3YuNToP_{JK22BL(U2=Jm6A6XG?3oe>ZYXW@*wZiD_r!?&6iV z<=vz=R5+cMLibroRC_u*f#Y1CcFNLjkW9VLwr`1xYhf|yKNv3O0LJGelikfZq|aN! zokdbNaoaIm^+b-hV}&3h2jIGxl_C9Y&5UV`dfSTC8&#}Wib4CSi|*>L83w4X`TAtr z^k+8Zof%EVbA&;Mo^}ldd}FS;#%;}>5?42%rP-jK-VBsh`~#I6gbsw*jXo+DZ_`6K zlxpmLqfUe-ZMB&(U~^~K;K?3;`8SQO(`D@F+KH)K<)Jk}aP&t`gQiBgb_@r%j>Xot+rEK%k^!tXFLa6wS>y2U%Z z9%ItHwbF-#?kkau(BE@jpst2cp!o4!9E14m>y=XLg5w4HM~W9~O`ZNm@q|b|AvTOu z`Wgj|@CgaVQWDUNU}0VH&T@5h?geD$-OC2}CV#oX**7iW=gpEvWbEJJ*zo45F7f>A0L*@1vQKvD+VL@OB>CLC{(ZQ^>y ziWQsB^i_}NnQA>i#^9dUxs`vl;*B4x<0YV37}6bV5p2v%fvOy?W&9sZhw%UtAI1LB z10hlkaOLr@;Oykow;WHF0+Pyl1}%)DZ~{Jz>%oK=<7^GIZf>(MG2& zyY}LGZA5uvux3Ntv>Ytf&rraTLl@hE~sG)dalbT!S!lCG@5Qe2 zZhi8V`<06`CT=E8EXVW9FLoqqADj`Sx?}ycNjZ``MZ&QRYX<#PVo^9-&{v_Gl`iKk z&RIX=vgiJVJ`h^?$RIlX&~h11TG?syaoxK=dzL=km?_(*{@Np664s#@Cpa(SL0!)< zKL-0wjqrT2Med3%gavYZ#WzAu<_dQY4ODI9DYAf#qzYg%c#8+e5|J%-x zrn#xP=s#8p*<2ew6WQ`e$cmHCAe76S#y`;V8BrPuWS$mKOcwa(dZpgvLS3zSvo`WA zH;PKjBQ|#fp>GL<1GF0a_&tjIM*u4oQo6rxXBx! zA9_W{;S{#~)<$ z51p~2t?SN=?kid6QEOEL@t{Yp)juL7=ySn*mcg9{A8@@2IJd4na02o#foBLrJKlIn zQxr3H2Aq$h>wj;Eub5h+r@B{gt6%Mnq||_e!z+U~(E_6Wbu&T}FZc3zGR(fD6Yej5 zQCj-L`Fc@EXu2Cks0u@5?o#AY_KexlG-jw?&LtGKJKF-Ov=2zh_xSKQl(4)f*11QVd;Jx-8gz?^QczFUz4Ms)O&!98`-)+X{>|TQEL(YpvDqes!QZ#b zo!4Fj*^Ngx7=^FZir<-kcsom%iC7)`QW7}vI1wvK0xt}pQLV6!&oEZw`){odJ*A9` zo#O7lF(cYNR|~?NsH2C-B*+E63rxUWeoj8#$hLKk>)K}NIWh^KurSaJnBST*S{@0C z3sinHzxWs9pRWA@?N3MpPwRY&9BPd+wMmIy#K!=BA7Y7wV~4swq>$#jXnUVDmp=sm zw01yDWp2yf954uWnp+yeSy@(6aQb zWc_k|kwajz0z))gHoWeq6{h1q9iKM@UNGEue6eUzH%p1WwTtr5w?PM`&qJR+Ff*8Y ziPDlXZj_L3j2yq@1EK=EGbV0n(%1G9@vd~g+=fmal@TPlwJBxXPh9?t(zchMa8^|b%7lsP4l7xMn52Gp|p8d!a6lh zCZFqYvzf+o)Wi>ZB6hHORc3nMEo=;T5okomNw|p@Y8y^Ad%Q-Ev93~t6Muh^mqi{K z$9 z;sfNWq9^LYSxI*n^!7czL?2n4g-uS7L6mRHZutY}xn=P6KbS~`@J0v67Ef->^7YM; z&$Hj>Mi?k%k|pg2`+t~Pz_2PK+un1qkUge>EE4j;IRRG|NAGXU8MWJmH$BnU9}{H* zx$H8#*Jq4*7xdh%`M+p(8M2nlic8M{H!aM+OhV|wIBo*%ZK*UbxkM-Kymav z=GQeJy8JxXb2w1F2tPT-2FRVZSw!3P>eig$d8hD=aT|3@bhpMkdv8V6l3%fcl4*t3 zBoSgD;NB{|n7IMH5r|W|DY7CS@@UU}Rrp9K95oBqHjm%BauG$C#!N$Yc^BvPUf#2)(10L12U{9zcVhu63%Q>Zi?@vUruT&>MYda_A3=O^>FkYNEg z4&&uq6`Y~#0EgE`k+tU!0$N@yMS6?))d`2BSGZwM4G3DR@vVeRQ+*WK?{{xvwJwIm z*Y`$^;ZaX3PAdxOZ~09wMldE`a*X{}1#A(c7xhu8*o;yEC&vLn@3i_38&EO(T;6WQ^W&ldFPS%-XzT{RWt!zfzNv z&x|@XjX&#KHqL=iB=54D8`BJ8`PGr}wZ?dl3hY&?sbp|ms_i2Eh6NZ+39h;0B^ino z4mn}LDJ!ClOnRxY6_uJ$uUdZg${l>}G0Cz;AKl9cSyLfpc9IZo|OSiUN*w}>4Hsc!2+rE5`3?pP079lMMo z1o;e^%k*qqFJrB>Se!a&r={7SAtN`*6i>Ob^OG*KjQLMK23pF>4D!p`p`Wn}m`o`SDa(QUzj zLEVS_LlrNj*?*&jOaG1q0OO;@{?df~UuBw1i1t})OO=Bpzg z7u*`I5!;Ha2RB>_Yy29&Ciu=+i=P+`OqFwQRZ!Y}ilt<*S6a zc(zB5M-_TvW0Gl6z@%)1$UqQ#h`{3G#36>_`HDjMDG$T~tZLA2cDLf@eaWC-eJ*Tx z%u=l$d2kTjJ#Mc2X|K_dpd6`7!I3ze7B=Vdn`FYcfR8$>CyDI9bG_yja+0vxl*8S@@ zScXe^Cf~ZG5Q^qi_DOnrz>!wnlX%!jA&pmV8;*?c+(KGwtrQ`ijIvwUH!2E_)BB_8 z&g3&-cX}GB?R`ML20+NRI9oH=7CQgx>d1aBGQz*Za3xB{l!ano>cN~pz%ys zWX;eh8S=WT@kkLM)*cVsAew1=Rww~KE<={9D$krZ@X<+DT0RM@Gmkql_?VUW4arMN z@W+EseAh2l<{w3V4!B4WD)(2Tll04G>sM*GH8-TS&7V{+MR&74=Q!*M=?GHpmf=L9 zl}rtavkgX;xk>LIvUtINz977czTZX^s`hc?r^2G5fk%$6#3T4L_hZM{B|)}#-j8`_ zlR&|t^i%x@V0nMQ&D?X=dT zuQBnSLDFmYQ-VoL=9vKnm=Et&r%-l`uB~o(Yyy=6PNL^Hb?TMk1URpV6e{h~`nB;) z!)CVlz{{fxj;_Mri=)|(J#hVcx(r>$YCf4OnK6JsynB@XE{~)ouYmXq3#7LE-+W#J z2gsC4pIOMUDKgZ=nk!wac|AnV^Oe*-q;xPN{1nQh9vuKf{Hgd|@Jf^!#`l_u^>DMW zj1A1iBLcc5xzY8*vFx70d`#?do6XC5+o#(WUty;e%~2AXX0{Zv-nQeQv(y?d+DD5g z);r;;>n&#g@_$$Fu?iyn+#VT$5dN^C;WA(7;QI2wM(=t{JNSe%_|!n0%Y4G$xCQo; z4Grqo8OZVZkCCmXYT|$T%sCr6KOb);Xw?Lh!;#-)(-MKn8X!vd$whwwtW=$x zcgwct9>B7n>g!AP_>zn3XMJ26mdXD5&36hZpW;DKH{%k_r&Lwa{WFFp)dHaDHhz3y zAF9se06pl^nb~-g9JQ<>@4*1o-}7D^(_~Gp69@6ag)eNSP+zFy?}f7OPVDubh@%=HEjj2~*s3zNCX>A8wz zuycqHuLKei#x`6wKo6E2UxIvIm(t_nXTOFnm&!3U2kK6Zc3{T+;jk?SkxBB^{D`b* ztf-c1F;8qgL6^55o}dw%yHG?|f8@(vy9NGoIyB1e1`w>&jr`+*a<$6l%jIl*-{aon0!+Q~ zKP1t`KRSBu>a1u2?q>wpb$|9lQ(9YsfBvu0t)=H88^%s|+}?P_%Lle^tee^4I=-~J zF9PI}*1C-Bl>(*vk)DaW$4{b1NA9c|R zsTr;UcHhV^qffDC9SGpKL|uy#roj}spYGTQo+=zxixbCcn#JbHsub$mT3Q8yC*m1= zbIKt!v5Bt8wF1)dc8uY7zzG};wAKzs`Mhk-cVOiiFTek93f?oJzoXxttpdws9{-k2 zEBdU=w*S)n>WY+>rtPy(sK<_(-pCm64P2r#q+>mKESI&JdmMI;Q=NVt+A#&ou zSO`E=!mER=ir@$>$w>!Nli;z z(dSwJ{HI@Lq<@JW(_DZg-kSa!7!0*dc7h%U{o7tH`tcD3XEv1*{s8RYMtvLLwwjJi znH^d8wPfX&SUS?mUv&o+x84s^)|;2si>#`A)iO)_;vLUb@UM6PI(=|M;z$=@bb3qo z9(}JN%}lcOX`NJ4J~^XvCR_c^n-&CBNUr zm&vMoHEA+pyK=EO@JY8BZ)`a4>hJ82vWptp>&#TE4Nj7PrW4`M*}R70bTEW5 zm@Z*>$D?P*Xeqkf8DH^hGW$kKytH#eQB*O6w{L|yh5e(-xLD0z@_*X$*Zk_n)uY>o zbwN=TIO-%Hs;14xq_eU=86gcigF-5J8l-}1UZU?d#dW(sAaYpn$@J-TnUNDfdc#

@*=^tfoTf#^mCi75>&0sn42-I?x>6ia>!x)Pttq4=r)H$(axBdFSdTit) zs8pr?A@!VJWCX_)fcN*~(~>(TuFNCj3G?UX`sOwC$?dxtz~w(B@-#n-stKIuqYpiI z>5JWOyk?SHSTa0)xyqExp^iz|~_ zMd$yzq>QqZEbe*&$46=+I?}k#FD6zFe5x`Xi+-FDJIM!m<1s0I*;%5N6l*<=u5h)` z8gCY=>Qm{CXr#wKyxnG>RvbS2L_uyAUsy&=@Sev=zdp$zbGi)vrhJa-%&xv)@RcJ+ z13cF$ZUGF0+J!^3;p3evZ(+1tPB^p(g+^F9`I>&SHe zno_Pml%Ycc{Y|yNhIQUIR*{Fvz%b*63IDo*4-{zC;k5YpAB?sP(65J4`xX22aLt|s zeS&V9j}$`y^H4>}n{3@zq`q=ma7_0=R#B9JQ1${YH?BML@7PRG9`%Tx(tEf_f2Lb6 zKP2I8N29vsZ9C!}hsb-f;;y{piczTf4J_0}g;p-m8DMTU&QucZ2uBsvi!EG~FKcml zueC4(MEC=ivivvezr*ofTsO{!!GUz&OGf=Kv+Tn_>fak~&)SdwU?zS;twyFb#$N-W z{~3)tx$s2CwIxvC6>_Wq^!@zgfq&iV&FnCm3YQo5XrecJt;jfcf4auJn0-4%tmKwU z7h`-6M_%GH^h;D(azIe4grIyuMUcPosQkdqgD3PdMenR$5k|-^D;z~aSkrakz{DmGZo6cS-aLN*cz-gyZVhb zXwuT6-7#W_+3D1xjtS0C{t%-F*oLmay(_pvN>dl6oct{W(}~jaY4go|F|pUjc}LI3 z2M*l?Ig&BU9~(O7b`K|bygm%A?_8eoba`~;?x0iHhJ}(QFi~7d%TQ2AQ6$S9>U~ul1!{_i+(zJ zx~HAc1t8;3Cl|8G)-0<&b5&wW2&~x5d-}@ZGD4P1W!H;}hy;TXQtRbAkPYf8m!-67 z?`R9;zP)voVe;vWo9SL67d6}^4bR&ZK=@NHF0{Cj(wl5WKBy$10=!o_8##JL7XLb! z8E>;iVBOQR&~djGfca|J6ls^(K34 zKs)xl&gx=T+QQ|(wi2AckT zs8kQLlA`x_23N#7{bLR92Q^JYY8x#HqXavAN-uPq$xQRYoiQI zj>){QkJUL*5Gg9R%H(vxDq|lFI*5*bIhQPoErtQQhF8Kk%2*Zb;-*757t6sl%ovv; zqg%AlaeCGA6EntI0X3D{04uydy$A&*`wM|8+qjR9X)Q`=y3k0C#JA!45E-+vF+M7g zqJpMe5s6O428zUlYn=8Ia;ue@9H8EeLdZ@i>rhhFPJ1wYxFTAmN&BcISkyH)*V(ov z4-9hB-D+;ak~V^;7%X;TJ^j?HkTcnztD1$?JL#~SX4wBa4%3wenU@o2P2mq?TJD&f2eWLEkP zfj8WG=_T=~>G~>3RNR0T_1QL*f`tS4FlE|XOrCpG)SVXNZF>$qdR?OPr=+=rrK_yv zT*C#jF7}-F2nl&G-W0T0rp0<}Yd+UQ5*XNn^DC$xs6fzc86!Irr$Pu*!{FR|@lgW{ z6+31|jde$XGft2#VWA8LrtY_M?pWiJT+XK|X3O(^7&F}OpGioroBX8BKPlhoa*s+! zoU78m{1~EZVP|9fJ7p`?TG}7rq+2QXbDM8UVr$5nBQP(BHoLa^rdvS${A`-{DCw*3 zPJF7qWUT8qx(#oxBKImIBN?1C9@l9~gCu|hBy=4H+ke_FH^XCh2DXuv3zIpF8ZB;& zm_MxlS~btXuR55xmR;Wu68~;mt2ZvAmTL)q^!RyakoP%-zje%&cYhVPwD}>H_IFI;e@%407oBA6zOl`MO+*cffK5w31O{rdm z$=nigIzUP>Y5RD@ZABoYEdh>Okhlo*6$qXFh}K3+!Qh((3EAC$4p37f96h=)_XDAE zR)ifXYqh1h+-TZ3I9)`9JtRFYCm!_o(A=Xe2<{VJXgGG$>njO?x36Pu|312oAX2}y z&>I=@FDBGl?<6{1G>Duw1jZ@1-rz^TD|~Mc`y)X}J0<=e=baG7Hsjqmr!N<2WPz8n zr+?kFIS3+M!lH3v&r167155ef_+Rd0ZSu;V+}W~+hw{*)uQl}G74@_rfZq=iia0jJ zs`IM;dz<4+kF=(IT`9`qi$5Ir$spmEn>Tnq<27i)Js#&>ICkqJw8;Pizkfcvvg@e1 zM|@)HPG6Ec^!L4zue)&AVyBVct5gyfcy2J7?TVNI_%VmKA3*sWbh9dD@g3dO#O-#{ z9Fd_cjN;*b+O8&l5U7AA_P0&jICM7DNa*<%#qBpMvuLKiG$8!b#7n$0E=Do-57OHo zHMR%1jH3PRcS@BXJvhCi*9#L>zOqC{?i=$xH`VDlLYqe^o~Z2%K|7eO+9|IqXtYCd z#OFs#4pt5cU75K1*iOeFA;ycIke-HiTU~iWw&!|=jnR7VVKitVCD9a%zUnNk$-v)e zq4n%z{^mYibj>4fbl!o|QXR+pdGW)IxOzXhnCJ zDYJaIoy?;7U}GI%0^K86(QL(8U7MnWm_Z1Ys6rR57D}ummabs_d`yDyKt>>)PpFYI zZ3&9fr2LlMt2Yt-tl=P$hU~YW+EiJH0duy3G38MwN}qZV5tN+m4#p^#JHPHBUPnZ( zPfJZ`Z|=id4!4X4%V6N*|9<(HfZls^@|gG} zt&DYvtLZ2G@7afBjsMBqZj0pG<=^NKSnW%*dGKib53CVDbhPxGdSNWli|>iA14`Q) z27T>|czj`Ii^?E3?^p#4P7%jv6=Mbo=jV03<#xl`?=S8Ga$&z5m}f~UA7}w2THlK$ z%bbsLUqQbebjMf>m^Vz4TtQs&o9-KRM$Q;BJ z5!z7Q?)70EIEMcKayPM2fW?>@|SX5d6 z*d1ah*vxe?0sv2$^cD({rPWsE+Xdt(kTtHREN!kS`S2FSn>jd_OBxDe^K=P+xeR?m z^^DE8Cu%XV6I8hbX3yG5wx!9uwIMctj2-^gsOXAO;gp*;T|<89`peBsbL(kC>xx-F zWHVz&oHvM<_!&tpd}L8IrnxcVsa8M!xRxGBg-?v1ev_0uLROkrUR2If1dqD^phPHx zlet4kvBdlEx1*vU*@A*}JP>P0-HbJQuuaNr?1RE9RJAFuu*C!Z6$c9831@kia_z`F z=o2*0zG#}FeN)p!Scuz7`^#?`Y++>u%VbMTa$_)8h1@3z$&Jq~p}BO0*xIT608pl4UT+;bm@&px!BNT?`S!MMuk3>Q9xihBYf%8}vlK>`VOOyX@~yojWzBk=UL)TRla# ztIIi(QsGA|KCDL$mAgIxZ1zq9b%ADy{d(2WBPECTPc#3OsmEaLqqb zt^b2T1y^+>RiCv#FR++rZylfiwW9iqBML739`o&zqI^u5Z<@2Ows8=f8kFSA4pX>3 zK9##~Ev6?RD#SV8$=FAZ?H(tmiVO`En*0iew^w8Oa!gF%H<6H$sK_V6Bc&M5-}O&?ygh|je5+^9-UAGAtuw!iK zA9G3}MppkeiUN~F@hMzGS{1jPVkLj;BoQJTvQJO^$Kd%z9h8$`e?NAmxAo-)&X&k+ zC1rzoLC)9JJQJ2@bf)VW5R2an^QZT1aK)CQsXMm#H-9Td2nVu|Xtj;R?p}EyOSVy2 zZIAcy5pLmMS?ADLiz^A)saxoAU7RvkqpEe@EOd|fIUqkPh)3pa z1UzPcPWc)OJzk4+QbylieObEcP;~Zuy3WcWTy*v_Z*3N3T8JYBz7V34ntEOnXL>s> zIL7AcvIrcBpKgg7b2jq3wo3c@qiA>iSJUFGI8`L+598k0{c_sYO0D!q2!2Z*#)D&A z5rFC@AtM0J>{^g0MSJNX5X*?AsCwq8UGkfR26E-^lsVsI1vv-sa&NLsSsQY+z%RO9 za-R_gY>4UlA57bmnh*pS_fmcF`rw3GTlH)ojLm6RW>f@+FyNKu*Ma53_`{JdH;)?} zifyGk2MB8c*X)|Mg^x1A&$c50ETZ^fUuMSJeEg-@HaTTTn z=KGkNKmd%smW?1?PhhMDIpgGn2cB#d#!nZAl45MXD7|L z;KCsNt2jxvvpT7LH*LfcNvK`JSI8esNYlD}5jSCcxwxm5ZiTU)SDT3F>gvh)+y3sh z4(1-UmZJ-(qgxX<*_=FNSw25mD%gRip$U&a3V(CLsGDlDhh1W%z1`JQ$GNuqK|ATn zurOqia+sQyw^y9+K;%;f?JF6}?m-bZw7pTE`8|W2_7=(ZC;{Sclg(f7+cwTRniC$P zLwQerhhT~`AFvHu-AN2K!p!iHi@3B9ev-Nl9$8FQ>VF+h+CwKEm@_(!U-oQQ6FOL# z)5oICAQ+x7*G`obtXY3bpJ5zz2T_>n)P|?psIarmKW?B81>eU zcyN+*JT8hv*oW7R zhR1SluzD*qR+m~4S9k9J%W}T`;>pgR+^F}dD6II5p_V_L#XJCjlr3L(eQRuQ6n*JDqdJNAoAC#-HRQdi2b2-Mb3MG zIo!sXo-!`PC|#Aj8D?6$hCXba_JgA0RQ`?Ka?ro>9=}xLr9FBMEK!T^l0}6+-(nUO zd|>$--01dFue)BP68uy}IHC3v%(VU`T0Pb>T?bhJ&lVxkiLjz9N!h7-g37xd8GQs# zhOjwV!eYklPWbEZvZ->(m>}CW382j8d-rp7Ow@Z(%Cs%GJvLb?@^uN(yX5VkZK^9<>#i?uhgNjpaSN{yv?$d=Nbf_ZJR4@!4N z>SS}++T6}JlSJFJ%^tF2mqOr=yN2V^p4?+wAjOKTO1M?jUXaiax z3h(6J!bn_vCVya%2t#&?gd~l|+37Z<4l#!g$QTYyCdTVkzgv zyl<5lrqU!Sa|)^3dbV^BCKtY({w{VTxQjU{oONGQhc2Bw4bl4s=h>TieRBc-9wp9LL@%K@ zQmc2KK~}_6GsRU1+ixr*5j&sZ{NL{~vwk)eqQ^FB;R>^IQ7PKs6gY(^T$zs8rR_&yErB{ z#JxF`jd@8{Asl`gGLnbxNQJG$I$nK!FhnnbkCF3CSMLmh60jH9Lj_&IBcCe|e})R5 z43hhiE!}SqJBW+5%)2GPoXE0VIcQK|CeBM>U%Y6?!pw722!0y*!EVIv=iS<{@Qwqt zmioBM`E zL*0TkUi~X8AVV`XH&t)b!OibtPyAn5nYE?Ti&MEC{=3SPR{KdjYAhN1d?%-JTsIiqcR<(*-K$2X+fu>pE)y7n;`ka1o zpxeV=^;!{M+RIn1DFDRMV1mkVz5_u5KAjfk(?*K=y+Z)x<8Zgpi5m~zNrEYhbRr?u z_w$`*-ThKW@WrK;o2v6fuDK7n*%p{-x3Q%~&rW-pN;Ie(2JW^-PrulfUmCvZb^(y! zG&;argRG%;NRr*W+)JD3v#h^~WG2iO^VF?d;&vc>c%a@GF(ZV4HB1$oeyZLPoWX}( zilkfHLyN&^P!KMX-EjHh&VCm?Cp_4+I2R>8a;oKUN4~{}mxh_G#!F6eIKC;U#6n7nCUw+hrX3lw^xYnDnfV@or8xUc;+ptV+;x zGNQrFj*51MYa$-_HPyB~^tibZTPMDmU1owyx-zr<5;Wu2CNTYCjnQ>J6z( zT9z}I&(T-seMiMCp1Fbp{s!Vm*t>2m4SLlL>zaSbyr7!M7gxL{y@6cUS<-U#?yj*v@?4frsNK8n| zuFHE@wgGa~Ops0ym0|fg+DTuNQ!d-vfy4mC>q{RtLp4Z}wu9+O(ioFOhp{gK04w5i z@6DcQ)#)9*Y0hazGx2ntha#5{fPIpD&vjbbAjpf?@2G{z;j1EY*W;3AwoCh&JdWbz zsUxc<3x4HTRj(=CK9y&+2X($L8Kdw;1B!~igSErtgci}H><`1V1mA#!ySmN$>Qexx z|0P4pkG_6a(Sn~@+(u1ZIwThad)3tmlmZEgX_^8*LIKh3iIg8KHqB9uq&V?$W5vm; zic5Hgd+eADYZVTbnrENBcMh)nU~a=C7*g|Ar_?uIe9{uoMlfv3;_uAo2vv`?NC2Y; zD{*<+dm)i5o3n+(c^}nYAULC=V^DjSFs$NfLdSxBWKG_MWQOPFi^{`tIJ9t0%!o>G zxUp9U%enGYLFIdm{pl}}r<#-|8$0S?>nxw8B$BHOP4m=IjA@4a%MBCh(fd-SVQPx# z@Fs@iw0F~`1;uLv$j0%*cAP_Q8iP5I(Rb)1rvC?#Bf^HsiK*O^W9g&D%-&XLdom`TcY;&~DH3QdV{KuUcULl-3suI+Y3)E<5|H}P?Q44ye z2L3zs!l2?}PrO3JEy8%4h8-5Q6vYFs#VAA0+qAJNBMX}pv>|4&3j4+?K@BUHR_ZVU z%^O1O>JIm|cVl$CZ_OkdzedIHJ0OLllI^&q>#mMVG|!^tQ0Mq*=*n^)Rqs0}4U_;5 z6GUxn5b5fuk&}!BQQ-z;aHKP!^87JWCTY9+SR|pp}aLlOXYu7 z35U5-f4|zMqYYJ0!2&U79c=9b=Bovb5mveP+JAlF-!W}u)nIROQ7`j{OO2nFf3I0? z-gTmH= zC#g0FEUl`7<;)i-2Q(%cCKqZQA?&Q8aX-3~W~ zsC+_NYI5@?eo0WSEf~O6Lc}OMyjLjL!aAusLp1;z(?H?vs_NTqLGngUy zh4U$Sd6!B9=}L-m9L2D5ZDf4a$cS;cKaYR$%z^sFNGa=h|Id8x=`<6`a{@ZPUxO3$6~%(q>ia-qgC zl4U4KyS7P)dqz#EA@Y7oa&b+?FDMv3w)+An7t46oMX=}H2uk&>J z*xt-k&h>0EVW9~lp4>%*SB#YQQ+^g)2PhiCW8Dc$0A&iXvvybB&b3pKDQ<$_zb4< zw^6s%9GOS4Vc4a0xKdM*gxCxyCvk4zjPib~lmT+Gpxx*^IVy6K`ByQ6^o^4lwfhL$ zwAa!de%lK^mA;tQJ_!kE>f2Eew{ewxu@vtJJyobe5r~VXNtcHLDGVvvRwbpcQ*3e|%8RAR9g-sTzRk66dA}Y%bx#`38KYOSf_Hmc8sP-Kh#rCM&$(9nn*AGFR z4RUHKYGz;eODrZRBC$7?ir6HW|7tf*=0YtD`U*)?KJl4wxZFq>BuwC9Kf z&lA+Cv*#3X_b#(?WjyyC&DfC8-Nt}u*JVJQ#E#NoNYa>t};3$b9zUvANbd-QSVy2S;F5Sx?IgsMTYC_*$3x5)TLkkw5a5*qlFCa zc))`g8!aoZ*L5MV&3{Ia&5{AiL2tRJKrUY|UF}HzeC_fQPcMjIb6i@=(&pCXtCJlg znS#wi25Vznb<^^b|D)#p$|7M^@(p)}yDte*d>Un_>cWA}MTucS;sK_!Z~RWuR=bC7 zP?P(2%NC)8-Q%Hun8VpJ*H@86+D?h4d*hk|jz%vwC}&!&fTY>I4&HkUu$BV3Q9HOf z@nEpJ7x=e6m3+Kgd|8mM)OMbFuyS@|ux}p(X!vO|7m6WDR)kpf62KGe*Aa-bg**c1 z{n_Qg-87K@w!Tdg=PNb;{pE@}B+$qD^^$zw?=N);KcNR8JvVPdH+DbZy-`)>r8w)s zV5?v^0e)?&(qOaP9SLgRKtKZ(NSOv=+Z~dBv3oMY5tPEOpmM^GE8`?qPyAvD>`TjN zmO{m(uoBEgO@4M&klhpL!P?%iZ4i@*RXixfS*%P6@?S(f!BeHuE>x~8LM;kHaNA{b zb6#9x_@WYhl0eHh!~W9DpuI8NSfTRZ-$C6VS11Dj-4N0kP+;^xRLnm9jTyF;)ZhLv zgLFFSC(cxyau8O~#1_0gkY@mNPf! zAm9{5lJY*DOx7+;d^q%GSW$%kS)v0@w8R07MFa^=dH3~U4p0U|B zv~wK9`_8w_%$zEN&5!Y<29}c5L6dfxnW)xageMDi{3V?(tL#sk1VjEi}uH-MwDcF(d>XE)_ax#xiDJf&kx*p{gBja2UY1IpNtG#zh7`C>{>gYQgS~pRo3W8XeU_a zy=2`9yz?y{wPEa}A$FzozrxuxTdosOX=%7nv>0Qs*%j1l8*(`#D&YN{mZ8Btm1H@& zH8ASe4V%boLmj!Vt&+X|$Sa1>xlMTb0SzN(i&%NT{brLKu-$8LFEQ6GPH7vIe$7nz zppN{1=sK&YIJ#(CH-X^p1P$))9ta-XJ;5CscMTBS-GVmOxO;GSZQLQaJGuSeb6(G5 zSC6rG?bo2YRwh$y>Lal!_(3=jd7}Y(sBm7+2ZDd&sD4n zG*{{LMmxTh410lfGxR8x_4-l|$KVn&2T$!lG_7U`q>S|WH|79zM$anYwKBp9qvj_1 z4l?Ve#htI%l7TAdDY>~i@ga*%=Re${$%+;YYQDq6!YAu+bNcvg@?FjYdm}khtdZEC z>=$z>mkY`ierIxi#fC0}e^*%Z%5iYvM86F^&a(0`cucX=`MYbTmx-EYjtMfJZun<8 zV7A+!pmy=<>T%LZvmkHQHYv6zc=v0>L2YgH2qW5TXrgTFb@@yp()!w^i@I9Fk1H;D z9qS1ry3o$-pjZSR1-sg=Pn|wGi`Qn%a*(cN+nGtVU^y25#WjWHWG|@4%sKH(_lNg9{`j)t?|smekB=8(WghjwD<2zd+)j zaGAkEal$#9p)P5&GNvx3+=O8w67Z?Ag~TSh1V6^DL^TkAPZ}_Dk!wwi8nW4ucMNh$ z$hKt%izUYsbAHIru{m#5RMQ>H`SucD1LZiFu;G8S7Ma6^swOJ&K(S(9gjZs_# zrrxAYz>+z$y`Jh`$lP34IQQl4!BbHqVN6QFQzLy5k==YVkTia|Fv=B1{l zI!s~)@(dRsanm6%sI^9!Xaq2Gg*KSC?e!C~`IDO;oXv_)%p#~ufa)=LDART-hfuui9HlwY)*V=-wcztsB)Da2osx_)jam72cD z>(_KJ*BI<{861=(>)24n4gRu!c@TlOo{UBzw&Ko09G7RBe=V(%Ec`$ci$)V!lvDi_ zkp7;pT9NDL=7WEZL7Bp)nhbEZo#jEiHM3$l?cgg~H~e<;i{;iy($B{_pl!dhq_MRM ze8Ucg@JW0rQ!_)<-e^0#T&!vefb90_39gJ`%i$`UmJGRS>|MMFNt3{TltqQPucn)? zvRA{q`uLMW&*b>CCwN=mDwi)_Dl8OFgEJ}n41waXtkP+vivUYhp0lfIcJ*x+oT62X zAaLKh^4YMpyz;7k?;ea|PkV4E>t{}_ie2WHGg_@~aI^6hk;0Pan8q)Tzhjiv7a6Tx zTKYT%PFud;o{$IpAwzj;xM)=h@w+8N=8Iw)O7Gj>1vj0Z3*A|~8$PT72@i%sveQ%* zvB&}iOFJ%CFv{<2cA|;TT8u5%lq*Feaz^-vS_2$v^EdE3u@?}6YZUD$F6mmZ%ZPdM$a1d zcA6<-jA8A+R&rZT-AA3Iw&Ym}NtBn!0?0u82e~YlVxEGUPn0@`kPfwp8UTl65{fRS z1R+WF7j2b6W0xz&2N!*t)&pQ7ZC<|#aA zY8&+6Du?}O%)<#}qMSf}HC+wgYqJ!+ok5fBpON4Qt)l7)p$h56x~8*{b}1HJm7nEe zmdd*Ax*q=k)ZhwE2VuoRBp5}B`MBrPyj?|6+8N`jd^?PQO}fXW0AZC-s8P+&2SZi< zNBFxJ=Z> zC}7F#AK=qi%aSVsp`pa8*xiEIVEcU}`O`snJXGOPS}S=dcg-esDso&!9Q+TE7I#i? zKi-{mC4A?`g2{U@&k}HP9S|esQekCg?syjW)~I|{Eyp4G4=V+^@?62qC$)}LJ}zH5GaQ@*N5uzhC2)O%lsx+WCWUv0ZNB#@aJQ;oL^l3%H^vOapx@c0`(PHhAkuf3eLK89q-Kh1JMyv zyfTgzW<8}1Z~GUq>lG3I#wwHWm#8P?YHp`rn~2@6_hc!V+rLh{tIye991K4{CevLx z1eOC!A$dj6Js4Nqi9wfhxj*9g2QVafwXKb%kCsa|ZWBUAh03#l0iQL=h)TNmcVB+q zj0Ak{pW2Bb`K{R%LVhS_7U957qtu<_8bxB{nn!tS z)VEQ+`L}r!lg0HS2x<#!AXcrMq)swgENPB&#cWT7F*j$9q-1Cf8rjA)%e>-R!*%|P zS+&XS1*R*~pt4vi+LW@@cZbNh8^?KO#%5}{&$+L*X&HM(;8oUP@JQRd0>?cme=G8m zBiMR32W_?6OR%7C+ncsJqllfWW?xNvh1PIgN3(i@mAUPQjckWBad$vy>T=c6Ro$@5 zkr&-+!`n3ha(|}kewdUemTOTk4cnTUcSF+l@NjPZQ+$yGf_b}LjI+ya39sr=B zMGlTaUo7LnDW>v1=S?1_5B{A0VoH zmYh_Y^EsR9eeJ)W)g}oPta4DhHl+&^jqaCQu$D*Ed%$qsX@?jz3gf_YJL<*~DXfH4-yU_p8cMyT>@*3PE#RWRu&D5DDX^0#R6DSX_cyI+ ziq(Ie3PjMWKC9Hed6&@D#Gj3Jc(=@c4CpOEiJ(pHsGt0jsH=2n=Ss8%5CQ=3j;EKo zDd>tV4Nma&nB?vkuA{9cgK|Z#L)C`H#0TP(_(>MSk}6kEyj}Hj%Il=~X}$)+_t1EM zkZ}+=HZr(UYIiy4&OI#CXQ>ED$~VgTnHs}m(W$=h@r09J>M+v2+~HSp(e`)6u~s@> zf&O@0<>MWw81h*urrc#oAL0FMIwCP&*KNHhze;KpFOPmP$ixjv9{AweEvj9x}c6cJjWb7~Bkh|qV&$(+R=p%$mH^pao0qu&URoNkKPCNdo= zX21<=OB|hmG@UluV=sN{4!fRI$Qsf7SW)p&XO#|Umj{v`m#B`HGGFiSRZc2qqAO&w zkIJ)s5_f3Ssb^!_D5V^p2)2QQxswK#q=fpUfyXPM5s)qQ11%vM!t2@UAr!>5i8gvD zp*Pq)GMZ=2QuJPo0z@6t>n#<>bxH*lX0k7{^?IYq;{P@H4*S1TQ)rMtS#u*MeGzKDHIPm}U63<*NTg156o1)tKGGk!6 z4*1^eIN}@VZo~+#3MJpfd0#ZdnCv(7dm*g>jp+ngxTlI^zMjX4w>qU(_3w)xw4m5K ztlz=dM5F5^sV~v+Vt>JU$~I~F2f#SutKDRIeHuKS8EyQguC5}cu=Jo(Vy5lM zGDNr4bH$vbZ0n7QNo}d}lvYieiO#CRjk{Z2S*6LE_UnIXsLQKQ?pyFsbmY!b7|5x@ z@x&;}9=F1FYJ;-%Aa}f_{jW<~ZoP-uyB=zph;31A3&yAMq0&kuaIV}pmujOsFq|S< ziwxmyd9qrtBan8L_v}D%FG)?=t--gYGJPB1aHioqZ5VrIEr!FhEVVslVIVEH+SNANtLQwm&~#Uy=ow$@K8 zK-*9WmC`2-`_}Zi6W)^6Yns6jQDB36IrgJeUk&h<$Sy(0XM5{IzPGO7yeI zsZ{>lcUvx}ucie0qh5OzQ|o}R!Wxk6wrXIjRYzOB{f8Q8*i^(C+8~vVTdv4 zuXj3v)#mt(GUiFseVRXMZK7sMCC{7Sz3s1&q{$dID$AT^8HgO3k&z-2Go$)6zU?x&~L+8Oc0BpY;!DpV@T$+ z&lX)&7x}R_Z9d3za2Bap-6_2#W>4q5C}pL@YC+$H+ef(RM@%Bym+5SP;YPzt1%r_N0Q@nvk|{? zQ_0TlB)F#A&x}*DBvc_Y4%E{`CDkP2+EM38UZWipx|P{#H$T*QHRCYTtOcUrsTK0~ z906XLKJ{7zPyVGxilNOk=1@7qXp&}XL2o(BP1#Wn69Eg0@hHe=#=HnY^Bdj$Gf4vs zBeU~ZWAD$~pmA;dS6rNbw(k%U9CrT!NF&?qY30;D5>EQrW-NtgyVBeSEAQjEYayzA zB)u{&`@!8O<^68ix7*l1^6W3W@)Ku_w9)me#sxW#imavE2$)IN-T&NZekE8C4MVnj z$w4vYrgGH*1x8aJI`yX#^+g0lY}{iP;g}qe)M`s9E|L<0{#0YDDA_eXIM`kKtuo&RrDH7#|#&Xtj&g;>u+TPY+ z5t*r?tOoHCEJm+}h|UI?Og6@pY{6d}E8*UX++vhpYvUj_hnRia3O|GJ+3n4#DFBw% zhmn&Uswoqs5Rvw+rHBr`e9y_lckE@8mimIzv>9)FRPeVp39!0{E#>*eJ=;n_X( z^%Mr)M)5|tpAR-+qMx(?9-%!2&n#BLs)&R%dSbg6!zP%u} zrr57u^cUGXnhpYQdAJ!`yBvb+8;VygDup698)Gcs&>Npo*Rw=9=~ar0U_8kdrger9 z&Nrhqg?xxK13y${x!_nRF(Gl^^2jzAkYq0~g-H{^gOUqCWGPXc@@v)ds*-jsrFmX) zda;HT*I`==Rz;gHpuhAjTKp@Ja>%dm5mbKLGgiZLm87@6bjD8N7wXzYaIC%R+|Zf>NQO!bbly-!uT1?mlEb7W?!Q+gG+7tB2Y zXxyRB!^-Sl(M~o<`4(HTBDS($<7hYS=C21*Af06{=!4YkZ6q*%z{@bQ_qn-^v1XZk z%R-BFPhnVX+3A!>&G2-Ms$3N56nkWi{^I-W-dgOxsPG+vv)!g zuNCjl0Y?(qijq$L(D*XHfb?S8uK2582V!jj56ah-hT6unzG9%_^J?d&E7HN4e#tno-pEn?U6gaa0wp6}r2-?wCZVF%B`!U(NfjLT$Maj-Vp|jHeTxsOp3Q^x zHH-VIc%dl+4VU`Mg}GFb`oCs*GyVZ$p+%m$ZZ=~vjv-+lKKef+Pi#}Q>w$kNrKE`D zqyqG@#0J>lO4pX^=}G14I$-|+zAkun(LJ(Ri`iv;Opn$zaz=t?+okf{ep*ooXgbt( zi}%}WmP}Wl14566ueaRdt~6@gd|Fb9JL4SsgX}&SUSoMA8bSx@(!4TvNM73UqkC?Y zdD`6aefNhB`yK$bE%h)>y>n>{K_^Dqn|RIm>?Atkk;}C@TK$`*w!Pz<4c|AIy#c## z3b(AHE>D4ejP(IOj!r`EeO%rb*UujFGU5)^-eAG5al{sGvM z0=7t@iBg}+XMN=_eH4gMMrvTq-eNDh#Ew&Z@)vi~-bKLY7Nn8=qoAXy2LDDJ8*Qqbi`)hT@01G*ngS=+ReHS8RU@-;HUZr!3U{&ZBYon6p)#qIes4u0Byb zElloNv1Y8zal?}{i=j$(1s!Be)41uVErA0+OiRQx9&PhsEc!h;j}ZE?mZtzcSFjAU z5Yuzjcf|x{f9!2OB0!5mgXKj2)Sh1wl8qFH#jX$kJ5sR>pU%=uD(b~@^9q+i1vTIq zWC!%fmg=i!E}@i3QxOg2km<2K61cv4K9v0f7*yOAbz}VllqY-72L$b5y~EmN4*C_6Y<>u)(#y5?uB+9{$HdAku%Y$j!6{-CpqJea$1ajA_9+?$}{ zkiE&LaH<>A1>F+A?i>b@2^VktMw->B;=c(fih9!r2b%(Ld~%d@7t-r-4&j+cNY(9y zdU)T<)jhsFLgqnA4mes?p~@FU&un*PCJ9^aiSQitZwHbM@q%WtjfVg82PjEn)@A~$ ze1Rk->}DrM=kD#4`px&($qc1ciu3ZMpmw%(z1@{kIVe9B;|()qb%dB;^tO7E%k6zh z)hiE}3WXi_P3I+>#`3wA&lW|7)@IH|vW#T-pfh&V1SP3LlYM2b>e|Bf=2ys!3l8c6nkH>aDD-jo{F(b33gQ z?1o`lEAP|CJu6iEO(~^ISsYkqn>9@r3pn6mNpkNTB%)!IN;0pKVG3TY4chVTO^Bfg z2r0)RtZn!EO8PJ67_V-{sIwF!mHW;!&O74KxIvMX$o0OUIK8Q^PAxdg%wUxMm4@78 z`eUHMl2tIK${~+=yxmi`;4s1EF5sGRk4yNHS8%@JId+eaF<(@2!c-PX+8|z*>IRBS zTlSrGm}+T73N$L#F}32pH~$B)uUGLA(sFPUn@LW9uj(>t> z;@#~#Gw-uwiE%`JJXBZ4KG~qU(o6{h`Bd;*71x0%A-m1-_A=!V89)U|pl2^TXoM#e$d=yQ812oeFG#Ad zujrwjrWOqAh`#VuYw%!oJ{zcHyvb&uh1T*pkd#S}JNbJt1I;v$wGYMqbIWb9RdD_q z*M<6OE2Hnm0^H5u?suNtX6AtmI6FghXB*GRmi6h#NULn$zVl26cKD4(kOUI7-wiMSpc!+^g#rV;$VZdPN^laSa)di^C~3ALIqPTLdE|HHOFpqs ze;d!Vcgcr;fV=hon&e4Jr7R?|?Zi*o2SX8Jdm;hQTIuG~Qfrd0x^U%~ReG|jl+R)E z|IOc@eJsI18Y{P{w|O9Uy(O1F|IB+Ob^o2CL91cONq6N52-ES5JQDJ)z2)r*j^Ut7 zPSe@l52N_r+U`r;!kWXGVNBa5`y4Jm5QMy%J?CeZgOaOHUk%!JD~biV-r1J%AyW+mrv@hR{R9 z73g`;&h{wRNG@{juK!fWSjd-{U5>3`O1M>fw$ZWKi^ILa{!`(GZN-6KIJWklXZo){ z|7BRzj?k)z+B>lqHX`oe=o+t?>5S|ok@CI~t$7%jakD*3j|J-m;Uh3sF3milZ?~>R zE0618f6UKaZ=h2|DaKdIH*|EsL3nU&vsFnBUADGc{m&}GxVZwyuJ}!n}C;~oXoF9 zk??qicaWBc*+FptOIC)pO3xAqoebeEvskPj1DUrFHKJT*zmhZT<#A1y_bN`@MhgM= zeeNYgx!<1rlV|Iz&?e3JW?}=2kI}4;qf4^ua$$cBCrX>@( z?1q#Coblgt{x?`4o{wu3noMiR%%$3vG$)Qz4SbZ4a^7%2TkNlPj zx{Tz1fG60ZtLFH!5@!7AUQ-V#j;dJ#t0$MFA=v9OJ6c*iC_P@fP&ke^t}`m2a9Ea3 z`28gF!nLj%um621iKdByn)sJ>Dpf%0X82DZ@`twuFZ?L&pb)AB-q#jDJ z{@5hrF+`cv&sJEHNGLgbVbzV3w9=FG#KLzW3jT1ISeYlo%P%U5!M%dp!$~ll$Z&+Q zF5BY|V4>od(=ObWwn(|7{searRFPn<6LQPP5u#4N!9r(g;r^XTB9=AArsgo(9DnED znj7*2#!ych(95n$lug|)q%OP036{oilvEAqmGL{|emCq+AH~e05zwR#np;~8h!ob` z&O_f^?yEGEvYP7bt;gWiHhw+$YK9l@4Xg1tq^+b*y7eCeI=!YKif{UHf_do@cLax|6bG3m~D8z+w- z;eduN$p<_^-}x!LhajJCJ~`IuJ38U!v6UL^cDGhp4g{DxVyx3GpDnQw@2kV{Wr8RU ztGucZ-f_Co-kL1m=M=;Ed!eJ^PdNfrYy^Xit@5mbO{uwz#5L=jG}h>AlM};YN|I(t$v~F*c8MROt8I=3s#AN02DC2kDg!LF zRnTRM?MDEyntRvY+Ov~J_u*SWJ*sa*j+P*r*Tj7&t;J;(>hi%z=TmEpsUNN%`Gjv2 ztzqWR5BjZg7_fe(lV+i!lWAP6or@1^ldZMLDfeZ-a*Z){t?2x=B=`4B5KTuyjg$9c zXh)<|l|cE@oE&yE>OuJ*<`PX-4!uTK>A|&B`<#>6_D%FhMn7BmEY%l@q5VBdvVA{4 zrqoCcGuTKF&3S44Tjb=9)md$utBkoESC1ATP{tXDLk)~d46bVmpDQ=x$~p2+j= z02Fp9bDc0DMG{+siAfKoyu*H|Yc`O5f(=n*I%irw0(CM6G7N}2q?mkH)*H%J`1Ap( z(x-iBSY;Ee@x$-uzR6qv-<|~Bt8X4DRFvfoUZMt>3&CqYKT6BpgFne(`P992VRHp@ z+89a~^fK zY)zZz{5|V~6d)ea&H)hn<+l6^ly(#BRl4^TE7S0#M_R~i@(w!ITy~>{sP3*`2$P6h!v!;Bcbu~34)sX!!uFJ@WZkA$v)~oz^X>I zF>OcO00-POtNT4iz|lgrHXYC1n$Ro5^LzM|B{_*7B1o9+<>Yn1#52k;8xg*L98v+_kSIes%ycojXxJQaBi`rZG+Fh3VdjVZE}oOy73OZ5+?bjy2Kn zVKH}Et|$ZOnSP?OE5R!vO$k)^6&lWWaM31hX9$zHGMUHTUnRtE2!0?5qVLP7uQ?kA zB;|f|)W=f8`+?Ydd?#v36bn|yQA%c5SMVsi0T29qN57Kl4x?^Kab?lcr3%9uN^r&< zrqq5J!z~A2hXnS(^kEr-2lFjKDYeaUzAdBrJ%tqB@$9%hi66iFYR~c*%0CO-M#Tvp z{pC%B48ExG8{yv&A0X?5Av??R-&W_{<^6i6y`=lqUBh;Ms&}nEZm}VWdrAz4lDJ|q zHR{DL)(7d56dBo9$C(P(ujp34&CcRh>ATkQ2ALoEQ7l4It`FW_9Ek(QP)tMOq|?eC zAPBoELROjlfG3sO2pRZq*WklnM(9)>4nj+xCn|H2b%=~bP-%jnZZ*af|rHC$EAhV4Jd8n5X0T z4q4`1a4^%+)lc@98Ap5GlO^Ms>8&>4T{HG_jpA*;^5p$U4&hyJ<&81dzDj3x-*AYQ z_M?H%>357{l@qP7`tbEPE?K8Eqyw@8?aUVCMorr%!%}|&-c{wyC6cWfE@fu%%JSmVRV!0DE<*)n1;InFXd3~F;d6A`?lN?a@F=+ib1c}6|)=IroC8+ zmGH5i!Pz46^d&UG6p>>kWkue9fRMG_DQokqe)@gLpNey6{25(ib;*N(F^R9(W#?jk z{-*B8z1*BiV-o>y9sfGXK*wzys@H&5|SH|mj|doT|v4?ap15Px4fKD(og zDAMqBHSd5L8`j*96TUgD;fzYZwzajr)!t>ay}dcor{KX;r)U+MVg%WceLwlb$}m4b zJLPe4(9h)WgTmZhR4r|qN85a7e-gD4uK(Fgdbo;$w96!dLFAniA*A6%J*t9+P^i1x z5F>qHn_<3~x2$9j{4Q;iw<&jPe4v3s~Ec}zd2J~&7h=WM8}|rjy#D0=T9(gs!Kp% z7!OGz7i5u$eIh~jT*A>)wFn6~=qwuPS-C5J)rOmen0x665RfGzU(34iaV-j5AVatwiP z>8?!gGIVm=U_9~Hq$IX~qg)lq9#=NGi$)nOo7H6U#C>#)z=s?n;u_D&365BHC1+dJ znJCQUqRLaAs^VXB2p$Gn*--o`{c2`E_IS2Q+N3}DI&ZYz<|W04RA~KFobXC=BQPkS z)8IsoIw97x0?RA9o6jLdII2v$)nw<4QY~hfCvr<^lMaviN~nc8TeIDV`UlifR6$9) z0_VhSdhpbhQtL>>;oI>k5|hfldibG2&0#&=6AwEE?(%wu!uOCMoiuGq+SOc! zl56|y%tI()US~VjzfmO|D6fSQm1ss>j8Q0^kk^!m zuuO|s+ID?kB9nbw8v!i+-I^SgBTl}lyjv z!a;=!BFJwq{{beumDwiyvteJ+UMp@;{wJ~UKjym8z1)@Zo|kU!4Es?*Q#Bz-joT{Y0$)hOkWTv_HXV9szX~U-Xr=Fd0`LX4U{1##2Z`nl1-?Hw&4);ihhrDkN6BP`_@ksFA3)=YakdP0FC{uL6%vfnI`HFu-^NKZNx0~7NVi!_q z%ebV!s4-F7IG^{Hofyor9x77Tf;LYeWetBpz$06JV*!eqvZDAlc#=iamLoeb?M2OOE1;6xOa>{sCT%5w4K;132wWT~rci0Ml$uUnEjt#l2` zJfGJNiwfa7I>|p3OZb1Ly)0=<-!ex2oJrIO zWoVmc?UA04S$#2tdPb{1|DZTCm_I#X032p+z^ilna^UDcmcu*TFOg3$O@s9WI&?`J zhOi8H^H4#c^9?W9Kp!(Rf1X1@;S+)gyN*FX2OnbV`Lb5eD{khoLkX2;D}*rVV-<3A zGNqhsfN}D*$bc77l>gFRH`g(zsOIoqRcmaa;jEo7Pbq6u$@l{nOHIbUTLe*&m)WEi zZVbXGE-L+TQc|LJ4;k4^hXQE4Z9F={tO=X4eDKeFeaEj}sUP8pn04F9IE~5KOUw_} zJnMZ$^#-o+x`QN`qT5@RM8i;hai-z5o=yaHYwn2XV17}M0{$+Q)7}s7=NstGv+#NN z`Q#ZQZKqnTM&5hhX{~lbLsHY~LhgMnQGh|YMSvdiA+R@V`*tUS@}+&=FP4?u_X8B> zJ-ShYc6rH<=#o!?nB(GDRnww&Plof|G}XjFdcx-qj0=m!ILmsv#48(LnRjO5pX6I% z_wC#i*Cz#be}e_gK}`HLJoLp8_X*nj?(KSFtWb{8*^!ke@5Y$QRcB6aLHn6mDwOUi zt)cA)T22T#Q}ev|Iw#CiAyaJ4$z98mtaL3n7`i`J$f1*hrC^ULQA$Mjq2)zC1ijK+ zSMNg!&cW}sk>DXG(&5|VlL0Z^uCV*(or8@7{RaQUZ-~I={bgr(_uW$RDqBq?7Z%sr zDdgmUdfIGlG2RF5=8}JaaAWnz_=~n#7-+LcSL{B;n=FS&w&&D#LhU=-ZNhw%HnpE` ztx>gAu!7Um5Z;B_Y_fkq#uZ_Xt-}5jwrnpe0a!Qk4A1LjFqMhw^9=@Ne&W=jfSpMU zQRX+7(LHE(%;B9bwQY{@-r1*^xc3wp{ox(4^=^S{QFtUt4(y8zgg22PyYi3t`5g&5%cHz<{#(m?Or3hA``y- zS+>&tp=oVQ7{4?rv+5cl#vTDD!;6 zLFD!U7lG94J8-Vk+-8Y^H>*^RB#yJgxh7LwQm$2*83QTVo- z@$64>VMd*G>Q7p|e=!G2)>p^egyAhzY6_A_z=7zmun%Dbt3;wH6Na*AYt&UPsNHPy zAiQG!(dVS}`NC7D(QM7Lsqg8-{@|96bMM$2_j-Sej8KR_{(;bMJRpZnrW@#{mKB%&_9CdFs5{-U5ig96l7 zxp#-&(W5M>oc@;Gzv%D!M~BG8kzSdqKN7k0Wvs9JTVh~OMpdg$%So?)@)NG|J_x5O zE)U&}gkVFfFgjjmYL9e^jSHXS4P{Lc3jvs1+0V}=<;s|`J3IV&zD~n4;is#%b2+V< zq9R5knY9Wp-FXRe%;xi)w>)X~T(C9QOiFynD|B)46D3i4zp93LQKPR^<@N_|n)PVA zhWV9pP8iN1s*T}fjjD!cnQl*6pM0(tDWzK|t}UWyvVk}P5nNp8^ZFik0;#D2n6D|J@t@PG=gQ>= zQM;PUabgV%=~N>ibTM&|xzpvM@p^U)p-wzV6D+&!*JmlnQU-IG4emt#3(B*j>V!mCr=+kNEn~IL9PYuE&uzIajXa;LW@y z()p$r5$M|~?@bR$p5A1llzK<@bUfd(>+BkE3ZYndb)+*f$@-VD=>wX z>+1M|eoX#si055=1_nj2g6g}X4Xp3C^AqWa1q}31;3lyQbp< z+04o0;k=C>KKKYbp4FCX+=g^)8DumxwTp5(d%NrDS9jGoky$$FJgaw>?R_?nUZBle z8G@q6kp6er_B7crp&0#dO~{ljO=!%QWg|M4(dHS!yXZl694)X*S}!|`g}ytGDdqXY zRtSlNJ?*cAY}AI)U%zuXb&!|B9g<2K9&|Qf6@}QYWY?NR{1BQxYSxpRH>Ig`^}8r% zlu*C*ARy&^H4<$VIbjsmjtmu*lahn>n>hboE|R$%Di$vvx)HyuGkqhYTH;eEMVggu zcyZj|2A-KGatk9-|3RIIhhJl>@IAZ=*`W)2r>4#O0s7pmuh6oGHH-kkm#q^{j*+wP z&*|6-6g^ia+jemit?+cn=9{|iBYKBB<@oEJH`Zunov8?3%>u=I!sFjS;HtTUIw+LC zyvigEGEMYeXy6!qKo{fy#V!LE~Z z+iBr$3IQ0*X|-80?HT7fT`1)vf#s2&2v%^lQ*=QaokkmjvvUWboQIXs3IflyBV}xC zES2`=9qkDe!EkKUu@dE!(TQ?LfGQznN21p+G`=+C_ezPctRNuSNkq^)ldT7#=B=0a z9ij}+NcbNUj=Q3xJ`eY2HZ9+CF}{0lYZ(H~kuUTEd_FWYta%HQUAmgM_3`C`iB1T+ z2B%%Lmw)BFwiZCb<+DD!T7O~H_s zMit}Gbok!fYS@fb5j01Ya4YiVSzEo*7yhzesbTW&tv^Qh>-JVCn(I{Iaj7Lz9FJzm+^I64!#13_mp{{b^ykxSQ^3;rX)jQktZ*Mp zW-Kxz)35i_pI~lpz9ea)S-sIDq1)TwZ9!Vh=V*n(>@ae_gCl><@TrS-VGY_IFd`Xh z2A6*jk8y!Yz&uTtA1ZjYt~|#Kjj@!0B+vFiE7o?csP#cFeNNFZ+S0q$x1?=%Y4M%y zjc1vAcbT960ay@9{{igW?~eSsJaAKxm15{@5+C#4p)Q^gujA=AvCx+MN=150EVmk` zwrJuugVD(XL%XViCb=dsZKi_t>kLikGUIE#G|^AB=(o!Zh~FO~gv;_77|{5lREB+( z5)SGp($I4=U^`6o+y}UW%egBQjRDf)`J?{94)h&6%yc zR59YW-0U zFO_9Rj=w5MO^0jfV#p^W1O9*Fnapqf#I*bka{{_G@7~Zoj=TlTEnNbl0{!RTXKE@)8?I1F7gmchK`v*+uPHjYJ5C}o`Y+usW6 zquLA88?69{8Ob4K=@{<;vl2qFfyr}*yzB|LBJ*R=!PlH0*U4nw*@w*_FmNz{Ra>a< zr%5XU#nx`*WHQkMOuQi33Lc59ss!b>3;;c)Ehd^Iwt%=e9v=LwSiGC!d42=G@p8V& z!`Ujku;2&FYhk^M-u8L7HYk22xEJFXPTOl}+^w%-+a(I0e*Wu zu^e~+SVuN~+yBmv*!;(S>4_RmrUU|5vNU&JnwMVA;?EZmMUC8b?F$#f&t|U3N{E%V z;~?00pLqt(>~G{8ue+b#X7nN$oUm-TgLKZ%ZCk_L2T^;nhV z>q>tZ4rQx9Fb8sog<7n*Q_6jW$NT?rUIrBc_y)nMxpYgU`WN2s6hq-e?|P)IZ;8Atnae5BRS)qxQX8;uFER0 zrB^-okSFZIhvZXAMGz1-#BwFGXQc?zEA_Ko%bK2kvwCZm?g$}~h7w@KF|)$^Xw-7z zAtO+Mt?XEf>`P=owKQ$&2j@&!`fD|ImXRGx`~nD(lR6p1a2{pRcA}26S+dA&(KA_I zvM06ODgLIONjcuH{{V9y?{3fL0YtpBNkhY%qupG-GJ~D}tJ-K!bmSTxzmJHJ!quK8 z6W8Fpf3>msuMnZZN9%(L<+IiPQkBAm`8WR*CdS_Gf2o*C=SeJSb_ZK?dk=o$Ve~fv zMh4t4GYV>AC;2{S(a3v=FeyjjA66LRq;@vR z#Cw5jtoAmZ@buNA;vK(smItNGbiM?Okye`-6X#AeCB#Mey0>Pqw4&WbVDi)(ChkPH zcBpmIi>S&wVY+BywFR7eux+#!7b%%=JBqfLHDK5jKD5|lF3F!*1?&+t{8%d7h z)bE`+A6btar=i+!Tb$#zB?C^F2{Ko=+!lq+kbJR7`p_ggMFQOSUevRb7J{e0*@p{u zW~il%xvrPX-pNAs5x1Ljusk$%H2@b`rxCVDE?o!;{-8G~q&wIY_=lPlJ?2(=19y|A1 zYtCyHy7uA|c}rY~OVGmgy%WH{uOjw}GD0;cjG5fYllP8E#N$VR(srKjh=&qARd9RB zt*HFqs5Tb+_RF|m^GjV&gEBTL`Jx!L4IY*A$lI=q4OQjo2R8Ce18=X@=5af-S+H*$ zgi>p1a$sm$>l+j|h;|zfaY?0Y7Mh^^S)}=OirU^x+t0D9g1I~%#b_B@bo59l>fQh)ruu6jw%Q+c&vsP2np>jg;ew z!Do^B&g=YGr8k*LcV%Zwm|H;plEIgUdV=p@l;7Ho)x-QkGXC9xHN^h@p(?V#MQV1T zQn=I#d2F~ssXzBiaeI|`B=QL!xkN8K6IO>AJe@Fq5e(Q9Y`9?#oo-$Gitrqhg}N0` z+*!Wc>+xuRK*{QR#Za+zsuB{h7-yoKn6!AuqXWvF71FS6n`V}L`@`z8+2NqXk@SQd z*=Vns&)#^pkHn}wZuk9Rz}HO#<>#u$z|c&V5DB~h9Jbk8f;5dNTZ=GEo>J`PdeT-A zNcym~6~sm5P9ivqUR1_~$mf~@2=p5~@_Bry4p2ty{YrF&dx;ImZwar!{Ff2?iFkSZefs#{bzV+GYuV}DY;I1iDJU^RXjEm%Xyp>XS{-2~Q|w>QVzdsF$KmP7jZufzfD2?tc_1P)x)uHnH3pDzzv@;E+w=gk5Fz8 zGTvZp8QEkv6iOWMaWhwU`w;)AwK!9DLXh$Oyi%r5koo)B_6K+D4(E)Eq*!vOh|zoM z7xY2LWTDuvB#if+t;o7BARUW7bI*z#)8Bo!Io4}fpW4zq&>NAlvPx7KQKCxY&sBrf zswZ&wgc9Vxw@MwDP^LKK*R7i+3e_GoQU0Wf?0KEn1kcbcQ{HN%Ganc*n1W{;l`W7q zTOKM*@gak2oPz2F5|}%Uj^bE6nlNr8<6`;^pwlK=+ay4n0LN2(QDh#cu|w;+H)Z$0 zDZrBd5B{H*`%1eD!%J^p8Q2Avzz-;LOkP5KAu9i&s=6)z5VE6xlCPG zM0pih<7uRg;_eBPVin&+hWK_o22(kMA*8szNY42@gm*JwzEg}gyxMGi9sm+ z7Iv5W#sccpTQL^Y=uT=7(SW})W`{9_-ESef&|Ac}&G}r3KI4m<`!|HS^eRUdJG=`A zu8E6;T^lH?7zEAKtN0ouSu*HE^SlqUTw|d81H7{f04yXI7VoNc=Qb#k)WI~|c)kJE zalI|;N?I-R4n+>)?2)4DVpC8dy)G_1?d2JJN2ukg5y@Wd1lO4`a`~0ws8PbgM$EFP z>O;z&fA=ZUqX3df$xOTPdLRBbILr?KVgRDaT_p+OyE+2{(W~=ju_G!1 z$BE@`#@_3R`MHHABZAp9@L~jOO8=J}vtaAQ1F;KAE%-H1NB2hsq20EP#4atEh%VSY8Nc`Q|NE7|R1>(^hSa+r< zSux3vgHhX}7+%s6sRq{w(>glK{T4aCVLCgJcol-Y0Gv?>$yIVnrinf3r%I>Q!7@U) z!1BQN03QI;F3ca$jn#+|vmqdYdUe<37|JbQ@V>*@e?t6$c9mv4yg-`hUCloLI$TnA zpW|7J5DhcQdnH1e#alZF3IV7LRC%K8`KC+Sx+}U0FW1r)Pw}CeO){3}^{-@!oT;eVEiA`)_kQ*$=l!gZXtAtQ z{D4qAgLT#Z*P~2nN^0`pxJ%5W)kZUer?3kvBU^v*9WDao&lFz5?1=bPs7mgHinP1f zv$i=(EJgD)cqQQ6go}M*iu41wLv8r*Y)t|wmR_L|Ii(Rk_iA6OwmM<1htdVj1vb(r zhZQf^NLFtDYZ*9U$*A`%!>lxxYO+N!zpswB1{82qI4z#=|fD`j;<%18Vwkr(c7 zxchfBzoGI8%d(1r9Tf9)^J7nBMuS0M=O9Ax93NdR&fx;=c7EkM5Ls>gZC!7?+<`o% zsjqZ7YnNbm+(?dtKx;Gy#j+kHR`Ztwc)Y6eqOc<;mwP5aIw{Sf@k;Z=SY|LKI|&uzRbk#laFmylXrchg+uc6Yu1 z)#T5=t%!(oq+%pULczuoHfqN!&}4h?u>);G*H2cM$>K(a4_%O$RNCg*fl2D>GRmC< zL4Y@P)LrnKFy#;bRF3Lb!O@X>S7Ww;Rs~D>(_f37B#=c2#_;2l8P_ixe=Wy#UvJST z(exq$T+cz&9j2^~?+@`Vqapr$pztlNE))d{iqfXd&&ci0)F69tNKIqztV zO~dJK3CLn(%g$4ktQV7Tmw&}+2)4XL#|?IKm0f5=zaC8;JK&VP)%NK?607QdcY;Nk z3>&&UjUS%X#98ifoSd5FRKx9$M{~;z;{Ir;{X{P!?knoW&s?rVUI0L}69#Cs9FsdjRXs=3}+-{#b%LHh|`m z{sUmzES>&;&l=auUG6WA9>l#>m6Tn=?mrh-cPvh3YCbBl@FrhWriW|Kr{pS-+#P(h zGdyvs@t;FLZpu_u#Lj9PXH-8YG;Y&=qi8jL6UKk`>sKoq=F#KtFRZx7@pzxm!|bY( zmTp1qS<#Aylej4ubQ?WCOabR1BLEhp*A|LxEPjxYlGcmVIO0Js*6ZI9BHz+5Rb1BI z#VJ+IH_#U5dUhdxKr$PVI;}=_< zu4#Bt{5$Xw__z5=r!9Iz1>@J<%8P1P^{l+TdhyVXlY~67j6Le_0D* zsh+IB)Vn&lmkdmABRTJ&B3TLaMoQM}Z~^Q^cFEqy@>X8vUO2&U+W3^)*puQ#d8E;M zZh4w<^!IbhnC__gl8kaWA?B!YKJWJMh&rZzU1!i34CFMFrOQ5+E>jyP#C?dP@WK3A zTH;}=sDtyixT}#mW*i@Dz1sgZc_(wus%~{p4MT1mRm(Ky(K^QGY)Rs$CT%;ilh3TG zX&d*!^H+xH1)QaK%D?8j8=n&(ouL}Cs&khFJgSPwdhyk1!A=zmF0`^{6V&`J8nGOj zdUU!Ul|EQsFzGbcX;tpX1YlCV9mC5I>bv}9SWLQ(`wNoIC0zE|T^>J@7(w{0g z`aglX#-m#(^M)HhAGre&NSM^vF#*CSg|_$(*$g{Q5|+(!3Li=7=PnSrYvuQwdzQ)1<^*<(xy5UAsGF z7;3ilrxauM`!cxD;hl8)K2s5fXO!{D?pwkQgSf5#LZpmnc8$Bq?*6CH|GkmIoA^ov zWQ25I^Fg8s!{7X1+-)Gtf1i|L|3CkIzL3-Z(|vr=C-ZZCrRhKuucJSe>MzM1l)9;VE=jrGgaYM&9g!ap3T>Q~#9zIyCcKggFSUY_ z=R82LXny7qz*=>GyC3u(@xOhyzpo;kR`rOQJ}O?-wh&OkA6C^_IF5`65U460tliIP zJ&>|ak5kywrcMXPNQE=<&QNCpJ=V-%$sqfwwpsxNt=f2FlSXPI(=0x@#e~n}4ioOgvdMd|TE?rfXFKJSNPP^$JOoJCyxzsXL)M4Z zWu{&?c1nkYibhrxm1Zkyv|v=~8_m{ZWpkp0ua@fvzf1#foc};<^RTu9RpWd*R(7Sg zpBtid;Dt($sMi37yF?K~syfqaOteIoTK1c2sitphf5ZhQ%o?_wW6`9Y1uneGWBBcU zlyiI-<7Gb{snnG$9W||KY5A509~3ckU?ChxoNLp*v%TGp7GMkfIQ7tut@*ZPf7%h3 zhIkpQ!&Rs>MxE;~2EWR^jSBppa$TsY^AbEaUsUeNl{C{sa^;$|(#&!>Y?M6GPo=#k z3zp0zv33TM0DXI!s(6mXQZ-vM5+B1 ztreo+8T8(|$-_dLyR}l#U?2C-Ui=p=a5?i=yuBCAQmL)gP-c&d83$1}^Kwf+ka_*Ng__TO z83(c1xO$R4{nA8xV&Nd*v!h&FQbjyq(w2I7mQ4y$m|^wDkhd5rtm4%pcVH}t|nKZpA^d%WFqx5T12qA~TGE{3~>KW-K z);gCJO-)5Q^as@${Y!^_8X;Z|r%W6F6+=e@717sOtT}`o!&~0K7arSc+OGO=(NIyC zHP|(GNBY0wD8P^o+UKJTO&KuCD-v0@}L#hks@k{t(A^Fjiv7)3V>TZJxL zfD00a0Wj<1QFQCRWfs}%%V5hXFmVfJL=lV|l`#I8ChG3<50L%_zjf1O7WFHDMhZ5S zb9?!gd3T@MQB55V8*|yx4uc;DSXh?_wT2X0TMp}$xt)XW;qU`!tBwvGGEprneW&Bx zP0`G)Pii5mKT8P09wie=eV_RNHIO2ldtyGzba5y!NfdqRqV9Qh>{fa;fbtstNn&dX z)FJW1+1avSog0z=?e?1*Rqo@Gb`KEg+-%luHS=zJR|jlnHs`&(A>B1VbzNQ6VOcUz z20^-(4ritsj+0CV?;zaQi*Z|VP3in0UK|Vn%mqK$KYRxIQE3`rx8+`dd-#lE)zpn;i|$jqda9ZTSq_gs=XTc=OV>0;$|i&Vn;857Od3{w z_YZ)mTiKNIXwMwICLjpft+6odHdquDX;>t52GRD3;H3HQWDojmEeac_TRyc~EVZik zOKK;zE7@9&omGfFq2x93`QnbhesEPa_jMnHs*#n>e0mAMp8m-!%IVa7SQUOxvo+ zwSTQuC(P1t{mk4r_t_Y1ZhV2=DXM-ebolmAU47)LXBYVo@DaVT(n$eUDIbMYZPRJu ztG2v=Zl0QRKc9s9IR2-3rSW*U<+fqk=h6W-%)ta4sq&AXjHC)9#>Iqw6u!g99-(?K zNnolB@V9YiaH=?8XjR5i_Gs2>`r@gJt7VDOGN>triYV~C{Q=|Bq-8bZriePFq(q5$ zMwW<=>9Y_uJLR_gH)*LAPAPxq)oNo2kTM`LoM6H|fN@rc#db>&_}Ncn@!Y|&VF_$s z?KU=+I6>c%KoJT4t@9WAkC-k8)n4|%TBl3OBJBavio!>^nC*azGuPA~j zRrpqiR_mqUil!X{_yGbA`Rm<*dEFSSg6T6Xh%{XY$1>`A+ zdCq{{ku3|p=h0QRM22{T6Q_(EKRadZzVAwqgO@h%3xl_2Fpf~KzyM*s%_xA zd`n_<$c-(f|Fy{M^c?f6l>Ws6h?>`~&#Sh1V?d8Dp?1BUXZX2vB2bx!p9YD1PyNA4 zU^3ZK%hXqspeiECfqz0f$iENqr^@J#xa(>W4T;o#o`W?uYW0WuxJegf9{6$3n01E9 zK8L4C*$UNKmbGStaxnw9is@%l&Lx~+c*v@>`a<(or1-P$sVcXDPQGC)W9x+WEq>C^ ztcB80Gkjq*VHV{L)t=G=Nu4sRi?{>gg$yY$VUV**o&B@5%e(W!LH*!au_A#R0P7vA?KkM*?2X5fe1u4a*vQ_n9vODRWU3+Z@bWP~VrhC^VuHjebby zX2ajQRsW=BIHs0JBXnQhQfaXo3I9FFW;~&tA7EPj4b$Af?NUz>nadG-?Vjda*bFr? z;x&4s9h;D|#<|8@UgUAc%nvm_Hk*kHHG1Q=N~8NA8qVx;4$u)@46Q=9UjN5^LHlEE zv`Trl{vz$934XcSCQtJP>PJ_Hw?&==mc1P#pZRi6nh7d!SG#n5X7hiODfSK-*J|V3 z7l;33vl3OTmbTV>ZuGDNf)H5QPBC`*at1rnIi5mX1w)CAp3w4Os~SD;y_aBf94!8? zpqZC&v8;t9=j!8!Vyk{7`?qj)&O4w_G=;*&IeYs;kO>%+mjL~dnl}@VH=6waESZZRv zt<)}`P_ECLBf~QX!sb(92g19Qg)PyTH97JQ<;>Ce0H#Q}lOYK$Ub^v886nfUg6~AO zw-Y+(J*Z}-waFxRr2Q1ySOKiJIp9e^mYn&aIsRI z*=xmF6}%-w$gyGA%M}zGFb@7K;v8$UKTzI^%s-)?oYt!3y|P8Wwx*sawHkKNq&K1J zA#l7=7`B3iw3GV8KGZwUlYDG$a zgVp;F$CfvFG?5r(cX}L*-dWs1;12oGVM_CiO)1q`PQ1Ke%eB{v5IB;bHJ( z7Nfv&5M~qzvh+H!zKy9#xzt#7&4(F9KeG(Auqv`Ewp?Gp;Su zM-IilU*ld%*`WREeoyq2ELR-Zc2&7 zM#!=?@sLimg+MrGwBZKWnBVX3D;XZo{|IR=o6|JilQtZaV|+q(v6VTJ7XVjxV~hwh zi8GVm>=9Im_sj?LLr%Y~5PW8Ar2;i`i%0&74DXF)vS40!y1s_pY=ycv*hhrR`%4e! z;B@6Ym;OqNf$A~C92n0&embB0vLHA->^&jOib23%=8P~_KTd%l3R6I?9f<3C;Y_c~ z6USc%c`PVp+rB@@vwwe@X1|w?s|`KDg^Sx_=(7!pK4SeLy@Y{N+bg9TChuy97)NoH zl28c?oWV|T=yo#C-f`y48v$@teKlJovCRon|3Wf0lj$iZi@e~$BvQTAe z5#!tNS zGQ}paBg{{%?Mn1jxuAM7H*JmS&(0%s_WghJ8?jzkXY>}PE5-6!}m{*PS-TzW@gPf7Y|#O!kajNd5lp%s&CrEb>$KT-y(ZOV$iS|#96^YSnUU+z1sMj?lPbBzTkbohjg-`w_>29%fCy&!99|q5{J3 zRIsHV$=1N*Z?iz0cF{1n?4thGZrbzcQ)zMgqAeoic@6)AcIFb&vtfv<%y-{BBAV^} z)B#^vKzNs@%aYY#ySSbV*+@O}ZDsp>AfPQc#^Fxy3X#K*KD4b)IrrpC+<4X6bfrS^ zl^L{X%VfgBFoP%_RV4btV&G3tVLNDEQmm_AAH)s%EQzuR%md6@(*6OEc3sJ~zar9A zbykOoF#Aa`In#vBcMz=9pDfo$3d?=%n;3en{r<8z9f0@*%=7!wT!s@vG zRnR9b&&aRkc9psb&eVxxSsIOLP?Nbe0DovZPMN9QszFPiYG}apP|vkYO-U`YqlnI1 z_OfE}5WMM{H|tiRjQC{K*T)-oKodelD9&*pBWrCwWPdTf^jMwCpq@6ELbAQ{b__+S-mE>l+IXkQ^yxYJzyr-K3)RrGwR=;)h zXg}F)wp<#YOE;_WN{#vRt>EqE6Yxv0dXjyT)R$o`a$(Q-?uEpo$V>8IA?frqe|`lh zb#8Bds|m|~2Z@J6dib=x@!Xz2(_hdM#6{T^QF#bL)3(`wOEzkAEL&x)EPAbF3%u{` zdsZ~$;IE#qV|zpv$t`>Ca)rKXFWw1UVl0aRcNN7KPPXmyy(!kIPa zv}SJs(b3nH{;~6{;!=p3ycQ`HJuVb|p7~n2#XgS|jse1sGRd_R|7fS2Jqpv0 zfTKlGmY-ZO@P^8sdizhb)THb$umuZ2B}6}B5`H~{zD~-nwIZ1B@u}i6l*+A`iz9tB ztgVb=WI*OS^Ts63v-`D?*R;haP%INNs&lQFt`Z&e@u4bh?h%JOxlb$l4Elkd$JM)?^2-Yxf=aG{$Jl z8W&2Lva#>x$%1ej<;qhhpQ8F%E-Mlruy@taY>vJW-iU;G>2aIs1M&zPe{7Y_MX#tXTuLRTa(X<1 z$6B{sA$Dz+>u(9L{z22>KR`s)Vm%~(%5gcZO(h;^BQE&IzuHr~#*7V07x|z!67;rWm| zDr4xyNF&e8Tg^u$z7PF*uFBjOu)mY;GeaPvB2z9){Ii+&Yo&eBXlqSk)4~r?Ij|!| z;uaSnh;!|*h?~OW+3Q8ES!`{?8=thpqd3KEjCet(^#&@D29$ zr!OVN3Yo{R#wU5&T1Zb9>KtDxya=ltC;9pz?Yq-^wHC|>DM|ZuQ&TB8(#BAq-@_g^ z!c10N3`^ap$SMh>B19YXXJ(2P0!=9z>BO|mcJIZ-VUmnsuio$ zZ>udy&lZy6eXgKAI7S>T$e3<#xs^J2woS{>#ORq+w0-YCesE@dJBl_jX?;qc<*3}L z8FcX#!zX9s?+&TS%T8I5sXXY0@jlPObjxRsLy$WQ6*NPt+rv*}ZHG|yjx0S?)9st7 zRxVG!FTct2f^%966?fwIGkhm`_?fDe5h}8@Yu!BgcW@QF3Bz%d&YCQd+~!H6F48`U zywuTd!ZsaSy?*1C+(cVtfs8!9=zEI{6#NpPDa^Wy%?S~~u!QGH#|LGmwo+8W)x)2*K zA*XjxdFCD_J~ApGm2p}Q9sV`?Z(&xl=f%dvsSI_YwMkx8_R@UN;}kg~fo}j0&wuq} zIZ={ugjGIhsh>U{1o*f3e6Z6u74Gv`seQF$z`BgM0mfaRZ0N_T87@ZiStoKP2R#$X zJxI^^cFHYmAk{a$58to>d;61x$-uakK~0+j9?0#5-`SZa@9A2ui}UurauDV;R_g01 zo04=*9-8EP)hPlsmp?tLQ4U>MkS%ngOi_f{PWka|7f)9a{CSSHz&OCTt2&^4ALu;c zq7|aixgK-e%5#a;&@VI2LO&Ra`S!*Ye zPmCxhIlE4KSD+%L9OTim=UMLiDaKc&;*II+b9g)_V^OWt@Lk0T!7hK5wS!%P>iiP) z_G0cQCf*+R^SW7x!`{JfUA!OCK3`C=Lt!!PEfj@^zblXsf)7qLN)UMGw_@*QBu6aX z1C*R~W(;I3dWz+_;#z#%iwo_YcEkqMv}oGT4A&`C^oxv|A`P%$J18xkZ0C+W&4GBS z4&)Tn*aP~&KSFb)YGe4ya&{NyL_;<QM+jn}4Esvs z7U=@4`*%HhW}a9kOI|Jg%JlIcK;-Yh{Ryn~;B^03Qt_}`_3-Wjq{bT?Gph#oJnK?Sv` zVOK^&wu~?O-x>0sx|Xrqe*EhD0KJ4Io#+4O&`?t3k{c9lV10tY`mMU3&i&c0`nEhn zgxt3j=?2>7Ub0esD#@%lR@WOlRGDg*K((Qhcm&L{bi#-@%VV$XUZ`(9mAS8xicp@v z^5NEVal%4ewrz@8noo(?KaLj$q&Uy^k|4^#JQey;c2gyzj^vVK($wM%UZ*t`b%}!S zV90xL%zEdzJWacUHr&s9#Egfjs13jjl<1UDvN%e3F6AZ5Gr(Ls(R%_rrI;BzKCquX zevgBn+^noRy@Py)+i_BpCi&~#J1IcGyDnQ;N|g#IoB!s{>V>(0M6KS&rEQ&SdN<)# zk6j*ql&Uq`ei0$nHIPGhruS8XVnB_)e?Ly{Ifme}Jq62*fvdnlp+*aOg%BMFmL&Kf zE_M)DK6NDSei<|>;L|gc!t2AnT|mX9LFXOqSrl!2#MO8YNPB~psUs!9xhaA zuE#j`q-~|>KD5dWV&H70D8!|J4Am_n3{Mv((fp$WeA@TnPSj)|t$w9K{BMnst?1+) z@=ILZ$|+?G9RC07(hshPy=VU7AK(g?3@Tt)->KnQ@PAK;?{2sjope(7=$ zXw1-z#7`ENL#4MbaTMCx&8sHkiX}o(!`==%bZMV`u>_}*aEwqcXp zPIkKL3mE$P>HK4!mkn8a)j04xrW>a0sW!{hUx2}Rbqj5y?rU=)OjxIb#Zw1eP4g^C z6!H_+U-DBZq;{kLBZJB~hhv`+s%{<~bDv2n&^#?-jKiOE1U>hU9}~XO$&aC=a!B*5 z#1ZGohbCxd%X~*iqC$ixo?moB#$}bX@Y8IcfZ!#(i=(Lju~wv0lV~vpKj}OqT(-cO z&O#M`GuSciW-Y(GP(-Rp9%;H3Q~q59F9 zNAM*mT3|5g$u^6r;j93h{|K1DZMk8!jHMcYCHlV}2#}OhypyD$w&WIg54xfD6)5j>sUGDA<3`K)mw$W+xtR^3 zS74T)c7`b<59*c!)i#oyjh&b2xSzLTK5)d_BsJ~uZ{m()ggYP!r9=StG^-$u-lS}rtgMKsK% z8G1Lm@-H`B&{K4q8u$5zhgg&S_IHVJ^VI5-A|d7hI4q&bkW_66#pc9=)c~cLK@9~Q+F%6=vL62}%Shg1CnfYnwEI?$O}@tY7Y%*`I>fUwVM1TDzbs~5@j2YSF{ z^qc1oeKp*9*90&44I<++J&Ofk!fDX5;M2KtQKoHgs$T%~7^~;ap_QgFvn(k-^L~v5)emPHH#V zFfE}f_H1yj|m3-eH1$hhRQo7I4yqa2{2Qq-ZSX6H;1I#BgK|lR==`oAG|NB zOF@5lTRHV4alR{o{iUmsSo#T1tz~H1B9biIzlfBhcT}_^B? z+Iv-@^b~o#49tbVQz^}4%y>64y@q$qRn;CE408m2k;ZbNiuO?5OiG&2X>av|*6|oi_f+F6Th0_scl#zfgdm^tDJ%^GZ}-q4?exeMaw_Wa8?K{bbjZ@8KR>g5P22B=d$~6BI%~!ZNoBEGdGjI5+PX!?Pxj&L?7BsfY{4U?U$ii8 z-(P0TRq3*tI?OnYtP&LWqQ1s$SSS?Ypvmsq-7^wvJ6(4vkXgsN?iB9IuEWW1nU36fkS(+J(ZwOaNsXYe4eRQ#19V$z zntwli)fTl-4h32An{Cv+u)o{1e0`hFVlX3XFin~ z*7FQmw@XIpB*%=OI%fs@MWh9?*idx>U@shNmMz3Uf}toJP}34Gw@- z`coSIC+M!lj6|PdK%-3kaXOn1BVfk*a(wUJa~YhINve$P^oDxN7RO4rq!cPMrwU4R z)#t$>b9Mo%d)QnoE}f$F*&{p_#miHE7$PX2rdX=&R^c$j&3(5nlM>qG03}b9lMbK? zHIfnmMT{#0J{v5|)3h%8vyLzI3i9!)UxDJi#06eVFE`Z)maF{M&=w!sfJ?^x+fDT&Lz{(!>JD@_!goyS05Ve0{D zr}2O!HEx7Qo@PjHWRi5u*5!&8ZwwLiKp3;@`_-+57GOD|W;^yjNJJj)^PfGuZ{>-k zVgH0Kv>EK5i1C(mdv7pnY_mICXB&(^tv`9}JS@mZ8K$Q&B%JSUU{|NI3+jgvyPt5x zf8EKZBlNpjHJdLI@xB?-Zmpz=Wm~V^`Q4dD@)LsqY$)H`MOT0I79PcR_Yf>UPbzcn z0FNlm@d<;f-bEvx58yn#o0VLTe~;r{lWZl+IPGjtS8Z9((Pw65YQ|u4P?+TG;W;nJ za}pIpIfOCl&7;M2fHt8!xD`tATt?A~p7+UBx5&(Q?ey%o&L2m;ze#`?n9}*9`OT0p zqs(D^3tf@x2=Byf9kW4w%L*c8e(VQ7ymKw_^t6_p!VLgZoQ*aLc2Q({kJ8sut#3$F zsOvUX59L_%1Z{DGNIOx{k*cZ9I3CN|0u$=q!SSD*c5n{p$d;9uBx{MXp;X%2x zT3d`XG2k{>3UxQGT{r50fq>^l zJjoD6sea_y55zT&9BZ)zfdPTMu^kb{yH6KH##vh6!sr>S`MLmLZ^P^)I{Sx}614iH z4mZ8-&L$IIEwvj4Tvkv7Cp39T-UoA-r`4Sc=?VfmBpb0G*QRYKA>pC4Yj^bh+0Ik^ zZa8%5{@7qn?YEu%PGf#TArW36V-Xe=B_?rZ1bjD6wIy+>lr#@Epu%;+VY%PvtD-_t zp0i|GP~)$_w@VgS|3%ZQ(5&Al0#M+&Q>a+Gf2B>?_Yz}z34Fib# z5?~B)O-wXJ{)Bl>XGrP)pPtj{r{0%^OO>t^ncD`pGT0P{bsXTDam;-$V)s)@tO~>( z%fGzlQT^)yOg)vaqL?E9EZ7ia48E^cyfVr}3knawUt}p=Q&{{aj?T5x%w6U%`2I`G zT%Z{J@LlT)r3(ykW(0B)C{Ot|+RgDC4%?C4Q=~RJ%RSc4M6hCRP-QKASq&^qhz6}o zdH+jCq6ZdZ~vz zfRTDCi(n>z&9P3ts@v?L8R5VJ8IOkIq;%P_!aQBGQnj)AU~$SgX5WWA83)?$E_kkJ z-9@SyPP_2cV51gYB8nCq+5B&SdmihL$-8wJx0V86ga*t(4~=*!Qv!#uhH3JMnj!{v zRU*Ey`-nS@MZI^->ko*-r^CmK1PEHUjZ{WpA30sb$bEKgg2kyMF18HcKYGK)i{{}|x0d5(xtIHIZdkHR zIScpaOkZn~)E68I8k*t&BTV11d>CUZhknB+`e+(|_DUJ+^8v0p1dmb2)~xFH^T|64 zUK6XvX$emO%o72X7)V}tx{H2tE1QuEX5{sZaZ+L`#KcarTfPl%kDos-U?Tds$? z2V~6oCyPL~)0cBsyLv`&OxYM3o|eg)SKk0(C5z|wIR%T`JvGsU>I$D7@9h4H6iOD^ z^U-y#s#22tb?0OeTKEHWA%lSiChw|=FdH++f2YoH2&}fbdt4Oa*aj1xLNQMz=MUr~ zZ{5qJ(;hR!HuqP9qd!c}3<;qNYT#{t5;9on?CkLQRrB@Xwibj|Q>>Yf5*iKnhqjx) zdXS)tQc1_h26JI;D*0`#4(Oko@NmcP3MZ>q(gFPkn5P;(Y`o46!E5F`&4dolyi3)> zOzh>zf*$9c-j}O;<@Wn9{}fUzs%$z>Z1&M_fY6U{>bF&b0DEU_aV0z_XxCJOye~!- z)EQChl`PIpOpWqsqvd-{%y~aa=Zjn`yaf9*LZf~5s~=cSpMwO)a3*R6y>G!I!(`_a zFLAO3gWg@Z21^+>&Zm|WPc8#*Yg5DJYg~bqVC8k-Vy1Dz;euXcShXc-Ua^6+t3CUu zEYSz`)2UJ+%sE18pF<(TE#psK5_%mAcYkVz&kq?X$V}&1Acca-fq(oC?Sc;w!*_Cv zY_;0}l`zI?pVCchKi&!&O|CML61>WvFqC>l^|_Lhv(Mwh z&%r;~X%B8-v~w^A#tI#G-K+Ve|2$!RqiG z-L`o3l=G&5pM7%$@{dk>lGI(&B|}ce)Sl%}Kl}?iMJ5q@=<+Ip$V=S)gwLkHjl}wn~PA z8&r?Y5TSug9@5qG)(|AfOTlBB;Jg57?R|E@kmLGc z@yhbqt3m}?+N!qmiFMWec1CQGd$~d5#H@L4TX%24H1ZL59Cl}x@sBPvW*ZSH16i@7%<9i5I{r?p2H z_j1xzZco*U0lcGJCj~Er^WL0Xr^XgR<25Y}P_U}p>T2Fom~U@um%>)P0WAJ|Gc{fB zq4E6K{V^?o9{7}GK;{KgD3~sG`cZdgzUucETS02~m)<`&n`4OPWcf24=3>jj&?NeC zmB1GcTYK(YEL-hVvL)PR?Zh5J?}Pph=7f5_6l!N7idNLGBUDL0Gp_3mBnr}0GJ;dv zS#b9)THLka^;m7bfQ^P`Uk-j_;wKq%`YFv5+@XnSAD9vRnKF|6vHm1IL?voH!{Z9Q z@5_Dbtde?^Y@O9Hw0SXH!|WfOVEOB9CXR2C9xw-i56&(jv6WjMP?CmD2)7f=jOI;t zuTXTmWY-$?pprSjs2KLH;O{1NW@7mR@UU%s_RFu{MLHAm zAH9plj@Z5dt|OHvgkSyZW$Hf8gKC66IY|oFAINfzUQ)-jrZ3fhj&>L6!+yS?NzugO zXCSsEq=#iiimLhBaD0&UI9{mdrc5+R$U=??xQ0G3Hm)+UFR$c>KEzBZ^=Ay;j`f?H zqcfVfMaZ<6=!|Z7Q`Hp40!my74N5J=s823$enz{OfAHFMO+?f5j)=1%Ua)?zbnH7T zL&Uq+f_6*abBB}7`5f-sv|=-!j**irC7<9dpa5x@G3^T8keQ2S->FQ8PPYgV=3hw_ zP*!}DZ@zc|wbRPV4?GNa5q1RZj)^|w*jCc<^gT6M1Gh=qw2%C{-|!n(J{ueMS-<{Y z)|N4`FuLO)FCSq^*k!}3BnI;^jz*S);LjnE#=UNo#!alg3dt>Jd!8Kv60kN@Z&@xd z1<{$TO(+Z@rj=T|ZQP{-3KNZdDKm4lEqF>4#qM$JGQo~FptMJ0R;!v36LK^vS4Pa+ zsgB@WHl-geqM4v>F#HGDf?DjQhgfNQ&OzA~zNZ~jaA~Pu(y)9-KD-tC5ipw>9uH^5TLSbY(zMp1M$_oU) zLAt8G?S93nxQkBskr|!X6~6N-<7U!2L+JONJbXY~(;(%PCOEm?Ln#C}hA&}cV#I-s zr*k6|4tjH2tz9EmSoz~}qT#V#B$vYZDN?wE8vU3Sx@io>3M~yPC0_9OJH&cePVD>plxf9o()H4fVHCN8(d zlYXkvPu!Tgq@c29&x9DH`Dk$>lo@ChrgNhEy-ufJ`r-a6+0pWiJNZBcT`Ht86D z)W~%9JGHi7^*_McH*N=IZ3eHZheJ#e;KU3f@=#$aXpv=xE6d@{C;Vrx&v)D*Ogih* zHTsat%jn_J<$?%yUE&`hN>fXhQW6IjAC{$N343%H zHd=rj=Vr)5C4kJSzdr*VrrZAO*}=9*Civ9ubZSmr>9T8((gFeOxDYm1fLo+0tdFpQ z;e;2webyxC5sq8dg2%|Fp?k|_+aw!FD_;cTK0pRK$BUu!?yjo4eg(zXtuJ<9Ve_gq zhZ?8jhV1x_sPF^|LT~zFnR4xJ(=tmwucS@PU9R`7TL)X`bh!Mmcd zF=qV=VEpAL@ZKxNdV9E;&|7zFxSdds?ij>C7H7lW_PM5B9kqttOwkJ)nD4UZt`?kE zymWT3H(W*DRan~^A3h$`w9!}+@Y*X~Tn0wf zS?-CxI*_uReQ{XM2L$egU!=hKpSe~JPAfC#8w~4FE-uV1VfiQ^4+SE3?I_sT8I5V< z%KonSF%y)CuwfASqpr=1=)1~wqx*DhKVgZI zJyS>nSt;Z=bh*W1my|gBtqz{>u?7kfz=1@Tj30$`E7m&<2weQ>LZ3 z8%6=*sgg=mp-@dC?;Nqc(xe?`A5M9QW4?M#6^PReLt!)J7a>yASh8>rj4#%zt)J# z?K1Yb{{pFu!nX$@y69y7Sd~qPTIj@TH{JD&-kLmwiQAinVbeINvR<1sxgezQ#!H}f3sQy%U z=UfDFRod*l?#E|h%jatrurk0kr)Gv3?ME`{>}6QzGBN|oZEt5)iN;=+250r{E(AZ z+}C_9zM{NPN$z!FIZ3ot@a9^1rf|@ZqjzKm{gVJIE`PqL=Y0L+vOInWYd=4Yp7XD- zz52B^mpV9nH{6a)HuNdos1vZzR=YMk&Z(A?mI*r?b!$n_nKW}ezUIOb0Dx3=D4>!0 zbr}si!$XN?ecVzyQ^vkT=qX#=UoCJFBCxB(?%e~b6`eRSW~=4m+qSX%w#?H>U6zb4Lx{dq zSjQT?SdNQOpH++|8S<^p%%#`t)_?HVn2Q~3rq9>X9EgiQSE3n`*EnLyiHHxhh#{PO zk7>5Jf^kA03o3N)SWM|XQyU?vqt3{w!l$7P^qgDWPZC2PsIX^l4nEcWve4(1XxQ-e zp3GX0Mbz!nR@JE-@A$LY%<_-lLcCfj5!s!HNOTB~%ovjdD}(|)w9kW>uJgb$MwZ7+ zL>|a;$9T)_LUJ)T_1obOLP?$=LeGz&%lOzK=rV_7I~BK=LGW#alzb6J@f>9T4{+R! z=xgpAr5DF{Joxh}a*PlrVb}DL|Np$V$qQ>t2AmbAE8^)`Ah5cUS_p(L8k*3hUT-|4 z?u_WxXHlcENn!f5jLN&!Qd1=mpi1p@t%V8eNUN|li5A4UyNTHs&sqjXzf^wd_j*>q z0$41X>L{b4A!)}tO3YVK%h4iOA#`U?nzcPl4_jfXv<@)qIIt$Qa5#*8N2%j~8GEz; zs9wEXim{(k^4!pb=2n-~{Ust&KwY(Lr^ei0n++|`++nY>lQK2@5kz{&BW6!GKez|& zr!!&h-z}mf$z`fV;WngPX~sgyu1a{`9eDebu}@7$9bNM96PlC1R#oTThnB5^ZpjfK zMkXEqP01x@_*WqnHL9rHRez-w+$ElRd0B?!`TSqoDWxCw*~aTM%(pYF2vN`Fcx5Mr z-*SBq`vp;t%a!nBS9@*GPsUIqSa{Y^wwC|#Tw>t5Z1Eh(l))gg#`7_Byzx5?U|s9) z(w^gIU70^COs_W=6aD8JDz;x!aV}L6(;~TA7o?Xt$&<}@eVRO8>f26R(gq(4TCdW? zv@mTNGC-{w&n*$3VlE9-5*AtX2nx8-{YJM;gYU#;4o|V6MBQ4zyVKNv$|JdYUc9`& zfltyNI6ewT%ty6Iyfj-8yXUqCDNNy-TS%Yb*|YXP?Qoo(5}W4|-ylv}#pDHTc#|s; ztW)@I!kD8SKg&5G%i*6+ghvgZh25WNH{L-{{Khh^Z40zBf_zrG)QRk+tkV;K z_ZjB!bkW;e>Yt?(pW(lz8;QB6p`QEz9^-23XQdWH2+~)PiD{wZ1CKCo+ zje7L_i=ea@ln+NxYj~ylm@-!o_p*Kz2?xPoXaiu3b-sUJ0)|Bw{P337rKOq4?)X=DqWUZ?s>-+9XyUU1iI)dZ1rsk z%0zTVxyfdxXE_?m$%G^M*l(W5CY&VHaX6&?EsZ zaEg`+<*I<+7p$kLtkw*VeyQr+q?eUbYgjMbzUN%=#Xss|3rxYR-)vi(ABZ-OkKr+O z5!v{SX=O#+ub$f9?n?ebJa>^t?`2Dq zzQrEALnZ$!62G1C5z)*eI%-V>woo6T7vf`IxGdE)gcvQ8uo|f;FAyTvM}mp3pl?54 zhE;W3AyC4}2wlFG{^P%B?vYX?4Y}*$S1tPi{{WSoDUIe05qcWES5oFKzURi1Tj`l^ zgY1|jLTIII)L&<=;S?6;TXZ|M2X2Hu^!*8Q+619F0I%Lgz1g`fp&>F|ke4H3PmtV? z80&gw|MQRcWan;UX#=FQslgf%fuE zEn70GuH+QAV)0_(2MRSwSRmFrRDsy|oP^Vc96oR`TVl1oB^KOY&s6AQscW9#Jq{O< znXwGXRq9mc%H`$g!b#?eNat+Pp#xc4g2)w;8d)m=*ZqP#aT6y%I!oM>;C z7mtnSH9y%mLD<^J`ufaEzkhE%?LEEDZ8cVipsH3pm#-Wrf_oY zy4H+TjnrN?#DAV#EPNjxu65V`YubZ2C=@kmOp}@9z1SxT(ZYs&@2jnbz5thN;0vbJ zZN$c^R9{1)f7jy=x66f`ml9(k*G-{0LF1JXqA)yq05<#JoA8%8+u(HOzCYQhyu&OX zo_{4M1?V7Kj9D?lW-|?roBSdOV~_Q}cq`4coMTxjq4390<&rr{Baz=g+%Ek4Am(Ei zC-f9I*x~;GC$l}r0@!YP|V+vO~h&7%B5|(rSgSyf+Eh%ZB|5Gz(#Y?Vi;x zHxxb&Hi0Axw4sY%`xKaVjhPI;+`;u<8Z%xj*6}nE29Bpi`Mb7y0vUCnVGo)GAYL1@ z9Q)v-s(hg|%{F&T${Fb&_0Z@vW*$)p;goaN8Wux zX3)I+XwnNQgvE#dVl%%MzAB>viq&pO2my_1Jy$3* zXOFmLp%iW?kEn`PhGO^`64!K9CsJZDP> zK$M9omf`HEbp#Xq3f8^KWo@L*!dM7~9|GAllXnnd{ZsJE^?3RYQH=I5-B@lPFy=o1 z+JB6y#(4yzNZkwlN^!rq-(F+`yO@8%z-V)don%dVnG?-|J)31)26lpk=<2s@I$ z9;}~wQgl3dfc;yHeKwZ64Sn`}f~%DCGVIuD+d==Jj$J&&B_9QMOZun4dy=||f=(#3Y440p}P7$>@vhs$au+co(Y~VdXS;KOTJ+urP-5I_YWSHhk8u z2e@=ixt{)N1Dmj@zp4GeF$rKv^KPwXf?V8EcE``TIm*}JUHk*ks3T@`ANS0{xaS_V zM(yVjeLJm*Pvb^h9gNriyv1S5$4mC2KGin%wZhik*(OLZnPuv`!`SO^oSqBz!SQ?h z!b8LsS?YwHdWc`3LACP28hLkjAEape)_mnuO&wofDqL~~==Ss7cj;nj^WeiGM9v)t z8K?(h{k|DDcoWAePu3+PR}7=)RxfecugG*`>^L8TR6g~{s413g?Y*qKc!fBFR@*A% zD<;49b2YH>)N38XL?>B?)(WR&wt-m7sZXV<47pmXFEN5$fCLXhPRb|7vpAIRX3UNR zem$J8G^1ej-SclsRt9a-d3`d>qN5D3f8FmN>dEK2OXiX2!*A0tULHj6S3PSl8RVTlw;#7>uCzp&r+pUDl-GYeaml<$zh^LX z_dX;uc_U4{W2t{mcKdgcFqw_@yFn9_-h^z*lKo-;kkuyJG@Hgv3{df5CfF!H&n&Ns29KOgD|qF zYK$bW74DkVgBHCBB(%nPu*gKg!z^qHA2Nvk}g?@3kZcbZRttXU7; z1w%;*kG3Gg2#!V_|4^5l?ep;2nDG96!s40F9ZH#6rH2OEvDr$OALepp@cGA382ghT z1(KMGkOrP-C+UK*gpaL`UNgL+R5AW3K53~CwZEmj6rVbE_FK(s7#~UFChY{5iwnfi z`xeOw0MHnHu=cgIH}?DU9x9FV@lv5Xo+vgs%WC|)rvXA zW+ngQphI+ZCgY#EwyTC_+N0UbwWhSZFF{|koc9tGQ`u`_^O4D)Odc_!nHJkfy$N9r zJ!B%bzFlj=qTRQUQI;{Nr|fc$_HWYzvFcMPiMCn+u}S~zokM~LZKn0b#x z=vr(4wk0>FXpE=9dj7o3pygUcat;peRc@|fK_g3n$K*RSQ@~PtTS1rR!}?tZDYDJ# zBNu#%yLf8XsYPucuj}gLw*HPot2fPgq@b2(q=`>6H&FySwX&zCo0z8CG$y&jsIiSc z@8?Y)Kk6;Q2yTt)?%gixSS!v5Krp>3FKcsjhRai1RVE$L7(xa!L;N|&edi`9?a=%E zy=AGl5Uj8V3&TCs+VafkB5i2YY6KP(ZyY1tEl^C0H^IJy0wg-tn*(LA}W&Wx(0?hqTX#Ki{mJ0 z2NCBHDUZFM7q-L0WkAdbpM~!UY+)#{1 z*N)*(u3nq6Iv-YdzW3^Lw@UWaP8d7O%N=&B+e%N&2;P~I9CDh+`ygJMDLa1kT|=-& zJwb)ahv~Au%(v~^BquwP)%MJ)r~w`fHI{|TjxMmYPeqJoW0O-(+%_pyqD;K~or%>4#H@D0@nfAhY#N(d)< zy(u}x&#h&;us}^!_TblWE~2CMdziAzPpVwml`Eo155KWUvw3MICtnBfouAS_aqOXy}}UD9pV48z&{*a_+B4Ny8GYj zq9N@&1~a1nhjD?*#_2hi=*V!D^uKz%`Y)NUxa2>*nBq$EiMRx=dLN|(t|IEcsRx|( zt}Cm%nAH*4paYS3j%V&wc1Y?ckXPZlR)?P;GIdq43k9~OrdDfzTC0Fro6=00TC51< z808BlD+cRTVm_Q`&6z>MWjif`CObvfdLEK#^T zCcdWIaK5*b^;8u(BHnl!mB(>o1V_43H=tv?Ni!?-@E$}*i&Xz6;ybI$O!xh)IdG}s z%HFob1Ml0`3F;DG3DBK-$I)5>UmP->8YH0`bgUp_67SEea5LELAT%P)hgXgy7j70? zXjrV%>?oVw-D>oM_D#FhvSC~|d3>hR6)Qm<-@$iC@;;H#XU}Q%X{Lb!W|OuvKeYpm zz<#aCW*bxMbhhrDFUL!v8Jy<-1A>*ehfINciNKl7P0Qe?-+RLA1=iG^n>q96VPL#8 ziboFF!)Gj=7OiM zK`$rv;ZHA2ka$Gmn!BiJKm0qi=gWEQ$Ix7s8R(@$=<~Eb^-X}~x3ujV{XH+NZeVCb z8|lT-y(1>)lcehmt8uMb>@f2kf!%8!)qemImpkass(XpeWHy?)7ICcG$iknOGSoF3 zJ%MKLkAm|YqWNty1f8KF&!QEgLHP~yAH2H_i@7_2=Wox3@t&o~XCFzj?x9~{v}GUx zwH@VTkXwAqJHKgv%1_iUVWSooj0EAkmaTxR$7~@ z=F=YkvqYZmK;F2qpaPs_L8MYM)zr0&M=C2is%aO1AoD0KSX*wrPvduC4^AEfwj&j9wF^9p z71bAS(atQ`@~W(i*1BGCEIf09ItgVL9;5J*B*qt?apKQ?bX!i_8fq3a9!jiZQtR#{ zdEngkj|cxXL(>R9?1~m%%baJy*j~dQC|+C5Rq#B!HGYJsyf$^mR+$fxv)HWmb&#gR z4`@)4&IW7G92M!0lu->R^1IbBq~ zcKR+^Wcba`K;xM2Rs=$i+uV|!++L*-k{Y>B-?3MirGL%pN=J}Yk^ubwg7JK}r4~%x zrN6c6yI6X-iE?$A@&~$wr876bKT@qpWLB@&u5I(dB(U0gW}r$!%X~OwgNIukk%Khf zaP~6eU`uOaS2)aL-YIY{q-=V#v`HAGy<%>EWG@X8ELW%}$77I@(RV(J3@1mz`w!wU zW{^#rn&VJl@y;kLVhSLNf_g&UnXPz+j;gxD#vHA+J|_pV4??3Y?h^8s`F1@1q?epr zRPQGFFbWz>_!Z>+bySR5>@`};Ulw0$Ax3~((g*(l|BG%ZKfYzNU0d1}m)w2>Ryx-Z zc@MtNs|7z^BD85=@#<*eAImKJoFC{aQf4VKx>~jHX013J;&i7KM!C*Xe29gdG^X;W z>{=28b8V8=XS|*wZ+3izr_BCFvM8_0JuoUO3j7%p_jyGxs&zL`L^f{YlLltodi%!U z64RFNHS%I#C zvAQMO+OzrixFD$NI&4eG3>0t3QTC>hVSZ1g{<%`AR^=d*r**1+9*EQnMPOIx<9DEc z0CBC`b3ZM3rn%de#kn1IotrjQm|&UdtoMedn2?_|(H#^8h5);JMLpmMD*JZCb&z3%@)9=5PIb0?>z13{Bs;yxbI+)(~uAaITjdg5N zJuog;|fU`yKJ7yuAwL8dl-E#iecj9#D49E zMU#OnlPL+5byr?$ zrg5jBe`0UHC&^VwNXreMX@IMH+=ZB3RTw$uJI=N6zVW_t!J9uUS^LU#s6!u2XY52G z3lKbYd^HXmU29s_OgEI&3v5$@-u@6p5VQ^{#CYMenh)^cMf)#5>MBcQ�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

sBDCX0Xz+o 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 literal 0 HcmV?d00001 diff --git a/gh-pages/bear-war/docs/images/battle.jpg b/gh-pages/bear-war/docs/images/battle.jpg new file mode 100644 index 0000000000000000000000000000000000000000..df9ef9962c906f7d26f9a1ee03a31887ef9c1962 GIT binary patch literal 124230 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

5+N|IJkRSmXk=s_fmAf3bR)6Uyt_bH|N_x52Z` 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%0A@>8JkgyOcv&r(Ubq}9+hm5(! zJa37<#hwpM2aIG~2knK(N9`xeB%H6J4(S+jnWVa_3$9?wCTGkXh);u1rdn3zyLbXV z>W5jqpkzsIif=9&DzJ_oM@PMQWs!QA;7B3=EV`r$&A@|dULko`-x9(b!qe#&OGq&UQ7IeR(_P$+O_F?z+`cB z{F!9j>@amcL1-XDAlz7gHl~cHl84i}%i}hB^vHWtYgE}d+QvsozqM@_xEhRPgHs4= zmrihtJ8w2^aP?e*VXJ|mzR<^F#c_}JSlM8*Oz|vpN5G zzvHB5k<>0!sMFB&(T#JqiI?(B z3`Mpb$}-f|K#sPJ%Gl_Bwe8L5SG=4r)Ag06bKHWRTC`d2S@IZB>&8v9@B`?DGkPv) zujIM1T(q!#n!VTgnSY*yfve;_8cDdt@gJgRZ45b_K5_fIT4MTb6Z_babYKK1V9&s-|@0^cFun=g3u*<^pwC%LHW47*(NFCyv&^wJlkeWZ}*v>IseSv8Ih$hckZ=JA3$oMih_~tEzkL71!N(}B(yW7@^YuE0+{BU5 z=t#j*aQa}!M!mLzFRB}A6X=5E>|Knx{JLE(yUtdo<1|OwG{P?2&fPoMo z^5@;uyhItK2|8*eAtI)-0%+?gL0V$bFXy$>2N^5b!^6(5*(OnjlUaoSYqHrHx>eCo9W9t$`s38#mtRr9(BB#!(9}} zpEIC{9ue373ahQDVy>4txdsVCs6G?h;-bEq&Ht91%u!+386G1mfD4ZRWr*==^~hq<6}UO0ic3p;{8%Ro>R z2c`NV^)<=q2LSk{0ziM{LeI7LR)FCE5x_K=K(kkwVB|Q zHYgQW3`kYhR-4K?anRHex10rn{2JS?PIIAeWWvfA$wpUAMUL9|5q~c&f1I!wm8?nT zTzp7K&X7iJs2ws6Mv82tj1J=%2&x_vu;Aq_YqZxXgm7D?=aNj%T^HZ3o-3|SmD}!9 zq@jRHu+3*6N>^I8uLOzuJ!2pSd6|m=M_dIDi!6H}x79G+)MUE(;l>1R$|lM?XLw1vt}0p@|{c0ZpO-?s^+&HI%p7r_=D5_@Q*0C8B_ zGuGz!TKt)5${{v*#v;EN!IpnwQfXYGZ!+(f)o{(VL{`i&z?y)lQ`KW26ct4Sm5F>hfJ`IArPhB7nc1` zR=O9BOJzYYh2!*Zj75BIo3SOyH^|2tv+NOm*+WseDL8EVvEMVHN(mR2n%7d%65^zf z$&V}SZxC%jp443EdVf=Z9My)s*zw`E%yMNVz+J@%`=;G|bZ{}f#yM}!9=0r%Z?KEK zqrwU#S`%49EM2{IKAR9s5N;*IvgE(ud7npKJ+}%*R-UbF%SqIf?e(B@U_200qYJq3 z-c!t|C?R2KLJZ-XF~$PK@&W@vS1_BMf(tR!dB0Lg)xw#GrW?eY2_wACQzySt*GK}Q zu`w9l5skv+w@5ZE)u~^;v-r*p~0zBH5~DWl$-qtZZ*b;LuKwg-_b?99SD#XxM( zQj2Q=1AmuLZ6X7a81zw-EEiW1@2273n@4QG$3}rH{3%y;;}E0>?GEJqn_dae35z8G zx+@y~&i0?oNb1~s;GSv9h%y7EdJEb_>+}acUSuFDf09gXVy}*rsU}$-eR|xd;K;k21nG9&qXI#qbDmFzZ7Lk0cl6P?kv|`Qm8>*3Llvex26@JuL;f^| zhB#@j@vBmQDf#l*fS{dk!rcvHQM`I zRYlQv?rt&b9VP#M;Fef@B;fSmQpDsCuhJv0n915uWH46rA4lf2Ag{SUi{k*&g@K68 z0mtXx@fjQbskQDHS@uP?`)b$(w-yO(xr6}Lma)*hKSq@ata5;(Gx!nlmc(n=(F+i( z81SGN3T|ZoIC^U=b?S+RLATlA^jecTPS~#nPQdQQjzqCQECfi7+A;}wj*SAe$)T8S zd+oroY^o+1`NDqZe2&4s`UXFup*UcW;*9{5zZ^={;(~p^MP@vd-&AB=N?1&qlkgym zB)oH6+TnU~ZoIdI5X?Y`JiG+(Z;xDNv7?1&aJ{JY99W$9Sm$qmcQHXZh4*gT5}aDj zF_JV^i=_*$QPPl$Ml3nOs)8OO(^^fGGwjbk{$4DD|YuQ*y5$0mD&-wV8Gevb5f1nQC_IBqV+` zZvyAn0?NE2NNaIrAfkN7>fs%3fP!<84fV0%dw`)|B1+Gc;H&~b|8HEJIi=`Q+g`HL ztSNA2-UvHt?e~WV^Sy(}420-FPScQ7NdCd#CG~A6(Dr~R z#Iw}aQ4A;aC7iDJr)t9WZEzsZZzWXu)!lz1kMK*MaMUo^7K8m) z`PS%t5utR@Gu5Z0{+6>_O7+p&%z#vPkyzmblJ0o@zqN*d-MwU4z5lley3B0jiQb@@ zgwsconH?#k&v;e4A5}G8eQPzpCT>TcsO%?H12)!FnHPkLcP*5e(`>uoVc{+?Q;@6{ zi0}jEE{OnSm^T==oXgEs&Ua11Q~z5gkk8dbSaz5njAK>6O9H!=G7;_?=EsiQX9@A{ zm)>-=X-YtM#>Z4A_>42L04MEDfAP_ujBs*w7OHMsxAb|AV+Xx?{oflu`U_Mi% z00H-UYIiry)gUD7{J5@>j{871ko6#*|0_SJ24w6->^Ml-ucEfTsI+`$Frzb7ZPzz7 zrK%0#XOh2}PXql=wkqHLYOGd;z7y5L z-V6Aesr9kU9f>)_N2|oPYtk&~n>?8eF7*>c?as%7Nv1=TiFW@kcq=r-EzYZf#?EhI z4N`)()5>Fwn}+%Ezam9&LQY(qlNekKsUCVrB6HAe%T*eNW2P(Z0M8>0`B;EGi`fQE z8Uv^Q><&6-bKuM>pdKnVCItx}j5`6{8heWW@e}A@2qWa?+Q%r6(J6WZtFj8GKM<5Y$SB~@S4Kac%Mfhk z-?9=V-M{sQH_Q)BU)T)zJQMqit=6!iNKq9hx|m;K;0;z=_-K^4#tUR&BPu)dL*z!X zQjgX7<}`%ti!w--mUmbf^g|qXuTDKE42yd1SHx)dHmZ1J`1-4bdZWeKFu01;5xNmJi+hK=jX+S-jU(Z!iCOV0+TJ0UJS%WsmOY&F*r58IZwKK=w0U6{i%5En-1Jw#on)dXXHwyH48p01Z{z@ zoqlZxDrtEBOL7SPZ146w5Y6?YNyc|m4`2ClSsf4XlyB}Lbu%)_VsKU5uKbpKU-+K# z0I8bj&!o#yE_DI2*7)SF-Aa1);_a z`YBK5bm^cVVl3tK6(*tcnQl4*DaScdaEaR0_ltkXgAoF|i6J#$KOV|7VcJGbpln>L zg9|&&!@b`f`ZvZc@)Vi>i?1CwU7_ML00+Jc1-H9@!BslON}uWhs4}swkz<}P-!PVzA4cD~(tLZw zZ!?LTKGHi3yFCTQi1Tvh;Wuu5AJ)co;N8}zzfD0(v?qXkPSTL{O#lx)$F(-XPFIbC zVI$-`FVk9nw+vQx?e-LR_h_0!`yHhZ#}U!BWwdNe%`pU|V&G{PfBXj$DBr8P~YzxM)VZ*y5dV~h_ z!D1(Z|9$c`!!EIHT4BPW${CWU5p${BI3(4O1>6k%3@g%lqt8XS<=^Onwmc06;&B+s zr;X}Oxj-F4(2oE3(#z~}h4@eas0Icij`uYncPUl?4}l87Uh6sMt2$X}Q}eyBDt2X1 z-7>A>6*fkSeo3D1H!3V5=$kQ4GTMH;ubXBvm@y<=GzomL!d)IVjX!e#xDKaP=;=*l zs|6=Wf|hu_j-93k_Mn;o0Ycd=R7(mH%jTDY9Ta0C%p!p2K4dp&T!#0K3AJ9Ie(#>S zeU|HGDbc*fF##}f2LLme?!r2)H!YXU$BLb2+lM2& zXQE#bwLQZKLaktj%|Pq~4kxp^^GC&DPA!$PG-A^Nzazcj0W$=}s{VvGZ9a~Jlta!a zYo7G#$gx^ZIuFLvWD)%G35-;N%{B;9o&%&ntMmb^NQ8gt-_EnS4;ov2E`p + + + + + Sprite Gallery + + + + + + diff --git a/gh-pages/bear-war/index.html b/gh-pages/bear-war/index.html new file mode 100644 index 00000000..303d1fcd --- /dev/null +++ b/gh-pages/bear-war/index.html @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + Bear War + + +

+ + + + diff --git a/gh-pages/bear-war/package-lock.json b/gh-pages/bear-war/package-lock.json new file mode 100644 index 00000000..bc4de978 --- /dev/null +++ b/gh-pages/bear-war/package-lock.json @@ -0,0 +1,1547 @@ +{ + "name": "bear-war", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "bear-war", + "version": "0.1.0", + "license": "MIT", + "dependencies": { + "ws": "^8.22.0" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "@types/ws": "^8.18.2", + "typescript": "^7.0.2", + "vite": "^8.3.4", + "vitest": "^5.0.4" + }, + "engines": { + "node": ">=22.18" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.154.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.154.0.tgz", + "integrity": "sha512-KYOUGQFlLWR+s4QMV2Q9ko38TZm7EdO87Qm7rvZLqk/webse/cDjtyxKhg/47l0byCbs/k46LT8qc4PKjAikzA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/oxc-project" + } + }, + "node_modules/@rolldown/binding-android-arm-eabi": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.14.tgz", + "integrity": "sha512-aWR+XKH9+rW7m5fWq/5360J3mHycrx/o6xZM2DQFdJnTMdyP7/WzxPjhtLbVWdKo2y612/4U1bWqb3HQhiF7vg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.14.tgz", + "integrity": "sha512-QiVxHofl6A8lQU7WAtOUY4QMVZXohxwz7WJJoeh8NBEUyKfIkMRKhYyq8az/1RypNiIXzNTtiS6KwHz0erxYRQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.14.tgz", + "integrity": "sha512-lTQOzHuBIooh3jIr5XINQkjPGEuGy+Dsy0meXDr6Sjty0b99kYgGh4uBC/8mJX1T0kC8d6S0aPP1Y+bHkh6aBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.14.tgz", + "integrity": "sha512-hyMKEGatjmBYCs/Q4sbIcL/GqpFEqsX3wc+HYVQ/85A4rxc1o7rPm4DUI4GV4k/h0ITddwFSGpqQunI14GXjTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.14.tgz", + "integrity": "sha512-u371GbXoVXrEgP20PwzoGNOdAOqAMhzo19JzzY+V4YmmnC+DTOoqdbMu+O+54emM59Mik/UdrtWgcuEDKWRPKA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.14.tgz", + "integrity": "sha512-BqwKsdZQf6EqVWr8fv2MrDYzAvOZ7+NYFSJESRGs87fkseEhs0TU9TXgRJTkZoRRkxTizyEg1LKUXfU3/78uqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.14.tgz", + "integrity": "sha512-SD9kY8M009x/7s57Z1dgUVlP5+uYBgKQK0Tkr1WIWngrtUJmRCW1RJFMY7QDla2khXhmg2i5Io7MUjcmZNZ/KQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.14.tgz", + "integrity": "sha512-BJ2CL19M6uuWesMgnqJzgxoI+N+eV7b0A9O9vLV42y+gitC41fNa8KElau1tHVzuLWX5GzEuxMPhAO0tQXQMkg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.14.tgz", + "integrity": "sha512-4+Kl8daPV8NDHaD7mfCcc1pRM+Qg7ab8DyMm5FG5WoZgv4h2i3KFN1CybYBNbQqmuYm7ftJLvmMI1eHYJ22S/Q==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.14.tgz", + "integrity": "sha512-V/h8ePq1fKDrmtFMz6xcCN3DKvuvMRrwwD1q0PkBeeo4DpK64biaKvqWGVxqCeYKqeRk7gE7/iKPxLCn08O97A==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.14.tgz", + "integrity": "sha512-WxalXzgrLVpKC93xfpaJ3gCXUWzeCr+FRCbDVD2mUAy4L6Vo3lNVz0epUEUpxPWJkLNYUwM4OSIikv36OJ9mLg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.14.tgz", + "integrity": "sha512-3G3tnAmTpdk9jO3GQtyHXqeFD2SlecfTws/Y3M2mmj9nMBwwuLT4kur8sZKeu7ENUHSpVO3cIsm5A52LEWWwlg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.14.tgz", + "integrity": "sha512-qyr5XiNGxm2ytx17vvtcJ9nXn6v1gENETi503bJx3OGwZ1tSonj7BosxbMa/xeAfSl5iS9X6KjEWYXLu6v1L7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.14.tgz", + "integrity": "sha512-oA4o5o0S2yB199YyYXDzKUv1Z56Rc+mgOjwm7Z/vJ9Z8igiDPYvoOuDyo6g7AK/ZaF09nPhDKVnGBcYHO11GXA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.14.tgz", + "integrity": "sha512-9mE94bf2GZUaR/fUSXCMpBoWkctu421Mi0ORpm4zqfbkjKep7RxzPFa5etcGs5h+2KApoWBOA/hAJa/7saGgEQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "26.6.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.5.tgz", + "integrity": "sha512-uMzsv9stDdofIZBx4UGh4Z9ZgXnGC9seRDscilBcTHAeOFk67MZg/k9bWVfO+bk4qZmG+l52hRnLwUiH2LnJTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.9.0" + } + }, + "node_modules/@types/ws": { + "version": "8.18.2", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz", + "integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@vitest/mocker": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.4.tgz", + "integrity": "sha512-tqAlrYWce4iUxhr3ncNohejtCTOcHeE8XWrSNZY3OhPoDn01yeGv+gnaBy64ATp7o6pb03eIbBAjzmibpc9vIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.4", + "estree-walker": "^3.0.3", + "magic-string": "^1.4.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9 || ^3.0.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.4.tgz", + "integrity": "sha512-MZKk/gSkm2q+ICobMxCpH1mA+cCAWm8WV7UhxlqGjq/sJ3AGc1BqFOs5CRVXxH78wQtme9q/NAAgBquj4AcyMw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/chai": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/lightningcss": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", + "integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.33.0", + "lightningcss-darwin-arm64": "1.33.0", + "lightningcss-darwin-x64": "1.33.0", + "lightningcss-freebsd-x64": "1.33.0", + "lightningcss-linux-arm-gnueabihf": "1.33.0", + "lightningcss-linux-arm64-gnu": "1.33.0", + "lightningcss-linux-arm64-musl": "1.33.0", + "lightningcss-linux-x64-gnu": "1.33.0", + "lightningcss-linux-x64-musl": "1.33.0", + "lightningcss-win32-arm64-msvc": "1.33.0", + "lightningcss-win32-x64-msvc": "1.33.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz", + "integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz", + "integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz", + "integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz", + "integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz", + "integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz", + "integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz", + "integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz", + "integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz", + "integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz", + "integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.33.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz", + "integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/magic-string": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz", + "integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/obug": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/obug/-/obug-3.0.0.tgz", + "integrity": "sha512-5vvB5+W7ePv+p3uqxi+RcW1XAzLW0/hxt3/4X4Lc4qHudzOhmBiBwOY6DRob4WnanAEGvNcLjF+KNOufrUoEQw==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rolldown": { + "version": "1.2.14", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.14.tgz", + "integrity": "sha512-eTBTTqMR6uHKLgcUqpJP8ntzDI6V4LYA8negAMBEI7gkgVvOdrx6VsvS2Ym+83KhOPdLCHWJu0w7UQFJZhHKNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.154.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm-eabi": "1.2.14", + "@rolldown/binding-android-arm64": "1.2.14", + "@rolldown/binding-darwin-arm64": "1.2.14", + "@rolldown/binding-darwin-x64": "1.2.14", + "@rolldown/binding-freebsd-x64": "1.2.14", + "@rolldown/binding-linux-arm-gnueabihf": "1.2.14", + "@rolldown/binding-linux-arm64-gnu": "1.2.14", + "@rolldown/binding-linux-arm64-musl": "1.2.14", + "@rolldown/binding-linux-ppc64-gnu": "1.2.14", + "@rolldown/binding-linux-s390x-gnu": "1.2.14", + "@rolldown/binding-linux-x64-gnu": "1.2.14", + "@rolldown/binding-linux-x64-musl": "1.2.14", + "@rolldown/binding-openharmony-arm64": "1.2.14", + "@rolldown/binding-win32-arm64-msvc": "1.2.14", + "@rolldown/binding-win32-x64-msvc": "1.2.14" + } + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/std-env": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.2.tgz", + "integrity": "sha512-nIZ2N2dFpjBsg2hgitKu7mA+FvsIJuGJOwysRngmVRRmWgR/UyPjwJj8c/fT/dhm8EkHnF5EOogRf+0Z2qrimA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz", + "integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz", + "integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vite": { + "version": "8.3.4", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz", + "integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.33.0", + "picomatch": "^4.0.7", + "postcss": "^8.5.29", + "rolldown": "~1.2.12", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.7.1", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.4.tgz", + "integrity": "sha512-z0WxZRV2Sg48wF0FSgMq3TFG8JS38/Qr96vZVZdX6+Em9raQTvnXQvdTFJe2m3rfyJnEHeHQ/snceIdi8D1pVg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.4", + "chai": "^6.3.0", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.4.3", + "obug": "^3.0.0", + "picomatch": "^4.0.7", + "std-env": "^4.3.0", + "tinybench": "^6.2.2", + "tinyexec": "^1.3.1", + "tinyglobby": "^0.2.17", + "why-is-node-running": "3.2.1" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.4", + "@vitest/browser-preview": "5.0.4", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.4", + "@vitest/coverage-v8": "5.0.4", + "@vitest/ui": "5.0.4", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/why-is-node-running": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz", + "integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==", + "dev": true, + "license": "MIT", + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=20.11" + } + }, + "node_modules/ws": { + "version": "8.22.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz", + "integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/gh-pages/bear-war/package.json b/gh-pages/bear-war/package.json new file mode 100644 index 00000000..4652fc07 --- /dev/null +++ b/gh-pages/bear-war/package.json @@ -0,0 +1,46 @@ +{ + "name": "bear-war", + "version": "0.1.0", + "description": "Open-source real-time strategy game for desktop and mobile browsers: three asymmetric races, skirmish vs AI offline, and online multiplayer.", + "license": "MIT", + "type": "module", + "private": true, + "engines": { + "node": ">=22.18" + }, + "scripts": { + "dev": "vite", + "dev:server": "node --watch server/index.ts", + "build": "vite build", + "preview": "vite preview", + "start": "node server/index.ts", + "typecheck": "tsc --noEmit -p .", + "test": "vitest run", + "simtest": "node scripts/simtest.ts", + "icons": "node scripts/make-icons.ts", + "docs": "node scripts/gen-docs.ts", + "battle": "node scripts/battle.ts", + "mapview": "node scripts/mapview.ts" + }, + "dependencies": { + "ws": "^8.22.0" + }, + "devDependencies": { + "@types/node": "^26.6.5", + "@types/ws": "^8.18.2", + "typescript": "^7.0.2", + "vite": "^8.3.4", + "vitest": "^5.0.4" + }, + "keywords": [ + "rts", + "strategy", + "game", + "html5", + "canvas", + "mobile", + "multiplayer", + "lockstep", + "pwa" + ] +} diff --git a/gh-pages/bear-war/public/apple-touch-icon.png b/gh-pages/bear-war/public/apple-touch-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..a044c7fb76337c8454e8e901a2a85c3530ba14d9 GIT binary patch literal 12023 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 literal 0 HcmV?d00001 diff --git a/gh-pages/bear-war/public/icon-192.png b/gh-pages/bear-war/public/icon-192.png new file mode 100644 index 0000000000000000000000000000000000000000..3bd4653ccaf2d6ee0e0ab512ade0e39bb269dfaf GIT binary patch literal 12786 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 S;~W5}pR0P;b*r_p;r|DCZ|Rl* literal 0 HcmV?d00001 diff --git a/gh-pages/bear-war/public/icon-512.png b/gh-pages/bear-war/public/icon-512.png new file mode 100644 index 0000000000000000000000000000000000000000..d2a1585cbbeaaf6d1963cb56c23fa526430a2bb6 GIT binary patch literal 35855 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

!<6t@Kix6vbp#8+C$L!d19oh8c;x_D}&A#?&1 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 + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/gh-pages/bear-war/public/manifest.webmanifest b/gh-pages/bear-war/public/manifest.webmanifest new file mode 100644 index 00000000..e70df848 --- /dev/null +++ b/gh-pages/bear-war/public/manifest.webmanifest @@ -0,0 +1,20 @@ +{ + "name": "Bear War", + "short_name": "Bear War", + "description": "Open-source real-time strategy: three asymmetric races, skirmish vs AI offline, online multiplayer.", + "id": "./", + "start_url": "./", + "scope": "./", + "display": "fullscreen", + "display_override": ["fullscreen", "standalone"], + "orientation": "landscape", + "background_color": "#120a04", + "theme_color": "#120a04", + "categories": ["games", "strategy"], + "icons": [ + { "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" }, + { "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" }, + { "src": "icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }, + { "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" } + ] +} diff --git a/gh-pages/bear-war/scripts/battle.ts b/gh-pages/bear-war/scripts/battle.ts new file mode 100644 index 00000000..9fc072d5 --- /dev/null +++ b/gh-pages/bear-war/scripts/battle.ts @@ -0,0 +1,110 @@ +// Balance lab: equal-cost armies fight on an open arena. node scripts/battle.ts [trials] +import { Game } from '../src/game/game.ts'; +import { MapBuilder } from '../src/game/map/builder.ts'; +import { DEFS } from '../src/game/data/index.ts'; +import type { RaceId } from '../src/game/data/types.ts'; +import { TILE } from '../src/game/constants.ts'; +import { useInstant } from '../src/game/systems/abilities.ts'; + +const ARMIES: Record = { + ironpaw: [['ranger', 12], ['grizzly', 4], ['medic', 2], ['kodiak', 2]], + horde: [['scrapper', 20], ['stinger', 6], ['buzzbomb', 4]], + starborn: [['spiritbear', 5], ['moonbear', 5], ['polarcolossus', 1]], +}; + +function arena(): MapBuilder { + const b = new MapBuilder('arena', 'Arena', 72, 48, 'none', 'badlands', 1); + b.fillElev(0); + b.base(6.5, 6.5, -2.3, { start: true }); + b.base(65.5, 41.5, 0.8, { start: true }); + return b; +} + +function value(es: { def: { cost: { m: number; g: number } } }[]): number { + return es.reduce((s, e) => s + e.def.cost.m + e.def.cost.g * 1.5, 0); +} + +function fight(a: RaceId, b: RaceId, seed: number, upgrades: boolean): { winner: number; left: number; time: number } { + const map = arena().build(); + const g = new Game({ map, seed, players: [{ name: 'A', race: a, color: 0, team: 0 }, { name: 'B', race: b, color: 1, team: 1 }] }); + // research everything relevant so both sides fight at full strength + for (const p of g.players) { + for (const r of ['honeyrush', 'thickfur', 'zoomies', 'busybees', 'longstingers', 'pounce', 'moonstep']) p.upgrades[r] = 1; + if (upgrades) for (const r of ['inf_w', 'inf_a', 'veh_w', 'claws', 'stings', 'hide', 'ground_w', 'ground_a']) p.upgrades[r] = 1; + } + // remove workers so they don't wander into the fight + for (const e of g.entities) if (e.def.worker || e.def.id === 'bumblebear') g.removeSilently(e); + const spawn = (pi: number, race: RaceId, cx: number, cy: number) => { + let k = 0; + for (const [id, n] of ARMIES[race]) { + for (let i = 0; i < n; i++) { + const u = g.spawnUnit(id, pi, cx + ((k % 6) - 2.5) * 22, cy + (Math.floor(k / 6) - 2) * 22); + u.energy = u.def.energy ?? 0; + k++; + } + } + }; + spawn(0, a, 22 * TILE, 24 * TILE); + spawn(1, b, 50 * TILE, 24 * TILE); + const army = (pi: number) => g.entities.filter((e) => e.alive && e.owner === pi && e.isUnit && e.def.kind === 'unit'); + for (const pi of [0, 1]) { + g.step([{ p: pi, c: { c: 'amove', ids: army(pi).map((e) => e.id), x: (pi === 0 ? 50 : 22) * TILE, y: 24 * TILE } }]); + } + for (let t = 0; t < 20 * 120; t++) { + // minimal micro: kodiaks brace up, stim when engaged + if (t % 10 === 0) { + for (const e of g.entities) { + if (!e.alive || e.owner < 0) continue; + if (e.def.id === 'kodiak' && t > 40) useInstant(g, e, 'brace'); + if ((e.def.id === 'ranger' || e.def.id === 'grizzly') && e.target && e.rushT === 0 && e.hp > 30) useInstant(g, e, 'honeyrush'); + } + } + // keep idle survivors hunting the enemy + const cmds: { p: number; c: { c: 'amove'; ids: number[]; x: number; y: number } }[] = []; + if (t % 40 === 0) { + for (const pi of [0, 1]) { + const foes = army(1 - pi); + const idle = army(pi).filter((e) => e.orders.length === 0 && !e.target); + if (!foes.length || !idle.length) continue; + const fx = foes.reduce((s, e) => s + e.x, 0) / foes.length; + const fy = foes.reduce((s, e) => s + e.y, 0) / foes.length; + cmds.push({ p: pi, c: { c: 'amove', ids: idle.map((e) => e.id), x: fx, y: fy } }); + } + } + g.step(cmds); + const A = army(0).length; + const B = army(1).length; + if (!A || !B) { + const w = A ? 0 : 1; + const v0 = value(ARMIES[[a, b][w]].flatMap(([id, n]) => Array(n).fill({ def: DEFS[id] }))); + return { winner: w, left: Math.round((value(army(w)) / v0) * 100), time: g.tick / 20 }; + } + } + if (process.env.DEBUG) { + for (const pi of [0, 1]) console.log(' draw leftovers p' + pi, army(pi).map((e) => `${e.def.id}@${(e.x / TILE).toFixed(0)},${(e.y / TILE).toFixed(0)} o=${e.orders[0]?.t} t=${e.target} morph=${e.morphTo}`).join(' ')); + } + return { winner: -1, left: 0, time: 120 }; +} + +const trials = Number(process.argv[2] ?? 4); +const races: RaceId[] = ['ironpaw', 'horde', 'starborn']; +for (const up of [false, true]) { + console.log(up ? '\n--- with +1 upgrades' : '--- no upgrades'); + for (let i = 0; i < races.length; i++) { + for (let j = i + 1; j < races.length; j++) { + const res: string[] = []; + let wa = 0; + for (let t = 0; t < trials; t++) { + const flip = t % 2 === 1; + const r = fight(flip ? races[j] : races[i], flip ? races[i] : races[j], 100 + t, up); + const winnerRace = r.winner < 0 ? 'draw' : (flip ? [races[j], races[i]] : [races[i], races[j]])[r.winner]; + if (winnerRace === races[i]) wa++; + res.push(`${winnerRace}(${r.left}% left, ${r.time}s)`); + } + console.log(`${races[i]} vs ${races[j]}: ${wa}/${trials} for ${races[i]} | ${res.join(', ')}`); + } + } +} +const cost = (r: RaceId) => ARMIES[r].reduce((s, [id, n]) => s + (DEFS[id].cost.m + (DEFS[id].morphFrom ? DEFS[DEFS[id].morphFrom!].cost.m / 2 : 0)) * n / (DEFS[id].count ?? 1), 0); +const gas = (r: RaceId) => ARMIES[r].reduce((s, [id, n]) => s + DEFS[id].cost.g * n / (DEFS[id].count ?? 1), 0); +console.log('\narmy cost (m/g):', races.map((r) => `${r} ${cost(r)}/${gas(r)}`).join(', ')); diff --git a/gh-pages/bear-war/scripts/gen-docs.ts b/gh-pages/bear-war/scripts/gen-docs.ts new file mode 100644 index 00000000..4a246bf0 --- /dev/null +++ b/gh-pages/bear-war/scripts/gen-docs.ts @@ -0,0 +1,69 @@ +// Generates docs/UNITS.md from the game data: node scripts/gen-docs.ts +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { ABILITY_DEFS, DEFS, RACES, RACE_IDS, RESEARCH_DEFS, RESEARCHED_AT, PRODUCERS } from '../src/game/data/index.ts'; +import type { EntityDef, WeaponDef } from '../src/game/data/types.ts'; + +const out: string[] = []; +const cost = (d: { cost: { m: number; g: number } }) => (d.cost.g ? `${d.cost.m} / ${d.cost.g}` : `${d.cost.m}`); +const weapon = (w: WeaponDef) => { + let s = `${w.name}: ${w.dmg}${w.hits && w.hits > 1 ? `×${w.hits}` : ''}`; + if (w.bonus) s += ` (${Object.entries(w.bonus).map(([k, v]) => `+${v} ${k}`).join(', ')})`; + s += `, range ${w.range < 1 ? 'melee' : w.range}, ${w.cd}s, ${w.targets}`; + if (w.splash) s += ', splash'; + if (w.bounce) s += ', bounces'; + return s; +}; +const name = (id: string) => DEFS[id]?.name ?? id; + +out.push('# Units & structures reference', ''); +out.push('_Generated from `src/game/data` by `node scripts/gen-docs.ts` - do not edit by hand._', ''); +out.push('Costs are minerals / gas; times are in game seconds; ranges and speeds in tiles.', ''); + +for (const r of RACE_IDS) { + const race = RACES[r]; + out.push(`## ${race.name}`, '', race.desc, ''); + out.push('### Units', ''); + out.push('| Unit | Cost | Supply | Time | HP | Armor | Speed | Weapons | Produced at | Requires |'); + out.push('|---|---|---|---|---|---|---|---|---|---|'); + const units = Object.values(DEFS).filter((d) => d.race === r && d.kind === 'unit' && d.order !== undefined).sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); + for (const u of units) { + const hp = u.shields ? `${u.hp} + ${u.shields} shields` : `${u.hp}`; + const src = u.base ? `mode of ${name(u.base)}` : u.lifetime ? `released by Den Mothers` : u.morphFrom ? `grown from ${name(u.morphFrom)}` : (PRODUCERS[u.id] ?? []).filter((b) => !DEFS[b].counts).map(name).join(', '); + const tags = [u.air ? 'air' : '', u.detector ? 'detector' : '', u.cloaked ? 'hidden' : '', u.worker ? 'worker' : '', u.radius ? `radius ${u.radius}` : ''].filter(Boolean).join(', '); + out.push( + `| **${u.name}**${tags ? ` _(${tags})_` : ''} | ${cost(u)} | ${(u.supply ?? 0) * (u.count ?? 1)}${u.count && u.count > 1 ? ` (×${u.count})` : ''} | ${u.time} | ${hp} | ${u.armor} | ${u.speed} | ${(u.weapons ?? []).map(weapon).join('
') || '-'} | ${src || '-'} | ${(u.requires ?? []).map(name).join(', ') || '-'} |`, + ); + } + out.push(''); + for (const u of units) { + const abs = (u.abilities ?? []).filter((a) => !['gather', 'return', 'build', 'build2', 'repair'].includes(a)); + if (abs.length) out.push(`- **${u.name}** - ${u.desc} Abilities: ${abs.map((a) => `*${ABILITY_DEFS[a].name}* (${ABILITY_DEFS[a].desc})`).join('; ')}`); + else out.push(`- **${u.name}** - ${u.desc}`); + } + out.push('', '### Structures', ''); + out.push('| Structure | Cost | Time | HP | Size | Provides | Requires | Notes |'); + out.push('|---|---|---|---|---|---|---|---|'); + const blds = Object.values(DEFS).filter((d) => d.race === r && d.kind === 'building').sort((a, b) => (a.order ?? 99) - (b.order ?? 99)); + for (const b of blds) { + const hp = b.shields ? `${b.hp} + ${b.shields}` : `${b.hp}`; + const notes: string[] = []; + if (b.produces) notes.push(`${b.spawnsCubs ? 'cubs grow into' : 'trains'} ${b.produces.map(name).join(', ')}`); + if (b.researches?.length) notes.push(`researches ${b.researches.map((x) => RESEARCH_DEFS[x].name).join(', ')}`); + if (b.weapons) notes.push(b.weapons.map(weapon).join('; ')); + if (b.detector) notes.push('detector'); + if (b.counts) notes.push(`evolved from ${name(b.counts[b.counts.length - 1])}`); + out.push(`| **${b.name}** | ${cost(b)} | ${b.time} | ${hp} | ${b.size?.join('×')} | ${b.provides ? `${b.provides} supply` : '-'} | ${(b.requires ?? []).map(name).join(', ') || '-'} | ${notes.join('; ') || '-'} |`); + } + out.push('', '### Research', ''); + out.push('| Research | Levels | Cost (per level) | Time | At |'); + out.push('|---|---|---|---|---|'); + for (const rd of Object.values(RESEARCH_DEFS).filter((x) => x.race === r)) { + out.push(`| **${rd.name}** - ${rd.desc} | ${rd.costs.length} | ${rd.costs.map((c) => cost({ cost: c })).join(', ')} | ${rd.times.join(', ')} | ${name(RESEARCHED_AT[rd.id])} |`); + } + out.push(''); +} +void ({} as EntityDef); +const file = path.resolve(import.meta.dirname, '..', 'docs', 'UNITS.md'); +writeFileSync(file, out.join('\n') + '\n'); +console.log('wrote', file); diff --git a/gh-pages/bear-war/scripts/make-icons.ts b/gh-pages/bear-war/scripts/make-icons.ts new file mode 100644 index 00000000..4a9a497a --- /dev/null +++ b/gh-pages/bear-war/scripts/make-icons.ts @@ -0,0 +1,125 @@ +// Generates the app icons (PNG) procedurally, with no dependencies: npm run icons +// Writes public/icon-192.png, icon-512.png, icon-maskable-512.png, apple-touch-icon.png. + +import { deflateSync } from 'node:zlib'; +import { writeFileSync } from 'node:fs'; +import path from 'node:path'; + +type RGBA = [number, number, number, number]; + +const OUT = path.resolve(import.meta.dirname, '..', 'public'); + +function crc32(buf: Uint8Array): number { + let c = ~0; + for (let i = 0; i < buf.length; i++) { + c ^= buf[i]; + for (let k = 0; k < 8; k++) c = c & 1 ? (c >>> 1) ^ 0xedb88320 : c >>> 1; + } + return ~c >>> 0; +} + +function chunk(type: string, data: Uint8Array): Buffer { + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length); + const td = Buffer.concat([Buffer.from(type, 'ascii'), Buffer.from(data)]); + const crc = Buffer.alloc(4); + crc.writeUInt32BE(crc32(td)); + return Buffer.concat([len, td, crc]); +} + +function png(w: number, h: number, px: Uint8Array): Buffer { + const raw = Buffer.alloc((w * 4 + 1) * h); + for (let y = 0; y < h; y++) { + raw[y * (w * 4 + 1)] = 0; + Buffer.from(px.buffer, px.byteOffset + y * w * 4, w * 4).copy(raw, y * (w * 4 + 1) + 1); + } + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(w, 0); + ihdr.writeUInt32BE(h, 4); + ihdr[8] = 8; + ihdr[9] = 6; + return Buffer.concat([ + Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), + chunk('IHDR', ihdr), + chunk('IDAT', deflateSync(raw, { level: 9 })), + chunk('IEND', new Uint8Array()), + ]); +} + +function lerp(a: RGBA, b: RGBA, t: number): RGBA { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t, a[3] + (b[3] - a[3]) * t]; +} + +function over(dst: RGBA, src: RGBA): RGBA { + const a = src[3] + dst[3] * (1 - src[3]); + if (a <= 0) return [0, 0, 0, 0]; + return [ + (src[0] * src[3] + dst[0] * dst[3] * (1 - src[3])) / a, + (src[1] * src[3] + dst[1] * dst[3] * (1 - src[3])) / a, + (src[2] * src[3] + dst[2] * dst[3] * (1 - src[3])) / a, + a, + ]; +} + +/** Sample the icon at normalised coords (0..1). */ +function sample(u: number, v: number, maskable: boolean): RGBA { + const x = u * 512; + const y = v * 512; + // background + let col: RGBA = [0, 0, 0, 0]; + const r = maskable ? 0 : 104; + const inRound = (() => { + if (maskable) return true; + const dx = Math.max(r - x, 0, x - (512 - r)); + const dy = Math.max(r - y, 0, y - (512 - r)); + return dx * dx + dy * dy <= r * r; + })(); + if (inRound) { + col = lerp([70, 42, 20, 1], [18, 10, 4, 1], v); + const d = Math.hypot(x - 256, y - 250) / 260; + col = over(col, [230, 150, 40, Math.max(0, 0.5 - d * 0.6)]); + } + // paw-print emblem (scaled down for maskable safe zone) + const s = maskable ? 0.72 : 0.9; + const ex = (x - 256) / s + 256; + const ey = (y - 256) / s + 256; + const pad = ((ex - 256) / 122) ** 2 + ((ey - 330) / 100) ** 2 < 1; + const toe = PAW_TOES.some(([cx, cy, r]) => (ex - cx) ** 2 + (ey - cy) ** 2 < r * r); + if (pad || toe) { + col = over(col, lerp([255, 226, 130, 1], [196, 120, 30, 1], Math.max(0, Math.min(1, (ey - 70) / 360)))); + if (ex > 256) col = over(col, [0, 0, 0, 0.12]); + } + return col; +} + +const PAW_TOES: [number, number, number][] = [[128, 206, 50], [204, 128, 54], [308, 128, 54], [384, 206, 50]]; + +function render(size: number, maskable: boolean): Buffer { + const px = new Uint8Array(size * size * 4); + const ss = 4; + for (let y = 0; y < size; y++) { + for (let x = 0; x < size; x++) { + let acc: [number, number, number, number] = [0, 0, 0, 0]; + for (let j = 0; j < ss; j++) { + for (let i = 0; i < ss; i++) { + const c = sample((x + (i + 0.5) / ss) / size, (y + (j + 0.5) / ss) / size, maskable); + acc = [acc[0] + c[0] * c[3], acc[1] + c[1] * c[3], acc[2] + c[2] * c[3], acc[3] + c[3]]; + } + } + const n = ss * ss; + const a = acc[3] / n; + const o = (y * size + x) * 4; + px[o] = a > 0 ? Math.round(acc[0] / acc[3]) : 0; + px[o + 1] = a > 0 ? Math.round(acc[1] / acc[3]) : 0; + px[o + 2] = a > 0 ? Math.round(acc[2] / acc[3]) : 0; + px[o + 3] = Math.round(a * 255); + } + } + return png(size, size, px); +} + +writeFileSync(path.join(OUT, 'icon-192.png'), render(192, false)); +writeFileSync(path.join(OUT, 'icon-512.png'), render(512, false)); +writeFileSync(path.join(OUT, 'icon-maskable-512.png'), render(512, true)); +writeFileSync(path.join(OUT, 'apple-touch-icon.png'), render(180, true)); +console.log('icons written to', OUT); diff --git a/gh-pages/bear-war/scripts/mapview.ts b/gh-pages/bear-war/scripts/mapview.ts new file mode 100644 index 00000000..63459fb0 --- /dev/null +++ b/gh-pages/bear-war/scripts/mapview.ts @@ -0,0 +1,34 @@ +// Prints a map as ASCII for quick layout iteration: node scripts/mapview.ts [mapId] +import { buildMapWithWarnings, MAPS } from '../src/game/map/maps.ts'; +import { F_CLIFF, F_RAMP, F_ROCK, F_WATER, F_EDGE, F_ROUGH } from '../src/game/map/gamemap.ts'; + +const id = process.argv[2] ?? MAPS[0].id; +const { map, warnings } = buildMapWithWarnings(id); +const grid: string[][] = []; +for (let y = 0; y < map.h; y++) { + const row: string[] = []; + for (let x = 0; x < map.w; x++) { + const f = map.flags[y * map.w + x]; + const e = map.elev[y * map.w + x]; + let c = e === 2 ? '^' : e === 1 ? '-' : '.'; + if (f & F_RAMP) c = '/'; + if (f & F_ROUGH) c = ','; + if (f & F_CLIFF) c = '#'; + if (f & F_ROCK) c = 'o'; + if (f & F_WATER) c = '~'; + if (f & F_EDGE) c = 'X'; + row.push(c); + } + grid.push(row); +} +for (const r of map.resources) { + const w = r.kind === 'mineral' ? 2 : 3; + const h = r.kind === 'mineral' ? 1 : 3; + for (let y = r.ty; y < r.ty + h; y++) for (let x = r.tx; x < r.tx + w; x++) grid[y][x] = r.kind === 'mineral' ? 'M' : 'G'; +} +for (const b of map.bases) { + for (let y = b.ty; y < b.ty + 5; y++) for (let x = b.tx; x < b.tx + 5; x++) grid[y][x] = b.start >= 0 ? String(b.start + 1) : 'H'; +} +console.log(grid.map((r) => r.join('')).join('\n')); +console.log(`${map.name} ${map.w}x${map.h} bases=${map.bases.length} resources=${map.resources.length} maxPlayers=${map.maxPlayers}`); +for (const w of warnings) console.log('WARN', w); diff --git a/gh-pages/bear-war/scripts/simtest.ts b/gh-pages/bear-war/scripts/simtest.ts new file mode 100644 index 00000000..42b1b6d9 --- /dev/null +++ b/gh-pages/bear-war/scripts/simtest.ts @@ -0,0 +1,66 @@ +// Headless simulation smoke test: node scripts/simtest.ts [ticks] [seed] [race1] [race2] [ai] +import { Game } from '../src/game/game.ts'; +import { loadMap } from '../src/game/map/maps.ts'; +import type { RaceId } from '../src/game/data/types.ts'; +import type { AIDifficulty } from '../src/game/player.ts'; + +const ticks = Number(process.argv[2] ?? 2400); +const seed = Number(process.argv[3] ?? 1234); +const r1 = (process.argv[4] ?? 'ironpaw') as RaceId; +const r2 = (process.argv[5] ?? 'horde') as RaceId; +const ai = (process.argv[6] ?? 'normal') as AIDifficulty | 'none'; + +function run(): { hash: number; game: Game; ms: number } { + const g = new Game({ + map: loadMap(process.env.MAP ?? 'twin-craters'), + seed, + players: [ + { name: 'P1', race: r1, color: 0, team: 0, ai: ai === 'none' ? null : ai }, + { name: 'P2', race: r2, color: 1, team: 1, ai: ai === 'none' ? null : ai }, + ], + }); + const t0 = performance.now(); + let worst = 0; + const verbose = process.env.VERBOSE === '1'; + for (let i = 0; i < ticks && !g.over; i++) { + const s = performance.now(); + g.step([]); + worst = Math.max(worst, performance.now() - s); + if (verbose && g.tick % 1200 === 0) { + for (const p of g.players) { + const mine = g.entities.filter((e) => e.owner === p.index); + const count = (pred: (e: (typeof mine)[number]) => boolean) => mine.filter(pred).length; + const blds: Record = {}; + const units: Record = {}; + for (const e of mine) { + if (e.isBuilding) blds[e.def.id] = (blds[e.def.id] ?? 0) + 1; + else if (e.def.kind === 'unit' && !e.def.worker) units[e.def.id] = (units[e.def.id] ?? 0) + 1; + } + const ai = g.ais.find((a) => a.pi === p.index); + console.log( + `[${(g.tick / 20 / 60).toFixed(1)}m] ${p.race.padEnd(8)} sup ${p.supplyUsed}/${p.supplyMax} min ${p.minerals | 0} gas ${p.gas | 0} workers ${count((e) => !!e.def.worker)} cub ${count((e) => e.def.kind === 'cub')} ai=${ai?.state}/${ai?.step} atk>=${ai?.attackSupply}`, + ); + console.log(' blds', JSON.stringify(blds)); + console.log(' units', JSON.stringify(units)); + } + } + } + const ms = performance.now() - t0; + console.log(`worst tick ${worst.toFixed(2)}ms`); + return { hash: g.hash(), game: g, ms }; +} + +const a = run(); +const b = run(); +const g = a.game; +console.log(`ran ${g.tick} ticks (${(g.tick / 20).toFixed(0)}s game time) in ${a.ms.toFixed(0)}ms, avg ${(a.ms / g.tick).toFixed(3)}ms/tick`); +for (const p of g.players) { + const units = g.entities.filter((e) => e.owner === p.index && e.isUnit).length; + const blds = g.entities.filter((e) => e.owner === p.index && e.isBuilding).length; + console.log( + `${p.name} ${p.race}: minerals=${p.minerals.toFixed(0)} gas=${p.gas.toFixed(0)} supply=${p.supplyUsed}/${p.supplyMax} units=${units} buildings=${blds} gathered=${p.stats.mineralsGathered}/${p.stats.gasGathered} defeated=${p.defeated}`, + ); +} +console.log(`over=${g.over} winners=${g.winners.join(',')}`); +console.log(`determinism: ${a.hash === b.hash ? 'OK' : 'MISMATCH'} (${a.hash} vs ${b.hash})`); +if (a.hash !== b.hash) process.exit(1); diff --git a/gh-pages/bear-war/server/index.ts b/gh-pages/bear-war/server/index.ts new file mode 100644 index 00000000..03884892 --- /dev/null +++ b/gh-pages/bear-war/server/index.ts @@ -0,0 +1,309 @@ +// Bear War multiplayer relay + static file server. +// +// npm run build && npm start (serves dist/ and the /ws lobby on PORT, default 8787) +// +// The server never simulates the game: it hosts lobbies and runs the lockstep turn +// clock (see src/net/room.ts). Every client runs the deterministic simulation itself. + +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { WebSocketServer, type WebSocket } from 'ws'; +import { Room, type RoomClient } from '../src/net/room.ts'; +import { PROTOCOL_VERSION, type C2S, type S2C } from '../src/net/protocol.ts'; +import { MAPS } from '../src/game/map/maps.ts'; +import { simHash } from './simhash.ts'; + +const PORT = Number(process.env.PORT ?? 8787); +const HOST = process.env.HOST ?? '0.0.0.0'; +const DIST = path.resolve(import.meta.dirname, '..', 'dist'); +const MAX_ROOMS = Number(process.env.MAX_ROOMS ?? 500); +const MOTD = process.env.MOTD ?? ''; +const SIM_HASH = simHash(); + +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.css': 'text/css; charset=utf-8', + '.json': 'application/json', + '.webmanifest': 'application/manifest+json', + '.svg': 'image/svg+xml', + '.png': 'image/png', + '.ico': 'image/x-icon', + '.txt': 'text/plain; charset=utf-8', + '.map': 'application/json', +}; + +// ------------------------------------------------------------------ static files + +/** Request path, or null if it is malformed (bad escapes, NUL bytes, unparsable URL). */ +function requestPath(raw: string | undefined): string | null { + try { + const p = decodeURIComponent(new URL(raw ?? '/', 'http://x').pathname); + return p.includes('\0') ? null : p; + } catch { + return null; + } +} + +const server = http.createServer((req, res) => { + try { + serveStatic(req, res); + } catch (err) { + console.error('request error', err); + if (!res.headersSent) res.writeHead(500); + res.end(); + } +}); + +function serveStatic(req: http.IncomingMessage, res: http.ServerResponse): void { + let p = requestPath(req.url); + if (p === null) { + res.writeHead(400).end(); + return; + } + if (p === '/healthz') { + res.writeHead(200, { 'content-type': 'application/json' }); + res.end(JSON.stringify({ ok: true, rooms: rooms.size, clients: clients.size, sim: SIM_HASH })); + return; + } + if (p.endsWith('/')) p += 'index.html'; + const file = path.normalize(path.join(DIST, p)); + if (!file.startsWith(DIST + path.sep)) { + res.writeHead(403).end(); + return; + } + fs.stat(file, (err, st) => { + const target = !err && st.isFile() ? file : path.join(DIST, 'index.html'); + fs.readFile(target, (err2, data) => { + if (err2) { + res.writeHead(404, { 'content-type': 'text/plain' }); + res.end(fs.existsSync(DIST) ? 'Not found' : 'Client not built yet. Run `npm run build` first.'); + return; + } + const ext = path.extname(target); + const immutable = target.includes(`${path.sep}assets${path.sep}`); + res.writeHead(200, { + 'content-type': MIME[ext] ?? 'application/octet-stream', + 'cache-control': immutable ? 'public, max-age=31536000, immutable' : 'no-cache', + 'x-content-type-options': 'nosniff', + }); + res.end(data); + }); + }); +} + +// ------------------------------------------------------------------ websocket lobby + +interface Client extends RoomClient { + ws: WebSocket; + room: Room | null; + hello: boolean; + alive: boolean; + budget: number; +} + +const clients = new Map(); +const rooms = new Map(); + +function newCode(): string { + const A = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; + for (;;) { + let s = ''; + for (let i = 0; i < 5; i++) s += A[crypto.randomInt(A.length)]; + if (!rooms.has(s)) return s; + } +} + +function cleanName(n: unknown): string { + return String(n ?? '').replace(/[^\p{L}\p{N} _\-.]/gu, '').trim().slice(0, 20) || 'Player'; +} + +function maxPlayers(map: string): number { + return MAPS.find((m) => m.id === map)?.players ?? 2; +} + +function publicRooms() { + return [...rooms.values()].filter((r) => r.isPublic && !r.started).map((r) => r.info()).slice(0, 50); +} + +const wss = new WebSocketServer({ noServer: true, maxPayload: 64 * 1024 }); + +server.on('upgrade', (req, socket, head) => { + socket.on('error', () => socket.destroy()); + if (requestPath(req.url) !== '/ws') { + socket.destroy(); + return; + } + try { + wss.handleUpgrade(req, socket, head, (ws) => wss.emit('connection', ws, req)); + } catch (err) { + console.error('upgrade error', err); + socket.destroy(); + } +}); + +wss.on('connection', (ws: WebSocket) => { + const c: Client = { + id: crypto.randomUUID(), + name: 'Player', + ws, + room: null, + hello: false, + alive: true, + budget: 120, + send(msg: S2C) { + if (ws.readyState === ws.OPEN) ws.send(JSON.stringify(msg)); + }, + }; + clients.set(c.id, c); + // protocol errors (oversized or invalid frames) close the socket; never crash the server + ws.on('error', () => ws.terminate()); + ws.on('pong', () => (c.alive = true)); + ws.on('message', (raw) => { + if (--c.budget < 0) return; // rate limited (refilled every second) + let m: C2S; + try { + m = JSON.parse(String(raw)); + } catch { + return; + } + if (!m || typeof m !== 'object') return; + try { + handle(c, m); + } catch (err) { + console.error('handler error', err); + } + }); + ws.on('close', () => { + clients.delete(c.id); + c.room?.leave(c); + }); +}); + +function handle(c: Client, m: C2S): void { + if (m.t === 'hello') { + c.name = cleanName(m.name); + // lockstep needs identical simulations: refuse other versions instead of desyncing later + const sim = typeof m.sim === 'string' ? m.sim : ''; + if (m.v !== PROTOCOL_VERSION || (sim !== SIM_HASH && sim !== 'dev')) { + c.send({ t: 'error', msg: 'Your game version does not match this server. Please reload the page to update.' }); + c.ws.close(4001, 'version mismatch'); + return; + } + c.hello = true; + c.send({ t: 'welcome', id: c.id, v: PROTOCOL_VERSION, motd: MOTD }); + return; + } + if (!c.hello) return; + switch (m.t) { + case 'ping': + c.send({ t: 'pong', at: m.at }); + return; + case 'list': + c.send({ t: 'rooms', rooms: publicRooms() }); + return; + case 'create': { + if (rooms.size >= MAX_ROOMS) { + c.send({ t: 'error', msg: 'Server is full, try again later' }); + return; + } + c.room?.leave(c); + const map = MAPS.some((x) => x.id === m.map) ? m.map : MAPS[0].id; + const room = new Room(newCode(), c, String(m.name ?? ''), map, maxPlayers(map), !!m.public); + room.onEmpty = () => rooms.delete(room.code); + rooms.set(room.code, room); + c.room = room; + c.send({ t: 'room', room: room.state() }); + return; + } + case 'join': { + const room = rooms.get(String(m.code ?? '').toUpperCase().trim()); + if (!room) { + c.send({ t: 'error', msg: 'Game not found, check the code' }); + return; + } + if (c.room && c.room !== room) { + c.room.leave(c); + c.room = null; + } + const err = room.join(c); + if (err) c.send({ t: 'error', msg: err }); + else c.room = room; + return; + } + case 'leaveRoom': + c.room?.leave(c); + c.room = null; + c.send({ t: 'room', room: null }); + return; + case 'rejoin': { + const room = rooms.get(String(m.code ?? '').toUpperCase()); + if (!room) { + c.send({ t: 'error', msg: 'That game has ended' }); + return; + } + const err = room.rejoin(c, String(m.token ?? ''), Number(m.from)); + if (err) c.send({ t: 'error', msg: err }); + else c.room = room; + return; + } + } + const room = c.room; + if (!room) return; + switch (m.t) { + case 'slot': + room.setSlot(c, m); + return; + case 'settings': + room.settings(c, m, maxPlayers); + return; + case 'chat': + room.chat(c, m.text); + return; + case 'start': { + const err = room.start(c, Date.now()); + if (err) c.send({ t: 'error', msg: err }); + return; + } + case 'cmd': + room.command(c, m.c); + return; + case 'ack': + room.ack(c, Number(m.turn), m.hash, m.htick); + return; + } +} + +// turn clocks +setInterval(() => { + const now = Date.now(); + for (const r of rooms.values()) { + r.tickClock(now); + if (r.abandoned) { + for (const m of r.members.values()) (m as Client).room = null; + rooms.delete(r.code); + } + } +}, 10); + +// rate-limit refill + heartbeat +setInterval(() => { + for (const c of clients.values()) c.budget = Math.min(240, c.budget + 120); +}, 1000); +setInterval(() => { + for (const c of clients.values()) { + if (!c.alive) { + c.ws.terminate(); + continue; + } + c.alive = false; + c.ws.ping(); + } +}, 20000); + +server.listen(PORT, HOST, () => { + console.log(`Bear War server listening on http://${HOST === '0.0.0.0' ? 'localhost' : HOST}:${PORT} (sim ${SIM_HASH})`); + if (!fs.existsSync(DIST)) console.log('Note: dist/ not found, run `npm run build` to serve the client from here.'); +}); diff --git a/gh-pages/bear-war/server/simhash.ts b/gh-pages/bear-war/server/simhash.ts new file mode 100644 index 00000000..5496d119 --- /dev/null +++ b/gh-pages/bear-war/server/simhash.ts @@ -0,0 +1,28 @@ +// Fingerprint of the simulation source code (src/game). Lockstep multiplayer only works +// when every client runs exactly the same simulation, so the server refuses clients +// whose build has a different fingerprint (for example a tab opened before a deploy). +// vite.config.ts bakes the same fingerprint into the client at build time. + +import fs from 'node:fs'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; + +export function simHash(root: string = path.resolve(import.meta.dirname, '..')): string { + const dir = path.join(root, 'src', 'game'); + const files: string[] = []; + const walk = (d: string): void => { + const ents = fs.readdirSync(d, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0)); + for (const f of ents) { + const p = path.join(d, f.name); + if (f.isDirectory()) walk(p); + else if (f.name.endsWith('.ts')) files.push(p); + } + }; + walk(dir); + const h = createHash('sha256'); + for (const f of files) { + h.update(path.relative(dir, f).split(path.sep).join('/')); + h.update(fs.readFileSync(f, 'utf8').replace(/\r\n/g, '\n')); + } + return h.digest('hex').slice(0, 12); +} diff --git a/gh-pages/bear-war/src/app/app.ts b/gh-pages/bear-war/src/app/app.ts new file mode 100644 index 00000000..372f8f0b --- /dev/null +++ b/gh-pages/bear-war/src/app/app.ts @@ -0,0 +1,477 @@ +import { TEAM_COLORS } from '../game/constants.ts'; +import { RACES, RACE_IDS, DEFS } from '../game/data/index.ts'; +import type { RaceId } from '../game/data/types.ts'; +import { Game } from '../game/game.ts'; +import { MAPS, loadMap } from '../game/map/maps.ts'; +import type { AIDifficulty, PlayerSetup } from '../game/player.ts'; +import { LocalSession } from '../net/session.ts'; +import { AudioEngine } from '../audio/audio.ts'; +import { SpriteCache } from '../render/sprites.ts'; +import { GameScreen, type GameResult } from './gamescreen.ts'; +import { loadSettings, saveSettings, type Settings } from './settings.ts'; +import { h, mapPreview, select } from './ui.ts'; +import { ReplaySession, downloadText, makeReplay, parseReplay } from '../net/replay.ts'; + +export const GAME_TITLE = 'Bear War'; +const SKIRMISH_KEY = 'bear-war:skirmish'; +export const VERSION = '0.1.0'; + +type RacePick = RaceId | 'random'; + +interface SlotCfg { + name: string; + race: RacePick; + color: number; + team: number; + ai: AIDifficulty | null; +} + +export class App { + root: HTMLElement; + settings: Settings; + audio: AudioEngine; + screen: HTMLElement | null = null; + game: GameScreen | null = null; + private icons = new SpriteCache(2, 1.5); + skirmish = { + map: MAPS[0].id, + speed: 1, + slots: [] as SlotCfg[], + }; + + constructor(root: HTMLElement) { + this.root = root; + this.settings = loadSettings(); + this.audio = new AudioEngine(this.settings); + this.skirmish.slots = [ + { name: this.settings.name, race: 'ironpaw', color: 1, team: 0, ai: null }, + { name: 'Computer', race: 'random', color: 0, team: 1, ai: 'normal' }, + ]; + try { + const saved = JSON.parse(localStorage.getItem(SKIRMISH_KEY) ?? 'null'); + if (saved && Array.isArray(saved.slots) && saved.slots.length >= 2 && MAPS.some((m) => m.id === saved.map)) { + this.skirmish.map = saved.map; + this.skirmish.speed = Number(saved.speed) || 1; + this.skirmish.slots = saved.slots; + this.skirmish.slots[0].name = this.settings.name; + } + } catch { + // ignore unreadable saved config + } + const unlock = () => this.audio.unlock(); + window.addEventListener('pointerdown', unlock, { once: false, passive: true }); + window.addEventListener('keydown', unlock, { once: false }); + } + + show(el: HTMLElement): void { + this.screen?.remove(); + this.screen = el; + this.root.appendChild(el); + } + + // ------------------------------------------------------------------ main menu + + mainMenu(): void { + this.audio.startMusic(); + const m = h( + 'div', + { class: 'menu' }, + h('div', { class: 'title' }, h('h1', {}, GAME_TITLE), h('p', {}, 'Real-time strategy · three bear clans · one forest')), + h('button', { class: 'mbtn primary', onclick: () => this.skirmishMenu() }, 'Skirmish vs AI', h('small', {}, 'Play offline against computer opponents')), + h('button', { class: 'mbtn', onclick: () => this.multiplayerMenu() }, 'Multiplayer', h('small', {}, 'Play online against friends')), + h('button', { class: 'mbtn', onclick: () => this.helpScreen() }, 'How to Play', h('small', {}, 'Controls, clans and tips')), + h('button', { class: 'mbtn', onclick: () => this.openReplay() }, 'Watch a Replay', h('small', {}, 'Load a saved .json replay file')), + h('button', { class: 'mbtn', onclick: () => this.settingsScreen(() => this.mainMenu()) }, 'Settings'), + h('button', { class: 'mbtn', onclick: () => this.aboutScreen() }, 'About & Licenses'), + h('p', { class: 'muted small', style: { textAlign: 'center' } }, `v${VERSION} · open source (MIT) · not affiliated with any commercial game publisher`), + ); + this.show(h('div', { class: 'screen' }, m, document.fullscreenEnabled ? h('button', { class: 'qbtn fs-btn', title: 'Fullscreen', onclick: () => toggleFullscreen() }, '⛶') : null)); + } + + // ------------------------------------------------------------------ skirmish + + private raceOptions(): { value: string; label: string }[] { + return [...RACE_IDS.map((r) => ({ value: r, label: RACES[r].name })), { value: 'random', label: 'Random' }]; + } + + skirmishMenu(): void { + const sk = this.skirmish; + const info = MAPS.find((m) => m.id === sk.map) ?? MAPS[0]; + const maxPlayers = info.players; + sk.slots = sk.slots.slice(0, maxPlayers); + const rerender = () => this.skirmishMenu(); + const slotsEl = h('div', {}); + sk.slots.forEach((s, i) => { + const row = h( + 'div', + { class: 'slot' }, + h('div', { class: 'who' }, i === 0 ? s.name : 'Computer'), + select(this.raceOptions(), s.race, (v) => (s.race = v as RacePick)), + i === 0 + ? h('span', { class: 'muted small' }, 'You') + : select( + [ + { value: 'easy', label: 'Easy' }, + { value: 'normal', label: 'Normal' }, + { value: 'hard', label: 'Hard' }, + { value: 'brutal', label: 'Brutal' }, + ], + s.ai ?? 'normal', + (v) => (s.ai = v as AIDifficulty), + ), + select( + Array.from({ length: maxPlayers }, (_, t) => ({ value: String(t), label: `Team ${t + 1}` })), + String(s.team), + (v) => (s.team = Number(v)), + ), + i === 0 + ? h('span', { class: 'swatch', style: { background: TEAM_COLORS[s.color].main } }) + : h('button', { class: 'qbtn', title: 'Remove', onclick: () => { sk.slots.splice(i, 1); rerender(); } }, '✕'), + ); + slotsEl.appendChild(row); + }); + const canAdd = sk.slots.length < maxPlayers; + const m = h( + 'div', + { class: 'menu' }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '30px' } }, 'Skirmish')), + h('div', { class: 'card' }, h('h3', {}, 'Choose your clan'), this.raceCards(sk.slots[0].race, (r) => { sk.slots[0].race = r; rerender(); })), + h( + 'div', + { class: 'card' }, + h('h3', {}, 'Battlefield'), + h( + 'div', + { class: 'row' }, + h('label', { class: 'field' }, 'Map', select(MAPS.map((m) => ({ value: m.id, label: `${m.name} (${m.players}p)` })), sk.map, (v) => { sk.map = v; rerender(); })), + h('label', { class: 'field' }, 'Game speed', select([ + { value: '0.75', label: 'Slower' }, + { value: '1', label: 'Normal' }, + { value: '1.4', label: 'Faster' }, + ], String(sk.speed), (v) => (sk.speed = Number(v)))), + ), + h('div', { class: 'map-row' }, mapPreview(sk.map, 150), h('p', { class: 'muted small' }, info.desc)), + ), + h( + 'div', + { class: 'card' }, + h('h3', {}, 'Players'), + slotsEl, + canAdd + ? h('button', { class: 'mbtn', onclick: () => { + const used = new Set(sk.slots.map((s) => s.color)); + const color = [0, 2, 3, 4, 5, 6, 7, 1].find((c) => !used.has(c)) ?? 0; + sk.slots.push({ name: 'Computer', race: 'random', color, team: sk.slots.length, ai: 'normal' }); + rerender(); + } }, '+ Add computer player') + : null, + ), + h('div', { class: 'row' }, + h('button', { class: 'mbtn', onclick: () => this.mainMenu() }, '← Back'), + h('button', { class: 'mbtn primary', onclick: () => this.startSkirmish() }, 'Start game ▶'), + ), + ); + this.show(h('div', { class: 'screen' }, m)); + } + + private raceCards(cur: RacePick, pick: (r: RacePick) => void): HTMLElement { + const wrap = h('div', { class: 'races' }); + for (const r of RACE_IDS) { + const race = RACES[r]; + const icon = this.icons.icon(DEFS[race.worker === 'hardhat' ? 'ranger' : race.worker === 'forager' ? 'scrapper' : 'spiritbear'], 1, 128); + const c = h('div', { class: 'race' + (cur === r ? ' sel' : ''), onclick: () => pick(r) }, icon, h('b', {}, race.name), h('small', {}, race.desc)); + wrap.appendChild(c); + } + const rnd = h('div', { class: 'race' + (cur === 'random' ? ' sel' : ''), onclick: () => pick('random'), style: { gridColumn: '1 / -1' } }, h('b', {}, '🎲 Random')); + wrap.appendChild(rnd); + return wrap; + } + + startSkirmish(): void { + const sk = this.skirmish; + try { + localStorage.setItem(SKIRMISH_KEY, JSON.stringify(sk)); + } catch { + // storage unavailable + } + const seed = (Math.random() * 2 ** 31) | 0; + const rnd = (r: RacePick): RaceId => (r === 'random' ? RACE_IDS[Math.floor(Math.random() * 3)] : r); + const players: PlayerSetup[] = sk.slots.map((s, i) => ({ + name: i === 0 ? this.settings.name || 'Commander' : `${(s.ai ?? 'normal')[0].toUpperCase()}${(s.ai ?? 'normal').slice(1)} AI`, + race: rnd(s.race), + color: s.color, + team: s.team, + ai: i === 0 ? null : s.ai ?? 'normal', + })); + // ensure at least two teams + if (new Set(players.map((p) => p.team)).size < 2 && players.length > 1) players.forEach((p, i) => (p.team = i)); + const game = new Game({ map: loadMap(sk.map), players, seed }); + const session = new LocalSession(game, 0); + session.speed = sk.speed; + this.launch(session); + } + + launch(session: LocalSession | import('../net/session.ts').Session): void { + this.screen?.remove(); + this.screen = null; + this.audio.stopMusic(); + this.audio.startMusic(); + const gs = new GameScreen(this.root, { + session, + settings: this.settings, + audio: this.audio, + onMenu: (s) => this.ingameMenu(s), + onEnd: (r) => this.endScreen(r), + }); + this.game = gs; + gs.start(); + } + + // ------------------------------------------------------------------ in-game menu + + ingameMenu(gs: GameScreen): void { + const s = gs.session; + const wasPaused = s.paused; + if (!s.online) s.paused = true; + const close = () => { + if (!s.online) s.paused = wasPaused; + overlay.remove(); + }; + const overlay = h( + 'div', + { class: 'screen overlay' }, + h( + 'div', + { class: 'menu', style: { maxWidth: '420px' } }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '28px' } }, s.online ? 'Menu' : 'Paused')), + s.localPlayer >= 0 && !s.online && s.log.length >= 0 + ? h('button', { class: 'mbtn', onclick: () => { + const g = s.game; + downloadText(`bear-war-${g.tick}.json`, JSON.stringify(makeReplay(g, g.setups, s.log, VERSION))); + } }, '💾 Save replay so far') + : null, + h('button', { class: 'mbtn primary', onclick: close }, 'Resume'), + document.fullscreenEnabled ? h('button', { class: 'mbtn', onclick: () => toggleFullscreen() }, document.fullscreenElement ? 'Exit fullscreen' : 'Fullscreen') : null, + h('button', { class: 'mbtn', onclick: () => { overlay.remove(); this.settingsScreen(() => this.root.appendChild(overlay), true); } }, 'Settings'), + h('button', { class: 'mbtn', onclick: () => { overlay.remove(); this.helpScreen(true, () => this.root.appendChild(overlay)); } }, 'How to Play'), + s.localPlayer >= 0 ? h('button', { class: 'mbtn danger', onclick: () => { s.issue({ c: 'surrender' }); close(); } }, 'Surrender') : null, + h('button', { class: 'mbtn danger', onclick: () => { if (s.online) s.issue({ c: 'leave' }); overlay.remove(); this.quitGame(); } }, 'Quit to main menu'), + ), + ); + this.root.appendChild(overlay); + } + + openReplay(): void { + const input = h('input', { type: 'file', accept: '.json,application/json', style: { display: 'none' } }) as HTMLInputElement; + input.addEventListener('change', async () => { + const f = input.files?.[0]; + input.remove(); + if (!f) return; + try { + const rep = parseReplay(await f.text()); + this.launch(new ReplaySession(rep)); + } catch (e) { + alert(`Could not open replay: ${(e as Error).message}`); + } + }); + document.body.appendChild(input); + input.click(); + } + + quitGame(): void { + this.game?.dispose(); + this.game = null; + this.mainMenu(); + } + + // ------------------------------------------------------------------ end screen + + endScreen(r: GameResult): void { + const g = r.session.game; + const rows = g.players.map((p) => + h( + 'tr', + {}, + h('td', {}, h('span', { class: 'swatch', style: { background: TEAM_COLORS[p.color].main, marginRight: '6px' } }), `${p.name} (${RACES[p.race].name})`), + h('td', {}, String(p.stats.mineralsGathered + p.stats.gasGathered)), + h('td', {}, String(p.stats.unitsProduced)), + h('td', {}, String(p.stats.unitsKilled + p.stats.buildingsKilled)), + h('td', {}, String(p.stats.unitsLost + p.stats.buildingsLost)), + h('td', {}, g.winners.includes(p.index) ? '🏆' : '-'), + ), + ); + const spectator = r.session.localPlayer < 0; + const mins = Math.floor(g.time / 60); + const secs = Math.floor(g.time % 60).toString().padStart(2, '0'); + const overlay = h( + 'div', + { class: 'screen overlay' }, + h( + 'div', + { class: 'menu' }, + h('div', { class: 'banner ' + (spectator ? 'win' : r.won ? 'win' : 'lose') }, spectator ? 'GAME OVER' : r.draw ? 'DRAW' : r.won ? 'VICTORY' : 'DEFEAT'), + h('p', { class: 'muted', style: { textAlign: 'center' } }, `Game time ${mins}:${secs}`), + h( + 'div', + { class: 'card' }, + h('table', { class: 'stats-table' }, + h('tr', {}, h('th', {}, 'Player'), h('th', {}, 'Resources'), h('th', {}, 'Units'), h('th', {}, 'Kills'), h('th', {}, 'Losses'), h('th', {}, '')), + ...rows, + ), + ), + spectator + ? null + : h('button', { class: 'mbtn', onclick: () => { + const rep = makeReplay(g, g.setups, r.session.log, VERSION); + const stamp = new Date().toISOString().slice(0, 16).replace(/[-:T]/g, ''); + downloadText(`bear-war-${stamp}.json`, JSON.stringify(rep)); + } }, '💾 Save replay'), + h('div', { class: 'row' }, + h('button', { class: 'mbtn', onclick: () => overlay.remove() }, 'Keep watching'), + h('button', { class: 'mbtn primary', onclick: () => { overlay.remove(); this.quitGame(); } }, 'Main menu'), + ), + ), + ); + this.root.appendChild(overlay); + if (!spectator) this.audio.say(r.won ? 'Victory' : 'Defeat'); + } + + // ------------------------------------------------------------------ settings + + settingsScreen(back: () => void, overlay = false): void { + const s = this.settings; + const save = () => { + saveSettings(s); + this.audio.applySettings(); + if (this.game) { + this.game.input.edgeScroll = s.edgeScroll; + this.game.input.scrollSpeed = s.scrollSpeed; + this.game.ctl.view.showAllBars = s.allBars; + this.game.renderer.fx.lowFx = s.lowFx; + this.game.renderer.maxDpr = s.maxDpr; + this.game.resize(); + } + }; + const range = (label: string, key: 'master' | 'sfx' | 'music' | 'scrollSpeed', min: number, max: number, step: number) => + h('label', { class: 'field' }, label, h('input', { type: 'range', min, max, step, value: String(s[key]), oninput: (e: Event) => { s[key] = Number((e.target as HTMLInputElement).value); save(); } })); + const toggle = (label: string, key: 'voice' | 'edgeScroll' | 'allBars' | 'lowFx' | 'tips') => + h('label', { class: 'toggle' }, label, h('input', { type: 'checkbox', checked: s[key], onchange: (e: Event) => { s[key] = (e.target as HTMLInputElement).checked; save(); } })); + const el = h( + 'div', + { class: 'screen' + (overlay ? ' overlay' : '') }, + h( + 'div', + { class: 'menu', style: { maxWidth: '520px' } }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '28px' } }, 'Settings')), + h('div', { class: 'card' }, + h('label', { class: 'field' }, 'Player name', h('input', { type: 'text', value: s.name, maxLength: 20, oninput: (e: Event) => { s.name = (e.target as HTMLInputElement).value.slice(0, 20); if (this.skirmish.slots[0]) this.skirmish.slots[0].name = s.name; save(); } })), + ), + h('div', { class: 'card' }, h('h3', {}, 'Audio'), range('Master volume', 'master', 0, 1, 0.05), range('Effects', 'sfx', 0, 1, 0.05), range('Music', 'music', 0, 1, 0.05), toggle('Spoken announcements', 'voice')), + h('div', { class: 'card' }, h('h3', {}, 'Controls'), range('Scroll speed', 'scrollSpeed', 0.4, 2.5, 0.1), toggle('Edge scrolling (mouse)', 'edgeScroll'), toggle('Show tips at the start of a game', 'tips')), + h('div', { class: 'card' }, + h('h3', {}, 'Graphics'), + toggle('Always show health bars', 'allBars'), + toggle('Reduced effects (faster on old phones)', 'lowFx'), + h('label', { class: 'field' }, 'Resolution', select([ + { value: '1', label: 'Standard (fastest)' }, + { value: '1.5', label: 'High' }, + { value: '2', label: 'Retina' }, + { value: '3', label: 'Maximum' }, + ], String(s.maxDpr), (v) => { s.maxDpr = Number(v); save(); })), + ), + h('button', { class: 'mbtn primary', onclick: () => { el.remove(); back(); } }, 'Done'), + ), + ); + if (overlay) this.root.appendChild(el); + else this.show(el); + } + + // ------------------------------------------------------------------ help & about + + helpScreen(overlay = false, back?: () => void): void { + const done = () => { + el.remove(); + if (back) back(); + else this.mainMenu(); + }; + const el = h( + 'div', + { class: 'screen' + (overlay ? ' overlay' : '') }, + h( + 'div', + { class: 'menu help' }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '28px' } }, 'How to Play')), + h('div', { class: 'card', html: HELP_HTML }), + h('button', { class: 'mbtn primary', onclick: done }, 'Back'), + ), + ); + if (overlay) this.root.appendChild(el); + else this.show(el); + } + + aboutScreen(): void { + this.show( + h( + 'div', + { class: 'screen' }, + h( + 'div', + { class: 'menu help' }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '28px' } }, 'About')), + h('div', { class: 'card', html: ABOUT_HTML }), + h('button', { class: 'mbtn primary', onclick: () => this.mainMenu() }, 'Back'), + ), + ), + ); + } + + multiplayerMenu(): void { + // implemented in net/lobby.ts (registered at startup) + this.mp?.(this); + } + mp?: (app: App) => void; +} + +const HELP_HTML = ` +

Goal

+

Gather resources, build an army and destroy every enemy structure.

+

Economy

+

Workers mine minerals and gas (gas needs a refinery on a geyser). Two to three workers per mineral field is ideal. Units cost supply: stock up before you are blocked (Ironpaw Larders, Honey Horde Bumblebears, Starborn Star Totems).

+

Clans

+

Ironpaw: Hardhats construct buildings and must stay until they are finished; they can repair machines. Damaged buildings burn down unless repaired.
+Honey Horde: Hives raise cubs; select a Hive to grow cubs into any bear. Foragers settle into buildings, which must stand on honeycomb. Every Horde bear heals over time.
+Starborn: Snowcubs call down buildings and are free immediately. Most buildings need a Star Totem's glow. Aurora shields regenerate out of combat.

+

Touch controls

+

Tap a unit to select it, double-tap to select all of that type. With units selected, tap the ground to move, tap an enemy to attack, tap minerals to mine. Drag with one finger to pan, pinch to zoom. Long-press then drag (or use the ▢ button) to box-select. Use the command card for attack-move, abilities and building. Long-press a button for details. Long-press a group number to assign a control group.

+

Mouse & keyboard

+

Left-click select · drag to box-select · Shift+click add/remove · double-click or Ctrl+click selects all of a type · right-click smart command (move, attack, gather, repair) · Shift queues orders.
+Command-card keys follow the button positions on the left of the keyboard, and the letter on each button matches your layout (QWERTY: Q W E R / A S D F / Z X C V) · Esc cancel.
+Ctrl+1-9 set control group · 1-9 select (twice to jump) · F1 idle worker · F2 army · Tab cycle subgroup · Space last alert · Backspace cycle bases · arrows / screen edges / middle-drag scroll · wheel zoom.

+

Tips

+

Set a rally point on minerals and new workers mine automatically. Ground units cannot see up cliffs. Hidden units (Shadowpaws, Little Dippers, dug-in Sappers and hibernating Burrowers) need detection: Flak Turrets, Big Sniff, Lookouts, Bumblebears, Sting Towers, Star Lanterns, Little Dippers or Revelation.

`; + +const ABOUT_HTML = ` +

${GAME_TITLE} is a free and open-source real-time strategy game for desktop and mobile browsers, inspired by classic RTS gameplay. Every clan, bear, name, graphic and sound in this game is original.

+

All graphics are drawn procedurally in code and all sound effects and music are synthesized in real time with the Web Audio API, so the game ships no third-party art, audio or fonts.

+

Source code is released under the MIT License. Runtime dependency: ws (MIT, server only). Build tools: Vite (MIT), TypeScript (Apache-2.0).

+

This project is not affiliated with or endorsed by any commercial game publisher. All trademarks belong to their respective owners.

`; + +export function boot(root: HTMLElement): App { + const app = new App(root); + app.mainMenu(); + return app; +} + +export function toggleFullscreen(): void { + if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}); + else { + void document.documentElement.requestFullscreen({ navigationUI: 'hide' }).catch(() => {}); + // lock to landscape where supported (Android) + const so = screen.orientation as ScreenOrientation & { lock?: (o: string) => Promise }; + so.lock?.('landscape').catch(() => {}); + } +} + +export function colorName(i: number): string { + return TEAM_COLORS[i]?.name ?? '?'; +} diff --git a/gh-pages/bear-war/src/app/gamescreen.ts b/gh-pages/bear-war/src/app/gamescreen.ts new file mode 100644 index 00000000..8a20823b --- /dev/null +++ b/gh-pages/bear-war/src/app/gamescreen.ts @@ -0,0 +1,348 @@ +import { TILE } from '../game/constants.ts'; +import { DEFS, RESEARCH_DEFS } from '../game/data/index.ts'; +import type { Entity } from '../game/entity.ts'; +import type { GameEvent } from '../game/types.ts'; +import type { Session } from '../net/session.ts'; +import { Renderer } from '../render/renderer.ts'; +import { Minimap } from '../render/minimap.ts'; +import { Controller, type Feedback } from '../ui/controller.ts'; +import { Hud } from '../ui/hud.ts'; +import { Input } from '../ui/input.ts'; +import type { AudioEngine } from '../audio/audio.ts'; +import { saveSettings, type Settings } from './settings.ts'; +import { supplyError } from '../game/systems/structures.ts'; + +export interface GameResult { + won: boolean; + draw: boolean; + session: Session; +} + +export interface GameScreenOptions { + session: Session; + settings: Settings; + audio: AudioEngine; + onMenu: (screen: GameScreen) => void; + onEnd: (result: GameResult) => void; +} + +export class GameScreen { + root: HTMLElement; + canvas: HTMLCanvasElement; + session: Session; + renderer: Renderer; + minimap: Minimap; + ctl: Controller; + hud: Hud; + input: Input; + opts: GameScreenOptions; + private raf = 0; + private last = 0; + private mmTimer = 0; + private ended = false; + private endTimer = -1; + private fpsAcc = 0; + private fpsN = 0; + fps = 60; + private onResize = () => this.resize(); + private bgTimer = 0; + private lastFrame = 0; + private lastBg = 0; + /** + * Online games must keep simulating even when the browser stops delivering animation + * frames (hidden or occluded tab, app switch on mobile), or every other player would + * be paused waiting for us. A watchdog timer steps the session whenever frames stall. + */ + private watchdog = () => { + const now = performance.now(); + if (now - this.lastFrame < 300) { + this.lastBg = now; + return; + } + const dt = Math.min(1000, now - (this.lastBg || now)); + this.lastBg = now; + this.session.update(dt); + const evs = this.session.drainEvents(); + this.handleEvents(evs.filter((e) => e.k === 'gameover' || e.k === 'defeat')); + }; + private onVisibility = () => { + if (document.hidden) return; + this.last = performance.now(); + this.resize(); + if (this.pendingCenter && window.innerWidth > 1) { + this.renderer.cam.centerOn(this.pendingCenter.x, this.pendingCenter.y); + this.pendingCenter = null; + } + }; + + constructor(parent: HTMLElement, opts: GameScreenOptions) { + this.opts = opts; + this.session = opts.session; + const g = this.session.game; + this.root = document.createElement('div'); + this.root.className = 'game-root'; + parent.appendChild(this.root); + this.canvas = document.createElement('canvas'); + this.canvas.className = 'world'; + this.root.appendChild(this.canvas); + this.renderer = new Renderer(this.canvas); + this.renderer.maxDpr = opts.settings.maxDpr; + this.renderer.setGame(g); + this.renderer.fx.lowFx = opts.settings.lowFx; + if (this.renderer.terrain && (matchMedia('(pointer: coarse)').matches || opts.settings.lowFx)) this.renderer.terrain.budgetMs = 4; + const audio = opts.audio; + const fb: Feedback = { + ack: (kind, e?: Entity) => { + if (kind === 'click') audio.ui('click'); + else if (kind === 'error') audio.ui('error'); + else audio.ack(kind, e?.def.race); + }, + message: (text, kind) => this.hud.message(text, kind === 'error' ? 'error' : 'info'), + }; + this.ctl = new Controller(g, this.session, this.renderer, fb); + this.ctl.view.showAllBars = opts.settings.allBars; + let boxMode = false; + const touch = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; + this.hud = new Hud(this.root, g, this.ctl, this.renderer.sprites, { + onMenu: () => opts.onMenu(this), + boxMode: () => boxMode, + setBoxMode: (on) => { + boxMode = on; + this.input.boxMode = on; + }, + touch: () => touch, + chat: 'chat' in this.session ? (text: string) => (this.session as unknown as { chat(t: string): void }).chat(text) : undefined, + setSpeed: this.session.online ? undefined : (sp: number) => (this.session.speed = sp), + togglePause: this.session.online ? undefined : () => (this.session.paused = !this.session.paused), + }); + this.minimap = new Minimap(this.hud.minimapCanvas); + this.minimap.setGame(g, this.renderer.terrain!); + this.input = new Input(this.canvas, this.ctl, this.hud, this.minimap); + this.input.edgeScroll = opts.settings.edgeScroll; + this.input.onMenu = () => opts.onMenu(this); + this.input.onPause = () => { + if (!this.session.online) this.session.paused = !this.session.paused; + this.hud.setStatus(this.session.paused ? 'Paused' : ''); + }; + this.input.scrollSpeed = opts.settings.scrollSpeed; + window.addEventListener('resize', this.onResize); + document.addEventListener('visibilitychange', this.onVisibility); + this.resize(true); + const chatSink = this.session as { onChat?: ((from: string, text: string, sys: boolean) => void) | null }; + if ('onChat' in chatSink) chatSink.onChat = (from, text, sys) => this.hud.message(sys ? text : `${from}: ${text}`, 'info'); + // start looking at our base + const me = g.players[this.session.localPlayer]; + const base = me ? g.map.bases[me.startBase] : g.map.bases[0]; + if (base) { + this.pendingCenter = { x: (base.tx + 2.5) * TILE, y: (base.ty + 2.5) * TILE }; + if (window.innerWidth > 1) { + this.renderer.cam.centerOn(this.pendingCenter.x, this.pendingCenter.y); + this.pendingCenter = null; + } + } + if (me) { + const hall = g.entities.find((e) => e.owner === me.index && e.def.townHall); + if (hall) this.ctl.lastAlert = { x: hall.x, y: hall.y }; + } + } + + private showTips(): void { + const touch = this.input.touchMode; + const race = this.session.game.players[this.session.localPlayer]?.race; + const tips = touch + ? [ + 'Tap a unit to select it. Tap the ground to move, an enemy to attack, minerals to mine.', + 'Drag with one finger to pan, pinch to zoom. Long-press and drag to box-select.', + 'Select your main building and train workers, and keep them mining!', + race === 'horde' ? 'Honey Horde: select a Hive to grow its cubs into bears. Raise Bumblebears for supply.' : race === 'starborn' ? 'Starborn: call down Star Totems first, most structures need their glow.' : 'Ironpaw: Hardhats must stay while constructing. Build Larders for supply.', + ] + : [ + 'Left-click to select, drag to box-select, right-click to move, attack or mine.', + 'Select your main building and train workers (hotkeys are shown on the buttons).', + 'Ctrl+1–9 sets control groups, F2 selects your army, A attack-moves.', + race === 'horde' ? 'Honey Horde: select a Hive to grow its cubs into bears. Raise Bumblebears for supply.' : race === 'starborn' ? 'Starborn: call down Star Totems first, most structures need their glow.' : 'Ironpaw: Hardhats must stay while constructing. Build Larders for supply.', + ]; + tips.forEach((t, i) => window.setTimeout(() => this.hud.message(t, 'info'), 1500 + i * 4500)); + this.opts.settings.tips = false; + saveSettings(this.opts.settings); + } + + start(): void { + if (this.opts.settings.tips && this.session.localPlayer >= 0 && !this.session.online) this.showTips(); + this.last = performance.now(); + this.lastFrame = this.last; + if (this.session.online) this.bgTimer = window.setInterval(this.watchdog, 100); + const loop = (t: number) => { + this.raf = requestAnimationFrame(loop); + this.frame(t); + }; + this.raf = requestAnimationFrame(loop); + } + + stop(): void { + cancelAnimationFrame(this.raf); + } + + dispose(): void { + this.stop(); + this.input.dispose(); + this.hud.dispose(); + this.session.dispose(); + window.clearInterval(this.bgTimer); + document.removeEventListener('visibilitychange', this.onVisibility); + window.removeEventListener('resize', this.onResize); + this.root.remove(); + } + + private pendingCenter: { x: number; y: number } | null = null; + + resize(first = false): void { + const w = window.innerWidth; + const h = window.innerHeight; + const cam = this.renderer.cam; + // a hidden tab can report a 0x0 window; wait until we are visible + if (w < 2 || h < 2) return; + const firstReal = cam.w < 2 || first; + this.renderer.resize(w, h); + const touch = this.input?.touchMode ?? matchMedia('(pointer: coarse)').matches; + // phones: ~22 tiles across in landscape, ~11 in portrait; desktop: ~30 tiles + const base = touch ? Math.min(Math.max(w, h) / (TILE * 22), Math.min(w, h) / (TILE * 11)) : Math.max(w, h) / (TILE * 30); + cam.minZoom = base * 0.5; + cam.maxZoom = base * 2.4; + if (firstReal || !(cam.zoom > 0)) cam.zoom = base; + else cam.setZoom(cam.zoom); + const mmSize = Math.min(w, h) < 520 || w < 760 ? 104 : 182; + this.minimap.resize(mmSize); + const bottom = this.root.querySelector('.hud-bottom') as HTMLElement | null; + cam.insetBottom = bottom ? Math.min(h * 0.4, bottom.getBoundingClientRect().height) : 0; + cam.clamp(); + } + + private frame(t: number): void { + const dtMs = Math.min(100, t - this.last); + this.last = t; + this.lastFrame = performance.now(); + this.lastBg = this.lastFrame; + const dt = dtMs / 1000; + this.fpsAcc += dtMs; + this.fpsN++; + if (this.fpsAcc > 1000) { + this.fps = (this.fpsN * 1000) / this.fpsAcc; + this.fpsAcc = 0; + this.fpsN = 0; + this.adaptQuality(); + } + const s = this.session; + const g = s.game; + const alpha = s.update(dtMs); + const events = s.drainEvents(); + if (events.length) { + this.renderer.fx.onEvents(events, g); + this.opts.audio.onEvents(events, g, s.localPlayer, this.renderer.cam); + this.handleEvents(events); + } + this.input.update(dt); + const ctl = this.ctl; + ctl.view.selection = ctl.selected(); + if (ctl.placing) { + // keep validity fresh while the world changes + ctl.view.placement = { def: ctl.placing, tx: ctl.placeTx, ty: ctl.placeTy, ...ctl.placementMask(ctl.placing, ctl.placeTx, ctl.placeTy) }; + } + this.renderer.draw(g, alpha, ctl.view, dt); + this.mmTimer -= dt; + if (this.mmTimer <= 0) { + this.mmTimer = 0.1; + this.minimap.draw(g, ctl.view.team, s.localPlayer, this.renderer.cam, 0.1); + } + this.hud.update(dt); + this.hud.setStatus(s.status || (s.paused && !s.online && s.localPlayer >= 0 ? 'Paused, press Pause to resume' : '')); + if (g.over && !this.ended) { + if (this.endTimer < 0) this.endTimer = 2.5; + this.endTimer -= dt; + if (this.endTimer <= 0) { + this.ended = true; + const me = g.players[s.localPlayer]; + const won = !!me && g.winners.includes(me.index); + this.opts.onEnd({ won, draw: g.winners.length === 0, session: s }); + } + } + } + + private slowSeconds = 0; + + /** Automatically trade resolution/effects for frame rate on slow devices. */ + private adaptQuality(): void { + if (document.hidden) return; + this.slowSeconds = this.fps < 38 ? this.slowSeconds + 1 : Math.max(0, this.slowSeconds - 1); + if (this.slowSeconds < 4) return; + this.slowSeconds = 0; + const r = this.renderer; + if (!r.fx.lowFx) { + r.fx.lowFx = true; + if (r.terrain) r.terrain.budgetMs = 3; + return; + } + if (r.maxDpr > 1) { + r.maxDpr = Math.max(1, r.maxDpr - 0.25); + this.resize(); + } + } + + private handleEvents(events: GameEvent[]): void { + const g = this.session.game; + const me = this.session.localPlayer; + const audio = this.opts.audio; + for (const ev of events) { + switch (ev.k) { + case 'error': + if (ev.owner === me) { + this.hud.message(ev.msg, 'error'); + audio.ui('error'); + } + break; + case 'alert': + if (ev.owner !== me) break; + if (ev.kind === 'supply') { + this.hud.message(supplyError(g, g.players[me]), 'error'); + audio.say('Not enough supply'); + } else { + const txt = ev.kind === 'base' ? 'Your base is under attack' : 'Your forces are under attack'; + this.hud.message(txt, 'alert'); + audio.say(txt); + audio.ui('alert'); + this.minimap.pings.push({ x: ev.x, y: ev.y, t: 2.5, color: '#ff4a3c' }); + this.ctl.lastAlert = { x: ev.x, y: ev.y }; + } + break; + case 'built': + if (ev.owner === me) { + const d = DEFS[ev.def]; + this.hud.message(`${d.name} complete`, 'info'); + audio.ui('complete'); + this.ctl.lastAlert = { x: ev.x, y: ev.y }; + } + break; + case 'researched': + if (ev.owner === me) { + const r = RESEARCH_DEFS[ev.id]; + const name = r.costs.length > 1 ? `${r.name} level ${ev.level}` : r.name; + this.hud.message(`Research complete: ${name}`, 'info'); + audio.ui('research'); + audio.say('Research complete'); + } + break; + case 'revealed': { + const p = g.players[ev.player]; + if (p.index === me) this.hud.message('You have no main base, your structures are revealed!', 'alert'); + else this.hud.message(`${p.name} has been revealed`, 'info'); + break; + } + case 'defeat': { + const p = g.players[ev.player]; + this.hud.message(p.index === me ? 'You have been defeated' : `${p.name} has been ${p.left ? 'disconnected' : 'defeated'}`, p.index === me ? 'alert' : 'info'); + break; + } + } + } + } +} diff --git a/gh-pages/bear-war/src/app/lobby.ts b/gh-pages/bear-war/src/app/lobby.ts new file mode 100644 index 00000000..5d377644 --- /dev/null +++ b/gh-pages/bear-war/src/app/lobby.ts @@ -0,0 +1,292 @@ +import { TEAM_COLORS } from '../game/constants.ts'; +import { RACES, RACE_IDS } from '../game/data/index.ts'; +import { Game } from '../game/game.ts'; +import { MAPS, loadMap } from '../game/map/maps.ts'; +import { NetClient, defaultServerUrl } from '../net/client.ts'; +import { NetSession } from '../net/netsession.ts'; +import type { RoomInfo, RoomState, S2C } from '../net/protocol.ts'; +import type { App } from './app.ts'; +import { saveSettings } from './settings.ts'; +import { h, mapPreview, select } from './ui.ts'; + +export function openMultiplayer(app: App): void { + new Lobby(app).connectScreen(); +} + +class Lobby { + app: App; + client: NetClient | null = null; + room: RoomState | null = null; + rooms: RoomInfo[] = []; + chat: { from: string; text: string; sys?: boolean }[] = []; + error = ''; + private listTimer = 0; + private unsub: (() => void) | null = null; + + constructor(app: App) { + this.app = app; + } + + private get s() { + return this.app.settings; + } + + connectScreen(msg = ''): void { + const nameIn = h('input', { type: 'text', value: this.s.name, maxLength: 20 }) as HTMLInputElement; + const urlIn = h('input', { type: 'url', value: this.s.server || defaultServerUrl(), spellcheck: false }) as HTMLInputElement; + const status = h('p', { class: 'small', style: { color: 'var(--bad)', minHeight: '1em' } }, msg); + const go = async () => { + this.s.name = nameIn.value.trim().slice(0, 20) || 'Commander'; + const url = urlIn.value.trim(); + this.s.server = url === defaultServerUrl() ? '' : url; + saveSettings(this.s); + status.style.color = 'var(--muted)'; + status.textContent = 'Connecting…'; + const c = new NetClient(); + try { + await c.connect(url, this.s.name); + } catch (e) { + status.style.color = 'var(--bad)'; + status.textContent = `${(e as Error).message}. Make sure an Bear War server is running at that address (see README → Multiplayer).`; + return; + } + this.client = c; + this.unsub = c.on((m) => this.onMessage(m)); + c.onClose(() => { + if (this.app.game) return; + this.stopList(); + this.connectScreen('Disconnected from server.'); + }); + const join = new URLSearchParams(location.search).get('join'); + if (join) c.send({ t: 'join', code: join }); + else this.browse(); + }; + this.app.show( + h( + 'div', + { class: 'screen' }, + h( + 'div', + { class: 'menu', style: { maxWidth: '560px' } }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '30px' } }, 'Multiplayer')), + h('div', { class: 'card' }, + h('label', { class: 'field' }, 'Your name', nameIn), + h('div', { style: { height: '10px' } }), + h('label', { class: 'field' }, 'Server', urlIn), + h('p', { class: 'muted small' }, 'Online games run through a small relay server. Anyone can host one, see README.'), + status, + ), + h('div', { class: 'row' }, + h('button', { class: 'mbtn', onclick: () => this.app.mainMenu() }, '← Back'), + h('button', { class: 'mbtn primary', onclick: go }, 'Connect'), + ), + ), + ), + ); + // auto-connect when the page was opened through an invite link + if (new URLSearchParams(location.search).has('join') && !msg) void go(); + } + + private onMessage(m: S2C): void { + switch (m.t) { + case 'rooms': + this.rooms = m.rooms; + if (!this.room) this.renderBrowse(); + break; + case 'room': + if (!m.room) { + this.room = null; + this.chat = []; + this.browse(); + } else { + const first = !this.room; + this.room = m.room; + if (first) this.chat = []; + this.stopList(); + this.renderRoom(); + } + break; + case 'chat': + this.chat.push({ from: m.from, text: m.text, sys: m.sys }); + if (this.chat.length > 100) this.chat.shift(); + if (this.room && !this.app.game) this.renderRoom(); + break; + case 'error': + this.error = m.msg; + if (this.room) this.renderRoom(); + else this.renderBrowse(); + window.setTimeout(() => (this.error = ''), 4000); + break; + case 'start': + this.startGame(m); + break; + } + } + + private stopList(): void { + window.clearInterval(this.listTimer); + this.listTimer = 0; + } + + browse(): void { + this.room = null; + this.client?.send({ t: 'list' }); + this.stopList(); + this.listTimer = window.setInterval(() => this.client?.send({ t: 'list' }), 3000); + this.renderBrowse(); + } + + private renderBrowse(): void { + if (this.app.game) return; + const c = this.client!; + let map = MAPS[0].id; + let isPublic = true; + const codeIn = h('input', { type: 'text', placeholder: 'Game code', maxLength: 5, style: { textTransform: 'uppercase' } }) as HTMLInputElement; + const list = h('div', {}); + if (!this.rooms.length) list.appendChild(h('p', { class: 'muted small' }, 'No open public games right now, create one!')); + for (const r of this.rooms) { + list.appendChild( + h('div', { class: 'row', style: { marginBottom: '6px' } }, + h('div', {}, h('b', {}, r.name), h('div', { class: 'muted small' }, `${MAPS.find((x) => x.id === r.map)?.name ?? r.map} · ${r.players}/${r.max} players`)), + h('button', { class: 'mbtn', style: { flex: '0 0 auto', width: 'auto' }, onclick: () => c.send({ t: 'join', code: r.code }) }, 'Join'), + ), + ); + } + this.app.show( + h( + 'div', + { class: 'screen' }, + h( + 'div', + { class: 'menu', style: { maxWidth: '620px' } }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '30px' } }, 'Multiplayer'), this.client?.motd ? h('p', {}, this.client.motd) : null), + this.error ? h('p', { style: { color: 'var(--bad)', textAlign: 'center' } }, this.error) : null, + h('div', { class: 'card' }, + h('h3', {}, 'Create a game'), + h('div', { class: 'row' }, + h('label', { class: 'field' }, 'Map', select(MAPS.map((m) => ({ value: m.id, label: `${m.name} (${m.players}p)` })), map, (v) => (map = v))), + h('label', { class: 'toggle', style: { flex: '0 0 auto' } }, 'Public', h('input', { type: 'checkbox', checked: true, onchange: (e: Event) => (isPublic = (e.target as HTMLInputElement).checked) })), + ), + h('div', { style: { height: '8px' } }), + h('button', { class: 'mbtn primary', onclick: () => c.send({ t: 'create', name: `${this.s.name}'s game`, map, maxPlayers: MAPS.find((m) => m.id === map)?.players ?? 2, public: isPublic }) }, 'Create game'), + ), + h('div', { class: 'card' }, + h('h3', {}, 'Join with a code'), + h('div', { class: 'row' }, codeIn, h('button', { class: 'mbtn', style: { flex: '0 0 auto', width: 'auto' }, onclick: () => c.send({ t: 'join', code: codeIn.value }) }, 'Join')), + ), + h('div', { class: 'card' }, h('h3', {}, 'Open games'), list), + h('button', { class: 'mbtn', onclick: () => this.quit() }, '← Back to menu'), + ), + ), + ); + } + + private renderRoom(): void { + if (this.app.game || !this.room) return; + const r = this.room; + const c = this.client!; + const me = c.id; + const isHost = r.host === me; + const raceOpts = [...RACE_IDS.map((x) => ({ value: x, label: RACES[x].name })), { value: 'random', label: 'Random' }]; + const slots = h('div', {}); + r.slots.forEach((s, i) => { + const mine = s.id === me; + const editable = mine || (isHost && s.kind === 'ai'); + let who: HTMLElement; + if (s.kind === 'human') who = h('div', { class: 'who' }, `${s.name}${s.id === r.host ? ' ★' : ''}`); + else if (isHost) + who = select( + [ + { value: 'open', label: 'Open' }, + { value: 'closed', label: 'Closed' }, + { value: 'ai:easy', label: 'AI · Easy' }, + { value: 'ai:normal', label: 'AI · Normal' }, + { value: 'ai:hard', label: 'AI · Hard' }, + { value: 'ai:brutal', label: 'AI · Brutal' }, + ], + s.kind === 'ai' ? `ai:${s.ai}` : s.kind, + (v) => { + if (v.startsWith('ai:')) c.send({ t: 'slot', slot: i, kind: 'ai', ai: v.slice(3) as 'easy' }); + else c.send({ t: 'slot', slot: i, kind: v as 'open' }); + }, + ); + else who = h('div', { class: 'who muted' }, s.kind === 'ai' ? `Computer (${s.ai})` : s.kind === 'open' ? 'Open' : 'Closed'); + const occupied = s.kind === 'human' || s.kind === 'ai'; + const row = h( + 'div', + { class: 'slot' }, + who, + occupied && editable ? select(raceOpts, s.race, (v) => c.send({ t: 'slot', slot: i, race: v as 'random' })) : h('span', { class: 'muted small' }, occupied ? (s.race === 'random' ? 'Random' : RACES[s.race].name) : ''), + occupied && editable + ? select(TEAM_COLORS.map((col, ci) => ({ value: String(ci), label: col.name })), String(s.color), (v) => c.send({ t: 'slot', slot: i, color: Number(v) })) + : h('span', {}, occupied ? h('span', { class: 'swatch', style: { background: TEAM_COLORS[s.color].main } }) : ''), + occupied && editable + ? select(Array.from({ length: r.slots.length }, (_, t) => ({ value: String(t), label: `Team ${t + 1}` })), String(s.team), (v) => c.send({ t: 'slot', slot: i, team: Number(v) })) + : h('span', { class: 'muted small' }, occupied ? `Team ${s.team + 1}` : ''), + isHost && s.kind === 'human' && !mine ? h('button', { class: 'qbtn', title: 'Kick', onclick: () => c.send({ t: 'slot', slot: i, kind: 'open' }) }, '✕') : h('span', {}), + ); + slots.appendChild(row); + }); + const chatBox = h('div', { class: 'chat' }); + for (const m of this.chat) chatBox.appendChild(h('div', { class: m.sys ? 'muted' : '' }, m.sys ? m.text : `${m.from}: ${m.text}`)); + const chatIn = h('input', { type: 'text', placeholder: 'Say something…', maxLength: 200 }) as HTMLInputElement; + const send = () => { + if (chatIn.value.trim()) c.send({ t: 'chat', text: chatIn.value }); + chatIn.value = ''; + }; + chatIn.addEventListener('keydown', (e) => { + if (e.key === 'Enter') send(); + }); + const link = `${location.origin}${location.pathname}?join=${r.code}${this.s.server ? `&server=${encodeURIComponent(this.s.server)}` : ''}`; + const el = h( + 'div', + { class: 'screen' }, + h( + 'div', + { class: 'menu' }, + h('div', { class: 'title' }, h('h1', { style: { fontSize: '26px' } }, r.name), h('p', {}, `Game code `, h('b', { style: { color: 'var(--accent)', letterSpacing: '0.2em' } }, r.code))), + this.error ? h('p', { style: { color: 'var(--bad)', textAlign: 'center' } }, this.error) : null, + h('div', { class: 'row' }, + h('button', { class: 'mbtn', onclick: () => { void navigator.clipboard?.writeText(link); this.chat.push({ from: '', text: 'Invite link copied to clipboard', sys: true }); this.renderRoom(); } }, '🔗 Copy invite link'), + navigator.share ? h('button', { class: 'mbtn', onclick: () => void navigator.share({ title: 'Bear War', text: `Join my Bear War game (code ${r.code})`, url: link }).catch(() => {}) }, '📤 Share') : null, + ), + h('div', { class: 'card' }, + h('h3', {}, 'Settings'), + isHost + ? h('div', { class: 'row' }, + h('label', { class: 'field' }, 'Map', select(MAPS.map((m) => ({ value: m.id, label: `${m.name} (${m.players}p)` })), r.map, (v) => c.send({ t: 'settings', map: v }))), + h('label', { class: 'field' }, 'Speed', select([{ value: '0.75', label: 'Slower' }, { value: '1', label: 'Normal' }, { value: '1.4', label: 'Faster' }], String(r.speed), (v) => c.send({ t: 'settings', speed: Number(v) }))), + ) + : h('p', {}, `${MAPS.find((m) => m.id === r.map)?.name ?? r.map} · speed ${r.speed}×`), + h('div', { class: 'map-row' }, mapPreview(r.map, 130), h('p', { class: 'muted small' }, MAPS.find((m) => m.id === r.map)?.desc ?? '')), + ), + h('div', { class: 'card' }, h('h3', {}, 'Players'), slots), + h('div', { class: 'card' }, h('h3', {}, 'Chat'), chatBox, h('div', { class: 'row', style: { marginTop: '8px' } }, chatIn, h('button', { class: 'mbtn', style: { flex: '0 0 auto', width: 'auto' }, onclick: send }, 'Send'))), + h('div', { class: 'row' }, + h('button', { class: 'mbtn', onclick: () => c.send({ t: 'leaveRoom' }) }, 'Leave'), + isHost ? h('button', { class: 'mbtn primary', onclick: () => c.send({ t: 'start' }) }, 'Start game ▶') : h('div', { class: 'muted', style: { textAlign: 'center' } }, 'Waiting for the host to start…'), + ), + ), + ); + this.app.show(el); + chatBox.scrollTop = chatBox.scrollHeight; + } + + private startGame(m: Extract): void { + const c = this.client!; + this.stopList(); + this.unsub?.(); + const game = new Game({ map: loadMap(m.map), players: m.players, seed: m.seed }); + const session = new NetSession(c, game, m.you, m.speed, { code: m.code, token: m.token, name: this.s.name }); + this.client = null; + this.app.launch(session); + } + + private quit(): void { + this.stopList(); + this.unsub?.(); + this.client?.close(); + this.client = null; + this.app.mainMenu(); + } +} diff --git a/gh-pages/bear-war/src/app/settings.ts b/gh-pages/bear-war/src/app/settings.ts new file mode 100644 index 00000000..56576df7 --- /dev/null +++ b/gh-pages/bear-war/src/app/settings.ts @@ -0,0 +1,49 @@ +export interface Settings { + name: string; + master: number; + sfx: number; + music: number; + voice: boolean; + scrollSpeed: number; + edgeScroll: boolean; + allBars: boolean; + lowFx: boolean; + maxDpr: number; + server: string; + tips: boolean; +} + +const KEY = 'bear-war:settings'; + +export const DEFAULT_SETTINGS: Settings = { + name: 'Commander', + master: 0.8, + sfx: 0.8, + music: 0.4, + voice: true, + scrollSpeed: 1, + edgeScroll: true, + allBars: false, + lowFx: false, + maxDpr: 2, + server: '', + tips: true, +}; + +export function loadSettings(): Settings { + try { + const raw = localStorage.getItem(KEY); + if (raw) return { ...DEFAULT_SETTINGS, ...JSON.parse(raw) }; + } catch { + // storage unavailable (private mode), use defaults + } + return { ...DEFAULT_SETTINGS }; +} + +export function saveSettings(s: Settings): void { + try { + localStorage.setItem(KEY, JSON.stringify(s)); + } catch { + // ignore + } +} diff --git a/gh-pages/bear-war/src/app/ui.ts b/gh-pages/bear-war/src/app/ui.ts new file mode 100644 index 00000000..f03d5acd --- /dev/null +++ b/gh-pages/bear-war/src/app/ui.ts @@ -0,0 +1,85 @@ +// Tiny DOM helpers for menu screens. + +import { loadMap } from '../game/map/maps.ts'; +import { TerrainRenderer } from '../render/terrain.ts'; + +export function h( + tag: K, + props: Record = {}, + ...children: (Node | string | null | undefined | false)[] +): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + for (const [k, v] of Object.entries(props)) { + if (v === undefined || v === null || v === false) continue; + if (k === 'class') e.className = String(v); + else if (k === 'style' && typeof v === 'object') Object.assign(e.style, v); + else if (k.startsWith('on') && typeof v === 'function') e.addEventListener(k.slice(2).toLowerCase(), v as EventListener); + else if (k === 'html') e.innerHTML = String(v); + else if (k in e && typeof v !== 'string') (e as unknown as Record)[k] = v; + else e.setAttribute(k, String(v)); + } + for (const c of children) { + if (c === null || c === undefined || c === false) continue; + e.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); + } + return e; +} + +export function select(options: { value: string; label: string }[], value: string, onChange: (v: string) => void): HTMLSelectElement { + const s = h('select', { onchange: () => onChange(s.value) }); + for (const o of options) { + const opt = h('option', { value: o.value }, o.label); + if (o.value === value) opt.selected = true; + s.appendChild(opt); + } + return s; +} + +export function esc(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +} + + +const previewCache = new Map(); + +/** Small top-down preview of a map with its start locations marked. */ +export function mapPreview(mapId: string, size = 150): HTMLCanvasElement { + const key = `${mapId}|${size}`; + const hit = previewCache.get(key); + if (hit) { + const c = document.createElement('canvas'); + c.width = hit.width; + c.height = hit.height; + c.getContext('2d')!.drawImage(hit, 0, 0); + c.className = 'map-preview'; + return c; + } + const map = loadMap(mapId); + const terr = new TerrainRenderer(map); + const img = terr.minimapImage(2); + const k = size / Math.max(map.w, map.h); + const c = document.createElement('canvas'); + c.width = Math.round(map.w * k); + c.height = Math.round(map.h * k); + const ctx = c.getContext('2d')!; + ctx.drawImage(img, 0, 0, c.width, c.height); + for (const r of map.resources) { + ctx.fillStyle = r.kind === 'mineral' ? '#7fdcff' : '#4fd06a'; + ctx.fillRect(r.tx * k, r.ty * k, Math.max(1, (r.kind === 'mineral' ? 2 : 3) * k), Math.max(1, (r.kind === 'mineral' ? 1 : 3) * k)); + } + ctx.font = `bold ${Math.round(size / 11)}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const b of map.starts) { + const x = (b.tx + 2.5) * k; + const y = (b.ty + 2.5) * k; + ctx.fillStyle = 'rgba(0,0,0,0.6)'; + ctx.beginPath(); + ctx.arc(x, y, size / 14, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = '#ffd36b'; + ctx.fillText(String(b.start + 1), x, y + 1); + } + previewCache.set(key, c); + return mapPreview(mapId, size); +} diff --git a/gh-pages/bear-war/src/audio/audio.ts b/gh-pages/bear-war/src/audio/audio.ts new file mode 100644 index 00000000..7be3d080 --- /dev/null +++ b/gh-pages/bear-war/src/audio/audio.ts @@ -0,0 +1,595 @@ +// Procedural audio: every sound effect is synthesized with the Web Audio API at runtime +// and the music is generated on the fly, no audio files are shipped. + +import type { GameEvent } from '../game/types.ts'; +import type { Game } from '../game/game.ts'; +import type { Camera } from '../render/camera.ts'; +import type { Settings } from '../app/settings.ts'; +import { DEFS } from '../game/data/index.ts'; + +type Voice = { t: number }; + +export class AudioEngine { + settings: Settings; + ctx: AudioContext | null = null; + private master!: GainNode; + private sfx!: GainNode; + private musicBus!: GainNode; + private comp!: DynamicsCompressorNode; + private noise!: AudioBuffer; + private recent = new Map(); + private voices: Voice[] = []; + private lastSay = new Map(); + private lastAnySay = 0; + private music: { stop: () => void } | null = null; + private wantMusic = false; + + constructor(settings: Settings) { + this.settings = settings; + } + + /** Browsers only allow audio after a user gesture. */ + unlock(): void { + if (this.ctx) { + if (this.ctx.state === 'suspended') void this.ctx.resume(); + return; + } + const AC = window.AudioContext ?? (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!AC) return; + const ctx = new AC(); + this.ctx = ctx; + this.comp = ctx.createDynamicsCompressor(); + this.comp.threshold.value = -16; + this.comp.ratio.value = 6; + this.master = ctx.createGain(); + this.sfx = ctx.createGain(); + this.musicBus = ctx.createGain(); + this.sfx.connect(this.comp); + this.musicBus.connect(this.master); + this.comp.connect(this.master); + this.master.connect(ctx.destination); + const len = ctx.sampleRate; + this.noise = ctx.createBuffer(1, len, ctx.sampleRate); + const d = this.noise.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + this.applySettings(); + if (this.wantMusic) this.startMusic(); + } + + applySettings(): void { + if (!this.ctx) return; + const s = this.settings; + this.master.gain.value = s.master; + this.sfx.gain.value = s.sfx; + this.musicBus.gain.value = s.music * 0.5; + } + + // ------------------------------------------------------------------ primitives + + private now(): number { + return this.ctx!.currentTime; + } + + private out(pan: number, vol: number): GainNode { + const ctx = this.ctx!; + const g = ctx.createGain(); + g.gain.value = vol; + if (pan !== 0 && ctx.createStereoPanner) { + const p = ctx.createStereoPanner(); + p.pan.value = Math.max(-1, Math.min(1, pan)); + g.connect(p); + p.connect(this.sfx); + } else g.connect(this.sfx); + return g; + } + + private env(g: GainNode, t: number, a: number, d: number, peak = 1): void { + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(peak, t + a); + g.gain.exponentialRampToValueAtTime(0.0001, t + a + d); + } + + private tone(dest: AudioNode, type: OscillatorType, f0: number, f1: number, t: number, dur: number, vol: number, attack = 0.005): void { + const ctx = this.ctx!; + const o = ctx.createOscillator(); + o.type = type; + o.frequency.setValueAtTime(f0, t); + if (f1 !== f0) o.frequency.exponentialRampToValueAtTime(Math.max(20, f1), t + dur); + const g = ctx.createGain(); + this.env(g, t, attack, dur, vol); + o.connect(g); + g.connect(dest); + o.start(t); + o.stop(t + attack + dur + 0.05); + } + + private noiseHit(dest: AudioNode, type: BiquadFilterType, f0: number, f1: number, q: number, t: number, dur: number, vol: number, attack = 0.003): void { + const ctx = this.ctx!; + const src = ctx.createBufferSource(); + src.buffer = this.noise; + src.playbackRate.value = 0.8 + Math.random() * 0.4; + const f = ctx.createBiquadFilter(); + f.type = type; + f.Q.value = q; + f.frequency.setValueAtTime(f0, t); + if (f1 !== f0) f.frequency.exponentialRampToValueAtTime(Math.max(30, f1), t + dur); + const g = ctx.createGain(); + this.env(g, t, attack, dur, vol); + src.connect(f); + f.connect(g); + g.connect(dest); + src.start(t, Math.random() * 0.5); + src.stop(t + attack + dur + 0.05); + } + + /** Rate limiting so 40 marines don't produce 40 simultaneous shots. */ + private allow(key: string, maxPer: number, windowS: number): boolean { + const t = performance.now() / 1000; + let list = this.recent.get(key); + if (!list) { + list = []; + this.recent.set(key, list); + } + while (list.length && t - list[0] > windowS) list.shift(); + if (list.length >= maxPer) return false; + list.push(t); + this.voices = this.voices.filter((v) => t - v.t < 0.25); + if (this.voices.length > 28) return false; + this.voices.push({ t }); + return true; + } + + // ------------------------------------------------------------------ sound bank + + play(name: string, pan = 0, vol = 1): void { + if (!this.ctx || this.ctx.state !== 'running' || vol < 0.03) return; + const t = this.now() + 0.005; + const o = this.out(pan, vol); + const r = (a: number, b: number) => a + Math.random() * (b - a); + switch (name) { + case 'rifle': + this.noiseHit(o, 'bandpass', r(2200, 2800), 900, 1.2, t, 0.05, 0.35); + this.tone(o, 'square', r(320, 380), 120, t, 0.03, 0.08); + break; + case 'autocannon': + this.noiseHit(o, 'lowpass', 1800, 400, 0.8, t, 0.09, 0.45); + this.tone(o, 'sine', 140, 60, t, 0.08, 0.35); + break; + case 'cannon': + this.tone(o, 'sine', 110, 45, t, 0.28, 0.6); + this.noiseHit(o, 'lowpass', 900, 150, 0.7, t, 0.25, 0.5); + break; + case 'boulder': + case 'keg': + this.tone(o, 'sine', 80, 30, t, 0.5, 0.8); + this.noiseHit(o, 'lowpass', 700, 80, 0.7, t, 0.5, 0.7); + this.noiseHit(o, 'bandpass', 2500, 600, 1, t, 0.12, 0.25); + break; + case 'sap': + this.tone(o, 'triangle', 220, 90, t, 0.12, 0.4); + this.noiseHit(o, 'lowpass', 1200, 300, 0.7, t, 0.1, 0.3); + break; + case 'missile': + this.noiseHit(o, 'bandpass', 700, 3200, 2, t, 0.25, 0.35, 0.02); + break; + case 'flame': + this.noiseHit(o, 'lowpass', 1400, 500, 0.6, t, 0.35, 0.45, 0.03); + this.noiseHit(o, 'bandpass', 300, 200, 1.5, t, 0.3, 0.3, 0.02); + break; + case 'guns': + this.tone(o, 'sawtooth', 1700, 500, t, 0.12, 0.16); + break; + case 'broadside': + this.tone(o, 'square', r(1400, 1600), 700, t, 0.07, 0.1); + break; + case 'prism': + case 'sunlance': + case 'lockon': + this.tone(o, 'sawtooth', 2200, 900, t, 0.09, 0.12); + this.tone(o, 'sine', 3300, 1500, t, 0.09, 0.08); + break; + case 'moonorb': + case 'frost': + case 'lantern': + this.tone(o, 'sine', 950, 280, t, 0.18, 0.35); + this.tone(o, 'triangle', 1900, 560, t, 0.12, 0.12); + break; + case 'starbolt': + this.tone(o, 'sine', 700, 120, t, 0.45, 0.5); + this.noiseHit(o, 'bandpass', 1800, 300, 1.5, t, 0.4, 0.3, 0.02); + break; + case 'eyebeam': + this.tone(o, 'sawtooth', 160, 120, t, 0.3, 0.2, 0.02); + this.noiseHit(o, 'bandpass', 2600, 1800, 2, t, 0.3, 0.2, 0.02); + break; + case 'quill': + case 'honey': + case 'eruption': + this.noiseHit(o, 'bandpass', 3200, 1600, 3, t, 0.06, 0.4); + this.tone(o, 'triangle', 600, 200, t, 0.05, 0.15); + break; + case 'glaive': + this.noiseHit(o, 'bandpass', 2400, 1200, 4, t, 0.12, 0.35); + this.tone(o, 'sine', 500, 900, t, 0.1, 0.1); + break; + case 'sting': + case 'tongue': + this.noiseHit(o, 'lowpass', 900, 400, 4, t, 0.15, 0.4); + this.tone(o, 'sine', 300, 150, t, 0.12, 0.2); + break; + case 'claw': + case 'maul': + this.noiseHit(o, 'highpass', 3000, 1500, 0.8, t, name === 'maul' ? 0.12 : 0.05, name === 'maul' ? 0.5 : 0.3); + if (name === 'maul') this.tone(o, 'sine', 120, 60, t, 0.15, 0.4); + break; + case 'blade': + case 'scythe': + this.noiseHit(o, 'highpass', 4500, 3000, 1, t, 0.07, 0.25); + this.tone(o, 'sine', name === 'scythe' ? 1200 : 1900, name === 'scythe' ? 600 : 1500, t, 0.12, 0.15); + break; + case 'torch': + case 'starspark': + this.tone(o, 'sawtooth', 180, 160, t, 0.08, 0.12); + this.noiseHit(o, 'highpass', 5000, 5000, 0.5, t, 0.06, 0.1); + break; + case 'thorn': + this.tone(o, 'sine', 170, 55, t, 0.2, 0.55); + this.noiseHit(o, 'lowpass', 600, 200, 1, t, 0.15, 0.3); + break; + case 'burst': + this.noiseHit(o, 'lowpass', 900, 120, 1.5, t, 0.45, 0.8); + this.tone(o, 'sine', 140, 40, t, 0.4, 0.6); + break; + case 'grandshot': + this.tone(o, 'sawtooth', 90, 40, t, 0.6, 0.5); + this.noiseHit(o, 'bandpass', 3000, 200, 1, t, 0.6, 0.7); + break; + case 'explode_s': + this.noiseHit(o, 'lowpass', 1500, 150, 0.7, t, 0.35, 0.6); + this.tone(o, 'sine', 90, 35, t, 0.3, 0.5); + break; + case 'explode_l': + this.noiseHit(o, 'lowpass', 1100, 60, 0.7, t, 0.9, 0.9, 0.01); + this.tone(o, 'sine', 70, 25, t, 0.8, 0.8); + this.noiseHit(o, 'bandpass', 400, 100, 1, t + 0.15, 0.6, 0.5); + break; + case 'squish': + this.noiseHit(o, 'bandpass', 800, 250, 3, t, 0.22, 0.5); + this.tone(o, 'sine', 260, 90, t, 0.18, 0.3); + break; + case 'scream': + this.tone(o, 'sawtooth', 600, 300, t, 0.25, 0.08); + break; + case 'shimmer': + for (let i = 0; i < 3; i++) this.tone(o, 'sine', 1400 - i * 300, 600 - i * 120, t + i * 0.04, 0.3, 0.15); + this.noiseHit(o, 'highpass', 6000, 3000, 0.7, t, 0.3, 0.15); + break; + case 'summon': + this.tone(o, 'sine', 300, 1200, t, 0.5, 0.25, 0.05); + this.tone(o, 'triangle', 600, 2400, t, 0.5, 0.1, 0.05); + break; + case 'construct': + this.noiseHit(o, 'bandpass', 1800, 1800, 6, t, 0.06, 0.2); + this.tone(o, 'square', 90, 90, t, 0.04, 0.08); + break; + case 'grow': + this.noiseHit(o, 'lowpass', 600, 200, 2, t, 0.5, 0.35, 0.05); + break; + case 'honeyrush': + this.tone(o, 'sine', 400, 900, t, 0.15, 0.2); + break; + case 'heal': + this.tone(o, 'sine', 880, 1320, t, 0.12, 0.08); + break; + case 'blink': + this.tone(o, 'sine', 1200, 300, t, 0.2, 0.3); + this.noiseHit(o, 'highpass', 4000, 1000, 0.7, t, 0.15, 0.2); + break; + case 'storm': + this.noiseHit(o, 'bandpass', 2000, 800, 2, t, 1.2, 0.4, 0.1); + this.tone(o, 'sawtooth', 70, 55, t, 1.2, 0.15, 0.1); + break; + case 'sniff': + this.tone(o, 'sine', 600, 1800, t, 0.6, 0.2, 0.05); + break; + } + } + + // ------------------------------------------------------------------ UI + + ui(name: string): void { + if (!this.ctx || this.ctx.state !== 'running') return; + if (!this.allow('ui:' + name, 2, 0.12)) return; + const t = this.now() + 0.005; + const o = this.out(0, 1); + switch (name) { + case 'click': + this.tone(o, 'sine', 1800, 1200, t, 0.03, 0.08); + break; + case 'error': + this.tone(o, 'square', 220, 200, t, 0.08, 0.12); + this.tone(o, 'square', 180, 160, t + 0.1, 0.1, 0.12); + break; + case 'complete': + this.tone(o, 'sine', 660, 660, t, 0.15, 0.2); + this.tone(o, 'sine', 990, 990, t + 0.12, 0.25, 0.2); + break; + case 'research': + [523, 659, 784].forEach((f, i) => this.tone(o, 'triangle', f, f, t + i * 0.1, 0.25, 0.18)); + break; + case 'alert': + for (let i = 0; i < 3; i++) this.tone(o, 'square', 880, 620, t + i * 0.18, 0.12, 0.12); + break; + } + } + + /** Unit acknowledgement chirps, flavoured per race. */ + ack(kind: string, race?: string): void { + if (!this.ctx || this.ctx.state !== 'running' || !this.allow('ack', 1, 0.18)) return; + const t = this.now() + 0.005; + const o = this.out(0, 0.6); + const hi = kind === 'attack' ? 1.25 : kind === 'select' ? 1.1 : 1; + if (race === 'horde') { + this.tone(o, 'sawtooth', 140 * hi, 90 * hi, t, 0.12, 0.12); + this.noiseHit(o, 'bandpass', 700 * hi, 400, 4, t, 0.12, 0.15); + } else if (race === 'starborn') { + this.tone(o, 'sine', 660 * hi, 880 * hi, t, 0.12, 0.12); + this.tone(o, 'sine', 990 * hi, 1320 * hi, t + 0.03, 0.12, 0.06); + } else { + this.tone(o, 'square', 1250 * hi, 1250 * hi, t, 0.03, 0.06); + this.tone(o, 'square', 1650 * hi, 1650 * hi, t + 0.06, 0.03, 0.06); + this.noiseHit(o, 'bandpass', 2500, 2500, 2, t, 0.1, 0.05); + } + } + + /** Spoken announcements via the browser's speech synthesis (if enabled). */ + say(text: string): void { + if (!this.settings.voice || typeof speechSynthesis === 'undefined') return; + const now = performance.now(); + if (now - this.lastAnySay < 2500 || now - (this.lastSay.get(text) ?? -1e9) < 8000) return; + this.lastAnySay = now; + this.lastSay.set(text, now); + try { + const u = new SpeechSynthesisUtterance(text); + u.rate = 1.05; + u.pitch = 0.85; + u.volume = Math.min(1, this.settings.master * 0.9); + speechSynthesis.speak(u); + } catch { + // speech unavailable + } + } + + // ------------------------------------------------------------------ game events + + onEvents(events: GameEvent[], g: Game, player: number, cam: Camera): void { + if (!this.ctx || this.ctx.state !== 'running') return; + const cx = cam.centerX; + const cy = cam.centerY; + const halfW = cam.viewW / 2; + const halfH = cam.viewH / 2; + const spatial = (x: number, y: number): [number, number] => { + const dx = (x - cx) / halfW; + const dy = (y - cy) / halfH; + const out = Math.max(Math.abs(dx), Math.abs(dy)); + const vol = out <= 1 ? 1 : Math.max(0, 1 - (out - 1) * 1.2); + return [Math.max(-0.9, Math.min(0.9, dx * 0.7)), vol]; + }; + const team = player >= 0 ? g.players[player].team : -1; + const seen = (x: number, y: number) => team < 0 || g.vision.visibleAt(team, x, y); + for (const ev of events) { + switch (ev.k) { + case 'fire': { + if (!seen(ev.x, ev.y)) break; + const [pan, vol] = spatial(ev.x, ev.y); + if (vol <= 0) break; + const heavy = ev.fx === 'boulder' || ev.fx === 'grandshot' || ev.fx === 'starbolt'; + if (!this.allow('fx:' + ev.fx, heavy ? 3 : 4, 0.09)) break; + this.play(ev.fx, pan, vol * (heavy ? 0.9 : 0.55)); + break; + } + case 'splash': + if (ev.fx === 'burst' && seen(ev.x, ev.y)) { + const [pan, vol] = spatial(ev.x, ev.y); + if (this.allow('burst', 3, 0.1)) this.play('burst', pan, vol); + } + break; + case 'death': { + if (!seen(ev.x, ev.y)) break; + const [pan, vol] = spatial(ev.x, ev.y); + if (vol <= 0) break; + const def = DEFS[ev.def]; + if (!def || def.kind === 'mineral' || def.kind === 'geyser') break; + let name = 'explode_s'; + if (ev.building) name = 'explode_l'; + else if (def.race === 'horde') name = 'squish'; + else if (def.race === 'starborn') name = def.attrs.includes('mech') ? 'explode_s' : 'shimmer'; + else if (!def.attrs.includes('mech')) name = 'scream'; + if (ev.r > 16 && !ev.building) name = 'explode_l'; + if (this.allow('death:' + name, 3, 0.12)) this.play(name, pan, vol * 0.8); + break; + } + case 'placed': { + if (ev.owner !== player) break; + const def = DEFS[ev.def]; + const [pan, vol] = spatial(ev.x, ev.y); + this.play(def.race === 'starborn' ? 'summon' : def.race === 'horde' ? 'grow' : 'construct', pan, vol * 0.7); + break; + } + case 'spark': { + if (!seen(ev.x, ev.y)) break; + const [pan, vol] = spatial(ev.x, ev.y); + if (this.allow('spark', 2, 0.25)) this.play('construct', pan, vol * 0.4); + break; + } + case 'trained': + if (ev.owner === player && this.allow('trained', 1, 0.5)) { + const def = DEFS[ev.def]; + const [pan, vol] = spatial(ev.x, ev.y); + if (def.race === 'starborn') this.play('summon', pan, vol * 0.4); + else this.ack('select', def.race); + } + break; + case 'ability': { + if (!seen(ev.tx, ev.ty) && ev.owner !== player) break; + const [pan, vol] = spatial(ev.tx, ev.ty); + const map: Record = { honeyrush: 'honeyrush', starfall: 'storm', sniff: 'sniff', moonstep: 'blink', bubble: 'heal', staticburst: 'grandshot', beecloud: 'grow', stingswarm: 'sting', haste: 'summon', royaljelly: 'grow', brace: 'construct', unbrace: 'construct', digin: 'construct', hibernate: 'grow', anchor: 'construct', hopoff: 'construct', releasecubs: 'grow', yank: 'blink', pollen: 'grow', hornetbomb: 'sting', spiritwalk: 'blink', snowball: 'storm', scaldinghoney: 'sting', lockon: 'sniff', sunlanceon: 'summon' }; + const n = map[ev.ab]; + if (n && this.allow('ab:' + n, 2, 0.2)) this.play(n, pan, vol * 0.8); + break; + } + case 'blink': { + const [pan, vol] = spatial(ev.x1, ev.y1); + if (this.allow('blink', 2, 0.1)) this.play('blink', pan, vol * 0.6); + break; + } + case 'cargo': { + if (ev.owner !== player && !seen(ev.x, ev.y)) break; + const [pan, vol] = spatial(ev.x, ev.y); + const race = g.byId.get(ev.carrier)?.def.race; + const n = race === 'starborn' ? 'blink' : race === 'horde' ? 'grow' : 'construct'; + if (this.allow('cargo', 2, 0.12)) this.play(n, pan, vol * 0.45); + break; + } + case 'heal': + if (ev.id && seen(ev.x, ev.y) && this.allow('heal', 1, 0.4)) { + const [pan, vol] = spatial(ev.x, ev.y); + this.play('heal', pan, vol * 0.5); + } + break; + } + } + } + + // ------------------------------------------------------------------ music + + startMusic(): void { + this.wantMusic = true; + if (!this.ctx || this.music) return; + this.music = generativeMusic(this.ctx, this.musicBus); + } + + stopMusic(): void { + this.wantMusic = false; + this.music?.stop(); + this.music = null; + } +} + +/** Slow evolving ambient pads with sparse bell notes (A minor / dorian). */ +function generativeMusic(ctx: AudioContext, out: GainNode): { stop: () => void } { + const bus = ctx.createGain(); + bus.gain.value = 0.0001; + bus.gain.exponentialRampToValueAtTime(0.9, ctx.currentTime + 4); + const lp = ctx.createBiquadFilter(); + lp.type = 'lowpass'; + lp.frequency.value = 900; + lp.Q.value = 0.5; + const lfo = ctx.createOscillator(); + lfo.frequency.value = 0.05; + const lfoGain = ctx.createGain(); + lfoGain.gain.value = 500; + lfo.connect(lfoGain); + lfoGain.connect(lp.frequency); + lfo.start(); + // feedback delay for space + const delay = ctx.createDelay(2); + delay.delayTime.value = 0.6; + const fb = ctx.createGain(); + fb.gain.value = 0.35; + delay.connect(fb); + fb.connect(delay); + delay.connect(bus); + lp.connect(bus); + lp.connect(delay); + bus.connect(out); + + const chords = [ + [57, 60, 64, 67], // Am7 + [53, 57, 60, 64], // Fmaj7 + [55, 59, 62, 65], // G7-ish + [52, 55, 59, 62], // Em7 + ]; + const scale = [57, 59, 60, 62, 64, 65, 67, 69, 72, 74, 76]; + const midi = (n: number) => 440 * Math.pow(2, (n - 69) / 12); + let stopped = false; + let step = 0; + const timers: number[] = []; + + const pad = (notes: number[], t: number, dur: number) => { + for (const n of notes) { + for (const det of [-6, 6]) { + const o = ctx.createOscillator(); + o.type = 'sawtooth'; + o.frequency.value = midi(n - 12); + o.detune.value = det; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.025, t + dur * 0.35); + g.gain.exponentialRampToValueAtTime(0.0001, t + dur); + o.connect(g); + g.connect(lp); + o.start(t); + o.stop(t + dur + 0.1); + } + } + }; + const bell = (n: number, t: number) => { + const o = ctx.createOscillator(); + o.type = 'sine'; + o.frequency.value = midi(n); + const o2 = ctx.createOscillator(); + o2.type = 'sine'; + o2.frequency.value = midi(n) * 2.76; + const g = ctx.createGain(); + g.gain.setValueAtTime(0.0001, t); + g.gain.exponentialRampToValueAtTime(0.06, t + 0.01); + g.gain.exponentialRampToValueAtTime(0.0001, t + 3); + const g2 = ctx.createGain(); + g2.gain.value = 0.25; + o.connect(g); + o2.connect(g2); + g2.connect(g); + g.connect(delay); + g.connect(bus); + o.start(t); + o2.start(t); + o.stop(t + 3.1); + o2.stop(t + 3.1); + }; + const tick = () => { + if (stopped) return; + const t = ctx.currentTime + 0.1; + const chord = chords[step % chords.length]; + pad(chord, t, 9); + // bass + const b = ctx.createOscillator(); + b.type = 'triangle'; + b.frequency.value = midi(chord[0] - 24); + const bg = ctx.createGain(); + bg.gain.setValueAtTime(0.0001, t); + bg.gain.exponentialRampToValueAtTime(0.08, t + 1); + bg.gain.exponentialRampToValueAtTime(0.0001, t + 8); + b.connect(bg); + bg.connect(bus); + b.start(t); + b.stop(t + 8.2); + for (let i = 0; i < 3; i++) if (Math.random() < 0.6) bell(scale[Math.floor(Math.random() * scale.length)], t + 1 + i * 2.2 + Math.random()); + step++; + timers.push(window.setTimeout(tick, 8000)); + }; + tick(); + return { + stop() { + stopped = true; + for (const t of timers) clearTimeout(t); + bus.gain.cancelScheduledValues(ctx.currentTime); + bus.gain.setValueAtTime(bus.gain.value, ctx.currentTime); + bus.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 1.5); + window.setTimeout(() => { + lfo.stop(); + bus.disconnect(); + }, 1700); + }, + }; +} diff --git a/gh-pages/bear-war/src/dev/gallery.ts b/gh-pages/bear-war/src/dev/gallery.ts new file mode 100644 index 00000000..e0c170e1 --- /dev/null +++ b/gh-pages/bear-war/src/dev/gallery.ts @@ -0,0 +1,51 @@ +// Dev-only page: renders every sprite at large scale (open /gallery.html with `npm run dev`). +import { DEFS } from '../game/data/index.ts'; +import { SpriteCache } from '../render/sprites.ts'; +import { ART } from '../render/art/index.ts'; + +const sprites = new SpriteCache(3, 2); +const q = new URLSearchParams(location.search); +const colors = (q.get('colors') ?? '1,0').split(',').map(Number); +const scale = Number(q.get('scale') ?? 2); + +const only = q.get('race'); +const kind = q.get('kind'); +for (const race of ['ironpaw', 'horde', 'starborn', 'neutral'] as const) { + if (only && only !== race) continue; + const h = document.createElement('h2'); + h.textContent = race; + document.body.appendChild(h); + const grid = document.createElement('div'); + grid.className = 'grid'; + document.body.appendChild(grid); + for (const def of Object.values(DEFS).filter((d) => d.race === race && (!kind || (kind === 'building') === (d.kind === 'building')))) { + for (const color of race === 'neutral' ? [-1] : colors) { + const art = sprites.art(def); + const frames = art.frames ?? 1; + for (let f = 0; f < Math.min(frames, q.has('frames') ? 4 : 1); f++) { + const s = sprites.get(def, color, 'body', f)!; + const c = document.createElement('canvas'); + c.width = s.w * scale; + c.height = s.h * scale; + const ctx = c.getContext('2d')!; + ctx.drawImage(s.canvas, 0, 0, c.width, c.height); + const t = sprites.get(def, color, 'turret', f); + if (t) ctx.drawImage(t.canvas, (c.width - t.w * scale) / 2, (c.height - t.h * scale) / 2, t.w * scale, t.h * scale); + if (art.overlay) { + ctx.save(); + ctx.translate(c.width / 2, c.height / 2); + ctx.scale(scale, scale); + art.overlay(ctx, { main: '#2f6fe8', light: '#78a6ff', dark: '#14306e', index: color }, { t: 1, built: true, powered: true, working: true, seed: 1 }, def); + ctx.restore(); + } + const cell = document.createElement('div'); + cell.className = 'cell'; + cell.appendChild(c); + const label = document.createElement('span'); + label.textContent = def.id + (frames > 1 ? ` f${f}` : '') + (ART[def.art ?? def.id] ? '' : ' (placeholder)'); + cell.appendChild(label); + grid.appendChild(cell); + } + } + } +} diff --git a/gh-pages/bear-war/src/env.d.ts b/gh-pages/bear-war/src/env.d.ts new file mode 100644 index 00000000..ac3ec612 --- /dev/null +++ b/gh-pages/bear-war/src/env.d.ts @@ -0,0 +1,2 @@ +/** Fingerprint of the simulation code, injected by vite.config.ts ('dev' in development). */ +declare const __SIM_HASH__: string; diff --git a/gh-pages/bear-war/src/game/ai/ai.ts b/gh-pages/bear-war/src/game/ai/ai.ts new file mode 100644 index 00000000..7621d138 --- /dev/null +++ b/gh-pages/bear-war/src/game/ai/ai.ts @@ -0,0 +1,1274 @@ +// Computer opponent. Runs inside the deterministic simulation (identically on every +// client), reads the game state and issues ordinary player commands. + +import { TILE } from '../constants.ts'; +import { DEFS, PRODUCERS, RACES, RESEARCHED_AT, RESEARCH_DEFS } from '../data/index.ts'; +import type { EntityDef } from '../data/types.ts'; +import type { Entity } from '../entity.ts'; +import type { Game } from '../game.ts'; +import { F_RAMP, type BaseSpot } from '../map/gamemap.ts'; +import { dist } from '../math.ts'; +import type { AIDifficulty, Player } from '../player.ts'; +import { Rng } from '../rng.ts'; +import { applyCommand } from '../commands.ts'; +import { canPlace } from '../systems/placement.ts'; +import type { Command } from '../types.ts'; +import { DIFFICULTY, PLANS, type DiffCfg, type RacePlan, type TechStep } from './plans.ts'; + +interface Snap { + halls: Entity[]; + workers: Entity[]; + army: Entity[]; + structures: Entity[]; + byDef: Map; + cubs: number; + pending: Map; + /** supply provided by unfinished supply sources */ + pendingSupply: number; + armySupply: number; +} + +interface Rect { + x0: number; + y0: number; + x1: number; + y1: number; +} + +const near = (a: { x: number; y: number }, b: { x: number; y: number }) => dist(a.x, a.y, b.x, b.y); +const centroid = (es: Entity[]) => ({ x: es.reduce((s, e) => s + e.x, 0) / es.length, y: es.reduce((s, e) => s + e.y, 0) / es.length }); + +export class AIController { + g: Game; + pi: number; + difficulty: AIDifficulty; + cfg: DiffCfg; + plan: RacePlan; + rng: Rng; + step = 0; + state: 'gather' | 'attack' | 'defend' = 'gather'; + attackStrength = 0; + attackSupply: number; + target: { x: number; y: number } | null = null; + enemyMem = new Map(); + enemyStarts: { x: number; y: number }[] = []; + scoutId = 0; + scoutDone = false; + expandWorker = 0; + private searchIdx = 0; + /** ground distance from our main to each base location (tiles) */ + groundDist = new Map(); + private expandTries = new Map(); + home: { x: number; y: number }; + gather: { x: number; y: number }; + lastAmove = -1000; + cmds = 0; + enemyAir = 0; + enemyLight = 0; + enemyArmored = 0; + /** largest enemy army (supply) seen recently */ + enemyArmy = 0; + cloakSeen = false; + private keepOut: Rect[] = []; + private braceIdle = new Map(); + + constructor(g: Game, pi: number, difficulty: AIDifficulty) { + this.g = g; + this.pi = pi; + this.difficulty = difficulty; + this.cfg = DIFFICULTY[difficulty] ?? DIFFICULTY.normal; + this.plan = PLANS[g.players[pi].race]; + this.rng = new Rng((g.seed ^ (pi * 0x9e3779b1)) >>> 0); + this.attackSupply = this.cfg.attackSupply; + const p = g.players[pi]; + p.incomeMult = this.cfg.income; + const base = g.map.bases[p.startBase]; + this.home = { x: (base.tx + 2.5) * TILE, y: (base.ty + 2.5) * TILE }; + for (const q of g.players) { + if (q.index === pi || !g.isEnemy(pi, q.index)) continue; + } + // every other start location might hold an enemy + for (const s of g.map.starts) { + const c = { x: (s.tx + 2.5) * TILE, y: (s.ty + 2.5) * TILE }; + if (near(c, this.home) > 10 * TILE) this.enemyStarts.push(c); + } + this.gather = this.home; + // ground distance (in tiles) from our main to every base location, for choosing expansions + const m = g.map; + const dist = new Int32Array(m.w * m.h).fill(-1); + const q: number[] = []; + const sx = base.tx + 2; + const sy = base.ty + 6 < m.h ? base.ty + 6 : base.ty - 2; + for (let dy = -3; dy <= 7; dy++) { + for (let dx = -3; dx <= 7; dx++) { + const x = base.tx + dx; + const y = base.ty + dy; + if (m.walkable(x, y) && dist[y * m.w + x] < 0) { + dist[y * m.w + x] = 0; + q.push(y * m.w + x); + } + } + } + void sx; + void sy; + for (let h = 0; h < q.length; h++) { + const i = q[h]; + const x = i % m.w; + const y = (i - x) / m.w; + for (const [ox, oy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + ox; + const ny = y + oy; + if (!m.walkable(nx, ny)) continue; + const j = ny * m.w + nx; + if (dist[j] >= 0) continue; + dist[j] = dist[i] + 1; + q.push(j); + } + } + m.bases.forEach((b, bi) => { + const d = dist[(b.ty + 2) * m.w + b.tx + 2]; + this.groundDist.set(bi, d < 0 ? Infinity : d); + }); + } + + get p(): Player { + return this.g.players[this.pi]; + } + + private issue(cmd: Command): void { + applyCommand(this.g, this.pi, cmd); + this.cmds++; + } + + update(): void { + const g = this.g; + if (this.p.defeated) return; + if ((g.tick + this.pi * 5) % this.cfg.think !== 0) return; + this.cmds = 0; + const s = this.snapshot(); + this.observe(s); + this.economy(s); + this.supply(s); + this.buildOrder(s); + this.production(s); + this.abilities(s); + this.military(s); + if (this.cfg.micro) this.micro(s); + this.scout(s); + } + + // ------------------------------------------------------------------ state + + private snapshot(): Snap { + const g = this.g; + const s: Snap = { + halls: [], workers: [], army: [], structures: [], byDef: new Map(), cubs: 0, pending: new Map(), pendingSupply: 0, armySupply: 0, + }; + for (const e of g.entities) { + if (!e.alive || e.owner !== this.pi) continue; + const list = s.byDef.get(e.def.id); + if (list) list.push(e); + else s.byDef.set(e.def.id, [e]); + if (e.isBuilding) { + s.structures.push(e); + if (e.def.townHall) s.halls.push(e); + if (!e.built && e.def.provides) s.pendingSupply += e.def.provides; + if (e.def.spawnsCubs) s.cubs += e.cubs.length; + continue; + } + if (e.def.kind === 'cocoon' && e.morphTo) { + const d = DEFS[e.morphTo]; + s.pending.set(d.id, (s.pending.get(d.id) ?? 0) + 1); + if (d.provides) s.pendingSupply += d.provides; + continue; + } + if (e.def.kind !== 'unit') continue; + if (e.def.worker) { + s.workers.push(e); + const o = e.orders[0]; + if (o && o.t === 'build') { + s.pending.set(o.def, (s.pending.get(o.def) ?? 0) + 1); + if (DEFS[o.def].provides) s.pendingSupply += DEFS[o.def].provides!; + } + } else if (e.def.id !== 'bumblebear' && e.def.id !== 'littledipper') { + s.army.push(e); + s.armySupply += e.def.supply ?? 0; + } + } + // queued units count as pending too + for (const b of s.structures) { + for (const q of b.queue) if (q.kind === 'unit') s.pending.set(q.id, (s.pending.get(q.id) ?? 0) + 1); + for (const q of b.queue) if (q.kind === 'morph') s.pending.set(q.id, (s.pending.get(q.id) ?? 0) + 1); + } + return s; + } + + private count(s: Snap, id: string, includePending = true): number { + let n = s.byDef.get(id)?.length ?? 0; + // morphed structures count as their base (a Royal Hive is still a Hive) + for (const [did, list] of s.byDef) if (did !== id && DEFS[did].counts?.includes(id)) n += list.length; + if (includePending) n += s.pending.get(id) ?? 0; + return n; + } + + private hasTech(id: string): boolean { + return (this.p.tech[id] ?? 0) > 0; + } + + /** Remember enemy structures we have seen; gauge enemy composition. */ + private observe(s: Snap): void { + const g = this.g; + const team = this.p.team; + let air = 0; + let light = 0; + let armored = 0; + let army = 0; + for (const e of g.entities) { + if (!e.alive || e.owner < 0 || !g.isEnemy(this.pi, e.owner) || e.inside) continue; + const vis = e.isBuilding + ? g.vision.visibleAt(team, e.x, e.y) || g.vision.visibleAt(team, e.x0 + 4, e.y0 + 4) || g.vision.visibleAt(team, e.x1 - 4, e.y1 - 4) + : g.vision.visibleAt(team, e.x, e.y); + if (!vis) continue; + if (e.isBuilding) this.enemyMem.set(e.id, { x: e.x, y: e.y, def: e.def.id }); + else if (e.def.kind === 'unit' && !e.def.worker) { + army += e.def.supply ?? 1; + if (e.air && e.def.weapons) air += e.def.supply ?? 1; + if (e.def.attrs.includes('light')) light += e.def.supply ?? 1; + if (e.def.attrs.includes('armored')) armored += e.def.supply ?? 1; + if (e.def.cloaked && !g.vision.detectedAt(team, e.x, e.y)) { + // close to our stuff? we "notice the shimmer" + for (const o of s.structures) if (near(o, e) < 14 * TILE) this.cloakSeen = true; + for (const o of s.army) if (near(o, e) < 9 * TILE) this.cloakSeen = true; + } + } + } + for (const [id, m] of this.enemyMem) { + if (!g.get(id) && g.vision.visibleAt(team, m.x, m.y)) this.enemyMem.delete(id); + } + // blend with history so composition shifts gradually + this.enemyAir = Math.max(air, this.enemyAir * 0.95); + this.enemyLight = Math.max(light, this.enemyLight * 0.95); + this.enemyArmored = Math.max(armored, this.enemyArmored * 0.95); + this.enemyArmy = Math.max(army, this.enemyArmy * 0.985); + // keep-out zones around our mineral lines + this.keepOut = []; + for (const h of s.halls) { + let x0 = h.tx; + let y0 = h.ty; + let x1 = h.tx + h.tw; + let y1 = h.ty + h.th; + for (const r of g.structuresNear(h.x, h.y, 11 * TILE, g.tmp)) { + if (!r.isResource) continue; + x0 = Math.min(x0, r.tx); + y0 = Math.min(y0, r.ty); + x1 = Math.max(x1, r.tx + r.tw); + y1 = Math.max(y1, r.ty + r.th); + } + this.keepOut.push({ x0: x0 - 1, y0: y0 - 1, x1: x1 + 1, y1: y1 + 1 }); + } + } + + // ------------------------------------------------------------------ economy + + private workerTarget(s: Snap): number { + let t = 4; + for (const h of s.halls) { + if (!h.built) continue; + let mins = 0; + for (const r of this.g.structuresNear(h.x, h.y, 10 * TILE, this.g.tmp)) if (r.def.kind === 'mineral') mins++; + t += mins * 2; + } + const refs = s.structures.filter((b) => b.def.refinery && b.built).length; + t += refs * 3; + return Math.min(this.cfg.maxWorkers, t); + } + + private economy(s: Snap): void { + const g = this.g; + const race = RACES[this.p.race]; + // idle workers go mining + const mineralLoad = new Map(); + for (const w of s.workers) { + const o = w.orders[0]; + if (o && o.t === 'gather') mineralLoad.set(o.target, (mineralLoad.get(o.target) ?? 0) + 1); + } + const halls = s.halls.filter((h) => h.built); + for (const w of s.workers) { + if (w.orders.length || w.inside || w.id === this.scoutId || w.morphTo) continue; + const m = this.bestMineral(halls, mineralLoad, w); + if (m) { + this.issue({ c: 'gather', ids: [w.id], t: m.id }); + mineralLoad.set(m.id, (mineralLoad.get(m.id) ?? 0) + 1); + } + } + // gas: up to 3 workers per finished refinery, fewer when gas is piling up + const pl = this.p; + let perGas = this.difficulty === 'easy' ? 2 : 3; + if (pl.gas > 900 && pl.gas > pl.minerals * 2.5) perGas = 1; + else if (pl.gas > 450 && pl.gas > pl.minerals * 1.5) perGas = 2; + for (const r of s.structures) { + if (!r.def.refinery || !r.built) continue; + const gey = g.get(r.link); + if (!gey || gey.amount <= 0) continue; + let have = 0; + for (const w of s.workers) { + const o = w.orders[0]; + if ((o && o.t === 'gather' && (o.target === r.id || o.target === gey.id)) || (w.inside && w.res === r.id)) have++; + } + if (have > perGas) { + const extra = s.workers.filter((w) => !w.inside && w.orders[0]?.t === 'gather' && (w.orders[0] as { target: number }).target === r.id).slice(0, have - perGas); + for (const w of extra) { + const m = this.bestMineral(halls, mineralLoad, w); + if (m) this.issue({ c: 'gather', ids: [w.id], t: m.id }); + } + continue; + } + if (have >= perGas) continue; + const cands = s.workers + .filter((w) => w.orders[0]?.t === 'gather' && !w.inside && g.get((w.orders[0] as { target: number }).target)?.def.kind === 'mineral' && w.id !== this.scoutId) + .sort((a, b) => near(a, r) - near(b, r) || a.id - b.id); + for (const w of cands.slice(0, perGas - have)) this.issue({ c: 'gather', ids: [w.id], t: r.id }); + } + // worker production (the Horde uses cubs, see production) + if (race.id !== 'horde') { + const target = this.workerTarget(s); + let total = s.workers.length + (s.pending.get(race.worker) ?? 0); + // after the opening, keep some army around before droning harder + const needArmy = this.g.time > 240 && total >= 16 && (s.armySupply < total * 0.45 || s.armySupply < this.enemyArmy * 0.8) && this.difficulty !== 'easy'; + if (needArmy && this.p.minerals < 400) total = target; + for (const h of halls) { + if (total >= target) break; + if (h.queue.length >= 1) continue; + if (this.p.minerals < 50) break; + this.issue({ c: 'train', ids: [h.id], d: race.worker }); + total++; + } + } + // ironpaw: resume abandoned construction + if (race.id === 'ironpaw') { + for (const b of s.structures) { + if (b.built) continue; + const builder = g.get(b.builder); + if (builder && builder.orders[0]?.t === 'construct') continue; + const w = this.pickWorker(s, b.x, b.y); + if (w) this.issue({ c: 'repair', ids: [w.id], t: b.id }); + } + } + } + + private mineralsNear(h: Entity): number { + let n = 0; + for (const m of this.g.structuresNear(h.x, h.y, 10 * TILE, this.g.tmp)) if (m.def.kind === 'mineral') n++; + return n; + } + + private bestMineral(halls: Entity[], load: Map, w: Entity): Entity | undefined { + const g = this.g; + let best: Entity | undefined; + let bestScore = 1e18; + for (const h of halls) { + for (const m of g.structuresNear(h.x, h.y, 10 * TILE, g.tmp)) { + if (m.def.kind !== 'mineral') continue; + const l = load.get(m.id) ?? 0; + if (l >= 3) continue; + const score = l * 1000 + near(m, w) * 0.05 + near(m, h) * 0.5; + if (score < bestScore) { + bestScore = score; + best = m; + } + } + } + return best; + } + + private pickWorker(s: Snap, x: number, y: number): Entity | undefined { + let best: Entity | undefined; + let bd = 1e18; + for (const w of s.workers) { + if (w.inside || w.id === this.scoutId || w.morphTo) continue; + const o = w.orders[0]; + if (o && o.t !== 'gather' && o.t !== 'return') continue; + const d = dist(w.x, w.y, x, y) + (w.carry > 0 ? 64 : 0); + if (d < bd) { + bd = d; + best = w; + } + } + return best; + } + + // ------------------------------------------------------------------ supply + + private supply(s: Snap): void { + const p = this.p; + if (p.supplyMax >= 200) return; + const race = RACES[p.race]; + const prod = s.halls.length + s.structures.filter((b) => b.def.produces && !b.def.townHall).length; + const buffer = p.supplyUsed < 30 ? 1 + prod : 3 + prod * 2 + (p.supplyUsed > 80 ? 6 : 0); + if (p.supplyMax + s.pendingSupply - p.supplyUsed >= buffer) return; + if (race.id === 'horde') { + if (s.cubs > 0 && p.minerals >= 100) this.trainCub(s, 'bumblebear'); + return; + } + if (p.minerals < DEFS[race.supplyUnit].cost.m) return; + this.buildStructure(s, race.supplyUnit); + } + + // ------------------------------------------------------------------ build order & macro + + private buildOrder(s: Snap): void { + const op = this.plan.opening; + while (this.step < op.length) { + const st = op[this.step]; + if (this.p.supplyUsed < st.supply * this.cfg.slow && !(this.p.minerals > 500 && st.build)) break; + const r = this.doStep(s, st, this.step); + if (r === 'wait') break; + this.step++; + } + if (this.step >= op.length || (this.step >= 4 && this.p.minerals > 450)) this.macro(s); + } + + private doStep(s: Snap, st: TechStep, idx: number): 'done' | 'wait' { + const p = this.p; + if (st.expand) { + const wanted = 1 + this.plan.opening.slice(0, idx + 1).filter((x) => x.expand).length; + if (this.count(s, RACES[p.race].townHall, false) >= wanted || s.halls.length >= this.cfg.maxBases) return 'done'; + if (this.difficulty === 'easy' && p.supplyUsed < 36) return 'done'; + this.expand(s); + // don't hold the whole build hostage if expanding keeps failing + return p.supplyUsed > st.supply + 14 ? 'done' : 'wait'; + } + if (st.build) { + const d = DEFS[st.build]; + const wanted = this.plan.opening.slice(0, idx + 1).filter((x) => x.build === st.build).length; + // only structures that actually exist count; pending orders just mean "keep waiting" + if (this.count(s, st.build, false) >= wanted) return 'done'; + if (d.requires && !this.g.techMet(p, d.requires)) return this.p.supplyUsed > st.supply + 20 ? 'done' : 'wait'; + if (d.refinery && !this.freeGeyser(s) && !(s.pending.get(st.build) ?? 0)) return 'done'; + if (this.count(s, st.build) >= wanted) return 'wait'; + this.buildStructure(s, st.build); + return 'wait'; + } + if (st.research) { + if (p.level(st.research) > 0 || (p.researching[st.research] ?? 0) > 0) return 'done'; + if (this.research(s, st.research)) return 'done'; + const at = RESEARCHED_AT[st.research]; + // research building lost or never built: don't stall the whole opening + if (!this.count(s, at) && p.supplyUsed > st.supply + 10) return 'done'; + return 'wait'; + } + if (st.morph) { + if (this.count(s, st.morph) > 0) return 'done'; + const d = DEFS[st.morph]; + if (!this.g.techMet(p, d.requires)) return p.supplyUsed > st.supply + 24 ? 'done' : 'wait'; + if (!this.g.canAfford(p, d.cost)) return 'wait'; + const src = s.halls.find((h) => h.built && h.def.morphs?.includes(st.morph!) && !h.queue.some((q) => q.kind === 'morph')); + if (src) this.issue({ c: 'morph', ids: [src.id], d: st.morph }); + return 'wait'; + } + return 'done'; + } + + private macro(s: Snap): void { + const p = this.p; + // rebuild lost tech / ensure opening structures + for (const st of this.plan.opening) { + if (!st.build || DEFS[st.build].refinery) continue; + if (this.count(s, st.build) === 0 && this.g.techMet(p, DEFS[st.build].requires) && this.g.canAfford(p, DEFS[st.build].cost)) { + if (this.buildStructure(s, st.build)) return; + } + } + // more production (and even more when money piles up) + const bank = p.minerals > 800 ? 1 + Math.floor((p.minerals - 800) / 600) : 0; + for (const [id, thresholds] of this.plan.production) { + const want = Math.max(1, Math.round(thresholds.filter((t) => p.supplyUsed >= t).length * this.cfg.prodScale)) + (id === this.plan.production[0][0] ? bank : 0); + if (this.count(s, id) < Math.min(10, want) && this.g.techMet(p, DEFS[id].requires) && p.minerals >= DEFS[id].cost.m) { + if (this.buildStructure(s, id)) return; + } + } + // replace bases that are running dry + const active = s.halls.filter((h) => this.mineralsNear(h) >= 4).length; + const maxBases = this.cfg.maxBases + Math.floor(this.g.time / 600); + if (active < Math.min(maxBases, 2 + Math.floor(this.g.time / 420)) && s.halls.length < maxBases + 2 && p.minerals >= DEFS[RACES[p.race].townHall].cost.m) { + if (this.expand(s)) return; + } + // horde: an extra hive for cubs when banking + if (p.race === 'horde' && p.minerals > 700 && s.cubs < 2 && s.halls.length < this.cfg.maxBases + 2) { + if (this.expand(s) || this.buildStructure(s, 'hive')) return; + } + // expand when rich + const bases = this.count(s, RACES[p.race].townHall); + if (bases < this.cfg.maxBases && ((p.minerals > 600 && p.supplyUsed > 50) || (p.supplyUsed > 90 && bases < 3))) { + if (this.expand(s)) return; + } + // more gas when gas-starved + if (p.gas < 150 && p.minerals > 400 && this.freeGeyser(s)) { + const refs = s.structures.filter((b) => b.def.refinery).length; + if (refs < s.halls.length * 2 && this.buildStructure(s, RACES[p.race].refinery)) return; + } + // a little static defence at the front for the stronger AIs + if ((this.difficulty === 'hard' || this.difficulty === 'brutal') && this.g.time > 270) { + const def = p.race === 'ironpaw' ? 'guntower' : p.race === 'horde' ? 'thornstump' : 'starlantern'; + const want = this.g.time > 600 ? 3 : 1; + if (this.count(s, def) < want && this.g.techMet(p, DEFS[def].requires) && p.minerals > DEFS[def].cost.m + 100) { + if (this.buildStructure(s, def, true)) return; + } + } + // detection + if (this.cloakSeen) { + const det = this.plan.detector; + if (this.count(s, det.building) < Math.min(3, s.halls.length + 1) && this.g.techMet(p, DEFS[det.building].requires)) { + this.buildStructure(s, det.building, true); + } + } + // upgrades + if (p.minerals > 250 && p.gas > 150) { + for (const r of this.plan.upgrades) if (this.research(s, r)) break; + } + } + + private research(s: Snap, id: string): boolean { + const p = this.p; + const rd = RESEARCH_DEFS[id]; + const lvl = p.level(id) + (p.researching[id] ?? 0); + if (lvl >= rd.costs.length) return false; + const reqs = rd.reqs?.[lvl]; + if (reqs && !this.g.techMet(p, reqs)) return false; + const at = RESEARCHED_AT[id]; + const blds = (s.byDef.get(at) ?? []).concat(...[...s.byDef.entries()].filter(([k]) => DEFS[k].counts?.includes(at)).map(([, v]) => v)); + const b = blds.find((x) => x.built && x.queue.length === 0 && (!x.def.needsPower || x.powered)); + if (!b) return false; + if (!this.g.canAfford(p, rd.costs[lvl])) return false; + this.issue({ c: 'research', ids: [b.id], r: id }); + return true; + } + + private freeGeyser(s: Snap): Entity | undefined { + const g = this.g; + for (const h of s.halls) { + if (!h.built) continue; + for (const r of g.structuresNear(h.x, h.y, 10 * TILE, g.tmp)) { + if (r.def.kind === 'geyser' && !g.get(r.link) && r.amount > 0) { + // not already targeted by a pending build + if (s.workers.some((w) => w.orders[0]?.t === 'build' && (w.orders[0] as { tx: number }).tx === r.tx && (w.orders[0] as { ty: number }).ty === r.ty)) continue; + return r; + } + } + } + return undefined; + } + + // ------------------------------------------------------------------ placement + + private buildStructure(s: Snap, id: string, defensive = false): boolean { + const p = this.p; + const d = DEFS[id]; + if (!this.g.canAfford(p, d.cost) || !this.g.techMet(p, d.requires)) return false; + if ((s.pending.get(id) ?? 0) > 0 && !d.provides) return false; + let spot: { tx: number; ty: number } | null = null; + if (d.refinery) { + const gey = this.freeGeyser(s); + if (gey) spot = { tx: gey.tx, ty: gey.ty }; + } else spot = this.findSpot(d, s, defensive); + if (!spot) return false; + const w = this.pickWorker(s, (spot.tx + d.size![0] / 2) * TILE, (spot.ty + d.size![1] / 2) * TILE); + if (!w) return false; + this.issue({ c: 'build', ids: [w.id], d: id, tx: spot.tx, ty: spot.ty }); + s.pending.set(id, (s.pending.get(id) ?? 0) + 1); + if (d.provides) s.pendingSupply += d.provides; + return true; + } + + private expand(s: Snap): boolean { + const g = this.g; + const p = this.p; + const hallId = RACES[p.race].townHall; + if ((s.pending.get(hallId) ?? 0) > 0) return true; + const d = DEFS[hallId]; + if (!g.canAfford(p, d.cost)) return false; + const gd = (b: BaseSpot) => this.groundDist.get(g.map.bases.indexOf(b)) ?? Infinity; + const spots = g.map.bases + .filter((b) => !this.baseTaken(b) && gd(b) < Infinity && (this.expandTries.get(g.map.bases.indexOf(b)) ?? 0) < 40) + .sort((a, b) => gd(a) - gd(b)); + for (const b of spots) { + const bi = g.map.bases.indexOf(b); + const c = this.spotCenter(b); + let danger = false; + for (const m of this.enemyMem.values()) if (near(m, c) < 16 * TILE) danger = true; + if (danger) continue; + const explored = g.vision.explored[p.team][(b.ty + 2) * g.map.w + b.tx + 2] && g.vision.explored[p.team][b.ty * g.map.w + b.tx]; + let w = g.get(this.expandWorker); + if (!w || !w.def.worker) { + w = this.pickWorker(s, c.x, c.y); + if (!w) return false; + this.expandWorker = w.id; + } + if (!explored) { + // walk there first; the build order is issued once the site has been seen + if (w.orders[0]?.t !== 'move') { + this.issue({ c: 'move', ids: [w.id], x: c.x, y: c.y }); + this.expandTries.set(bi, (this.expandTries.get(bi) ?? 0) + 1); + } + return true; + } + if (canPlace(g, this.pi, hallId, b.tx, b.ty, w.id) === null) { + this.issue({ c: 'build', ids: [w.id], d: hallId, tx: b.tx, ty: b.ty }); + this.expandWorker = 0; + return true; + } + } + return false; + } + + private spotCenter(b: BaseSpot): { x: number; y: number } { + return { x: (b.tx + 2.5) * TILE, y: (b.ty + 2.5) * TILE }; + } + + private baseTaken(b: BaseSpot): boolean { + const c = this.spotCenter(b); + for (const e of this.g.structuresNear(c.x, c.y, 4 * TILE, this.g.tmp)) if (e.def.townHall) return true; + return false; + } + + private findSpot(d: EntityDef, s: Snap, defensive: boolean): { tx: number; ty: number } | null { + const g = this.g; + const [w, h] = d.size!; + const anchors: { x: number; y: number }[] = []; + const halls = s.halls.filter((x) => x.built); + const main = halls.find((x) => near(x, this.home) < 4 * TILE) ?? halls[0]; + if (!main) return null; + const away = this.awayFromMinerals(main); + if (defensive) { + // between our front base and the enemy + const front = this.frontBase(s) ?? main; + const en = this.enemyStarts[0] ?? { x: g.map.w * TILE / 2, y: g.map.h * TILE / 2 }; + const dd = near(front, en) || 1; + anchors.push({ x: front.x + ((en.x - front.x) / dd) * 7 * TILE, y: front.y + ((en.y - front.y) / dd) * 7 * TILE }); + } + if (d.needsPower) { + for (const c of s.byDef.get('startotem') ?? []) if (c.built) anchors.push(c); + } + if (d.needsComb) { + for (const h of halls) { + if (h === main) continue; + const aw = this.awayFromMinerals(h); + anchors.push({ x: h.x + aw.x * 6 * TILE, y: h.y + aw.y * 6 * TILE }); + } + } + anchors.push({ x: main.x + away.x * 7 * TILE, y: main.y + away.y * 7 * TILE }); + anchors.push({ x: main.x + away.y * 7 * TILE, y: main.y - away.x * 7 * TILE }); + anchors.push({ x: main.x - away.y * 7 * TILE, y: main.y + away.x * 7 * TILE }); + for (const a of anchors) { + const spot = this.spiral(d, a.x, a.y, w, h, d.id === 'startotem' ? s : null); + if (spot) return spot; + } + return null; + } + + private awayFromMinerals(hall: Entity): { x: number; y: number } { + let mx = 0; + let my = 0; + let n = 0; + for (const r of this.g.structuresNear(hall.x, hall.y, 10 * TILE, this.g.tmp)) { + if (!r.isResource) continue; + mx += r.x; + my += r.y; + n++; + } + if (!n) return { x: 0, y: 1 }; + const dx = hall.x - mx / n; + const dy = hall.y - my / n; + const l = Math.sqrt(dx * dx + dy * dy) || 1; + return { x: dx / l, y: dy / l }; + } + + private spiral(d: EntityDef, cx: number, cy: number, w: number, h: number, conduitSpread: Snap | null): { tx: number; ty: number } | null { + const g = this.g; + const map = g.map; + const ctx = Math.floor(cx / TILE); + const cty = Math.floor(cy / TILE); + for (let r = 0; r <= 14; r++) { + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; + const tx = ctx + dx - Math.floor(w / 2); + const ty = cty + dy - Math.floor(h / 2); + if (tx < 2 || ty < 2 || tx + w > map.w - 2 || ty + h > map.h - 2) continue; + if (this.inKeepOut(tx, ty, w, h)) continue; + if (!this.gapOk(tx, ty, w, h)) continue; + if (conduitSpread) { + let close = false; + for (const c of conduitSpread.byDef.get('startotem') ?? []) if (dist(c.x, c.y, (tx + 1) * TILE, (ty + 1) * TILE) < 6 * TILE) close = true; + if (close) continue; + } + if (canPlace(g, this.pi, d.id, tx, ty, 0) === null) return { tx, ty }; + } + } + } + return null; + } + + private inKeepOut(tx: number, ty: number, w: number, h: number): boolean { + for (const k of this.keepOut) if (tx < k.x1 && tx + w > k.x0 && ty < k.y1 && ty + h > k.y0) return true; + return false; + } + + /** Leave a one-tile corridor around structures and stay clear of ramps. */ + private gapOk(tx: number, ty: number, w: number, h: number): boolean { + const g = this.g; + const map = g.map; + const cw = g.grid.cw; + for (let y = ty - 1; y <= ty + h; y++) { + for (let x = tx - 1; x <= tx + w; x++) { + if (!map.inBounds(x, y)) return false; + const inside = x >= tx && x < tx + w && y >= ty && y < ty + h; + if (map.flags[y * map.w + x] & F_RAMP) return false; + if (inside) continue; + if (g.grid.occ[y * 2 * cw + x * 2]) return false; + if (!map.walkable(x, y)) return false; + } + } + return true; + } + + // ------------------------------------------------------------------ production + + private production(s: Snap): void { + const p = this.p; + if (p.race === 'horde') { + this.hordeProduction(s); + return; + } + // reserve money for the next opening structure + const reserve = this.reserve(s); + const queueMax = this.difficulty === 'hard' || this.difficulty === 'brutal' ? 2 : 1; + for (const b of s.structures) { + if (!b.built || !b.def.produces || b.def.townHall) continue; + if (b.queue.length >= queueMax || (b.def.needsPower && !b.powered)) continue; + if (this.cmds >= this.cfg.apm) return; + let u = this.pickUnit(s, b.def.produces); + if (!u) continue; + let d = DEFS[u]; + if (p.gas - reserve.g < d.cost.g && p.minerals - reserve.m > 300) { + // gas-starved but rich in minerals: build whatever we can afford instead + const alt = this.pickUnit(s, b.def.produces.filter((x) => DEFS[x].cost.g <= p.gas - reserve.g)); + if (alt) { + u = alt; + d = DEFS[u]; + } + } + if (p.minerals - reserve.m < d.cost.m || p.gas - reserve.g < d.cost.g) continue; + if (p.supplyUsed + (d.supply ?? 0) > p.supplyMax) return; + this.issue({ c: 'train', ids: [b.id], d: u }); + s.pending.set(u, (s.pending.get(u) ?? 0) + 1); + s.armySupply += d.supply ?? 0; + } + } + + private reserve(s: Snap): { m: number; g: number } { + const st = this.plan.opening[this.step]; + if (!st || this.p.supplyUsed < st.supply * this.cfg.slow - 2) return { m: 0, g: 0 }; + if (st.build) return DEFS[st.build].cost; + if (st.expand) return DEFS[RACES[this.p.race].townHall].cost; + if (st.research) { + const r = RESEARCH_DEFS[st.research]; + return r.costs[Math.min(r.costs.length - 1, this.p.level(st.research))]; + } + if (st.morph) return DEFS[st.morph].cost; + void s; + return { m: 0, g: 0 }; + } + + /** Pick the unit (from `options`) most under-represented relative to the target composition. */ + private pickUnit(s: Snap, options: string[]): string | null { + const p = this.p; + let best: string | null = null; + let bestDef = -1e9; + let totalW = 0; + const weights = new Map(); + for (const c of this.plan.comp) { + const d = DEFS[c.unit]; + if (!this.g.techMet(p, d.requires)) continue; + if (!PRODUCERS[c.unit]?.some((b) => this.hasTech(b)) && !d.morphFrom) continue; + let w = c.weight; + // lean on gas-heavy units when gas is piling up (and vice versa) + const gasHeavy = d.cost.g >= d.cost.m * 0.5; + if (gasHeavy && p.gas > 500 && p.gas > p.minerals) w *= 1.8; + if (gasHeavy && p.gas < 100 && p.minerals > 400) w *= 0.4; + if (this.enemyAir > 4) w += c.vsAir ?? 0; + if (this.enemyLight > 10) w += c.vsLight ?? 0; + if (this.enemyArmored > 10) w += c.vsArmored ?? 0; + if (w <= 0) continue; + weights.set(c.unit, w); + totalW += w; + } + const total = Math.max(1, s.armySupply); + for (const u of options) { + const w = weights.get(u); + if (!w) continue; + const c = this.plan.comp.find((x) => x.unit === u)!; + const have = this.count(s, u); + if (c.max !== undefined && have >= c.max) continue; + const share = (have * (DEFS[u].supply ?? 1)) / total; + const deficit = w / totalW - share + this.rng.next() * 0.04; + if (deficit > bestDef) { + bestDef = deficit; + best = u; + } + } + // detection unit when cloaked enemies were seen + const det = this.plan.detector.unit; + if (det && this.cloakSeen && options.includes(det) && this.count(s, det) < 2 && this.g.techMet(p, DEFS[det].requires)) return det; + return best; + } + + private hordeProduction(s: Snap): void { + const p = this.p; + if (s.cubs <= 0) { + this.growUnits(s); + return; + } + const target = this.workerTarget(s); + const reserve = this.reserve(s); + let cubs = s.cubs; + let workers = s.workers.length + (s.pending.get('forager') ?? 0); + const threatened = this.state === 'defend'; + while (cubs > 0 && this.cmds < this.cfg.apm) { + // supply first + if (p.supplyMax + s.pendingSupply - p.supplyUsed < 2 && p.supplyMax < 200) { + if (p.minerals < 100) return; + this.trainCub(s, 'bumblebear'); + cubs--; + continue; + } + const wantWorker = workers < target && (!threatened || workers < 16) && (s.armySupply > workers * 0.35 || workers < 20 || this.difficulty === 'easy') && (s.armySupply >= this.enemyArmy * 0.8 || workers < 20); + if (wantWorker) { + if (p.minerals - reserve.m < 50) return; + this.trainCub(s, 'forager'); + workers++; + cubs--; + continue; + } + const opts = this.plan.comp.map((c) => c.unit); + const u = this.pickUnit(s, opts); + if (!u) return; + const d = DEFS[u]; + if (p.minerals - reserve.m < d.cost.m || p.gas - reserve.g < d.cost.g) { + // can't afford the ideal unit: fall back to scrappers if minerals are plentiful + if (p.minerals - reserve.m > 150 && this.g.techMet(p, DEFS.scrapper.requires)) { + this.trainCub(s, 'scrapper'); + cubs--; + continue; + } + return; + } + this.trainCub(s, u); + s.armySupply += (d.supply ?? 0) * (d.count ?? 1); + cubs--; + } + this.growUnits(s); + } + + private trainCub(s: Snap, unit: string): void { + const hives = s.halls.filter((h) => h.built && h.def.spawnsCubs && h.cubs.length > 0); + if (!hives.length) return; + this.issue({ c: 'train', ids: hives.map((n) => n.id), d: unit }); + s.pending.set(unit, (s.pending.get(unit) ?? 0) + 1); + if (DEFS[unit].provides) s.pendingSupply += DEFS[unit].provides!; + } + + private growUnits(s: Snap): void { + this.growInto(s, 'scrapper', 'buzzbomb', 0.4, 2); + this.growInto(s, 'stinger', 'bombardier', 0.35, 1); + this.growInto(s, 'stinger', 'burrower', 0.3, 1); + } + + /** Grow some `from` units into `to` until there are `ratio` of them per remaining `from`. */ + private growInto(s: Snap, from: string, to: string, ratio: number, n: number): void { + const p = this.p; + const d = DEFS[to]; + if (!this.g.techMet(p, d.requires) || !this.g.canAfford(p, { m: d.cost.m + 35, g: d.cost.g + 15 })) return; + const src = s.byDef.get(from) ?? []; + if (this.count(s, to) >= src.length * ratio) return; + const pick = src.filter((e) => !e.morphTo).slice(0, n); + if (pick.length) this.issue({ c: 'morph', ids: pick.map((e) => e.id), d: to }); + } + + // ------------------------------------------------------------------ abilities + + private abilities(s: Snap): void { + const p = this.p; + if (p.race === 'horde') { + for (const n of s.halls) if (n.built && n.energy >= 25 && n.jellyT === 0) this.issue({ c: 'ability', ids: [n.id], a: 'royaljelly' }); + } + if (p.race === 'starborn') { + const sanct = s.halls.find((h) => h.built && h.energy >= 50); + if (sanct) { + const busy = s.structures.find((b) => b.built && b.queue.length > 0 && b.hasteT === 0 && !b.def.townHall) ?? s.halls.find((h) => h.queue.length > 0 && h.hasteT === 0); + if (busy) this.issue({ c: 'ability', ids: [sanct.id], a: 'haste', t: busy.id, x: busy.x, y: busy.y }); + } + } + if (p.race === 'ironpaw' && this.cloakSeen) { + // scan cloaked attackers near our forces + const hub = s.halls.find((h) => h.built && h.energy >= 50); + if (hub) { + const team = p.team; + for (const e of this.g.entities) { + if (!e.alive || !e.def.cloaked || !this.g.isEnemy(this.pi, e.owner) || this.g.vision.detectedAt(team, e.x, e.y)) continue; + const close = s.army.some((a) => near(a, e) < 7 * TILE) || s.structures.some((b) => near(b, e) < 8 * TILE); + if (close && this.g.vision.visibleAt(team, e.x, e.y)) { + this.issue({ c: 'ability', ids: [hub.id], a: 'sniff', x: e.x, y: e.y }); + break; + } + } + } + } + } + + // ------------------------------------------------------------------ military + + private frontBase(s: Snap): Entity | undefined { + const en = this.enemyStarts[0]; + if (!en) return s.halls[0]; + let best: Entity | undefined; + let bd = 1e18; + for (const h of s.halls) { + const d = near(h, en); + if (d < bd) { + bd = d; + best = h; + } + } + return best; + } + + private military(s: Snap): void { + const g = this.g; + const p = this.p; + const front = this.frontBase(s); + if (front) { + const en = this.enemyStarts[0] ?? { x: g.map.w * TILE / 2, y: g.map.h * TILE / 2 }; + const d = near(front, en) || 1; + const gx = front.x + ((en.x - front.x) / d) * 8 * TILE; + const gy = front.y + ((en.y - front.y) / d) * 8 * TILE; + const free = g.grid.nearestFreePoint(gx, gy, 2, 20); + if (free) this.gather = free; + } + // threats near our structures + const team = p.team; + let threatX = 0; + let threatY = 0; + let threatN = 0; + let threatSupply = 0; + for (const e of g.entities) { + if (!e.alive || e.owner < 0 || !g.isEnemy(this.pi, e.owner) || e.isBuilding || e.inside || e.def.kind !== 'unit') continue; + if (!g.vision.visibleAt(team, e.x, e.y)) continue; + if (e.def.cloaked && !g.vision.detectedAt(team, e.x, e.y)) continue; + let close = false; + for (const b of s.structures) { + if (near(b, e) < 18 * TILE) { + close = true; + break; + } + } + if (!close) continue; + threatX += e.x; + threatY += e.y; + threatN++; + threatSupply += e.def.worker ? 0.5 : e.def.supply ?? 1; + } + const army = s.army.filter((e) => !e.morphTo); + const ids = army.map((e) => e.id); + if (threatN > 0 && (this.state !== 'attack' || threatSupply > s.armySupply * 0.4)) { + if (this.state === 'attack') this.attackSupply += 6; + this.state = 'defend'; + const tx = threatX / threatN; + const ty = threatY / threatN; + if (g.tick - this.lastAmove > 40 && ids.length) { + this.issue({ c: 'amove', ids, x: tx, y: ty }); + this.lastAmove = g.tick; + } + // emergency: workers fight back when there is no army (hard+) + if (s.armySupply < threatSupply * 0.5 && threatSupply >= 3 && (this.difficulty === 'hard' || this.difficulty === 'brutal')) { + const pull = s.workers.filter((w) => near(w, { x: tx, y: ty }) < 12 * TILE).slice(0, Math.ceil(threatSupply * 2)); + if (pull.length && g.tick % 40 < this.cfg.think) this.issue({ c: 'amove', ids: pull.map((w) => w.id), x: tx, y: ty }); + } + return; + } + if (this.state === 'defend') { + this.state = 'gather'; + if (ids.length) this.issue({ c: 'move', ids, x: this.gather.x, y: this.gather.y }); + // workers that were pulled go back to work + for (const w of s.workers) if (w.orders[0]?.t === 'amove') this.issue({ c: 'stop', ids: [w.id] }); + return; + } + if (this.state === 'gather') { + const idle = army.filter((e) => e.orders.length === 0 && near(e, this.gather) > 7 * TILE); + if (idle.length) this.issue({ c: 'amove', ids: idle.map((e) => e.id), x: this.gather.x, y: this.gather.y }); + const outnumbered = this.difficulty !== 'easy' && this.enemyArmy > s.armySupply * 1.25 && p.supplyUsed < 170; + const ready = (s.armySupply >= this.attackSupply && !outnumbered) || p.supplyUsed >= 185; + if (ready && g.time >= this.cfg.firstAttackTime * (this.difficulty === 'easy' ? 1 : 0.6)) { + this.state = 'attack'; + this.attackStrength = s.armySupply; + this.target = this.pickTarget(s); + this.lastAmove = -1000; + } + return; + } + // attacking + if (s.armySupply < this.attackStrength * 0.35 && p.supplyUsed < 170) { + this.state = 'gather'; + this.attackSupply = Math.min(150, this.attackSupply + 12); + if (ids.length) this.issue({ c: 'move', ids, x: this.gather.x, y: this.gather.y }); + return; + } + // re-target when the current objective is gone, or when the army has arrived and stands idle + const idleAtTarget = this.target && army.length > 0 && army.filter((e) => e.orders.length === 0).length > army.length * 0.6 && army.some((e) => near(e, this.target!) < 8 * TILE); + if (!this.target || this.targetCleared(this.target) || idleAtTarget) this.target = this.pickTarget(s); + if (!this.target) return; + if (g.tick - this.lastAmove > 80 && ids.length) { + // only re-issue to units that are idle or far behind, so fights aren't interrupted + const send = army.filter((e) => e.orders.length === 0 || (e.orders[0].t === 'amove' && !e.target)); + if (send.length) this.issue({ c: 'amove', ids: send.map((e) => e.id), x: this.target.x, y: this.target.y }); + this.lastAmove = g.tick; + } + } + + private targetCleared(t: { x: number; y: number }): boolean { + const g = this.g; + if (!g.vision.visibleAt(this.p.team, t.x, t.y)) return false; + for (const m of this.enemyMem.values()) if (near(m, t) < 10 * TILE) return false; + return true; + } + + private pickTarget(s: Snap): { x: number; y: number } | null { + let from: { x: number; y: number } = s.halls[0] ?? this.home; + if (s.army.length) { + let ax = 0; + let ay = 0; + for (const a of s.army) { + ax += a.x; + ay += a.y; + } + from = { x: ax / s.army.length, y: ay / s.army.length }; + } + let best: { x: number; y: number } | null = null; + let bd = 1e18; + for (const m of this.enemyMem.values()) { + const d = near(m, from) + (DEFS[m.def]?.townHall ? -6 * TILE : 0); + if (d < bd) { + bd = d; + best = { x: m.x, y: m.y }; + } + } + if (best) return best; + // unexplored enemy starts first, then sweep every base location in turn + const team = this.p.team; + for (const st of this.enemyStarts) if (!this.g.vision.exploredAt(team, st.x, st.y)) return st; + const bases = this.g.map.bases.map((b) => this.spotCenter(b)); + for (let k = 0; k < bases.length; k++) { + const c = bases[(this.searchIdx + k) % bases.length]; + if (!this.g.vision.visibleAt(team, c.x, c.y)) { + this.searchIdx = (this.searchIdx + k + 1) % bases.length; + return c; + } + } + return this.enemyStarts[0] ?? null; + } + + // ------------------------------------------------------------------ micro + + private micro(s: Snap): void { + const g = this.g; + const p = this.p; + const team = p.team; + const enemiesNear = (e: Entity, r: number, groundOnly = false): Entity[] => { + const out: Entity[] = []; + for (const u of g.unitsNear(e.x, e.y, r, g.tmp2)) { + if (!g.isEnemy(this.pi, u.owner) || u.inside || (groundOnly && u.air)) continue; + if (!g.vision.visibleAt(team, u.x, u.y)) continue; + if (u.def.cloaked && !g.vision.detectedAt(team, u.x, u.y)) continue; + out.push(u); + } + return out; + }; + for (const e of s.army) { + if (this.cmds >= this.cfg.apm * 2) return; + switch (e.def.id) { + case 'kodiak': + case 'burrower': { + if (e.morphTo) break; + const r = e.def.id === 'kodiak' ? 12 : 8; + const en = enemiesNear(e, r * TILE, true); + const blds = e.def.id === 'kodiak' && g.structuresNear(e.x, e.y, r * TILE, g.tmp).some((b) => g.isEnemy(this.pi, b.owner) && g.vision.visibleAt(team, b.x, b.y)); + if (en.length >= 2 || blds) this.issue({ c: 'ability', ids: [e.id], a: e.def.id === 'kodiak' ? 'brace' : 'hibernate' }); + break; + } + case 'sapper': + if (!e.morphTo && enemiesNear(e, 8 * TILE).length) this.issue({ c: 'ability', ids: [e.id], a: 'digin' }); + break; + case 'hoverbear': { + if (e.morphTo) break; + const en = enemiesNear(e, 10 * TILE, true); + if (en.length >= 3) { + const c = centroid(en); + this.issue({ c: 'ability', ids: [e.id], a: 'anchor', x: c.x, y: c.y }); + } + break; + } + case 'kodiak_b': + case 'sapper_b': + case 'burrower_b': + case 'hoverbear_a': { + if (e.morphTo) break; + const en = enemiesNear(e, 14 * TILE, e.def.id !== 'sapper_b'); + const blds = e.def.id === 'kodiak_b' && g.structuresNear(e.x, e.y, 13 * TILE, g.tmp).some((b) => g.isEnemy(this.pi, b.owner) && g.vision.visibleAt(team, b.x, b.y)); + const idleT = (this.braceIdle.get(e.id) ?? 0) + (en.length || blds ? -1000 : this.cfg.think); + this.braceIdle.set(e.id, Math.max(0, idleT)); + if (idleT > 100 && (this.state === 'attack' || near(e, this.gather) > 10 * TILE)) { + this.issue({ c: 'ability', ids: [e.id], a: e.def.abilities![0] }); + this.braceIdle.set(e.id, 0); + } + break; + } + case 'blazerider': + if (!e.morphTo && this.hasTech('armory') && this.enemyLight > 10 && enemiesNear(e, 6 * TILE, true).length >= 3) this.issue({ c: 'ability', ids: [e.id], a: 'hopoff' }); + break; + case 'tracker': + if (!e.lockT && !(e.cooldowns.lockon > 0)) { + const en = enemiesNear(e, 7 * TILE).sort((a, b) => b.hp + b.shields - (a.hp + a.shields) || a.id - b.id); + if (en.length) this.issue({ c: 'ability', ids: [e.id], a: 'lockon', t: en[0].id, x: en[0].x, y: en[0].y }); + } + break; + case 'denmother': + if (!(e.cooldowns.releasecubs > 0)) { + const en = enemiesNear(e, 14 * TILE, true); + if (en.length) this.issue({ c: 'ability', ids: [e.id], a: 'releasecubs', x: en[0].x, y: en[0].y }); + } + break; + case 'bombardier': + if (!(e.cooldowns.scaldinghoney > 0)) this.castOnCluster(e, 'scaldinghoney', 9, 0.75, 2, enemiesNear(e, 10 * TILE), false); + break; + case 'longtongue': { + const en = enemiesNear(e, 10 * TILE); + if (e.energy >= 75) { + const big = en.filter((u) => (u.def.supply ?? 0) >= 3).sort((a, b) => (b.def.supply ?? 0) - (a.def.supply ?? 0) || a.id - b.id)[0]; + if (big) { + this.issue({ c: 'ability', ids: [e.id], a: 'yank', t: big.id, x: big.x, y: big.y }); + break; + } + } + if (e.energy >= 125) { + const flyers = en.filter((u) => u.air); + if (flyers.length >= 3) { + this.issue({ c: 'ability', ids: [e.id], a: 'hornetbomb', t: flyers[0].id, x: flyers[0].x, y: flyers[0].y }); + break; + } + } + if (e.energy >= 100) this.castOnCluster(e, 'pollen', 11, 2, 4, en.filter((u) => !u.air && u.def.weapons?.some((w) => !w.melee)), false); + break; + } + case 'seer': + if (e.energy >= 60 && enemiesNear(e, 6 * TILE, true).some((u) => u.def.attrs.includes('light'))) this.issue({ c: 'ability', ids: [e.id], a: 'sunlanceon' }); + break; + case 'seer_b': + if (!enemiesNear(e, 9 * TILE, true).length) this.issue({ c: 'ability', ids: [e.id], a: 'sunlanceoff' }); + break; + case 'snowballer': + if (!(e.cooldowns.snowball > 0)) this.castOnCluster(e, 'snowball', 8, 1.5, 3, enemiesNear(e, 10 * TILE, true), true); + break; + case 'ranger': + case 'grizzly': + if (p.has('honeyrush') && e.rushT === 0 && e.target && e.hp > (e.def.id === 'ranger' ? 30 : 60) && enemiesNear(e, 7 * TILE).length >= 3) { + this.issue({ c: 'ability', ids: [e.id], a: 'honeyrush' }); + } + break; + case 'pandasage': + if (p.has('starfall') && e.energy >= 75) this.castOnCluster(e, 'starfall', 9, 1.5, 4, enemiesNear(e, 11 * TILE), true); + break; + case 'swarmcaller': + if (e.energy >= 100) { + const en = enemiesNear(e, 9 * TILE).filter((u) => u.def.weapons?.some((w) => !w.melee)); + if (en.length >= 4) { + const mine = s.army.filter((a) => !a.air && near(a, e) < 8 * TILE); + if (mine.length >= 3) { + let mx = 0; + let my = 0; + for (const a of mine) { + mx += a.x; + my += a.y; + } + this.issue({ c: 'ability', ids: [e.id], a: 'beecloud', x: mx / mine.length, y: my / mine.length }); + break; + } + } + } + if (p.has('stingswarm') && e.energy >= 75) this.castOnCluster(e, 'stingswarm', 9, 2, 4, enemiesNear(e, 11 * TILE), false); + break; + case 'lookout': + if (e.energy >= 100) { + const hurt = s.army.filter((a) => near(a, e) < 9 * TILE && a.bubble <= 0 && a.target && a.hp < (a.def.hp * 0.6)); + if (hurt.length) this.issue({ c: 'ability', ids: [e.id], a: 'bubble', t: hurt[0].id, x: hurt[0].x, y: hurt[0].y }); + } + if (p.has('staticburst') && e.energy >= 75) { + const en = enemiesNear(e, 11 * TILE).filter((u) => u.shields > 30 || u.energy > 50); + this.castOnCluster(e, 'staticburst', 10, 1.5, 3, en, false); + } + break; + case 'ironmama': + if (p.has('grandcannon') && e.energy >= 125) { + const en = enemiesNear(e, 10 * TILE).filter((u) => u.hp + u.shields >= 150).sort((a, b) => b.hp + b.shields - (a.hp + a.shields)); + if (en.length) this.issue({ c: 'ability', ids: [e.id], a: 'grandcannon', t: en[0].id, x: en[0].x, y: en[0].y }); + } + break; + } + } + } + + private castOnCluster(caster: Entity, ab: string, range: number, radius: number, minHits: number, enemies: Entity[], avoidFriendly: boolean): void { + let best: Entity | null = null; + let bestN = 0; + const r = radius * TILE; + for (const c of enemies) { + if (near(c, caster) > (range + 1) * TILE) continue; + let n = 0; + for (const o of enemies) if (near(o, c) <= r) n++; + if (avoidFriendly) { + for (const f of this.g.unitsNear(c.x, c.y, r, this.g.tmp)) if (f.owner === this.pi) n -= 1.5; + } + if (n > bestN) { + bestN = n; + best = c; + } + } + if (best && bestN >= minHits) this.issue({ c: 'ability', ids: [caster.id], a: ab, x: best.x, y: best.y }); + } + + // ------------------------------------------------------------------ scouting + + private scout(s: Snap): void { + if (!this.cfg.scout || this.scoutDone) return; + const g = this.g; + if (!this.scoutId) { + if (this.p.supplyUsed < 15 || !this.enemyStarts.length) return; + const w = this.pickWorker(s, this.home.x, this.home.y); + if (!w) return; + this.scoutId = w.id; + } + const w = g.get(this.scoutId); + if (!w) { + this.scoutDone = true; + return; + } + const team = this.p.team; + const next = this.enemyStarts.find((st) => !g.vision.exploredAt(team, st.x, st.y)); + if (!next || this.enemyMem.size > 0) { + // found them: back to work + this.scoutDone = true; + this.scoutId = 0; + this.issue({ c: 'stop', ids: [w.id] }); + return; + } + if (w.orders[0]?.t !== 'move') this.issue({ c: 'move', ids: [w.id], x: next.x, y: next.y }); + } +} diff --git a/gh-pages/bear-war/src/game/ai/plans.ts b/gh-pages/bear-war/src/game/ai/plans.ts new file mode 100644 index 00000000..643f1b46 --- /dev/null +++ b/gh-pages/bear-war/src/game/ai/plans.ts @@ -0,0 +1,189 @@ +import type { RaceId } from '../data/types.ts'; +import type { AIDifficulty } from '../player.ts'; + +export interface TechStep { + supply: number; + build?: string; + research?: string; + morph?: string; + expand?: boolean; +} + +export interface CompEntry { + unit: string; + weight: number; + /** extra weight when the enemy fields air units */ + vsAir?: number; + /** extra weight when the enemy has many light units */ + vsLight?: number; + /** extra weight when the enemy has armored units */ + vsArmored?: number; + /** cap on how many to keep */ + max?: number; +} + +export interface RacePlan { + opening: TechStep[]; + comp: CompEntry[]; + /** production structure targets: [structure id, [supply thresholds...]], one more building per threshold passed */ + production: [string, number[]][]; + /** upgrade lines researched in the late game, in priority order */ + upgrades: string[]; + detector: { building: string; unit?: string }; +} + +export const PLANS: Record = { + ironpaw: { + opening: [ + { supply: 14, build: 'larder' }, + { supply: 15, build: 'bunkhouse' }, + { supply: 16, build: 'refinery' }, + { supply: 19, expand: true }, + { supply: 20, build: 'steamworks' }, + { supply: 21, build: 'academy' }, + { supply: 22, build: 'refinery' }, + { supply: 24, research: 'honeyrush' }, + { supply: 30, build: 'workshop' }, + { supply: 32, research: 'thickfur' }, + { supply: 34, research: 'inf_w' }, + { supply: 38, build: 'airfield' }, + { supply: 44, build: 'armory' }, + { supply: 48, build: 'refinery' }, + { supply: 50, research: 'inf_a' }, + { supply: 58, expand: true }, + { supply: 60, build: 'refinery' }, + { supply: 66, build: 'sciencelab' }, + { supply: 70, research: 'veh_w' }, + { supply: 84, research: 'grandcannon' }, + { supply: 100, expand: true }, + ], + comp: [ + { unit: 'ranger', weight: 4 }, + { unit: 'grizzly', weight: 2, vsArmored: 1.5 }, + { unit: 'medic', weight: 1, max: 8 }, + { unit: 'blazerider', weight: 0.6, vsLight: 1.2, max: 8 }, + { unit: 'sapper', weight: 0.4, max: 6 }, + { unit: 'tracker', weight: 0.8, vsArmored: 0.6, vsAir: 0.6 }, + { unit: 'kodiak', weight: 2.2, vsArmored: 1 }, + { unit: 'ironhide', weight: 0.8, vsAir: 2.5 }, + { unit: 'barnstormer', weight: 0.4, vsAir: 2 }, + { unit: 'hoverbear', weight: 0.5, vsAir: 1.5 }, + { unit: 'lookout', weight: 0.2, max: 2 }, + { unit: 'ironmama', weight: 1.4 }, + ], + production: [ + ['bunkhouse', [0, 26, 40, 70, 110]], + ['steamworks', [0, 60]], + ['airfield', [0, 100]], + ], + upgrades: ['inf_w', 'inf_a', 'veh_w', 'veh_a', 'ship_w', 'staticburst'], + detector: { building: 'flakturret', unit: 'lookout' }, + }, + horde: { + opening: [ + { supply: 16, expand: true }, + { supply: 17, build: 'nectartap' }, + { supply: 17, build: 'cubden' }, + { supply: 24, research: 'zoomies' }, + { supply: 28, morph: 'royalhive' }, + { supply: 30, build: 'nectartap' }, + { supply: 32, build: 'honeycellar' }, + { supply: 34, build: 'apiary' }, + { supply: 36, build: 'stingerden' }, + { supply: 40, research: 'stings' }, + { supply: 44, build: 'nectartap' }, + { supply: 46, research: 'longstingers' }, + { supply: 50, research: 'biggerwings' }, + { supply: 52, expand: true }, + { supply: 54, build: 'hornetnest' }, + { supply: 58, research: 'claws' }, + { supply: 60, build: 'swarmpit' }, + { supply: 64, build: 'nectartap' }, + { supply: 66, research: 'hide' }, + { supply: 72, morph: 'elderhive' }, + { supply: 74, build: 'burrowden' }, + { supply: 80, build: 'titancavern' }, + { supply: 90, research: 'combarmor' }, + { supply: 100, expand: true }, + ], + comp: [ + { unit: 'scrapper', weight: 3, vsArmored: -1 }, + { unit: 'stinger', weight: 3, vsAir: 2 }, + { unit: 'hornet', weight: 1.6, vsAir: 1 }, + { unit: 'cavetitan', weight: 2, vsLight: 1 }, + { unit: 'swarmcaller', weight: 0.4, max: 4 }, + { unit: 'denmother', weight: 0.5, max: 4 }, + { unit: 'longtongue', weight: 0.3, max: 2 }, + ], + production: [], + upgrades: ['stings', 'claws', 'hide', 'flyer_w', 'flyer_a', 'boundinggait', 'busybees', 'stingswarm'], + detector: { building: 'stingtower' }, + }, + starborn: { + opening: [ + { supply: 14, build: 'startotem' }, + { supply: 16, build: 'moongate' }, + { supply: 16, build: 'dewshrine' }, + { supply: 19, build: 'spiritlodge' }, + { supply: 20, build: 'dewshrine' }, + { supply: 21, expand: true }, + { supply: 30, build: 'starforge' }, + { supply: 32, build: 'sagelibrary' }, + { supply: 34, research: 'ground_w' }, + { supply: 36, research: 'pounce' }, + { supply: 42, build: 'frostworks' }, + { supply: 46, build: 'dewshrine' }, + { supply: 48, research: 'moonstep' }, + { supply: 52, build: 'dewshrine' }, + { supply: 54, expand: true }, + { supply: 60, research: 'starfall' }, + { supply: 64, build: 'skyaltar' }, + { supply: 70, research: 'ground_a' }, + { supply: 76, build: 'greatbeacon' }, + { supply: 100, expand: true }, + ], + comp: [ + { unit: 'spiritbear', weight: 3, vsLight: 0.5 }, + { unit: 'moonbear', weight: 3, vsAir: 2, vsArmored: 1 }, + { unit: 'glacierbear', weight: 1, vsLight: 1.2 }, + { unit: 'pandasage', weight: 0.6, max: 6 }, + { unit: 'shadowpaw', weight: 0.4, max: 4 }, + { unit: 'polarcolossus', weight: 1.4, vsLight: 1 }, + { unit: 'starwing', weight: 0.6, vsAir: 2.5 }, + { unit: 'seer', weight: 0.3, max: 3 }, + { unit: 'snowballer', weight: 0.4, max: 3 }, + { unit: 'greatbear', weight: 1.2 }, + ], + production: [ + ['moongate', [0, 24, 36, 56, 80, 110]], + ['frostworks', [42]], + ['skyaltar', [64, 120]], + ], + upgrades: ['ground_w', 'ground_a', 'aurora', 'air_w', 'air_a', 'longgaze'], + detector: { building: 'starlantern', unit: 'littledipper' }, + }, +}; + +export interface DiffCfg { + think: number; + maxWorkers: number; + attackSupply: number; + firstAttackTime: number; + micro: boolean; + scout: boolean; + income: number; + maxBases: number; + /** multiplier on opening supply thresholds (slower AIs build later) */ + slow: number; + /** fraction of production buildings it bothers to build */ + prodScale: number; + /** maximum commands issued per think */ + apm: number; +} + +export const DIFFICULTY: Record = { + easy: { think: 40, maxWorkers: 22, attackSupply: 24, firstAttackTime: 480, micro: false, scout: false, income: 1, maxBases: 2, slow: 1.4, prodScale: 0.5, apm: 3 }, + normal: { think: 20, maxWorkers: 44, attackSupply: 44, firstAttackTime: 330, micro: true, scout: true, income: 1, maxBases: 3, slow: 1.1, prodScale: 0.8, apm: 6 }, + hard: { think: 10, maxWorkers: 62, attackSupply: 60, firstAttackTime: 300, micro: true, scout: true, income: 1, maxBases: 4, slow: 1, prodScale: 1, apm: 12 }, + brutal: { think: 8, maxWorkers: 70, attackSupply: 64, firstAttackTime: 280, micro: true, scout: true, income: 1.35, maxBases: 5, slow: 1, prodScale: 1.2, apm: 16 }, +}; diff --git a/gh-pages/bear-war/src/game/commands.ts b/gh-pages/bear-war/src/game/commands.ts new file mode 100644 index 00000000..f1939b9b --- /dev/null +++ b/gh-pages/bear-war/src/game/commands.ts @@ -0,0 +1,474 @@ +import { TICK_RATE, TILE } from './constants.ts'; +import { ABILITY_DEFS, DEFS, RESEARCH_DEFS } from './data/index.ts'; +import type { Entity, Order } from './entity.ts'; +import type { Game } from './game.ts'; +import { dist } from './math.ts'; +import { maxHp } from './stats.ts'; +import type { Command } from './types.ts'; +import { weaponFor } from './systems/combat.ts'; +import { cancelSlot, enqueue, supplyError } from './systems/structures.ts'; +import { canPlace } from './systems/placement.ts'; +import { castAbility, useInstant } from './systems/abilities.ts'; +import { canRepairTarget } from './systems/units.ts'; +import { canBoard, canCarry, focusBunker, unloadAll, unloadOne } from './systems/cargo.ts'; + +const MAX_IDS = 400; +/** queued (shift) orders per unit */ +const MAX_ORDERS = 32; + +function isNum(v: unknown): v is number { + return typeof v === 'number' && Number.isFinite(v); +} + +/** Apply a player command. Every client runs this identically (lockstep). */ +export function applyCommand(g: Game, pi: number, cmd: Command): void { + const p = g.players[pi]; + if (!cmd || typeof cmd !== 'object') return; + if (cmd.c === 'surrender' || cmd.c === 'leave') { + if (cmd.c === 'leave') p.left = true; + g.defeat(pi); + return; + } + const own = (ids: unknown): Entity[] => { + if (!Array.isArray(ids)) return []; + const out: Entity[] = []; + for (const id of ids.slice(0, MAX_IDS)) { + if (!isNum(id)) continue; + const e = g.get(id); + if (e && e.owner === pi && !e.inside && !out.includes(e)) out.push(e); + } + return out; + }; + const mobile = (es: Entity[]) => + es.filter((e) => e.isUnit && e.def.kind === 'unit' && (e.def.speed ?? 0) > 0 && !e.morphTo); + + const setOrder = (e: Entity, o: Order, q: boolean | undefined) => { + if (q && e.orders.length > 0) { + if (e.orders.length < MAX_ORDERS) e.orders.push(o); + return; + } + e.orders = [o]; + e.ost = 0; + e.path = null; + e.target = 0; + e.holdPos = false; + e.arrived = false; + e.stuckT = 0; + }; + + switch (cmd.c) { + case 'move': + case 'amove': + case 'patrol': { + if (!isNum(cmd.x) || !isNum(cmd.y)) return; + const units = mobile(own(cmd.ids)); + const pts = formation(units, cmd.x, cmd.y); + const gid = g.groupSeq++; + units.forEach((u, i) => { + const [x, y] = pts[i]; + u.group = gid; + u.groupN = units.length; + if (cmd.c === 'move') setOrder(u, { t: 'move', x, y, target: 0 }, cmd.q); + else if (cmd.c === 'amove') setOrder(u, { t: 'amove', x, y }, cmd.q); + else setOrder(u, { t: 'patrol', x, y, x2: u.x, y2: u.y }, cmd.q); + }); + return; + } + case 'smart': { + if (!isNum(cmd.x) || !isNum(cmd.y)) return; + const all = own(cmd.ids); + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + // structures: set rally; bunkers focus fire + for (const b of all) { + if (b.isBuilding && b.built && (b.def.produces || b.def.spawnsCubs)) b.rally = { x: cmd.x, y: cmd.y, target: t && t !== b ? t.id : 0 }; + if (t && g.isEnemy(pi, t.owner)) { + if (b.def.bunker) focusBunker(g, b, t); + else if (b.isUnit && !(b.def.speed ?? 0) && b.def.weapons && weaponFor(b, t) >= 0) b.target = t.id; + } + } + const units = mobile(all); + const movers: Entity[] = []; + for (const u of units) { + if (!t) { + movers.push(u); + continue; + } + if (t.isResource || (t.def.refinery && t.owner === pi)) { + if (u.def.worker && (t.def.kind === 'mineral' || t.def.refinery || (t.def.kind === 'geyser' && g.get(t.link)?.owner === pi))) { + setOrder(u, { t: 'gather', target: t.id }, cmd.q); + } else movers.push(u); + continue; + } + if (g.isEnemy(pi, t.owner)) { + if (weaponFor(u, t) >= 0) setOrder(u, { t: 'attack', target: t.id }, cmd.q); + else setOrder(u, { t: 'move', x: t.x, y: t.y, target: t.id }, cmd.q); + continue; + } + if (u.def.worker && t.owner === pi) { + if (u.def.race === 'ironpaw' && t.isBuilding && !t.built && t.def.race === 'ironpaw') { + setOrder(u, { t: 'construct', target: t.id }, cmd.q); + continue; + } + if (u.carry > 0 && t.def.depot && t.built) { + setOrder(u, { t: 'return' }, cmd.q); + continue; + } + } + if (u.def.id === 'hardhat' && canRepairTarget(g, u, t) && t.hp < maxHp(t, g.players[t.owner])) { + setOrder(u, { t: 'repair', target: t.id }, cmd.q); + continue; + } + // board a friendly transport or bunker, or (transport) pick up a friendly unit + if (t.owner === pi && t !== u) { + if (t.def.cargo && canBoard(g, u, t)) { + setOrder(u, { t: 'load', target: t.id }, cmd.q); + continue; + } + if (u.def.cargo && canBoard(g, t, u)) { + setOrder(u, { t: 'pickup', target: t.id }, cmd.q); + continue; + } + } + if (t.isUnit && t !== u) setOrder(u, { t: 'move', x: t.x, y: t.y, target: t.id }, cmd.q); + else movers.push(u); + } + if (movers.length) { + const pts = formation(movers, cmd.x, cmd.y); + const gid = g.groupSeq++; + movers.forEach((u, i) => { + u.group = gid; + u.groupN = movers.length; + setOrder(u, { t: 'move', x: pts[i][0], y: pts[i][1], target: 0 }, cmd.q); + }); + } + return; + } + case 'attack': { + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + if (!t) return; + for (const u of mobile(own(cmd.ids))) { + if (weaponFor(u, t) >= 0 && g.isEnemy(pi, t.owner)) setOrder(u, { t: 'attack', target: t.id }, cmd.q); + else setOrder(u, { t: 'move', x: t.x, y: t.y, target: t.isUnit ? t.id : 0 }, cmd.q); + } + if (!g.isEnemy(pi, t.owner)) return; + for (const b of own(cmd.ids)) { + // structures and immobile units (braced Kodiaks) just switch targets + if ((b.isBuilding || !(b.def.speed ?? 0)) && b.def.weapons && weaponFor(b, t) >= 0) { + b.target = t.id; + if (b.isUnit) b.orders = []; + } + if (b.def.bunker) focusBunker(g, b, t); + } + return; + } + case 'stop': + for (const u of own(cmd.ids)) { + if (u.def.bunker) focusBunker(g, u, undefined); + if (!u.isUnit) continue; + u.orders = []; + u.ost = 0; + u.path = null; + u.target = 0; + u.holdPos = false; + } + return; + case 'hold': + for (const u of own(cmd.ids)) { + if (!u.isUnit || u.def.kind !== 'unit') continue; + setOrder(u, { t: 'hold' }, false); + u.holdPos = true; + } + return; + case 'gather': { + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + if (!t || !gatherable(g, pi, t)) return; + for (const u of own(cmd.ids)) if (u.def.worker && !u.morphTo) setOrder(u, { t: 'gather', target: t.id }, cmd.q); + return; + } + case 'return': + for (const u of own(cmd.ids)) if (u.def.worker && u.carry > 0) setOrder(u, { t: 'return' }, cmd.q); + return; + case 'repair': { + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + if (!t) return; + for (const u of own(cmd.ids)) { + if (u.def.id !== 'hardhat') continue; + if (t.isBuilding && !t.built && t.owner === pi && t.def.race === 'ironpaw') setOrder(u, { t: 'construct', target: t.id }, cmd.q); + else if (canRepairTarget(g, u, t)) setOrder(u, { t: 'repair', target: t.id }, cmd.q); + } + return; + } + case 'build': { + const d = DEFS[cmd.d]; + if (!d || d.kind !== 'building' || d.race !== p.race || !isNum(cmd.tx) || !isNum(cmd.ty)) return; + if (d.counts) return; // morph-only structures can't be placed + const workers = own(cmd.ids).filter((u) => u.def.worker && u.def.race === p.race && !u.morphTo); + if (!workers.length) return; + const cx = (cmd.tx + d.size![0] / 2) * TILE; + const cy = (cmd.ty + d.size![1] / 2) * TILE; + workers.sort((a, b) => dist(a.x, a.y, cx, cy) - dist(b.x, b.y, cx, cy) || a.id - b.id); + const w = workers[0]; + const err = + (g.techMet(p, d.requires) ? null : `Requires ${g.missingTech(p, d.requires)}`) ?? + g.affordError(p, d.cost) ?? + canPlace(g, pi, d.id, cmd.tx, cmd.ty, w.id); + if (err) { + g.error(pi, err); + return; + } + setOrder(w, { t: 'build', def: d.id, tx: cmd.tx, ty: cmd.ty }, cmd.q); + return; + } + case 'train': { + const d = DEFS[cmd.d]; + if (!d || d.kind !== 'unit') return; + const blds = own(cmd.ids).filter((b) => b.isBuilding && b.built && b.def.produces?.includes(d.id)); + if (!blds.length) return; + if (blds[0].def.spawnsCubs) { + trainFromCubs(g, pi, blds, d.id); + return; + } + const ready = blds.filter((b) => !b.def.needsPower || b.powered); + if (!ready.length) { + g.error(pi, 'Structure is unpowered'); + return; + } + ready.sort((a, b) => a.queue.length - b.queue.length || a.id - b.id); + const err = enqueue(g, ready[0], 'unit', d.id); + if (err) g.error(pi, err); + return; + } + case 'research': { + const blds = own(cmd.ids).filter((b) => b.isBuilding && b.built && b.def.researches?.includes(cmd.r)); + if (!blds.length) return; + blds.sort((a, b) => a.queue.length - b.queue.length || a.id - b.id); + const err = enqueue(g, blds[0], 'research', cmd.r); + if (err) g.error(pi, err); + return; + } + case 'morph': { + const d = DEFS[cmd.d]; + if (!d) return; + const sel = own(cmd.ids); + const blds = sel.filter((b) => b.isBuilding && b.built && b.def.morphs?.includes(d.id) && !b.queue.some((q) => q.kind === 'morph')); + if (blds.length) { + const err = enqueue(g, blds[0], 'morph', d.id); + if (err) g.error(pi, err); + return; + } + for (const u of sel) { + if (!u.isUnit || !u.def.unitMorphs?.includes(d.id) || u.morphTo) continue; + const more = (d.supply ?? 0) - (u.def.supply ?? 0); + const err = (g.techMet(p, d.requires) ? null : `Requires ${g.missingTech(p, d.requires)}`) ?? g.affordError(p, d.cost) ?? + (more > 0 && p.supplyUsed + more > p.supplyMax ? supplyError(g, p) : null); + if (err) { + g.error(pi, err); + return; + } + g.spend(p, d.cost); + p.supplyUsed += Math.max(0, more); + u.morphTo = d.id; + u.morphT = 0; + u.morphTotal = Math.round(d.time * TICK_RATE); + u.morphCost = d.cost; + u.orders = []; + u.path = null; + u.target = 0; + } + return; + } + case 'cancel': { + const e = isNum(cmd.id) ? g.get(cmd.id) : undefined; + if (!e || e.owner !== pi || !isNum(cmd.slot)) return; + cancelSlot(g, e, cmd.slot); + return; + } + case 'rally': { + if (!isNum(cmd.x) || !isNum(cmd.y)) return; + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + for (const b of own(cmd.ids)) { + if (b.isBuilding && (b.def.produces || b.def.spawnsCubs)) b.rally = { x: cmd.x, y: cmd.y, target: t && t !== b ? t.id : 0 }; + } + return; + } + case 'autocast': + for (const u of own(cmd.ids)) u.autocast = !!cmd.on; + return; + case 'load': { + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + if (!t || t.owner !== pi) return; + const sel = own(cmd.ids); + if (t.def.cargo) { + // the selected units board t + if (!canCarry(g, t)) { + g.error(pi, t.def.cargoReq ? `Requires ${RESEARCH_DEFS[t.def.cargoReq]?.name ?? 'research'}` : 'Cannot load units'); + return; + } + const fit = mobile(sel).filter((u) => canBoard(g, u, t, false)); + const go = fit.filter((u) => canBoard(g, u, t)); + for (const u of go) setOrder(u, { t: 'load', target: t.id }, cmd.q); + if (!go.length) g.error(pi, fit.length ? 'Not enough room' : "Those units can't go in there"); + return; + } + // the selected transports pick up t: send the closest one with room + const carriers = sel.filter((c) => c.isUnit && canBoard(g, t, c)); + if (!carriers.length) { + g.error(pi, sel.some((c) => c.def.cargo && canCarry(g, c)) && canBoard(g, t, sel.find((c) => c.def.cargo)!, false) ? 'Not enough room' : "Can't load that unit"); + return; + } + carriers.sort((a, b) => dist(a.x, a.y, t.x, t.y) - dist(b.x, b.y, t.x, t.y) || a.id - b.id); + setOrder(carriers[0], { t: 'pickup', target: t.id }, cmd.q); + return; + } + case 'unload': { + for (const c of own(cmd.ids)) { + if (!c.def.cargo) continue; + if (isNum(cmd.u)) { + const u = g.get(cmd.u); + if (u && u.carrier === c.id && !unloadOne(g, c, u)) g.error(pi, 'Unable to unload here'); + continue; + } + if (c.isBuilding) { + unloadAll(g, c); + continue; + } + if (!c.cargo.length && !cmd.q) continue; + let x = isNum(cmd.x) ? cmd.x : c.x; + let y = isNum(cmd.y) ? cmd.y : c.y; + x = Math.max(0, Math.min(g.map.w * TILE - 1, x)); + y = Math.max(0, Math.min(g.map.h * TILE - 1, y)); + // aim for walkable ground near the chosen spot + const pt = g.grid.nearestFreePoint(x, y, 1, 24); + if (pt && dist(pt.x, pt.y, x, y) <= 6 * TILE) { + x = pt.x; + y = pt.y; + } + setOrder(c, { t: 'unload', x, y }, cmd.q); + } + return; + } + case 'ability': { + const ab = ABILITY_DEFS[cmd.a]; + if (!ab) return; + const casters = own(cmd.ids).filter((e) => e.def.abilities?.includes(ab.id) && (!e.isBuilding || e.built) && !e.morphTo); + if (!casters.length) return; + if (ab.requires && !p.has(ab.requires)) { + g.error(pi, `Requires ${ab.name} research`); + return; + } + if (ab.target === 'none') { + let firstErr: string | null = null; + for (const e of casters) { + const err = useInstant(g, e, ab.id); + if (err && !firstErr) firstErr = err; + if (ab.id === 'royaljelly' && !err) break; // one Hive per command + } + if (firstErr && ab.id !== 'honeyrush') g.error(pi, firstErr); + return; + } + const t = isNum(cmd.t) ? g.get(cmd.t) : undefined; + const x = isNum(cmd.x) ? cmd.x : t?.x; + const y = isNum(cmd.y) ? cmd.y : t?.y; + if (x === undefined || y === undefined) return; + if ((ab.target === 'unit' || ab.target === 'ally' || ab.target === 'enemy' || ab.target === 'own-building') && !t) { + g.error(pi, 'Must target a unit'); + return; + } + // energy spells: only the best caster casts (smart casting); cooldown spells: everyone + let chosen = casters; + if (ab.energy) { + const able = casters.filter((e) => e.energy >= ab.energy!); + if (!able.length) { + g.error(pi, 'Not enough energy'); + return; + } + able.sort((a, b) => dist(a.x, a.y, x, y) - dist(b.x, b.y, x, y) || b.energy - a.energy || a.id - b.id); + // don't double-cast with casters already moving to cast the same spell + chosen = [able.find((e) => !(e.orders[0]?.t === 'ability' && (e.orders[0] as { ab: string }).ab === ab.id)) ?? able[0]]; + } else if (ab.cooldown) { + chosen = casters.filter((e) => !(e.cooldowns[ab.id] > 0)); + if (!chosen.length) { + g.error(pi, 'Ability is cooling down'); + return; + } + } + for (const e of chosen) { + if (e.isBuilding) castAbility(g, e, ab, x, y, t); + else setOrder(e, { t: 'ability', ab: ab.id, x, y, target: t ? t.id : 0 }, cmd.q); + } + return; + } + } +} + +/** Minerals, own finished refineries, and geysers under one. */ +export function gatherable(g: Game, pi: number, t: Entity): boolean { + if (t.def.kind === 'mineral') return true; + if (t.def.refinery) return t.owner === pi; + if (t.def.kind === 'geyser') return g.get(t.link)?.owner === pi; + return false; +} + +function trainFromCubs(g: Game, pi: number, hives: Entity[], unitId: string): void { + const p = g.players[pi]; + const d = DEFS[unitId]; + const err = (g.techMet(p, d.requires) ? null : `Requires ${g.missingTech(p, d.requires)}`) ?? g.affordError(p, d.cost); + if (err) { + g.error(pi, err); + return; + } + const sup = (d.supply ?? 0) * (d.count ?? 1); + if (sup > 0 && p.supplyUsed + sup > p.supplyMax) { + g.error(pi, supplyError(g, p)); + return; + } + hives.sort((a, b) => b.cubs.length - a.cubs.length || a.id - b.id); + const hive = hives[0]; + const cubId = hive.cubs.find((id) => g.get(id)?.def.kind === 'cub'); + const cub = cubId ? g.get(cubId) : undefined; + if (!cub) { + g.error(pi, 'No cubs available'); + return; + } + g.spend(p, d.cost); + p.supplyUsed += sup; + hive.cubs.splice(hive.cubs.indexOf(cub.id), 1); + cub.def = DEFS.cocoon; + cub.r = DEFS.cocoon.radius ?? 8; + cub.hp = DEFS.cocoon.hp; + cub.morphTo = d.id; + cub.morphT = 0; + cub.morphTotal = Math.round(d.time * TICK_RATE); + cub.morphCost = d.cost; + cub.wantMove = false; +} + +/** + * Destinations for a group move. If the group is compact and the target lies outside + * its bounding box, units keep their relative positions ("magic box"); otherwise they + * all head to the same point. + */ +export function formation(units: Entity[], x: number, y: number): [number, number][] { + if (units.length <= 1) return units.map(() => [x, y]); + let minX = 1e9; + let minY = 1e9; + let maxX = -1e9; + let maxY = -1e9; + let cx = 0; + let cy = 0; + for (const u of units) { + cx += u.x; + cy += u.y; + if (u.x < minX) minX = u.x; + if (u.y < minY) minY = u.y; + if (u.x > maxX) maxX = u.x; + if (u.y > maxY) maxY = u.y; + } + cx /= units.length; + cy /= units.length; + const inside = x >= minX && x <= maxX && y >= minY && y <= maxY; + const compact = maxX - minX < 10 * TILE && maxY - minY < 10 * TILE; + if (inside || !compact) return units.map(() => [x, y]); + return units.map((u) => [x + (u.x - cx), y + (u.y - cy)]); +} + diff --git a/gh-pages/bear-war/src/game/constants.ts b/gh-pages/bear-war/src/game/constants.ts new file mode 100644 index 00000000..b009421d --- /dev/null +++ b/gh-pages/bear-war/src/game/constants.ts @@ -0,0 +1,60 @@ +// Core simulation constants. The simulation is deterministic: every client runs the +// exact same ticks with the exact same commands (lockstep), so nothing in src/game +// may depend on wall-clock time, Math.random, or non-deterministic Math functions. + +/** World pixels per map tile. */ +export const TILE = 32; +/** Pathing grid cell size in world pixels (2x2 cells per tile). */ +export const CELL = 16; +export const CELLS_PER_TILE = TILE / CELL; + +/** Simulation ticks per game second. */ +export const TICK_RATE = 20; +export const TICK_MS = 1000 / TICK_RATE; +/** Ticks per network turn (lockstep). */ +export const TICKS_PER_TURN = 2; + +export const MAX_SUPPLY = 200; +export const MAX_PLAYERS = 8; +export const MAX_QUEUE = 5; + +export const VISION_INTERVAL = 4; // ticks between vision refreshes +export const COMB_INTERVAL = 5; // ticks between comb growth steps +export const ACQUIRE_INTERVAL = 3; // ticks between auto-target scans + +export const MINERAL_TRIP = 5; +export const GAS_TRIP = 4; +export const MINE_TICKS = 56; // 2.8s mining +export const GAS_TICKS = 28; // 1.4s inside refinery +export const MINERAL_AMOUNT = 1500; +export const MINERAL_SMALL = 900; +export const GEYSER_AMOUNT = 2250; + +export const ENERGY_REGEN = 0.7875 / TICK_RATE; +export const SHIELD_REGEN = 2 / TICK_RATE; +export const SHIELD_DELAY = 10 * TICK_RATE; +export const HORDE_REGEN = 0.4 / TICK_RATE; +export const BURN_RATE = 2 / TICK_RATE; // ironpaw buildings in the red burn down +export const COMB_SPEED_BONUS = 1.3; + +export const CUB_INTERVAL = 11 * TICK_RATE; +export const CUB_MAX = 3; +export const CUB_CAP = 19; + +/** Tiles from a resource field that a town hall must keep clear. */ +export const TOWNHALL_RESOURCE_GAP = 3; + +export const STARTING_MINERALS = 50; +export const STARTING_GAS = 0; +export const STARTING_WORKERS = 12; + +export const TEAM_COLORS: { name: string; main: string; light: string; dark: string }[] = [ + { name: 'Red', main: '#e0352b', light: '#ff7a63', dark: '#7a1510' }, + { name: 'Blue', main: '#2f6fe8', light: '#78a6ff', dark: '#14306e' }, + { name: 'Teal', main: '#1fbfae', light: '#6cf0de', dark: '#0b5c54' }, + { name: 'Purple', main: '#8b45d8', light: '#bf8cff', dark: '#3e1a6b' }, + { name: 'Yellow', main: '#e8c22a', light: '#fff07a', dark: '#6e5508' }, + { name: 'Orange', main: '#ee7d1f', light: '#ffb36a', dark: '#753608' }, + { name: 'Green', main: '#3fae38', light: '#8fe782', dark: '#174d14' }, + { name: 'Pink', main: '#e3559f', light: '#ff9ccd', dark: '#6e1a47' }, +]; diff --git a/gh-pages/bear-war/src/game/data/abilities.ts b/gh-pages/bear-war/src/game/data/abilities.ts new file mode 100644 index 00000000..ab2b2dc0 --- /dev/null +++ b/gh-pages/bear-war/src/game/data/abilities.ts @@ -0,0 +1,85 @@ +import type { AbilityDef } from './types.ts'; + +export const ABILITIES: AbilityDef[] = [ + // generic worker commands (shown on command cards) + { id: 'gather', name: 'Gather', target: 'unit', icon: 'gather', desc: 'Harvest minerals or gas.' }, + { id: 'return', name: 'Return Cargo', target: 'none', icon: 'return', desc: 'Return carried resources.' }, + { id: 'repair', name: 'Repair', target: 'unit', icon: 'repair', desc: 'Repair a mechanical unit or structure. Costs resources.' }, + { id: 'build', name: 'Build', target: 'none', icon: 'build', desc: 'Basic structures.' }, + { id: 'build2', name: 'Advanced Build', target: 'none', icon: 'build2', desc: 'Advanced structures.' }, + + // Ironpaw + { id: 'sniff', name: 'Big Sniff', target: 'point', energy: 50, radius: 13, duration: 12.3, icon: 'sniff', + desc: 'Sniffs out a target area anywhere on the map, revealing and detecting it for 12 seconds.' }, + { id: 'honeyrush', name: 'Honey Rush', target: 'none', hpCost: 10, duration: 11, requires: 'honeyrush', icon: 'honeyrush', + desc: 'Costs health. +50% attack speed and movement speed for 11 seconds.' }, + { id: 'brace', name: 'Brace', target: 'none', icon: 'brace', mode: { to: 'kodiak_b', time: 2.7 }, + desc: 'Lie down and brace for long-range artillery.' }, + { id: 'unbrace', name: 'Get Up', target: 'none', icon: 'unbrace', mode: { to: 'kodiak', time: 2.7 }, + desc: 'Get back on four paws and move again.' }, + { id: 'hopoff', name: 'Hop Off', target: 'none', icon: 'hopoff', needs: ['armory'], mode: { to: 'blazebrute', time: 2.1 }, + desc: 'Park the bike and fight on foot as a Blaze Brute.' }, + { id: 'hopon', name: 'Hop On', target: 'none', icon: 'hopon', needs: ['armory'], mode: { to: 'blazerider', time: 2.1 }, + desc: 'Climb back on the bike.' }, + { id: 'digin', name: 'Dig In', target: 'none', icon: 'digin', mode: { to: 'sapper_b', time: 2 }, + desc: 'Dig in and hide. Dug-in Sappers lob their keg at enemy units that come close.' }, + { id: 'digout', name: 'Dig Out', target: 'none', icon: 'digout', mode: { to: 'sapper', time: 0.7 }, + desc: 'Climb out of the hole.' }, + { id: 'lockon', name: 'Lock On', target: 'enemy', range: 7, cooldown: 10, icon: 'lockon', + desc: 'Locks onto an enemy unit: 300 damage over 12 seconds while it stays within 14 range. The Tracker can keep moving.' }, + { id: 'anchor', name: 'Anchor Down', target: 'point', range: 5, icon: 'anchor', mode: { to: 'hoverbear_a', time: 1.8 }, + desc: 'Anchor over a spot. Ground units inside the 5-wide circle below get pounded.' }, + { id: 'liftanchor', name: 'Lift Anchor', target: 'none', icon: 'liftanchor', mode: { to: 'hoverbear', time: 1.2 }, + desc: 'Haul up the anchor and fly again.' }, + { id: 'bandage', name: 'Honey Bandage', target: 'ally', range: 4, passive: true, autocast: true, icon: 'bandage', + desc: 'Automatically heals nearby biological units (costs energy).' }, + { id: 'bubble', name: 'Shield Bubble', target: 'ally', energy: 100, range: 9, duration: 11, icon: 'bubble', + desc: 'Wraps a friendly unit in a bubble that absorbs 100 damage for 11 seconds.' }, + { id: 'staticburst', name: 'Static Burst', target: 'point', energy: 75, range: 10, radius: 1.5, requires: 'staticburst', icon: 'staticburst', + desc: 'Removes 100 shields and 100 energy from all units in the area.' }, + { id: 'grandcannon', name: 'Grand Cannon', target: 'enemy', energy: 125, range: 10, requires: 'grandcannon', icon: 'grandcannon', + desc: 'After a brief charge, deals 240 damage to the target.' }, + + // Honey Horde + { id: 'royaljelly', name: 'Royal Jelly', target: 'none', energy: 25, duration: 29, icon: 'royaljelly', + desc: 'After 29 seconds the Hive raises 3 extra cubs.' }, + { id: 'smash', name: 'Smash', target: 'none', icon: 'smash', desc: 'Smash the hive right now.' }, + { id: 'hibernate', name: 'Hibernate', target: 'none', icon: 'digin', mode: { to: 'burrower_b', time: 1.4 }, + desc: 'Dig in and hide. Only hibernating Burrowers can attack.' }, + { id: 'wakeup', name: 'Wake Up', target: 'none', icon: 'digout', mode: { to: 'burrower', time: 0.7 }, + desc: 'Climb out and move again.' }, + { id: 'scaldinghoney', name: 'Scalding Honey', target: 'point', range: 9, radius: 0.75, duration: 2.5, cooldown: 7, icon: 'scaldinghoney', + desc: 'Lobs boiling honey at a spot. 2.5 seconds later it deals 60 damage to every enemy there, ground or air.' }, + { id: 'releasecubs', name: 'Release Cubs', target: 'point', cooldown: 40, duration: 18, icon: 'releasecubs', + desc: 'Two Wild Cubs burst out of the den and rush the target area. They fade after 18 seconds.' }, + { id: 'yank', name: 'Yank', target: 'unit', energy: 75, range: 9, icon: 'yank', + desc: 'Grabs a unit with an absurdly long tongue and pulls it right next to the Longtongue.' }, + { id: 'pollen', name: 'Pollen Cloud', target: 'point', energy: 100, range: 11, radius: 2, duration: 5.7, icon: 'pollen', + desc: 'Ground units inside sneeze uncontrollably: their ranged attacks only reach melee range.' }, + { id: 'hornetbomb', name: 'Hornet Bomb', target: 'enemy', energy: 125, range: 8, radius: 3, duration: 7, icon: 'hornetbomb', + desc: 'Sets a hornet swarm on an enemy flyer: 120 damage over 7 seconds to every enemy flyer near it.' }, + { id: 'beecloud', name: 'Bee Cloud', target: 'point', energy: 100, range: 9, radius: 2.5, duration: 11, icon: 'beecloud', + desc: 'A buzzing bee cloud that blocks all ranged attacks against ground units inside it.' }, + { id: 'stingswarm', name: 'Sting Swarm', target: 'point', energy: 75, range: 9, radius: 2, duration: 8, requires: 'stingswarm', icon: 'stingswarm', + desc: 'Deals 120 damage over 8 seconds to all enemy units and structures in the area. Cannot kill.' }, + + // Starborn + { id: 'haste', name: 'Moonlight Haste', target: 'own-building', energy: 50, duration: 20, icon: 'haste', + desc: 'Speeds up production and research of a structure by 50% for 20 seconds.' }, + { id: 'pounce', name: 'Pounce', target: 'none', passive: true, requires: 'pounce', icon: 'pounce', + desc: 'Spirit Bears move faster and pounce onto nearby targets.' }, + { id: 'moonstep', name: 'Moonstep', target: 'point', cooldown: 7, range: 8, requires: 'moonstep', icon: 'moonstep', + desc: 'Instantly teleports a short distance.' }, + { id: 'spiritwalk', name: 'Spirit Walk', target: 'point', cooldown: 11, duration: 7, icon: 'spiritwalk', + desc: 'The bear\'s spirit runs to a spot. 7 seconds later the bear appears wherever its spirit is.' }, + { id: 'starfall', name: 'Starfall', target: 'point', energy: 75, range: 9, radius: 1.5, duration: 2.85, requires: 'starfall', icon: 'starfall', + desc: 'Deals 80 damage over 3 seconds to ALL units in the area, including your own.' }, + { id: 'snowball', name: 'Star Snowball', target: 'point', range: 9, radius: 1.5, cooldown: 21, icon: 'snowball', + desc: 'Rolls a crackling snowball toward a spot. It bursts after 2 seconds: 145 damage (+55 to shields) to ALL ground units nearby, including your own.' }, + { id: 'sunlanceon', name: 'Light Sunlance', target: 'none', energy: 25, icon: 'sunlanceon', mode: { to: 'seer_b', time: 0 }, + desc: 'Light a searing beam that hits ground targets. Burns 2 energy per second.' }, + { id: 'sunlanceoff', name: 'Douse Sunlance', target: 'none', icon: 'sunlanceoff', mode: { to: 'seer', time: 0 }, + desc: 'Put the beam out to save energy.' }, + { id: 'revelation', name: 'Revelation', target: 'point', energy: 25, range: 9, radius: 6, duration: 12, icon: 'sniff', + desc: 'Reveals and detects an area for 12 seconds.' }, +]; diff --git a/gh-pages/bear-war/src/game/data/horde.ts b/gh-pages/bear-war/src/game/data/horde.ts new file mode 100644 index 00000000..dda27ba6 --- /dev/null +++ b/gh-pages/bear-war/src/game/data/horde.ts @@ -0,0 +1,269 @@ +import type { EntityDef } from './types.ts'; + +// HONEY HORDE: feral bears living alongside giant bees. Every Hive raises cubs that grow +// into any Horde bear. Foragers are used up when they settle into buildings, which must +// stand on the honeycomb that Hives spread. Horde bears heal over time and run faster on comb. + +const S = (attrs: EntityDef['attrs']) => attrs; +const HIVE_UNITS = ['forager', 'bumblebear', 'scrapper', 'stinger', 'hornet', 'denmother', 'longtongue', 'cavetitan', 'swarmcaller']; + +export const HORDE: EntityDef[] = [ + // ---------------------------------------------------------------- special + { + id: 'cub', name: 'Cub', race: 'horde', kind: 'cub', + desc: 'Raised by Hives. Can grow into any Horde bear.', + cost: { m: 0, g: 0 }, time: 0, supply: 0, + hp: 25, armor: 10, speed: 0.6, radius: 4, sight: 5, attrs: S(['light', 'bio']), priority: 2, + }, + { + id: 'cocoon', name: 'Honey Cocoon', race: 'horde', kind: 'cocoon', + desc: 'A cub sleeping in wax while it grows into a new bear.', + cost: { m: 0, g: 0 }, time: 0, supply: 0, + hp: 200, armor: 10, speed: 0, radius: 8, sight: 5, attrs: S(['bio']), priority: 3, + }, + + // ---------------------------------------------------------------- units + { + id: 'forager', name: 'Forager', race: 'horde', kind: 'unit', order: 0, + desc: 'Worker. Gathers resources and settles into Horde structures (it is used up).', + cost: { m: 50, g: 0 }, time: 12, supply: 1, cargoSize: 1, + hp: 40, armor: 0, speed: 3.94, radius: 7, sight: 8, attrs: S(['light', 'bio']), + worker: true, + weapons: [{ name: 'Little Claws', dmg: 5, cd: 1.07, range: 0.1, targets: 'ground', melee: true, fx: 'claw' }], + abilities: ['gather', 'return', 'build', 'build2'], + }, + { + id: 'bumblebear', name: 'Bumblebear', race: 'horde', kind: 'unit', order: 1, + desc: 'A fuzzy striped bear buzzing along on tiny wings. Provides 8 supply and detects hidden units. With Cargo Pouch it carries 8 slots of ground units.', + cost: { m: 100, g: 0 }, time: 18, supply: 0, provides: 8, air: true, + hp: 200, armor: 0, speed: 0.9, radius: 20, sight: 11, detector: 8, attrs: S(['armored', 'bio']), + armorUpg: 'flyer_a', priority: 10, cargo: 8, cargoReq: 'pouch', + }, + { + id: 'scrapper', name: 'Scrapper', race: 'horde', kind: 'unit', order: 2, count: 2, + requires: ['cubden'], + desc: 'Fast, cheap feral cubs. Come in pairs. Can grow into Buzzbombs.', + cost: { m: 50, g: 0 }, time: 17, supply: 0.5, cargoSize: 1, + hp: 35, armor: 0, speed: 4.13, radius: 5, sight: 8, attrs: S(['light', 'bio']), armorUpg: 'hide', + weapons: [{ name: 'Tiny Claws', dmg: 5, cd: 0.5, range: 0.1, targets: 'ground', melee: true, upg: 'claws', fx: 'claw' }], + unitMorphs: ['buzzbomb'], + }, + { + id: 'buzzbomb', name: 'Buzzbomb', race: 'horde', kind: 'unit', order: 3, morphFrom: 'scrapper', + requires: ['apiary'], + desc: 'A cub hugging a stolen beehive. Rushes into enemies and smashes it: the furious swarm devastates light units and buildings.', + cost: { m: 25, g: 25 }, time: 14, supply: 0.5, cargoSize: 1, + hp: 30, armor: 0, speed: 3.5, radius: 7, sight: 8, attrs: S(['bio']), armorUpg: 'hide', + weapons: [{ + name: 'Hive Smash', dmg: 16, bonus: { light: 19, structure: 64 }, cd: 0.8, range: 0.2, targets: 'ground', + melee: true, upg: 'claws', upgDmg: 2, upgBonus: 2, splash: { r: [2.2], pct: [1] }, suicide: true, fx: 'burst', + }], + abilities: ['smash'], + }, + { + id: 'stinger', name: 'Stinger', race: 'horde', kind: 'unit', order: 4, requires: ['stingerden'], + desc: 'Shaggy bear that flings barbed stingers at ground and air. Can grow into a Bombardier or a Burrower.', + cost: { m: 100, g: 50 }, time: 24, supply: 2, cargoSize: 2, + hp: 100, armor: 0, speed: 3.15, radius: 10, sight: 9, attrs: S(['light', 'bio']), armorUpg: 'hide', + weapons: [{ + name: 'Stinger Volley', dmg: 12, cd: 0.59, range: 5, targets: 'both', upg: 'stings', proj: 'quill', projSpeed: 26, fx: 'quill', + }], + unitMorphs: ['bombardier', 'burrower'], + }, + { + id: 'bombardier', name: 'Bombardier', race: 'horde', kind: 'unit', order: 5, morphFrom: 'stinger', + requires: ['stingerden'], + desc: 'Stocky bear with bulging honey sacs. Spits honey, and lobs Scalding Honey that lands on a spot moments later.', + cost: { m: 25, g: 75 }, time: 9, supply: 3, cargoSize: 4, + hp: 120, armor: 1, speed: 3.85, radius: 13, sight: 9, attrs: S(['bio']), armorUpg: 'hide', + weapons: [{ + name: 'Honey Spit', dmg: 16, cd: 1.14, range: 6, targets: 'ground', upg: 'stings', proj: 'honey', projSpeed: 18, fx: 'honey', + }], + abilities: ['scaldinghoney'], + }, + { + id: 'burrower', name: 'Burrower', race: 'horde', kind: 'unit', order: 6, morphFrom: 'stinger', + requires: ['burrowden'], + desc: 'Spiny armored bear. Hibernates underground, hidden, and erupts spines through every enemy in a line.', + cost: { m: 50, g: 100 }, time: 18, supply: 3, cargoSize: 4, + hp: 190, armor: 1, speed: 4.13, radius: 14, sight: 10, attrs: S(['armored', 'bio']), armorUpg: 'hide', + abilities: ['hibernate'], + }, + { + id: 'burrower_b', name: 'Burrower (Hibernating)', race: 'horde', kind: 'unit', order: 6.5, base: 'burrower', + desc: 'Hidden under the comb until detected. Spines erupt in a long line, hitting every enemy ground unit and structure along it.', + cost: { m: 50, g: 100 }, time: 18, supply: 3, cloaked: true, burrowed: true, + hp: 190, armor: 1, speed: 0, radius: 14, sight: 10, attrs: S(['armored', 'bio']), armorUpg: 'hide', + weapons: [{ + name: 'Spine Eruption', dmg: 20, bonus: { armored: 10 }, cd: 1.43, range: 8, line: 0.5, targets: 'ground', + upg: 'stings', upgDmg: 2, upgBonus: 1, fx: 'eruption', + }], + abilities: ['wakeup'], + }, + { + id: 'hornet', name: 'Hornet', race: 'horde', kind: 'unit', order: 7, requires: ['hornetnest'], air: true, + desc: 'Wasp-winged bear. Its stinger bounces between up to three targets.', + cost: { m: 100, g: 100 }, time: 24, supply: 2, + hp: 120, armor: 0, speed: 5.6, radius: 12, sight: 11, attrs: S(['light', 'bio']), armorUpg: 'flyer_a', + weapons: [{ + name: 'Bouncing Stinger', dmg: 9, cd: 1.52, range: 3, targets: 'both', upg: 'flyer_w', proj: 'glaive', projSpeed: 20, + bounce: { n: 2, falloff: 3, range: 3 }, fx: 'glaive', + }], + }, + { + id: 'denmother', name: 'Den Mother', race: 'horde', kind: 'unit', order: 8, requires: ['swarmpit'], + desc: 'Big she-bear carrying a den on her back. Releases Wild Cubs that rush the enemy and fade after 18 seconds.', + cost: { m: 100, g: 75 }, time: 29, supply: 3, cargoSize: 4, + hp: 160, armor: 1, speed: 3.15, radius: 16, sight: 10, attrs: S(['armored', 'bio']), armorUpg: 'hide', + abilities: ['releasecubs'], + }, + { + id: 'wildcub', name: 'Wild Cub', race: 'horde', kind: 'unit', order: 8.5, lifetime: 18, + desc: 'Released by a Den Mother. Free, fierce, and gone after 18 seconds.', + cost: { m: 0, g: 0 }, time: 0, supply: 0, + hp: 50, armor: 0, speed: 4.13, radius: 5, sight: 6, attrs: S(['light', 'bio']), armorUpg: 'hide', + weapons: [{ name: 'Feral Bite', dmg: 10, cd: 0.6, range: 0.1, targets: 'ground', melee: true, upg: 'claws', fx: 'claw' }], + }, + { + id: 'longtongue', name: 'Longtongue', race: 'horde', kind: 'unit', order: 9, requires: ['elderhive'], air: true, + desc: 'Winged sun bear with an absurdly long tongue. Yanks enemies close, blinds gunners with Pollen Cloud and sets Hornet Bombs on flyers.', + cost: { m: 100, g: 200 }, time: 29, supply: 3, + hp: 150, armor: 1, speed: 4.13, radius: 14, sight: 11, attrs: S(['armored', 'bio', 'psionic']), armorUpg: 'flyer_a', + energy: 200, startEnergy: 50, + abilities: ['yank', 'pollen', 'hornetbomb'], + }, + { + id: 'cavetitan', name: 'Cave Titan', race: 'horde', kind: 'unit', order: 10, requires: ['titancavern'], + desc: 'Colossal prehistoric cave bear in honeycomb armor. Its claws cleave through groups of ground units.', + cost: { m: 275, g: 200 }, time: 39, supply: 6, cargoSize: 4, + hp: 500, armor: 2, speed: 4.13, radius: 34, sight: 9, attrs: S(['armored', 'bio', 'massive']), armorUpg: 'hide', + weapons: [{ + name: 'Titan Claws', dmg: 35, cd: 0.61, range: 1, targets: 'ground', melee: true, upg: 'claws', upgDmg: 3, + splash: { r: [0.8], pct: [0.33] }, fx: 'maul', + }], + }, + { + id: 'swarmcaller', name: 'Swarmcaller', race: 'horde', kind: 'unit', order: 11, requires: ['swarmpit'], + desc: 'Bear shaman wreathed in bees. Bee Cloud shields ground units from ranged attacks; Sting Swarm wears down everything in an area.', + cost: { m: 100, g: 150 }, time: 36, supply: 2, cargoSize: 2, + hp: 90, armor: 0, speed: 3.15, radius: 12, sight: 10, attrs: S(['armored', 'bio', 'psionic']), armorUpg: 'hide', + energy: 200, startEnergy: 50, + abilities: ['beecloud', 'stingswarm'], + }, + + // ---------------------------------------------------------------- buildings + { + id: 'hive', name: 'Great Hive', race: 'horde', kind: 'building', order: 0, buildPage: 1, + desc: 'Main base. Raises cubs, accepts resources and spreads honeycomb. Can grow into a Royal Hive.', + cost: { m: 300, g: 0 }, time: 71, provides: 6, size: [5, 5], + hp: 1500, armor: 1, sight: 12, attrs: S(['armored', 'bio', 'structure']), + produces: HIVE_UNITS, + spawnsCubs: true, depot: true, townHall: true, combRadius: 11, energy: 200, startEnergy: 25, + abilities: ['royaljelly'], morphs: ['royalhive'], + }, + { + id: 'royalhive', name: 'Royal Hive', race: 'horde', kind: 'building', counts: ['hive'], art: 'hive', + desc: 'Grown Hive. Unlocks advanced Horde tech. Can grow into an Elder Hive.', + cost: { m: 150, g: 100 }, time: 57, provides: 6, size: [5, 5], requires: ['cubden'], + hp: 2000, armor: 1, sight: 12, attrs: S(['armored', 'bio', 'structure']), + produces: HIVE_UNITS, + spawnsCubs: true, depot: true, townHall: true, combRadius: 12, energy: 200, startEnergy: 25, + abilities: ['royaljelly'], morphs: ['elderhive'], + }, + { + id: 'elderhive', name: 'Elder Hive', race: 'horde', kind: 'building', counts: ['hive', 'royalhive'], art: 'hive', + desc: 'Ancient Hive. Unlocks the mightiest Horde bears.', + cost: { m: 200, g: 150 }, time: 71, provides: 6, size: [5, 5], requires: ['swarmpit'], + hp: 2500, armor: 1, sight: 12, attrs: S(['armored', 'bio', 'structure']), + produces: HIVE_UNITS, + spawnsCubs: true, depot: true, townHall: true, combRadius: 13, energy: 200, startEnergy: 25, + abilities: ['royaljelly'], + }, + { + id: 'nectartap', name: 'Nectar Tap', race: 'horde', kind: 'building', order: 1, buildPage: 1, + desc: 'Grown onto a gas geyser so Foragers can harvest gas.', + cost: { m: 25, g: 0 }, time: 21, size: [3, 3], refinery: true, + hp: 500, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + }, + { + id: 'cubden', name: 'Cub Den', race: 'horde', kind: 'building', order: 2, buildPage: 1, + requires: ['hive'], needsComb: true, + desc: 'Unlocks Scrappers and defensive growths. Researches Zoomies.', + cost: { m: 200, g: 0 }, time: 46, size: [3, 3], + hp: 1000, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['zoomies'], + }, + { + id: 'honeycellar', name: 'Honey Cellar', race: 'horde', kind: 'building', order: 3, buildPage: 1, + requires: ['hive'], needsComb: true, + desc: 'Researches claw, sting and hide upgrades, and Bumblebear wings and pouches.', + cost: { m: 75, g: 0 }, time: 25, size: [3, 3], + hp: 750, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['claws', 'stings', 'hide', 'biggerwings', 'pouch'], + }, + { + id: 'thornstump', name: 'Thorn Stump', race: 'horde', kind: 'building', order: 4, buildPage: 1, + requires: ['cubden'], needsComb: true, + desc: 'Defensive growth that impales ground units.', + cost: { m: 100, g: 0 }, time: 36, size: [2, 2], combRadius: 3, + hp: 300, armor: 2, sight: 11, attrs: S(['armored', 'bio', 'structure']), + weapons: [{ name: 'Thorn Spike', dmg: 25, bonus: { armored: 5 }, cd: 1.85, range: 7, targets: 'ground', fx: 'thorn' }], + }, + { + id: 'stingtower', name: 'Sting Tower', race: 'horde', kind: 'building', order: 5, buildPage: 1, + requires: ['cubden'], needsComb: true, + desc: 'Anti-air hive of guard bees. Detects hidden units.', + cost: { m: 75, g: 0 }, time: 21, size: [2, 2], combRadius: 3, + hp: 400, armor: 1, sight: 11, detector: 11, attrs: S(['armored', 'bio', 'structure']), + weapons: [{ + name: 'Sting Burst', dmg: 15, bonus: { bio: 15 }, cd: 0.61, range: 7, targets: 'air', proj: 'sting', projSpeed: 20, fx: 'sting', + }], + }, + { + id: 'apiary', name: 'Apiary', race: 'horde', kind: 'building', order: 6, buildPage: 1, + requires: ['cubden'], needsComb: true, + desc: 'Lets Scrappers grow into Buzzbombs. Researches Busy Bees.', + cost: { m: 100, g: 50 }, time: 43, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['busybees'], + }, + { + id: 'stingerden', name: 'Stinger Den', race: 'horde', kind: 'building', order: 7, buildPage: 2, + requires: ['royalhive'], needsComb: true, + desc: 'Unlocks Stingers and Bombardiers. Researches stinger range and speed.', + cost: { m: 100, g: 100 }, time: 29, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['longstingers', 'boundinggait'], + }, + { + id: 'burrowden', name: 'Burrow Den', race: 'horde', kind: 'building', order: 8, buildPage: 2, + requires: ['royalhive', 'stingerden'], needsComb: true, + desc: 'Lets Stingers grow into Burrowers.', + cost: { m: 100, g: 150 }, time: 57, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + }, + { + id: 'hornetnest', name: 'Hornet Nest', race: 'horde', kind: 'building', order: 9, buildPage: 2, + requires: ['royalhive'], needsComb: true, + desc: 'Unlocks Hornets. Researches flyer stings and hide.', + cost: { m: 200, g: 200 }, time: 71, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['flyer_w', 'flyer_a'], + }, + { + id: 'swarmpit', name: 'Swarm Pit', race: 'horde', kind: 'building', order: 10, buildPage: 2, + requires: ['royalhive'], needsComb: true, + desc: 'Unlocks Swarmcallers, Den Mothers and the Elder Hive. Researches Sting Swarm.', + cost: { m: 100, g: 100 }, time: 36, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['stingswarm'], + }, + { + id: 'titancavern', name: 'Titan Cavern', race: 'horde', kind: 'building', order: 11, buildPage: 2, + requires: ['elderhive'], needsComb: true, + desc: 'Unlocks Cave Titans. Researches Honeycomb Armor.', + cost: { m: 150, g: 200 }, time: 46, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'bio', 'structure']), + researches: ['combarmor'], + }, +]; diff --git a/gh-pages/bear-war/src/game/data/index.ts b/gh-pages/bear-war/src/game/data/index.ts new file mode 100644 index 00000000..dcf5d404 --- /dev/null +++ b/gh-pages/bear-war/src/game/data/index.ts @@ -0,0 +1,114 @@ +import type { AbilityDef, EntityDef, RaceDef, RaceId, ResearchDef } from './types.ts'; +import { IRONPAW } from './ironpaw.ts'; +import { HORDE } from './horde.ts'; +import { STARBORN } from './starborn.ts'; +import { NEUTRAL } from './neutral.ts'; +import { RESEARCH } from './research.ts'; +import { ABILITIES } from './abilities.ts'; + +export * from './types.ts'; + +export const RACES: Record = { + ironpaw: { + id: 'ironpaw', name: 'Ironpaw', color: '#c58a4e', build: 'construct', + worker: 'hardhat', townHall: 'lodge', supplyUnit: 'larder', refinery: 'refinery', + desc: 'Hard-working brown bears of the timber country. Hardhats build and repair; rugged infantry, steam-plated war bears and armored airships.', + }, + horde: { + id: 'horde', name: 'Honey Horde', color: '#e3a92b', build: 'settle', + worker: 'forager', townHall: 'hive', supplyUnit: 'bumblebear', refinery: 'nectartap', + desc: 'Feral bears living alongside giant bees. Cubs grow into any bear; dens stand on honeycomb; everyone heals over time.', + }, + starborn: { + id: 'starborn', name: 'Starborn', color: '#8fd3ff', build: 'summon', + worker: 'snowcub', townHall: 'startemple', supplyUnit: 'startotem', refinery: 'dewshrine', + desc: 'Panda Sage polar, spirit and moon bears. Structures are called down by starlight and need a Star Totem; every bear wears an aurora shield.', + }, +}; + +export const RACE_IDS: RaceId[] = ['ironpaw', 'horde', 'starborn']; + +export const DEFS: Record = {}; +for (const d of [...NEUTRAL, ...IRONPAW, ...HORDE, ...STARBORN]) { + if (DEFS[d.id]) throw new Error(`duplicate def id ${d.id}`); + DEFS[d.id] = d; +} + +export const RESEARCH_DEFS: Record = {}; +for (const r of RESEARCH) RESEARCH_DEFS[r.id] = r; + +export const ABILITY_DEFS: Record = {}; +for (const a of ABILITIES) ABILITY_DEFS[a.id] = a; + +/** Unit modes and evolved structures share selection groups with what they came from. */ +export function family(d: EntityDef): string { + return d.base ?? d.counts?.[0] ?? d.id; +} + +/** Is e switching modes (as opposed to growing into something else)? */ +export function switchingMode(morphTo: string | null, d: EntityDef): boolean { + return !!morphTo && family(DEFS[morphTo]) === family(d); +} + +/** ids a building satisfies for tech requirements (itself + `counts`). */ +export function satisfies(def: EntityDef): string[] { + return def.counts ? [def.id, ...def.counts] : [def.id]; +} + +/** Largest unit radius and structure half-diagonal (px): spatial queries must look this far. */ +export const MAX_UNIT_RADIUS = Math.max(...Object.values(DEFS).map((d) => d.radius ?? 0)); +export const MAX_STRUCT_REACH = Math.max(...Object.values(DEFS).map((d) => (d.size ? Math.sqrt(d.size[0] * d.size[0] + d.size[1] * d.size[1]) * 16 : 0))); + +/** For each unit id, the building ids that can produce it. */ +export const PRODUCERS: Record = {}; +/** For each research id, the building id that researches it. */ +export const RESEARCHED_AT: Record = {}; +for (const d of Object.values(DEFS)) { + for (const u of d.produces ?? []) (PRODUCERS[u] ??= []).push(d.id); + for (const r of d.researches ?? []) RESEARCHED_AT[r] ??= d.id; +} + +export function buildablesFor(race: RaceId, page: 1 | 2): EntityDef[] { + return Object.values(DEFS) + .filter((d) => d.race === race && d.kind === 'building' && d.buildPage === page) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +export function unitDefsFor(race: RaceId): EntityDef[] { + return Object.values(DEFS) + .filter((d) => d.race === race && d.kind === 'unit' && d.order !== undefined) + .sort((a, b) => (a.order ?? 0) - (b.order ?? 0)); +} + +/** Throws if any cross-reference in the data is broken. Run at startup and in tests. */ +export function validateData(): void { + const errors: string[] = []; + const has = (id: string) => !!DEFS[id]; + for (const d of Object.values(DEFS)) { + for (const r of d.requires ?? []) if (!has(r)) errors.push(`${d.id}.requires -> ${r}`); + for (const r of d.counts ?? []) if (!has(r)) errors.push(`${d.id}.counts -> ${r}`); + for (const u of d.produces ?? []) if (!has(u)) errors.push(`${d.id}.produces -> ${u}`); + for (const u of d.morphs ?? []) if (!has(u)) errors.push(`${d.id}.morphs -> ${u}`); + for (const u of d.unitMorphs ?? []) if (!has(u)) errors.push(`${d.id}.unitMorphs -> ${u}`); + for (const r of d.researches ?? []) if (!RESEARCH_DEFS[r]) errors.push(`${d.id}.researches -> ${r}`); + for (const a of d.abilities ?? []) if (!ABILITY_DEFS[a]) errors.push(`${d.id}.abilities -> ${a}`); + if (d.morphFrom && !has(d.morphFrom)) errors.push(`${d.id}.morphFrom -> ${d.morphFrom}`); + if (d.kind === 'unit' && !d.radius) errors.push(`${d.id} missing radius`); + if ((d.kind === 'building' || d.kind === 'mineral' || d.kind === 'geyser') && !d.size) errors.push(`${d.id} missing size`); + if (d.cargoReq && !RESEARCH_DEFS[d.cargoReq]) errors.push(`${d.id}.cargoReq -> ${d.cargoReq}`); + if (d.cargoSize && (d.air || d.kind !== 'unit')) errors.push(`${d.id} has cargoSize but can't be carried`); + if (d.cargo && d.kind === 'unit' && !d.air) errors.push(`${d.id}: ground transports are not supported`); + } + for (const r of RESEARCH) { + if (!RESEARCHED_AT[r.id]) errors.push(`research ${r.id} not available anywhere`); + if (r.costs.length !== r.times.length) errors.push(`research ${r.id} costs/times mismatch`); + for (const lvl of r.reqs ?? []) for (const b of lvl) if (!has(b)) errors.push(`research ${r.id} req -> ${b}`); + } + for (const a of ABILITIES) if (a.requires && !RESEARCH_DEFS[a.requires]) errors.push(`ability ${a.id} requires -> ${a.requires}`); + for (const race of Object.values(RACES)) { + for (const k of ['worker', 'townHall', 'supplyUnit', 'refinery'] as const) { + if (!has(race[k])) errors.push(`race ${race.id}.${k} -> ${race[k]}`); + } + } + if (errors.length) throw new Error('Data validation failed:\n' + errors.join('\n')); +} diff --git a/gh-pages/bear-war/src/game/data/ironpaw.ts b/gh-pages/bear-war/src/game/data/ironpaw.ts new file mode 100644 index 00000000..e102b8ac --- /dev/null +++ b/gh-pages/bear-war/src/game/data/ironpaw.ts @@ -0,0 +1,296 @@ +import type { EntityDef } from './types.ts'; + +// IRONPAW: hard-working brown bears of the timber country. Hardhats construct buildings +// (and must stay to build), can repair machines, and damaged buildings burn down. Rugged +// infantry, steam-plated war bears and armored airships. + +const S = (attrs: EntityDef['attrs']) => attrs; + +export const IRONPAW: EntityDef[] = [ + // ---------------------------------------------------------------- units + { + id: 'hardhat', name: 'Hardhat', race: 'ironpaw', kind: 'unit', order: 0, + desc: 'Worker. A young brown bear in a hard hat: gathers resources, constructs buildings and repairs machines.', + cost: { m: 50, g: 0 }, time: 12, supply: 1, cargoSize: 1, + hp: 45, armor: 0, speed: 3.94, radius: 7, sight: 8, attrs: S(['light', 'bio', 'mech']), + worker: true, + weapons: [{ name: 'Blowtorch', dmg: 5, cd: 1.07, range: 0.1, targets: 'ground', melee: true, fx: 'torch' }], + abilities: ['gather', 'return', 'repair', 'build', 'build2'], + }, + { + id: 'ranger', name: 'Ranger', race: 'ironpaw', kind: 'unit', order: 1, + desc: 'Black bear rifleman. Hits ground and air. Can use Honey Rush once researched.', + cost: { m: 50, g: 0 }, time: 18, supply: 1, cargoSize: 1, + hp: 45, armor: 0, speed: 3.15, radius: 8, sight: 9, attrs: S(['light', 'bio']), armorUpg: 'inf_a', + weapons: [{ name: 'Bramble Rifle', dmg: 6, cd: 0.61, range: 5, targets: 'both', upg: 'inf_w', fx: 'rifle' }], + abilities: ['honeyrush'], + }, + { + id: 'grizzly', name: 'Grizzly', race: 'ironpaw', kind: 'unit', order: 2, + desc: 'Armored grizzly. Sticky sap shells slow their targets; strong against armored ground.', + cost: { m: 100, g: 25 }, time: 21, supply: 2, requires: ['academy'], cargoSize: 1, + hp: 125, armor: 1, speed: 3.15, radius: 12, sight: 10, attrs: S(['armored', 'bio']), armorUpg: 'inf_a', + weapons: [{ + name: 'Sap Launcher', dmg: 10, bonus: { armored: 10 }, cd: 1.07, range: 6, targets: 'ground', + upg: 'inf_w', upgDmg: 1, upgBonus: 1, proj: 'sap', projSpeed: 22, slow: { pct: 0.5, dur: 1.07 }, fx: 'sap', + }], + abilities: ['honeyrush'], + }, + { + id: 'medic', name: 'Honey Medic', race: 'ironpaw', kind: 'unit', order: 3, + desc: 'Sun bear field medic. Patches up nearby biological units with honey bandages, using energy.', + cost: { m: 50, g: 25 }, time: 21, supply: 1, requires: ['academy'], cargoSize: 1, + hp: 60, armor: 1, speed: 3.15, radius: 8, sight: 9, attrs: S(['light', 'bio']), armorUpg: 'inf_a', + energy: 200, startEnergy: 50, + abilities: ['bandage'], + }, + { + id: 'blazerider', name: 'Blaze Rider', race: 'ironpaw', kind: 'unit', order: 4, + desc: 'Sloth bear on a steam bike. Its flamer scorches every enemy in a short line, deadly to light units. Can hop off to fight as a Blaze Brute.', + cost: { m: 100, g: 0 }, time: 21, supply: 2, cargoSize: 2, + hp: 90, armor: 0, speed: 5.95, radius: 11, sight: 10, attrs: S(['light', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Steam Flamer', dmg: 8, bonus: { light: 6 }, cd: 1.79, range: 5, targets: 'ground', + upg: 'veh_w', upgDmg: 1, upgBonus: 1, splash: { r: [0.6], pct: [1] }, fx: 'flame', + }], + abilities: ['hopoff'], + }, + { + id: 'blazebrute', name: 'Blaze Brute', race: 'ironpaw', kind: 'unit', order: 4.5, base: 'blazerider', + desc: 'The Blaze Rider on foot in steam armor. Short-range flame paws roast clumps of light units; Honey Medics can heal it.', + cost: { m: 100, g: 0 }, time: 21, supply: 2, cargoSize: 2, + hp: 135, armor: 0, speed: 3.15, radius: 11, sight: 10, attrs: S(['light', 'bio', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Inferno Paws', dmg: 18, bonus: { light: 12 }, cd: 1.43, range: 2, targets: 'ground', + upg: 'veh_w', upgDmg: 2, upgBonus: 1, splash: { r: [1.1], pct: [1] }, fx: 'flame', + }], + abilities: ['hopon'], + }, + { + id: 'sapper', name: 'Sapper', race: 'ironpaw', kind: 'unit', order: 5, + desc: 'Sun bear with a powder keg. Digs in to hide, then lobs the keg at the first enemy unit that comes close.', + cost: { m: 75, g: 25 }, time: 21, supply: 2, cargoSize: 2, + hp: 90, armor: 0, speed: 3.94, radius: 7, sight: 7, attrs: S(['light', 'mech']), armorUpg: 'veh_a', + abilities: ['digin'], + }, + { + id: 'sapper_b', name: 'Sapper (Dug In)', race: 'ironpaw', kind: 'unit', order: 5.5, base: 'sapper', + desc: 'Hidden under the dirt until detected. Its keg hits the target for 125 and splashes everything around it, friends included. Reloads in 20 seconds.', + cost: { m: 75, g: 25 }, time: 21, supply: 2, cloaked: true, burrowed: true, + hp: 90, armor: 0, speed: 0, radius: 7, sight: 7, attrs: S(['light', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Powder Keg', dmg: 125, cd: 20, range: 5, targets: 'both', unitsOnly: true, proj: 'keg', projSpeed: 16, + splash: { r: [1.75], pct: [0.32], friendly: true }, fx: 'keg', + }], + abilities: ['digout'], + }, + { + id: 'kodiak', name: 'Kodiak', race: 'ironpaw', kind: 'unit', order: 6, turret: true, + desc: 'A huge steam-plated Kodiak with a cannon on its back. Braces for devastating long-range splash artillery.', + cost: { m: 150, g: 125 }, time: 32, supply: 3, cargoSize: 4, + hp: 175, armor: 1, speed: 3.15, radius: 18, sight: 11, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Back Cannons', dmg: 15, bonus: { armored: 10 }, cd: 1.04, range: 7, targets: 'ground', + upg: 'veh_w', upgDmg: 2, upgBonus: 1, fx: 'cannon', + }], + abilities: ['brace'], + }, + { + id: 'kodiak_b', name: 'Kodiak (Braced)', race: 'ironpaw', kind: 'unit', order: 6.5, base: 'kodiak', turret: true, + desc: 'Dug in and braced. Long range splash artillery that also hurts friendly units. Cannot move.', + cost: { m: 150, g: 125 }, time: 32, supply: 3, + hp: 175, armor: 1, speed: 0, radius: 18, sight: 11, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Boulder Shells', dmg: 40, bonus: { armored: 30 }, cd: 2.14, range: 13, minRange: 2, targets: 'ground', + upg: 'veh_w', upgDmg: 4, upgBonus: 1, splash: { r: [0.47, 0.78, 1.25], pct: [1, 0.5, 0.25], friendly: true }, + fx: 'boulder', + }], + abilities: ['unbrace'], + }, + { + id: 'tracker', name: 'Tracker', race: 'ironpaw', kind: 'unit', order: 7, + desc: 'Brown bear in a steam buggy. Locks onto a target and keeps pounding it while kiting at speed.', + cost: { m: 150, g: 100 }, time: 32, supply: 3, cargoSize: 4, turret: true, + hp: 160, armor: 1, speed: 4.13, radius: 13, sight: 11, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [{ name: 'Paw Cannon', dmg: 14, cd: 0.8, range: 5, targets: 'both', upg: 'veh_w', fx: 'autocannon' }], + abilities: ['lockon'], + }, + { + id: 'ironhide', name: 'Ironhide', race: 'ironpaw', kind: 'unit', order: 8, requires: ['armory'], + desc: 'Spectacled bear in a steam battle suit. Autocannons for ground targets and long-range missiles against air.', + cost: { m: 125, g: 50 }, time: 30, supply: 2, cargoSize: 2, + hp: 150, armor: 1, speed: 3.15, radius: 16, sight: 10, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [ + { name: 'Steam Autocannons', dmg: 12, cd: 1.0, range: 6, targets: 'ground', upg: 'veh_w', fx: 'autocannon' }, + { + name: 'Skyhook Missiles', dmg: 10, hits: 2, bonus: { armored: 4 }, cd: 1.6, range: 9, targets: 'air', + upg: 'veh_w', upgDmg: 1, upgBonus: 1, proj: 'missile', projSpeed: 16, fx: 'missile', + }, + ], + }, + { + id: 'barnstormer', name: 'Barnstormer', race: 'ironpaw', kind: 'unit', order: 9, + desc: 'Bear ace in a biplane. Long-range rockets against air; strafes ground targets.', + cost: { m: 150, g: 75 }, time: 30, supply: 2, air: true, + hp: 135, armor: 0, speed: 3.85, radius: 13, sight: 10, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [ + { + name: 'Prop Rockets', dmg: 10, hits: 2, bonus: { armored: 4 }, cd: 2.0, range: 9, targets: 'air', + upg: 'ship_w', upgDmg: 1, upgBonus: 1, proj: 'missile', projSpeed: 16, fx: 'missile', + }, + { name: 'Strafing Guns', dmg: 12, cd: 1.4, range: 5, targets: 'ground', upg: 'ship_w', fx: 'guns' }, + ], + }, + { + id: 'hoverbear', name: 'Hoverbear', race: 'ironpaw', kind: 'unit', order: 10, + desc: 'Twin-rotor gunship. Flak pods shred groups of flyers; anchors over a zone to pound any ground unit inside it.', + cost: { m: 150, g: 125 }, time: 43, supply: 3, air: true, + hp: 180, armor: 0, speed: 4.72, radius: 14, sight: 10, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Flak Pods', dmg: 5, hits: 2, cd: 1.29, range: 5, targets: 'air', upg: 'ship_w', + proj: 'missile', projSpeed: 18, splash: { r: [1.2], pct: [1] }, fx: 'missile', + }], + abilities: ['anchor'], + }, + { + id: 'hoverbear_a', name: 'Hoverbear (Anchored)', race: 'ironpaw', kind: 'unit', order: 10.5, base: 'hoverbear', + desc: 'Anchored over a zone. Its cannon hits any ground unit inside the circle hard. Cannot move or attack air.', + cost: { m: 150, g: 125 }, time: 43, supply: 3, air: true, + hp: 180, armor: 0, speed: 0, radius: 14, sight: 12, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + weapons: [{ + name: 'Anchor Cannon', dmg: 75, cd: 1.14, range: 10, zone: 5, targets: 'ground', unitsOnly: true, + upg: 'ship_w', upgDmg: 5, fx: 'cannon', + }], + abilities: ['liftanchor'], + }, + { + id: 'bearloon', name: 'Bearloon', race: 'ironpaw', kind: 'unit', order: 11, + desc: 'Bear-shaped hot-air balloon with a roomy basket. Carries 8 slots: infantry take 1, riders and battle suits 2, Kodiaks 4.', + cost: { m: 100, g: 100 }, time: 36, supply: 2, air: true, + hp: 150, armor: 1, speed: 3.5, radius: 18, sight: 8, attrs: S(['armored', 'mech']), armorUpg: 'veh_a', + cargo: 8, + }, + { + id: 'lookout', name: 'Lookout', race: 'ironpaw', kind: 'unit', order: 12, + requires: ['sciencelab'], + desc: 'Gyrocopter with a spyglass. Detects hidden units, wraps allies in a Shield Bubble and drains shields and energy with Static Burst.', + cost: { m: 100, g: 150 }, time: 36, supply: 2, air: true, + hp: 140, armor: 1, speed: 3.85, radius: 15, sight: 11, attrs: S(['light', 'mech']), armorUpg: 'veh_a', + energy: 200, startEnergy: 50, detector: 11, + abilities: ['bubble', 'staticburst'], + }, + { + id: 'ironmama', name: 'Iron Mama', race: 'ironpaw', kind: 'unit', order: 13, + requires: ['sciencelab'], + desc: 'Armored flagship with a mama bear figurehead. Broadside batteries hit ground and air; the Grand Cannon obliterates a single target.', + cost: { m: 400, g: 300 }, time: 64, supply: 6, air: true, + hp: 550, armor: 3, speed: 2.62, radius: 36, sight: 12, attrs: S(['armored', 'mech', 'massive']), armorUpg: 'veh_a', + energy: 200, startEnergy: 50, + weapons: [{ name: 'Broadside Batteries', dmg: 8, cd: 0.16, range: 6, targets: 'both', upg: 'ship_w', fx: 'broadside' }], + abilities: ['grandcannon'], + }, + + // ---------------------------------------------------------------- buildings + { + id: 'lodge', name: 'Grand Lodge', race: 'ironpaw', kind: 'building', order: 0, buildPage: 1, + desc: 'Main base. Trains Hardhats, accepts resources, and can sniff out any location.', + cost: { m: 400, g: 0 }, time: 71, provides: 15, size: [5, 5], + hp: 1500, armor: 1, sight: 11, attrs: S(['armored', 'mech', 'structure']), + produces: ['hardhat'], depot: true, townHall: true, energy: 200, startEnergy: 50, + abilities: ['sniff'], + }, + { + id: 'larder', name: 'Larder', race: 'ironpaw', kind: 'building', order: 1, buildPage: 1, + desc: 'Stocked with salmon and honey. Provides 8 supply.', + cost: { m: 100, g: 0 }, time: 21, provides: 8, size: [2, 2], + hp: 400, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + }, + { + id: 'refinery', name: 'Refinery', race: 'ironpaw', kind: 'building', order: 2, buildPage: 1, + desc: 'Built on a gas geyser so Hardhats can harvest gas.', + cost: { m: 75, g: 0 }, time: 21, size: [3, 3], refinery: true, + hp: 500, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + }, + { + id: 'bunkhouse', name: 'Bunkhouse', race: 'ironpaw', kind: 'building', order: 3, buildPage: 1, + requires: ['larder'], + desc: 'Trains infantry.', + cost: { m: 150, g: 0 }, time: 46, size: [3, 3], + hp: 1000, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + produces: ['ranger', 'grizzly', 'medic'], + }, + { + id: 'workshop', name: 'Workshop', race: 'ironpaw', kind: 'building', order: 4, buildPage: 1, + requires: ['lodge'], + desc: 'Researches infantry weapons and armor. Required for Flak Turrets.', + cost: { m: 125, g: 0 }, time: 25, size: [3, 3], + hp: 850, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + researches: ['inf_w', 'inf_a'], + }, + { + id: 'guntower', name: 'Gun Tower', race: 'ironpaw', kind: 'building', order: 5, buildPage: 1, + requires: ['bunkhouse'], + desc: 'Defensive tower that fires on ground units.', + cost: { m: 125, g: 0 }, time: 25, size: [2, 2], + hp: 350, armor: 1, sight: 10, attrs: S(['armored', 'mech', 'structure']), + weapons: [{ name: 'Heavy Autogun', dmg: 12, cd: 0.9, range: 7, targets: 'ground', fx: 'autocannon' }], + }, + { + id: 'bunker', name: 'Bunker', race: 'ironpaw', kind: 'building', order: 6, buildPage: 1, + requires: ['bunkhouse'], + desc: 'Log-and-sandbag emplacement. Up to 4 infantry can take cover inside and fire out with +1 range.', + cost: { m: 100, g: 0 }, time: 21, size: [3, 2], + hp: 400, armor: 1, sight: 10, attrs: S(['armored', 'mech', 'structure']), + cargo: 4, cargoBio: true, bunker: 1, + }, + { + id: 'flakturret', name: 'Flak Turret', race: 'ironpaw', kind: 'building', order: 7, buildPage: 1, + requires: ['workshop'], + desc: 'Anti-air defense. Detects hidden units.', + cost: { m: 100, g: 0 }, time: 18, size: [2, 2], + hp: 250, armor: 0, sight: 11, detector: 11, attrs: S(['armored', 'mech', 'structure']), + weapons: [{ + name: 'Flak Rockets', dmg: 12, hits: 2, cd: 0.86, range: 7, targets: 'air', proj: 'missile', projSpeed: 18, fx: 'missile', + }], + }, + { + id: 'academy', name: 'Academy', race: 'ironpaw', kind: 'building', order: 8, buildPage: 1, + requires: ['bunkhouse'], + desc: 'Unlocks Grizzlies and Honey Medics. Researches Honey Rush and Thick Fur.', + cost: { m: 150, g: 0 }, time: 25, size: [3, 3], + hp: 600, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + researches: ['honeyrush', 'thickfur'], + }, + { + id: 'steamworks', name: 'Steamworks', race: 'ironpaw', kind: 'building', order: 9, buildPage: 2, + requires: ['bunkhouse'], + desc: 'Builds steam vehicles and war bears.', + cost: { m: 150, g: 100 }, time: 43, size: [3, 3], + hp: 1250, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + produces: ['blazerider', 'sapper', 'tracker', 'kodiak', 'ironhide'], + }, + { + id: 'armory', name: 'Armory', race: 'ironpaw', kind: 'building', order: 10, buildPage: 2, + requires: ['steamworks'], + desc: 'Unlocks Ironhides and lets Blaze Riders hop off. Researches vehicle and airship upgrades.', + cost: { m: 150, g: 50 }, time: 46, size: [3, 3], + hp: 750, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + researches: ['veh_w', 'veh_a', 'ship_w'], + }, + { + id: 'airfield', name: 'Airfield', race: 'ironpaw', kind: 'building', order: 11, buildPage: 2, + requires: ['steamworks'], + desc: 'Builds aircraft: Barnstormers, Hoverbears, Bearloons, Lookouts and Iron Mamas.', + cost: { m: 150, g: 100 }, time: 36, size: [3, 3], + hp: 1300, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + produces: ['barnstormer', 'hoverbear', 'bearloon', 'lookout', 'ironmama'], + }, + { + id: 'sciencelab', name: 'Science Lab', race: 'ironpaw', kind: 'building', order: 12, buildPage: 2, + requires: ['airfield'], + desc: 'Unlocks Lookouts and Iron Mamas. Researches Static Burst and the Grand Cannon.', + cost: { m: 150, g: 150 }, time: 46, size: [3, 3], + hp: 750, armor: 1, sight: 9, attrs: S(['armored', 'mech', 'structure']), + researches: ['staticburst', 'grandcannon'], + }, +]; diff --git a/gh-pages/bear-war/src/game/data/neutral.ts b/gh-pages/bear-war/src/game/data/neutral.ts new file mode 100644 index 00000000..c1cc2603 --- /dev/null +++ b/gh-pages/bear-war/src/game/data/neutral.ts @@ -0,0 +1,12 @@ +import type { EntityDef } from './types.ts'; + +export const NEUTRAL: EntityDef[] = [ + { + id: 'mineral', name: 'Mineral Field', race: 'neutral', kind: 'mineral', desc: 'Workers harvest 5 minerals per trip.', + cost: { m: 0, g: 0 }, time: 0, size: [2, 1], hp: 1, armor: 0, sight: 0, attrs: ['structure'], + }, + { + id: 'geyser', name: 'Gas Geyser', race: 'neutral', kind: 'geyser', desc: 'Build a refinery here to harvest gas.', + cost: { m: 0, g: 0 }, time: 0, size: [3, 3], hp: 1, armor: 0, sight: 0, attrs: ['structure'], + }, +]; diff --git a/gh-pages/bear-war/src/game/data/research.ts b/gh-pages/bear-war/src/game/data/research.ts new file mode 100644 index 00000000..6091b851 --- /dev/null +++ b/gh-pages/bear-war/src/game/data/research.ts @@ -0,0 +1,78 @@ +import type { Cost, ResearchDef } from './types.ts'; + +const tiers = (a: number, b: number, c: number): Cost[] => [ + { m: a, g: a }, + { m: b, g: b }, + { m: c, g: c }, +]; +const T3 = [90, 110, 130]; + +export const RESEARCH: ResearchDef[] = [ + // ---------------- Ironpaw + { id: 'inf_w', name: 'Infantry Weapons', race: 'ironpaw', icon: 'up_weapon', + desc: 'Increases the damage of infantry weapons.', costs: tiers(100, 175, 250), times: T3, reqs: [[], ['armory'], ['armory']] }, + { id: 'inf_a', name: 'Infantry Armor', race: 'ironpaw', icon: 'up_armor', + desc: 'Increases the armor of infantry.', costs: tiers(100, 175, 250), times: T3, reqs: [[], ['armory'], ['armory']] }, + { id: 'veh_w', name: 'Vehicle Weapons', race: 'ironpaw', icon: 'up_weapon', + desc: 'Increases the damage of steam vehicles and war bears.', costs: tiers(100, 175, 250), times: T3 }, + { id: 'veh_a', name: 'Steam Plating', race: 'ironpaw', icon: 'up_armor', + desc: 'Increases the armor of steam vehicles, war bears and aircraft.', costs: tiers(100, 175, 250), times: T3 }, + { id: 'ship_w', name: 'Aircraft Weapons', race: 'ironpaw', icon: 'up_air', + desc: 'Increases the damage of aircraft weapons.', costs: tiers(100, 175, 250), times: T3 }, + { id: 'honeyrush', name: 'Honey Rush', race: 'ironpaw', icon: 'honeyrush', + desc: 'Rangers and Grizzlies can trade health for +50% attack and movement speed.', costs: [{ m: 100, g: 100 }], times: [70] }, + { id: 'thickfur', name: 'Thick Fur', race: 'ironpaw', icon: 'thickfur', + desc: 'Rangers gain +10 maximum health.', costs: [{ m: 100, g: 100 }], times: [57] }, + { id: 'staticburst', name: 'Static Burst', race: 'ironpaw', icon: 'staticburst', + desc: 'Lookouts can drain shields and energy in an area.', costs: [{ m: 150, g: 150 }], times: [79] }, + { id: 'grandcannon', name: 'Grand Cannon', race: 'ironpaw', icon: 'grandcannon', + desc: 'Iron Mamas can fire a 240-damage Grand Cannon.', costs: [{ m: 150, g: 150 }], times: [79] }, + + // ---------------- Honey Horde + { id: 'claws', name: 'Sharper Claws', race: 'horde', icon: 'up_claw', + desc: 'Increases the damage of Horde melee attacks.', costs: tiers(100, 150, 200), times: T3, reqs: [[], ['royalhive'], ['elderhive']] }, + { id: 'stings', name: 'Barbed Stings', race: 'horde', icon: 'up_sting', + desc: 'Increases the damage of Horde ranged attacks.', costs: tiers(100, 150, 200), times: T3, reqs: [[], ['royalhive'], ['elderhive']] }, + { id: 'hide', name: 'Thick Hide', race: 'horde', icon: 'up_hide', + desc: 'Increases the armor of Horde ground units.', costs: tiers(150, 225, 300), times: T3, reqs: [[], ['royalhive'], ['elderhive']] }, + { id: 'flyer_w', name: 'Flyer Stings', race: 'horde', icon: 'up_air', + desc: 'Increases the damage of Horde flyers.', costs: tiers(100, 175, 250), times: T3, reqs: [[], [], ['elderhive']] }, + { id: 'flyer_a', name: 'Flyer Hide', race: 'horde', icon: 'up_hide', + desc: 'Increases the armor of Horde flyers.', costs: tiers(150, 225, 300), times: T3, reqs: [[], [], ['elderhive']] }, + { id: 'zoomies', name: 'Zoomies', race: 'horde', icon: 'speed', + desc: 'Scrappers move 60% faster.', costs: [{ m: 100, g: 100 }], times: [79] }, + { id: 'biggerwings', name: 'Bigger Wings', race: 'horde', icon: 'speed', + desc: 'Bumblebears fly almost three times faster.', costs: [{ m: 100, g: 100 }], times: [43], reqs: [['royalhive']] }, + { id: 'pouch', name: 'Cargo Pouch', race: 'horde', icon: 'load', + desc: 'Bumblebears can carry ground units (8 slots).', costs: [{ m: 200, g: 200 }], times: [79], reqs: [['royalhive']] }, + { id: 'busybees', name: 'Busy Bees', race: 'horde', icon: 'speed', + desc: 'Buzzbombs move faster.', costs: [{ m: 100, g: 100 }], times: [79] }, + { id: 'longstingers', name: 'Long Stingers', race: 'horde', icon: 'range', + desc: 'Stingers gain +1 attack range.', costs: [{ m: 100, g: 100 }], times: [71] }, + { id: 'boundinggait', name: 'Bounding Gait', race: 'horde', icon: 'speed', + desc: 'Stingers move 25% faster.', costs: [{ m: 100, g: 100 }], times: [71] }, + { id: 'combarmor', name: 'Honeycomb Armor', race: 'horde', icon: 'up_hide', + desc: 'Cave Titans gain +2 armor.', costs: [{ m: 150, g: 150 }], times: [79] }, + { id: 'stingswarm', name: 'Sting Swarm', race: 'horde', icon: 'stingswarm', + desc: 'Swarmcallers can cast Sting Swarm.', costs: [{ m: 150, g: 150 }], times: [79] }, + + // ---------------- Starborn + { id: 'ground_w', name: 'Ground Weapons', race: 'starborn', icon: 'up_weapon', + desc: 'Increases the damage of ground units.', costs: tiers(100, 150, 200), times: T3, reqs: [[], ['sagelibrary'], ['sagelibrary']] }, + { id: 'ground_a', name: 'Ground Armor', race: 'starborn', icon: 'up_armor', + desc: 'Increases the armor of ground units.', costs: tiers(100, 150, 200), times: T3, reqs: [[], ['sagelibrary'], ['sagelibrary']] }, + { id: 'aurora', name: 'Aurora Shields', race: 'starborn', icon: 'up_shield', + desc: 'Increases the shield armor of all units and structures.', costs: tiers(150, 225, 300), times: T3, reqs: [[], ['sagelibrary'], ['sagelibrary']] }, + { id: 'air_w', name: 'Air Weapons', race: 'starborn', icon: 'up_air', + desc: 'Increases the damage of flyers.', costs: tiers(100, 175, 250), times: T3, reqs: [[], ['greatbeacon'], ['greatbeacon']] }, + { id: 'air_a', name: 'Air Armor', race: 'starborn', icon: 'up_armor', + desc: 'Increases the armor of flyers.', costs: tiers(150, 225, 300), times: T3, reqs: [[], ['greatbeacon'], ['greatbeacon']] }, + { id: 'pounce', name: 'Pounce', race: 'starborn', icon: 'pounce', + desc: 'Spirit Bears move faster and pounce onto targets.', costs: [{ m: 100, g: 100 }], times: [100] }, + { id: 'moonstep', name: 'Moonstep', race: 'starborn', icon: 'moonstep', + desc: 'Moon Bears can teleport a short distance.', costs: [{ m: 150, g: 150 }], times: [90] }, + { id: 'starfall', name: 'Starfall', race: 'starborn', icon: 'starfall', + desc: 'Panda Sages can cast Starfall.', costs: [{ m: 200, g: 200 }], times: [79] }, + { id: 'longgaze', name: 'Long Gaze', race: 'starborn', icon: 'range', + desc: 'Polar Colossi gain +2 attack range.', costs: [{ m: 150, g: 150 }], times: [71] }, +]; diff --git a/gh-pages/bear-war/src/game/data/starborn.ts b/gh-pages/bear-war/src/game/data/starborn.ts new file mode 100644 index 00000000..a112e1db --- /dev/null +++ b/gh-pages/bear-war/src/game/data/starborn.ts @@ -0,0 +1,221 @@ +import type { EntityDef } from './types.ts'; + +// STARBORN: mystic polar, spirit and moon bears who draw on starlight. Snowcubs call +// structures down from the sky and are free at once while they take shape. Structures need +// a Star Totem's glow. Every Starborn unit and building wears a regenerating aurora shield. + +const S = (attrs: EntityDef['attrs']) => attrs; + +export const STARBORN: EntityDef[] = [ + // ---------------------------------------------------------------- units + { + id: 'snowcub', name: 'Snowcub', race: 'starborn', kind: 'unit', order: 0, + desc: 'Worker. A little polar cub with a star on its brow: gathers resources and calls structures down from the sky, then is free to go.', + cost: { m: 50, g: 0 }, time: 12, supply: 1, cargoSize: 1, + hp: 20, shields: 20, armor: 0, speed: 3.94, radius: 7, sight: 8, attrs: S(['light', 'mech']), + worker: true, + weapons: [{ name: 'Star Spark', dmg: 5, cd: 1.07, range: 0.1, targets: 'ground', melee: true, fx: 'starspark' }], + abilities: ['gather', 'return', 'build', 'build2'], + }, + { + id: 'spiritbear', name: 'Spirit Bear', race: 'starborn', kind: 'unit', order: 1, + desc: 'White spirit bear with blazing aurora claws. Learns to Pounce once researched.', + cost: { m: 100, g: 0 }, time: 27, supply: 2, cargoSize: 2, + hp: 100, shields: 50, armor: 1, speed: 3.15, radius: 11, sight: 9, attrs: S(['light', 'bio']), armorUpg: 'ground_a', + weapons: [{ name: 'Aurora Claws', dmg: 8, hits: 2, cd: 0.86, range: 0.1, targets: 'ground', melee: true, upg: 'ground_w', fx: 'blade' }], + abilities: ['pounce'], + }, + { + id: 'moonbear', name: 'Moon Bear', race: 'starborn', kind: 'unit', order: 2, requires: ['spiritlodge'], + desc: 'Agile bear with a glowing crescent that fires moon orbs at ground and air. Can Moonstep short distances.', + cost: { m: 125, g: 50 }, time: 30, supply: 2, cargoSize: 2, + hp: 90, shields: 80, armor: 1, speed: 4.13, radius: 14, sight: 10, attrs: S(['armored', 'mech']), armorUpg: 'ground_a', + weapons: [{ + name: 'Moon Orbs', dmg: 14, bonus: { armored: 5 }, cd: 1.34, range: 6, targets: 'both', upg: 'ground_w', upgDmg: 1, upgBonus: 1, + proj: 'moonorb', projSpeed: 18, fx: 'moonorb', + }], + abilities: ['moonstep'], + }, + { + id: 'glacierbear', name: 'Glacier Bear', race: 'starborn', kind: 'unit', order: 3, requires: ['spiritlodge'], + desc: 'Blue-grey raider whose ice glaives shred light units. Its spirit runs ahead, and the bear follows in a flash.', + cost: { m: 100, g: 25 }, time: 30, supply: 2, cargoSize: 2, + hp: 70, shields: 70, armor: 1, speed: 3.5, radius: 10, sight: 9, attrs: S(['light', 'bio']), armorUpg: 'ground_a', + weapons: [{ + name: 'Frost Glaive', dmg: 10, bonus: { light: 12 }, cd: 1.61, range: 4, targets: 'ground', upg: 'ground_w', upgDmg: 1, upgBonus: 1, + proj: 'frost', projSpeed: 20, fx: 'frost', + }], + abilities: ['spiritwalk'], + }, + { + id: 'pandasage', name: 'Panda Sage', race: 'starborn', kind: 'unit', order: 4, requires: ['sagelibrary'], + desc: 'Floating panda mystic. Starfall tears apart everything caught beneath it, friend or foe.', + cost: { m: 50, g: 150 }, time: 39, supply: 2, cargoSize: 2, + hp: 40, shields: 40, armor: 0, speed: 2.62, radius: 10, sight: 10, attrs: S(['light', 'bio', 'psionic']), armorUpg: 'ground_a', + energy: 200, startEnergy: 50, + abilities: ['starfall'], + }, + { + id: 'shadowpaw', name: 'Shadowpaw', race: 'starborn', kind: 'unit', order: 5, requires: ['sagelibrary'], + desc: 'Assassin wrapped in shadow. Always hidden unless detected.', + cost: { m: 125, g: 125 }, time: 39, supply: 2, cloaked: true, cargoSize: 2, + hp: 40, shields: 80, armor: 1, speed: 3.94, radius: 10, sight: 8, attrs: S(['light', 'bio', 'psionic']), armorUpg: 'ground_a', + weapons: [{ name: 'Shadow Scythe', dmg: 45, cd: 1.21, range: 0.1, targets: 'ground', melee: true, upg: 'ground_w', upgDmg: 5, fx: 'scythe' }], + }, + { + id: 'littledipper', name: 'Little Dipper', race: 'starborn', kind: 'unit', order: 6, air: true, + desc: 'A tiny bear of stars, nearly invisible. Scouts and detects hidden units.', + cost: { m: 25, g: 75 }, time: 21, supply: 1, cloaked: true, + hp: 40, shields: 20, armor: 0, speed: 2.63, radius: 8, sight: 11, detector: 11, attrs: S(['light', 'mech']), armorUpg: 'air_a', + }, + { + id: 'floebear', name: 'Floe Bear', race: 'starborn', kind: 'unit', order: 7, air: true, + desc: 'Polar bear steering a flying ice floe. Carries 8 slots: Snowcubs take 1, warriors 2, Polar Colossi 4.', + cost: { m: 200, g: 0 }, time: 36, supply: 2, + hp: 80, shields: 60, armor: 1, speed: 3.5, radius: 17, sight: 8, attrs: S(['armored', 'mech']), armorUpg: 'air_a', + cargo: 8, + }, + { + id: 'snowballer', name: 'Snowballer', race: 'starborn', kind: 'unit', order: 8, requires: ['sagelibrary'], + desc: 'Polar bear with a crackling star-snowball. Rolls it into the enemy, where it bursts and hurts every ground unit nearby, friend or foe.', + cost: { m: 150, g: 150 }, time: 36, supply: 3, cargoSize: 4, + hp: 100, shields: 100, armor: 1, speed: 3.15, radius: 14, sight: 9, attrs: S(['armored', 'mech']), armorUpg: 'ground_a', + abilities: ['snowball'], + }, + { + id: 'polarcolossus', name: 'Polar Colossus', race: 'starborn', kind: 'unit', order: 9, + desc: 'Towering polar bear in starmetal armor. Its eye beams scorch groups of ground units.', + cost: { m: 300, g: 200 }, time: 54, supply: 6, cargoSize: 4, + hp: 200, shields: 150, armor: 1, speed: 3.15, radius: 30, sight: 10, attrs: S(['armored', 'mech', 'massive']), armorUpg: 'ground_a', + weapons: [{ + name: 'Eye Beams', dmg: 12, hits: 2, bonus: { light: 5 }, cd: 1.07, range: 7, targets: 'ground', upg: 'ground_w', upgDmg: 1, upgBonus: 1, + splash: { r: [0.9], pct: [1] }, fx: 'eyebeam', + }], + }, + { + id: 'starwing', name: 'Starwing', race: 'starborn', kind: 'unit', order: 10, air: true, + desc: 'Spirit bear on wings of light. Fast air superiority; attacks air units only.', + cost: { m: 150, g: 100 }, time: 25, supply: 2, + hp: 120, shields: 60, armor: 0, speed: 5.95, radius: 12, sight: 10, attrs: S(['light', 'mech']), armorUpg: 'air_a', + weapons: [{ name: 'Prism Lances', dmg: 5, hits: 2, bonus: { light: 5 }, cd: 0.79, range: 5, targets: 'air', upg: 'air_w', fx: 'prism' }], + }, + { + id: 'seer', name: 'Seer', race: 'starborn', kind: 'unit', order: 11, air: true, + desc: 'Spectacled bear on a floating star disc. Fast raider: lights its Sunlance to burn light ground units, and casts Revelation to expose an area.', + cost: { m: 150, g: 150 }, time: 37, supply: 3, + hp: 100, shields: 60, armor: 0, speed: 5.6, radius: 12, sight: 10, attrs: S(['armored', 'mech', 'psionic']), armorUpg: 'air_a', + energy: 200, startEnergy: 50, + abilities: ['sunlanceon', 'revelation'], + }, + { + id: 'seer_b', name: 'Seer (Sunlance)', race: 'starborn', kind: 'unit', order: 11.5, air: true, base: 'seer', drain: 2.75, + desc: 'Sunlance lit: a searing beam against ground targets that burns energy every second.', + cost: { m: 150, g: 150 }, time: 37, supply: 3, + hp: 100, shields: 60, armor: 0, speed: 5.6, radius: 12, sight: 10, attrs: S(['armored', 'mech', 'psionic']), armorUpg: 'air_a', + energy: 200, startEnergy: 50, + weapons: [{ name: 'Sunlance', dmg: 15, bonus: { light: 7 }, cd: 0.61, range: 4, targets: 'ground', upg: 'air_w', fx: 'sunlance' }], + abilities: ['sunlanceoff', 'revelation'], + }, + { + id: 'greatbear', name: 'Great Bear', race: 'starborn', kind: 'unit', order: 12, air: true, + requires: ['greatbeacon'], + desc: 'The Great Bear constellation come down to fight. Star bolts at long range against ground; comet lances outrange every flyer.', + cost: { m: 350, g: 250 }, time: 64, supply: 6, + hp: 300, shields: 150, armor: 2, speed: 2.62, radius: 38, sight: 12, attrs: S(['armored', 'mech', 'massive']), armorUpg: 'air_a', + weapons: [ + { + name: 'Comet Lance', dmg: 30, bonus: { massive: 22 }, cd: 2.6, range: 14, targets: 'air', upg: 'air_w', upgDmg: 3, + proj: 'starbolt', projSpeed: 18, fx: 'starbolt', + }, + { + name: 'Star Bolt', dmg: 30, bonus: { massive: 15 }, cd: 1.9, range: 10, targets: 'ground', upg: 'air_w', upgDmg: 3, + proj: 'starbolt', projSpeed: 14, fx: 'starbolt', + }, + ], + }, + + // ---------------------------------------------------------------- buildings + { + id: 'startemple', name: 'Star Temple', race: 'starborn', kind: 'building', order: 0, buildPage: 1, + desc: 'Main base. Trains Snowcubs, accepts resources, and can hasten other structures.', + cost: { m: 400, g: 0 }, time: 71, provides: 15, size: [5, 5], + hp: 1000, shields: 1000, armor: 1, sight: 11, attrs: S(['armored', 'structure']), + produces: ['snowcub'], depot: true, townHall: true, energy: 200, startEnergy: 50, + abilities: ['haste'], + }, + { + id: 'startotem', name: 'Star Totem', race: 'starborn', kind: 'building', order: 1, buildPage: 1, + desc: 'Provides 8 supply and a glow that other structures need.', + cost: { m: 100, g: 0 }, time: 18, provides: 8, size: [2, 2], powerRadius: 6.5, + hp: 200, shields: 200, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + }, + { + id: 'dewshrine', name: 'Dew Shrine', race: 'starborn', kind: 'building', order: 2, buildPage: 1, + desc: 'Called down onto a gas geyser so Snowcubs can harvest gas.', + cost: { m: 75, g: 0 }, time: 21, size: [3, 3], refinery: true, + hp: 450, shields: 450, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + }, + { + id: 'moongate', name: 'Moon Gate', race: 'starborn', kind: 'building', order: 3, buildPage: 1, + requires: ['startemple'], needsPower: true, + desc: 'Calls down ground warriors.', + cost: { m: 150, g: 0 }, time: 46, size: [3, 3], + hp: 500, shields: 500, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + produces: ['spiritbear', 'moonbear', 'glacierbear', 'pandasage', 'shadowpaw'], + }, + { + id: 'starforge', name: 'Star Forge', race: 'starborn', kind: 'building', order: 4, buildPage: 1, + requires: ['startemple'], needsPower: true, + desc: 'Researches ground weapons, armor and aurora shields. Required for Star Lanterns.', + cost: { m: 150, g: 0 }, time: 32, size: [3, 3], + hp: 400, shields: 400, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + researches: ['ground_w', 'ground_a', 'aurora'], + }, + { + id: 'starlantern', name: 'Star Lantern', race: 'starborn', kind: 'building', order: 5, buildPage: 1, + requires: ['starforge'], needsPower: true, + desc: 'Defensive lantern that hits ground and air. Detects hidden units.', + cost: { m: 150, g: 0 }, time: 29, size: [2, 2], + hp: 150, shields: 150, armor: 1, sight: 11, detector: 11, attrs: S(['armored', 'structure']), + weapons: [{ name: 'Lantern Bolt', dmg: 20, cd: 0.89, range: 7, targets: 'both', proj: 'lantern', projSpeed: 20, fx: 'lantern' }], + }, + { + id: 'spiritlodge', name: 'Spirit Lodge', race: 'starborn', kind: 'building', order: 6, buildPage: 2, + requires: ['moongate'], needsPower: true, + desc: 'Unlocks Moon Bears, Glacier Bears and advanced structures. Researches air weapons and armor.', + cost: { m: 150, g: 0 }, time: 36, size: [3, 3], + hp: 550, shields: 550, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + researches: ['air_w', 'air_a'], + }, + { + id: 'skyaltar', name: 'Sky Altar', race: 'starborn', kind: 'building', order: 7, buildPage: 2, + requires: ['spiritlodge'], needsPower: true, + desc: 'Calls down flyers.', + cost: { m: 150, g: 150 }, time: 43, size: [3, 3], + hp: 600, shields: 600, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + produces: ['starwing', 'seer', 'greatbear'], + }, + { + id: 'frostworks', name: 'Frost Works', race: 'starborn', kind: 'building', order: 8, buildPage: 2, + requires: ['spiritlodge'], needsPower: true, + desc: 'Builds Little Dippers, Floe Bears, Snowballers and Polar Colossi. Researches Long Gaze.', + cost: { m: 150, g: 100 }, time: 46, size: [3, 3], + hp: 450, shields: 450, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + produces: ['littledipper', 'floebear', 'snowballer', 'polarcolossus'], researches: ['longgaze'], + }, + { + id: 'sagelibrary', name: 'Sage Library', race: 'starborn', kind: 'building', order: 9, buildPage: 2, + requires: ['spiritlodge'], needsPower: true, + desc: 'Unlocks Panda Sages, Shadowpaws and Snowballers. Researches Pounce, Moonstep and Starfall.', + cost: { m: 150, g: 150 }, time: 36, size: [3, 3], + hp: 500, shields: 500, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + researches: ['pounce', 'moonstep', 'starfall'], + }, + { + id: 'greatbeacon', name: 'Great Beacon', race: 'starborn', kind: 'building', order: 10, buildPage: 2, + requires: ['skyaltar'], needsPower: true, + desc: 'Unlocks the Great Bear.', + cost: { m: 300, g: 200 }, time: 43, size: [3, 3], + hp: 500, shields: 500, armor: 1, sight: 9, attrs: S(['armored', 'structure']), + }, +]; diff --git a/gh-pages/bear-war/src/game/data/types.ts b/gh-pages/bear-war/src/game/data/types.ts new file mode 100644 index 00000000..46d9d5aa --- /dev/null +++ b/gh-pages/bear-war/src/game/data/types.ts @@ -0,0 +1,197 @@ +export type RaceId = 'ironpaw' | 'horde' | 'starborn'; +export type Attr = 'light' | 'armored' | 'bio' | 'mech' | 'massive' | 'structure' | 'psionic'; +export type TargetFilter = 'ground' | 'air' | 'both'; +export type DefKind = 'unit' | 'building' | 'mineral' | 'geyser' | 'cub' | 'cocoon'; + +export interface Cost { + m: number; + g: number; +} + +export interface SplashDef { + /** radii in tiles, inner to outer */ + r: number[]; + /** damage fraction for each ring */ + pct: number[]; + /** also damages the attacker's own/allied units */ + friendly?: boolean; +} + +export interface WeaponDef { + name: string; + dmg: number; + /** hits per attack, armor applies to each (e.g. 8x2) */ + hits?: number; + /** bonus damage against attributes */ + bonus?: Partial>; + /** seconds between attacks */ + cd: number; + /** tiles, edge-to-edge */ + range: number; + minRange?: number; + targets: TargetFilter; + /** upgrade line that increases this weapon's damage */ + upg?: string; + upgDmg?: number; + upgBonus?: number; + /** projectile visual; undefined = instant hit */ + proj?: string; + /** projectile speed in tiles per second */ + projSpeed?: number; + splash?: SplashDef; + /** bounce to additional targets (Hornet glaive) */ + bounce?: { n: number; falloff: number; range: number }; + melee?: boolean; + slow?: { pct: number; dur: number }; + /** the attacker dies when it attacks (Buzzbomb) */ + suicide?: boolean; + /** can't attack structures (Sapper) */ + unitsOnly?: boolean; + /** only hits targets within this radius (tiles) of the anchor point (Hoverbear) */ + zone?: number; + /** hits everything along a line of this half-width in tiles (Burrower) */ + line?: number; + /** visual + audio effect id */ + fx: string; +} + +export interface EntityDef { + id: string; + name: string; + race: RaceId | 'neutral'; + kind: DefKind; + desc: string; + cost: Cost; + /** build / train time in seconds */ + time: number; + supply?: number; + provides?: number; + hp: number; + shields?: number; + armor: number; + energy?: number; + startEnergy?: number; + /** tiles per second */ + speed?: number; + /** collision radius in world px (units) */ + radius?: number; + /** footprint in tiles (buildings & resources) */ + size?: [number, number]; + air?: boolean; + /** sight range in tiles */ + sight: number; + attrs: Attr[]; + weapons?: WeaponDef[]; + abilities?: string[]; + /** units this building trains (for cub-producers: units its cubs can morph into) */ + produces?: string[]; + researches?: string[]; + /** building morphs available (Hive -> Royal Hive) */ + morphs?: string[]; + /** unit morphs available (Scrapper -> Buzzbomb) */ + unitMorphs?: string[]; + /** tech requirements: building ids that must exist (completed) */ + requires?: string[]; + /** counts as this building id for requirements (Royal Hive counts as Hive) */ + counts?: string[]; + worker?: boolean; + depot?: boolean; + refinery?: boolean; + townHall?: boolean; + detector?: number; + cloaked?: boolean; + powerRadius?: number; + needsPower?: boolean; + combRadius?: number; + needsComb?: boolean; + spawnsCubs?: boolean; + /** units created per production (Scrappers come in pairs) */ + count?: number; + /** hp regenerated per second (in addition to race regen) */ + regen?: number; + /** transports & bunkers: cargo capacity in slots */ + cargo?: number; + /** cargo slots this unit takes up (unset: can't be carried) */ + cargoSize?: number; + /** only biological units may enter (Bunker) */ + cargoBio?: boolean; + /** research needed before this unit can carry cargo (Bumblebear) */ + cargoReq?: string; + /** units inside fire out of this structure with this much bonus range (tiles) */ + bunker?: number; + /** upgrade line that adds armor */ + armorUpg?: string; + /** morph source for unit morphs */ + morphFrom?: string; + /** alternate mode of this unit id (Kodiak braced) */ + base?: string; + /** other units walk over it */ + burrowed?: boolean; + /** only the turret layer aims (Kodiak) */ + turret?: boolean; + /** timed life in seconds */ + lifetime?: number; + /** energy per second while in this mode, back to `base` at 0 */ + drain?: number; + /** target priority for auto-acquire (higher = preferred) */ + priority?: number; + /** render scale tweak / art variant id */ + art?: string; + /** cannot be selected/attacked directly (internal) */ + hidden?: boolean; + /** build menu page: 1 = basic, 2 = advanced */ + buildPage?: 1 | 2; + /** sort order within menus */ + order?: number; +} + +export interface ResearchDef { + id: string; + name: string; + race: RaceId; + desc: string; + /** one entry per level */ + costs: Cost[]; + times: number[]; + /** extra building requirements per level */ + reqs?: string[][]; + /** icon id */ + icon: string; +} + +export type AbilityTarget = 'none' | 'point' | 'unit' | 'ally' | 'own-building' | 'enemy'; + +export interface AbilityDef { + id: string; + name: string; + desc: string; + target: AbilityTarget; + energy?: number; + hpCost?: number; + cooldown?: number; // seconds + range?: number; // tiles (cast range) + radius?: number; // tiles (effect radius) + duration?: number; // seconds + requires?: string; // research id + /** building tech requirements */ + needs?: string[]; + autocast?: boolean; + icon: string; + /** passive abilities are listed for info only */ + passive?: boolean; + /** switch to def `to` over `time` seconds */ + mode?: { to: string; time: number }; +} + +export interface RaceDef { + id: RaceId; + name: string; + desc: string; + worker: string; + townHall: string; + supplyUnit: string; // building (or unit) that provides supply + refinery: string; + /** construction style */ + build: 'construct' | 'settle' | 'summon'; + color: string; +} diff --git a/gh-pages/bear-war/src/game/entity.ts b/gh-pages/bear-war/src/game/entity.ts new file mode 100644 index 00000000..17305a3a --- /dev/null +++ b/gh-pages/bear-war/src/game/entity.ts @@ -0,0 +1,197 @@ +import type { EntityDef } from './data/types.ts'; +import { TILE } from './constants.ts'; + +export type Order = + | { t: 'move'; x: number; y: number; target: number } + | { t: 'amove'; x: number; y: number } + | { t: 'attack'; target: number } + | { t: 'patrol'; x: number; y: number; x2: number; y2: number } + | { t: 'hold' } + | { t: 'gather'; target: number } + | { t: 'return' } + | { t: 'build'; def: string; tx: number; ty: number } + | { t: 'construct'; target: number } + | { t: 'repair'; target: number } + | { t: 'ability'; ab: string; x: number; y: number; target: number } + /** board the transport or bunker `target` */ + | { t: 'load'; target: number } + /** transport: fly to unit `target` and pick it up */ + | { t: 'pickup'; target: number } + /** transport: fly to (x,y) and drop all cargo */ + | { t: 'unload'; x: number; y: number }; + +export type QueueKind = 'unit' | 'research' | 'morph'; + +export interface QueueItem { + kind: QueueKind; + id: string; + /** research level being researched */ + level: number; + progress: number; + total: number; + started: boolean; + cost: { m: number; g: number }; +} + +export interface Rally { + x: number; + y: number; + target: number; +} + +export class Entity { + id: number; + def: EntityDef; + owner: number; + x: number; + y: number; + px: number; + py: number; + angle = 0; + turret = 0; + r: number; + hp: number; + shields: number; + energy: number; + alive = true; + air: boolean; + isBuilding: boolean; + isResource: boolean; + /** footprint (tiles), for buildings & resources */ + tx = 0; + ty = 0; + tw = 0; + th = 0; + + // ----- structures + built = true; + progress = 0; + builder = 0; + queue: QueueItem[] = []; + rally: Rally | null = null; + powered = true; + cubT = 0; + cubs: number[] = []; + jellyT = 0; + hasteT = 0; + burnAcc = 0; + /** hive structure grown from a forager (refund the forager on cancel) */ + fromWorker = false; + + // ----- resources + amount = 0; + occupiedBy = 0; + /** refinery <-> geyser link */ + link = 0; + + // ----- units + orders: Order[] = []; + /** order sub-state, reset whenever the current order changes */ + ost = 0; + path: number[] | null = null; + pathI = 0; + pathGoalX = 0; + pathGoalY = 0; + pathVer = 0; + repathT = 0; + groupN = 1; + stuckT = 0; + lastProgressX = 0; + lastProgressY = 0; + vx = 0; + vy = 0; + wantMove = false; + target = 0; + cds: number[] = [0, 0]; + holdPos = false; + group = 0; + arrived = false; + /** worker cargo */ + carry = 0; + carryKind = 0; + /** assigned resource (mineral or refinery) */ + res = 0; + mineT = 0; + inside = false; + /** carried unit: the transport or bunker it is in */ + carrier = 0; + /** transports & bunkers: ids of the units inside */ + cargo: number[] = []; + /** ticks until the next unit can be dropped */ + dropT = 0; + lastHit = -100000; + lastAttackTick = -100000; + rushT = 0; + slowT = 0; + slowPct = 0; + bubble = 0; + bubbleT = 0; + /** cub/cocoon: owning hive */ + hive = 0; + morphTo: string | null = null; + morphT = 0; + morphTotal = 0; + morphCost: { m: number; g: number } | null = null; + /** ticks until each cooldown ability can be used again */ + cooldowns: Record = {}; + lockT = 0; + lockTarget = 0; + /** timed life left (ticks); 0 = lives forever */ + lifeT = 0; + /** anchor zone centre (Hoverbear) */ + zoneX = 0; + zoneY = 0; + /** ticks left inside a Pollen Cloud */ + blindT = 0; + channelT = 0; + wanderT = 0; + kills = 0; + spawnTick = 0; + /** ticks this entity has been idle (for UI idle-worker button and AI) */ + idleT = 0; + autocast = true; + + constructor(id: number, def: EntityDef, owner: number, x: number, y: number) { + this.id = id; + this.def = def; + this.owner = owner; + this.x = x; + this.y = y; + this.px = x; + this.py = y; + this.air = !!def.air; + this.isBuilding = def.kind === 'building'; + this.isResource = def.kind === 'mineral' || def.kind === 'geyser'; + if (def.size) { + this.tw = def.size[0]; + this.th = def.size[1]; + this.r = (Math.min(this.tw, this.th) * TILE) / 2; + } else { + this.r = def.radius ?? 8; + } + this.hp = def.hp; + this.shields = def.shields ?? 0; + this.energy = def.startEnergy ?? 0; + this.angle = 1.5707963267948966; // face "down" (towards the camera) + } + + get isUnit(): boolean { + return !this.isBuilding && !this.isResource; + } + get order(): Order | undefined { + return this.orders[0]; + } + /** footprint rect in world px */ + get x0(): number { + return this.tx * TILE; + } + get y0(): number { + return this.ty * TILE; + } + get x1(): number { + return (this.tx + this.tw) * TILE; + } + get y1(): number { + return (this.ty + this.th) * TILE; + } +} diff --git a/gh-pages/bear-war/src/game/game.ts b/gh-pages/bear-war/src/game/game.ts new file mode 100644 index 00000000..8b571a69 --- /dev/null +++ b/gh-pages/bear-war/src/game/game.ts @@ -0,0 +1,617 @@ +// The deterministic game simulation. Advance it with step(); feed it the same commands +// on every client and every client stays in sync. + +import { + CELL, CUB_MAX, MAX_SUPPLY, STARTING_WORKERS, TICK_RATE, TILE, VISION_INTERVAL, COMB_INTERVAL, +} from './constants.ts'; +import { DEFS, MAX_STRUCT_REACH, MAX_UNIT_RADIUS, RACES, satisfies, validateData } from './data/index.ts'; +import type { Cost, EntityDef } from './data/types.ts'; +import { Entity } from './entity.ts'; +import { PathGrid, clearanceFor } from './grid.ts'; +import type { GameMap } from './map/gamemap.ts'; +import { Player, type PlayerSetup } from './player.ts'; +import { Rng } from './rng.ts'; +import { SpatialHash } from './spatial.ts'; +import { dist, hash32 } from './math.ts'; +import type { AreaEffect, GameEvent, Projectile, TickCommand } from './types.ts'; +import { maxHp, maxShields } from './stats.ts'; +import { Vision } from './systems/vision.ts'; +import { Comb } from './systems/comb.ts'; +import { updateStructures, completeBuilding } from './systems/structures.ts'; +import { updateUnits } from './systems/units.ts'; +import { moveUnits } from './systems/movement.ts'; +import { updateProjectiles } from './systems/combat.ts'; +import { updateEffects } from './systems/abilities.ts'; +import { applyCommand } from './commands.ts'; +import { releaseCargo } from './systems/cargo.ts'; +import { AIController } from './ai/ai.ts'; + +validateData(); + +export interface GameInit { + map: GameMap; + players: PlayerSetup[]; + seed: number; +} + +export class Game { + map: GameMap; + grid: PathGrid; + rng: Rng; + seed: number; + /** the setup the game was created with (for replays) */ + setups: PlayerSetup[]; + tick = 0; + players: Player[]; + entities: Entity[] = []; + byId = new Map(); + nextId = 1; + nextFxId = 1; + groupSeq = 1; + uhash: SpatialHash; + bhash: SpatialHash; + bhashDirty = true; + vision: Vision; + comb: Comb; + effects: AreaEffect[] = []; + projectiles: Projectile[] = []; + events: GameEvent[] = []; + over = false; + winners: number[] = []; + powerDirty = true; + ais: AIController[] = []; + /** recently computed paths, shared by units heading to the same place */ + pathCache = new Map(); + /** A* node expansions still allowed this tick */ + pathBudget = 0; + /** scratch array for spatial queries (not re-entrant!) */ + tmp: Entity[] = []; + tmp2: Entity[] = []; + + constructor(init: GameInit) { + this.map = init.map; + this.setups = init.players.map((p) => ({ ...p })); + this.seed = init.seed >>> 0; + this.rng = new Rng(this.seed); + this.grid = new PathGrid(this.map); + const ww = this.map.w * TILE; + const wh = this.map.h * TILE; + this.uhash = new SpatialHash(ww, wh, 64); + this.bhash = new SpatialHash(ww, wh, 128); + this.players = init.players.map((s, i) => new Player(i, s)); + const teamIds = [...new Set(this.players.map((p) => p.team))].sort((a, b) => a - b); + // normalise team ids to 0..n-1 + for (const p of this.players) p.team = teamIds.indexOf(p.team); + this.vision = new Vision(this.map.w, this.map.h, teamIds.length); + this.comb = new Comb(this.map.w, this.map.h); + this.setup(init.players); + } + + // ------------------------------------------------------------------ setup + + private setup(setups: PlayerSetup[]): void { + for (const r of this.map.resources) { + const def = DEFS[r.kind]; + const e = this.addEntity(def, -1, (r.tx + def.size![0] / 2) * TILE, (r.ty + def.size![1] / 2) * TILE); + e.tx = r.tx; + e.ty = r.ty; + e.amount = r.amount; + this.grid.setFootprint(e.tx, e.ty, e.tw, e.th, true); + } + // assign start locations + const starts = this.map.starts; + const free = starts.map((_, i) => i); + const assigned: number[] = new Array(this.players.length).fill(-1); + setups.forEach((s, i) => { + if (s.start !== undefined && s.start >= 0 && free.includes(s.start)) { + assigned[i] = s.start; + free.splice(free.indexOf(s.start), 1); + } + }); + this.rng.shuffle(free); + for (let i = 0; i < assigned.length; i++) if (assigned[i] < 0) assigned[i] = free.shift() ?? 0; + + for (const p of this.players) { + const base = starts[assigned[p.index]]; + p.startBase = this.map.bases.indexOf(base); + const race = RACES[p.race]; + const hall = this.placeBuilding(race.townHall, p.index, base.tx, base.ty, true); + completeBuilding(this, hall, true); + const minerals = this.entities + .filter((e) => e.def.kind === 'mineral' && dist(e.x, e.y, hall.x, hall.y) < 12 * TILE) + .sort((a, b) => dist(a.x, a.y, hall.x, hall.y) - dist(b.x, b.y, hall.x, hall.y) || a.id - b.id); + for (let i = 0; i < STARTING_WORKERS; i++) { + const m = minerals[i % Math.max(1, minerals.length)]; + const ang = (i / STARTING_WORKERS) * 6.283185307179586; + let wx = hall.x + (m ? (m.x - hall.x) * 0.45 : 0); + let wy = hall.y + (m ? (m.y - hall.y) * 0.45 : 0); + wx += ((hash32(i * 7 + 1) % 17) - 8) * 1.5 + ang; + wy += ((hash32(i * 13 + 5) % 17) - 8) * 1.5; + const w = this.spawnUnit(race.worker, p.index, wx, wy); + if (m) w.orders.push({ t: 'gather', target: m.id }); + } + if (p.race === 'horde') { + this.spawnUnit('bumblebear', p.index, hall.x + (hall.x < this.map.w * TILE / 2 ? 3 : -3) * TILE, hall.y); + for (let i = 0; i < CUB_MAX; i++) spawnCubFor(this, hall); + } + } + this.comb.recomputeTarget(this); + this.comb.fillToTarget(); + this.updateSupplyAndTech(); + this.updatePower(); + this.grid.recomputeClearance(); + this.rebuildBHash(); + this.vision.update(this); + this.ais = this.players.filter((p) => p.ai).map((p) => new AIController(this, p.index, p.ai!)); + } + + // ------------------------------------------------------------------ entities + + addEntity(def: EntityDef, owner: number, x: number, y: number): Entity { + const e = new Entity(this.nextId++, def, owner, x, y); + e.spawnTick = this.tick; + this.entities.push(e); + this.byId.set(e.id, e); + if (e.isBuilding || e.isResource) this.bhashDirty = true; + return e; + } + + get(id: number): Entity | undefined { + if (!id) return undefined; + const e = this.byId.get(id); + return e && e.alive ? e : undefined; + } + + spawnUnit(defId: string, owner: number, x: number, y: number): Entity { + const def = DEFS[defId]; + if (!def.air) { + const p = this.grid.nearestFreePoint(x, y, clearanceFor(def.radius ?? 8)); + if (p) { + x = p.x; + y = p.y; + } + } + const e = this.addEntity(def, owner, x, y); + const p = this.players[owner]; + e.hp = maxHp(e, p); + e.lastProgressX = x; + e.lastProgressY = y; + if (def.lifetime) e.lifeT = Math.round(def.lifetime * TICK_RATE); + return e; + } + + placeBuilding(defId: string, owner: number, tx: number, ty: number, built: boolean): Entity { + const def = DEFS[defId]; + const [w, h] = def.size!; + const e = this.addEntity(def, owner, (tx + w / 2) * TILE, (ty + h / 2) * TILE); + e.tx = tx; + e.ty = ty; + e.built = built; + e.progress = built ? 1 : 0; + const mh = maxHp(e, this.players[owner]); + e.hp = built ? mh : Math.max(1, mh * 0.1); + e.shields = built ? maxShields(e) : maxShields(e) * 0.1; + if (def.refinery) { + const gey = this.entities.find((g) => g.alive && g.def.kind === 'geyser' && g.tx === tx && g.ty === ty); + if (gey) { + gey.link = e.id; + e.link = gey.id; + } + } else { + this.grid.setFootprint(tx, ty, w, h, true); + } + this.bhashDirty = true; + this.powerDirty = true; + return e; + } + + /** Remove without death effects (forager growing, cub morph, etc.). */ + removeSilently(e: Entity): void { + if (!e.alive) return; + e.alive = false; + if (e.cargo.length) releaseCargo(this, e, undefined, false); + this.onRemoved(e); + } + + kill(e: Entity, killer?: Entity): void { + if (!e.alive) return; + e.alive = false; + e.hp = 0; + const owner = this.players[e.owner]; + if (owner) { + if (e.isBuilding) owner.stats.buildingsLost++; + else if (e.def.kind === 'unit') owner.stats.unitsLost++; + } + if (killer) { + killer.kills++; + const kp = this.players[killer.owner]; + if (kp && this.isEnemy(killer.owner, e.owner)) { + if (e.isBuilding) kp.stats.buildingsKilled++; + else if (e.def.kind === 'unit') kp.stats.unitsKilled++; + } + } + this.events.push({ + k: 'death', id: e.id, def: e.def.id, owner: e.owner, x: e.x, y: e.y, air: e.air, building: e.isBuilding, r: e.r, + }); + // bunkers let their units out; transports take theirs down with them + if (e.cargo.length) releaseCargo(this, e, killer, true); + this.onRemoved(e); + } + + private onRemoved(e: Entity): void { + // research in progress here is lost and can be started again elsewhere + const p = this.players[e.owner]; + if (p) for (const q of e.queue) if (q.kind === 'research') p.researching[q.id] = Math.max(0, (p.researching[q.id] ?? 1) - 1); + if (e.carrier) { + const c = this.byId.get(e.carrier); + const i = c ? c.cargo.indexOf(e.id) : -1; + if (i >= 0) c!.cargo.splice(i, 1); + } + if (e.isBuilding || e.isResource) { + if (e.isResource || !e.def.refinery) this.grid.setFootprint(e.tx, e.ty, e.tw, e.th, false); + this.bhashDirty = true; + this.powerDirty = true; + if (e.def.refinery) { + const gey = this.get(e.link); + if (gey) gey.link = 0; + if (e.occupiedBy) { + const w = this.get(e.occupiedBy); + if (w) w.inside = false; + } + } + if (e.def.combRadius) this.comb.dirty = true; + if (e.def.spawnsCubs) for (const lid of e.cubs) { + const l = this.get(lid); + if (l && l.def.kind === 'cub') this.kill(l); + } + if (e.def.kind === 'geyser' && e.link) { + const ref = this.get(e.link); + if (ref) ref.link = 0; + } + } + if (e.def.kind === 'cub' || e.def.kind === 'cocoon') { + const n = this.get(e.hive); + if (n) { + const i = n.cubs.indexOf(e.id); + if (i >= 0) n.cubs.splice(i, 1); + } + } + // release mineral occupancy + if (e.res) { + const r = this.byId.get(e.res); + if (r && r.occupiedBy === e.id) r.occupiedBy = 0; + } + if (e.inside) { + const r = this.byId.get(e.res); + if (r && r.occupiedBy === e.id) r.occupiedBy = 0; + } + } + + rebuildBHash(): void { + this.bhash.clear(); + for (const e of this.entities) if (e.alive && (e.isBuilding || e.isResource)) this.bhash.insert(e); + this.bhashDirty = false; + } + + rebuildUHash(): void { + this.uhash.clear(); + for (const e of this.entities) if (e.alive && !e.isBuilding && !e.isResource && !e.inside) this.uhash.insert(e); + } + + // ------------------------------------------------------------------ players + + isEnemy(a: number, b: number): boolean { + if (a < 0 || b < 0 || a === b) return false; + return this.players[a].team !== this.players[b].team; + } + isAlly(a: number, b: number): boolean { + if (a < 0 || b < 0) return false; + return a === b || this.players[a].team === this.players[b].team; + } + teamOf(owner: number): number { + return owner < 0 ? -1 : this.players[owner].team; + } + + canAfford(p: Player, c: Cost): boolean { + return p.minerals >= c.m && p.gas >= c.g; + } + /** Returns an error message if the player can't afford it. */ + affordError(p: Player, c: Cost): string | null { + if (p.minerals < c.m) return 'Not enough minerals'; + if (p.gas < c.g) return 'Not enough gas'; + return null; + } + spend(p: Player, c: Cost): void { + p.minerals -= c.m; + p.gas -= c.g; + p.stats.mineralsSpent += c.m; + p.stats.gasSpent += c.g; + } + refund(p: Player, c: Cost, pct = 1): void { + p.minerals += Math.floor(c.m * pct); + p.gas += Math.floor(c.g * pct); + p.stats.mineralsSpent -= Math.floor(c.m * pct); + p.stats.gasSpent -= Math.floor(c.g * pct); + } + techMet(p: Player, req: string[] | undefined): boolean { + if (!req) return true; + for (const r of req) if (!p.tech[r]) return false; + return true; + } + missingTech(p: Player, req: string[] | undefined): string | null { + if (!req) return null; + for (const r of req) if (!p.tech[r]) return DEFS[r]?.name ?? r; + return null; + } + error(owner: number, msg: string): void { + this.events.push({ k: 'error', owner, msg }); + } + + // ------------------------------------------------------------------ tick + + step(cmds: TickCommand[]): void { + this.events.length = 0; + for (const e of this.entities) { + e.px = e.x; + e.py = e.y; + } + for (const pr of this.projectiles) { + pr.px = pr.x; + pr.py = pr.y; + } + this.updateSupplyAndTech(); + for (const tc of cmds) { + const p = this.players[tc.p]; + if (!p || p.defeated) continue; + p.stats.commands++; + applyCommand(this, tc.p, tc.c); + } + if (!this.over) for (const ai of this.ais) ai.update(); + + updateStructures(this); + if (this.grid.dirty) this.grid.recomputeClearance(); + if (this.bhashDirty) this.rebuildBHash(); + if (this.powerDirty) this.updatePower(); + this.rebuildUHash(); + this.pathBudget = 24000; + if (this.tick % 40 === 0) { + for (const [k, v] of this.pathCache) if (this.tick - v.tick > 60 || v.ver !== this.grid.version) this.pathCache.delete(k); + } + updateUnits(this); + moveUnits(this); + updateProjectiles(this); + updateEffects(this); + this.cleanup(); + if (this.tick % VISION_INTERVAL === 0) this.vision.update(this); + if (this.tick % COMB_INTERVAL === 0) this.comb.update(this); + if (this.tick % 10 === 0) this.checkVictory(); + this.tick++; + } + + private cleanup(): void { + let w = 0; + const ents = this.entities; + for (let i = 0; i < ents.length; i++) { + const e = ents[i]; + if (e.alive) ents[w++] = e; + else this.byId.delete(e.id); + } + ents.length = w; + let pw = 0; + for (const pr of this.projectiles) if (pr.alive) this.projectiles[pw++] = pr; + this.projectiles.length = pw; + } + + updateSupplyAndTech(): void { + for (const p of this.players) { + p.supplyUsed = 0; + p.supplyMax = 0; + p.tech = {}; + } + for (const e of this.entities) { + if (!e.alive || e.owner < 0) continue; + const p = this.players[e.owner]; + const d = e.def; + if (e.isBuilding) { + if (e.built) { + p.supplyMax += d.provides ?? 0; + for (const s of satisfies(d)) p.tech[s] = (p.tech[s] ?? 0) + 1; + } + const q = e.queue[0]; + if (q && q.started && q.kind === 'unit') { + const ud = DEFS[q.id]; + p.supplyUsed += (ud.supply ?? 0) * (ud.count ?? 1); + } + } else { + p.supplyMax += d.provides ?? 0; + if (d.kind === 'cocoon' && e.morphTo) { + const ud = DEFS[e.morphTo]; + p.supplyUsed += (ud.supply ?? 0) * (ud.count ?? 1); + } else p.supplyUsed += d.supply ?? 0; + } + } + for (const p of this.players) if (p.supplyMax > MAX_SUPPLY) p.supplyMax = MAX_SUPPLY; + } + + updatePower(): void { + const totems = this.entities.filter((e) => e.alive && e.built && e.def.powerRadius); + for (const e of this.entities) { + if (!e.alive || !e.def.needsPower) continue; + let powered = false; + for (const c of totems) { + if (c.owner !== e.owner) continue; + const r = c.def.powerRadius! * TILE; + if (dist(c.x, c.y, e.x, e.y) <= r) { + powered = true; + break; + } + } + e.powered = powered; + } + this.powerDirty = false; + } + + /** Is (x,y) inside a power field of `owner`? */ + hasPower(owner: number, x: number, y: number): boolean { + for (const c of this.entities) { + if (!c.alive || !c.built || !c.def.powerRadius || c.owner !== owner) continue; + if (dist(c.x, c.y, x, y) <= c.def.powerRadius * TILE) return true; + } + return false; + } + + private checkVictory(): void { + if (this.over) return; + for (const p of this.players) { + if (p.defeated) continue; + let alive = false; + let hall = false; + for (const e of this.entities) { + if (!e.alive || e.owner !== p.index || !e.isBuilding) continue; + alive = true; + if (e.def.townHall) { + hall = true; + break; + } + } + if (!alive) { + this.defeat(p.index); + continue; + } + if (hall) { + p.noHallSince = -1; + p.revealed = false; + } else if (p.noHallSince < 0) p.noHallSince = this.tick; + else if (!p.revealed && this.tick - p.noHallSince > 30 * TICK_RATE) { + p.revealed = true; + this.events.push({ k: 'revealed', player: p.index }); + } + } + const teams = new Set(this.players.filter((p) => !p.defeated).map((p) => p.team)); + if (teams.size <= 1 && this.players.length > 1) { + this.over = true; + this.winners = this.players.filter((p) => !p.defeated).map((p) => p.index); + this.events.push({ k: 'gameover', winners: this.winners }); + } else if (this.players.length === 1 && this.players[0].defeated) { + this.over = true; + this.events.push({ k: 'gameover', winners: [] }); + } + } + + defeat(index: number): void { + const p = this.players[index]; + if (p.defeated) return; + p.defeated = true; + for (const e of this.entities) if (e.alive && e.owner === index) this.kill(e); + this.events.push({ k: 'defeat', player: index }); + } + + // ------------------------------------------------------------------ queries + + /** Units (incl. air) within r px of (x,y), exact by centre distance + their radius. */ + unitsNear(x: number, y: number, r: number, out: Entity[] = []): Entity[] { + out.length = 0; + this.uhash.query(x, y, r + MAX_UNIT_RADIUS, out); + let w = 0; + for (let i = 0; i < out.length; i++) { + const e = out[i]; + if (!e.alive) continue; + const dx = e.x - x; + const dy = e.y - y; + const rr = r + e.r; + if (dx * dx + dy * dy <= rr * rr) out[w++] = e; + } + out.length = w; + return out; + } + + /** Buildings and resources whose footprint intersects the circle. */ + structuresNear(x: number, y: number, r: number, out: Entity[] = []): Entity[] { + out.length = 0; + this.bhash.query(x, y, r + MAX_STRUCT_REACH, out); + let w = 0; + for (let i = 0; i < out.length; i++) { + const e = out[i]; + if (!e.alive) continue; + const dx = x < e.x0 ? e.x0 - x : x > e.x1 ? x - e.x1 : 0; + const dy = y < e.y0 ? e.y0 - y : y > e.y1 ? y - e.y1 : 0; + if (dx * dx + dy * dy <= r * r) out[w++] = e; + } + out.length = w; + return out; + } + + /** Entity at a world point (for clicks). Prefers units over structures. */ + pick(x: number, y: number, slack = 6): Entity | undefined { + let best: Entity | undefined; + let bestD = 1e9; + for (const e of this.unitsNear(x, y, slack + 24, [])) { + if (e.def.hidden) continue; + const d = dist(e.x, e.y, x, y) - e.r - (e.air ? 6 : 0); + if (d <= slack && d < bestD) { + bestD = d; + best = e; + } + } + if (best) return best; + for (const e of this.structuresNear(x, y, 1, [])) return e; + return undefined; + } + + nearestDepot(owner: number, x: number, y: number): Entity | undefined { + let best: Entity | undefined; + let bestD = 1e18; + for (const e of this.entities) { + if (!e.alive || e.owner !== owner || !e.def.depot || !e.built) continue; + const d = dist(e.x, e.y, x, y); + if (d < bestD) { + bestD = d; + best = e; + } + } + return best; + } + + /** State checksum for desync detection. */ + hash(): number { + let h = 0x811c9dc5 ^ this.tick; + const mix = (v: number) => { + // quantise floats so the hash only depends on the deterministic state + const q = Math.round(v * 256) | 0; + h = Math.imul(h ^ q, 0x01000193); + }; + for (const e of this.entities) { + if (!e.alive) continue; + mix(e.id); + mix(e.x); + mix(e.y); + mix(e.hp); + mix(e.shields); + mix(e.energy); + } + for (const p of this.players) { + mix(p.minerals); + mix(p.gas); + } + return h >>> 0; + } + + get time(): number { + return this.tick / TICK_RATE; + } + + cellSize(): number { + return CELL; + } +} + +/** Spawn a cub for a hive (used at setup and by the cub timer). */ +export function spawnCubFor(g: Game, hive: Entity): Entity { + const n = hive.cubs.length; + const lx = hive.x + (((n * 37 + hive.id * 11) % 5) - 2) * 14; + const ly = hive.y1 + 6 + ((n * 17) % 3) * 4; + const l = g.addEntity(DEFS.cub, hive.owner, lx, ly); + l.hive = hive.id; + hive.cubs.push(l.id); + return l; +} diff --git a/gh-pages/bear-war/src/game/grid.ts b/gh-pages/bear-war/src/game/grid.ts new file mode 100644 index 00000000..f23fa2b1 --- /dev/null +++ b/gh-pages/bear-war/src/game/grid.ts @@ -0,0 +1,413 @@ +// Walkability grid (16px cells) with clearance values and an A* pathfinder. +// Units only treat terrain and structures as obstacles; other units are handled by +// local collision avoidance, like modern RTS games. + +import { CELL, CELLS_PER_TILE } from './constants.ts'; +import type { GameMap } from './map/gamemap.ts'; + +const SQRT2 = 1.4142135623730951; +const HEUR_W = 1.25; // slightly greedy A*: far fewer expansions, near-optimal paths + +export class PathGrid { + cw: number; + ch: number; + terrain: Uint8Array; + occ: Uint8Array; + clear: Uint8Array; + dirty = false; + version = 0; + /** node expansions performed by the most recent findPath (for budgeting) */ + lastExpanded = 0; + + private g: Float64Array; + private parent: Int32Array; + private seen: Uint32Array; + private closed: Uint32Array; + private gen = 0; + private heapNode: Int32Array; + private heapF: Float64Array; + private heapH: Float64Array; + private heapLen = 0; + + constructor(map: GameMap) { + this.cw = map.w * CELLS_PER_TILE; + this.ch = map.h * CELLS_PER_TILE; + const n = this.cw * this.ch; + this.terrain = new Uint8Array(n); + this.occ = new Uint8Array(n); + this.clear = new Uint8Array(n); + for (let cy = 0; cy < this.ch; cy++) { + for (let cx = 0; cx < this.cw; cx++) { + const tx = Math.floor(cx / CELLS_PER_TILE); + const ty = Math.floor(cy / CELLS_PER_TILE); + this.terrain[cy * this.cw + cx] = map.walkable(tx, ty) ? 0 : 1; + } + } + this.g = new Float64Array(n); + this.parent = new Int32Array(n); + this.seen = new Uint32Array(n); + this.closed = new Uint32Array(n); + this.heapNode = new Int32Array(4096); + this.heapF = new Float64Array(4096); + this.heapH = new Float64Array(4096); + this.recomputeClearance(); + } + + blockedIdx(i: number): boolean { + return this.terrain[i] !== 0 || this.occ[i] !== 0; + } + + /** Mark (or unmark) a building/resource footprint given in tiles. */ + setFootprint(tx: number, ty: number, tw: number, th: number, add: boolean): void { + const x0 = tx * CELLS_PER_TILE; + const y0 = ty * CELLS_PER_TILE; + const x1 = (tx + tw) * CELLS_PER_TILE; + const y1 = (ty + th) * CELLS_PER_TILE; + for (let y = y0; y < y1; y++) { + if (y < 0 || y >= this.ch) continue; + for (let x = x0; x < x1; x++) { + if (x < 0 || x >= this.cw) continue; + const i = y * this.cw + x; + this.occ[i] = add ? this.occ[i] + 1 : Math.max(0, this.occ[i] - 1); + } + } + this.dirty = true; + } + + /** Euclidean distance (in cells, floored) from each cell to the nearest blocked cell or the map edge. */ + recomputeClearance(): void { + const { cw, ch, clear } = this; + const W = cw + 2; + const H = ch + 2; + const INF = 1e9; + const d2 = new Float64Array(W * H); + for (let y = 0; y < H; y++) { + for (let x = 0; x < W; x++) { + const inside = x > 0 && y > 0 && x <= cw && y <= ch; + d2[y * W + x] = inside && !this.blockedIdx((y - 1) * cw + x - 1) ? INF : 0; + } + } + const n = Math.max(W, H); + const f = new Float64Array(n); + const out = new Float64Array(n); + const v = new Int32Array(n); + const z = new Float64Array(n + 1); + // Felzenszwalb's 1D squared distance transform, run over columns then rows + const pass = (len: number) => { + let k = 0; + v[0] = 0; + z[0] = -INF; + z[1] = INF; + for (let q = 1; q < len; q++) { + let sx = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); + while (sx <= z[k]) { + k--; + sx = (f[q] + q * q - (f[v[k]] + v[k] * v[k])) / (2 * q - 2 * v[k]); + } + k++; + v[k] = q; + z[k] = sx; + z[k + 1] = INF; + } + k = 0; + for (let q = 0; q < len; q++) { + while (z[k + 1] < q) k++; + out[q] = (q - v[k]) * (q - v[k]) + f[v[k]]; + } + }; + for (let x = 0; x < W; x++) { + for (let y = 0; y < H; y++) f[y] = d2[y * W + x]; + pass(H); + for (let y = 0; y < H; y++) d2[y * W + x] = out[y]; + } + for (let y = 1; y <= ch; y++) { + for (let x = 0; x < W; x++) f[x] = d2[y * W + x]; + pass(W); + for (let x = 1; x <= cw; x++) { + const c = Math.floor(Math.sqrt(out[x])); + clear[(y - 1) * cw + x - 1] = c > 255 ? 255 : c; + } + } + this.dirty = false; + this.version++; + } + + cellX(x: number): number { + const c = Math.floor(x / CELL); + return c < 0 ? 0 : c >= this.cw ? this.cw - 1 : c; + } + cellY(y: number): number { + const c = Math.floor(y / CELL); + return c < 0 ? 0 : c >= this.ch ? this.ch - 1 : c; + } + cellAt(x: number, y: number): number { + return this.cellY(y) * this.cw + this.cellX(x); + } + passable(cx: number, cy: number, req: number): boolean { + if (cx < 0 || cy < 0 || cx >= this.cw || cy >= this.ch) return false; + return this.clear[cy * this.cw + cx] >= req; + } + passableAt(x: number, y: number, req: number): boolean { + if (x < 0 || y < 0) return false; + return this.passable(Math.floor(x / CELL), Math.floor(y / CELL), req); + } + + /** Nearest passable cell to (cx, cy) within maxR rings, or -1. */ + nearestPassable(cx: number, cy: number, req: number, maxR: number): number { + if (this.passable(cx, cy, req)) return cy * this.cw + cx; + for (let r = 1; r <= maxR; r++) { + let best = -1; + let bestD = 1e18; + for (let dy = -r; dy <= r; dy++) { + for (let dx = -r; dx <= r; dx++) { + if (Math.abs(dx) !== r && Math.abs(dy) !== r) continue; + const x = cx + dx; + const y = cy + dy; + if (!this.passable(x, y, req)) continue; + const d = dx * dx + dy * dy; + if (d < bestD) { + bestD = d; + best = y * this.cw + x; + } + } + } + if (best >= 0) return best; + } + return -1; + } + + /** Nearest passable point (cell centre) to a world position. */ + nearestFreePoint(x: number, y: number, req: number, maxR = 40): { x: number; y: number } | null { + const c = this.nearestPassable(this.cellX(x), this.cellY(y), req, maxR); + if (c < 0) return null; + const cx = c % this.cw; + const cy = (c - cx) / this.cw; + if (cx === this.cellX(x) && cy === this.cellY(y)) return { x, y }; + return { x: (cx + 0.5) * CELL, y: (cy + 0.5) * CELL }; + } + + /** Supercover line test: every cell touched by the segment must be passable. */ + lineClear(x0: number, y0: number, x1: number, y1: number, req: number): boolean { + let cx = Math.floor(x0 / CELL); + let cy = Math.floor(y0 / CELL); + const ex = Math.floor(x1 / CELL); + const ey = Math.floor(y1 / CELL); + if (!this.passable(cx, cy, req)) return false; + const dx = x1 - x0; + const dy = y1 - y0; + const stepX = dx > 0 ? 1 : -1; + const stepY = dy > 0 ? 1 : -1; + const adx = dx < 0 ? -dx : dx; + const ady = dy < 0 ? -dy : dy; + let tMaxX = adx > 0 ? (stepX > 0 ? (cx + 1) * CELL - x0 : x0 - cx * CELL) / adx : 1e18; + let tMaxY = ady > 0 ? (stepY > 0 ? (cy + 1) * CELL - y0 : y0 - cy * CELL) / ady : 1e18; + const tdx = adx > 0 ? CELL / adx : 1e18; + const tdy = ady > 0 ? CELL / ady : 1e18; + let guard = Math.abs(ex - cx) + Math.abs(ey - cy) + 4; + while ((cx !== ex || cy !== ey) && guard-- > 0) { + if (tMaxX < tMaxY - 1e-12) { + tMaxX += tdx; + cx += stepX; + } else if (tMaxY < tMaxX - 1e-12) { + tMaxY += tdy; + cy += stepY; + } else { + // passing exactly through a corner: both side cells must be open + if (!this.passable(cx + stepX, cy, req) || !this.passable(cx, cy + stepY, req)) return false; + tMaxX += tdx; + tMaxY += tdy; + cx += stepX; + cy += stepY; + } + if (!this.passable(cx, cy, req)) return false; + } + return true; + } + + private heapPush(node: number, f: number, h: number): void { + if (this.heapLen >= this.heapNode.length) { + const n = this.heapNode.length * 2; + const a = new Int32Array(n); + a.set(this.heapNode); + this.heapNode = a; + const b = new Float64Array(n); + b.set(this.heapF); + this.heapF = b; + const c = new Float64Array(n); + c.set(this.heapH); + this.heapH = c; + } + const hn = this.heapNode; + const hf = this.heapF; + const hh = this.heapH; + let i = this.heapLen++; + while (i > 0) { + const p = (i - 1) >> 1; + if (hf[p] < f || (hf[p] === f && (hh[p] < h || (hh[p] === h && hn[p] < node)))) break; + hn[i] = hn[p]; + hf[i] = hf[p]; + hh[i] = hh[p]; + i = p; + } + hn[i] = node; + hf[i] = f; + hh[i] = h; + } + + private heapPop(): number { + const hn = this.heapNode; + const hf = this.heapF; + const hh = this.heapH; + const top = hn[0]; + const n = --this.heapLen; + if (n > 0) { + const node = hn[n]; + const f = hf[n]; + const h = hh[n]; + let i = 0; + for (;;) { + let c = 2 * i + 1; + if (c >= n) break; + const r = c + 1; + if (r < n && (hf[r] < hf[c] || (hf[r] === hf[c] && (hh[r] < hh[c] || (hh[r] === hh[c] && hn[r] < hn[c]))))) c = r; + if (f < hf[c] || (f === hf[c] && (h < hh[c] || (h === hh[c] && node < hn[c])))) break; + hn[i] = hn[c]; + hf[i] = hf[c]; + hh[i] = hh[c]; + i = c; + } + hn[i] = node; + hf[i] = f; + hh[i] = h; + } + return top; + } + + /** + * Find a path from (sx,sy) to (gx,gy) for a unit needing clearance `req`. + * Returns smoothed waypoints as a flat [x0,y0,x1,y1,...] array (start excluded), + * or null if no progress is possible. If the goal is unreachable, the path leads + * to the closest reachable point. + */ + findPath(sx: number, sy: number, gx: number, gy: number, req: number, maxNodes = 25000): number[] | null { + const cw = this.cw; + let s = this.nearestPassable(this.cellX(sx), this.cellY(sy), req, 4); + if (s < 0) return null; + let goalX = gx; + let goalY = gy; + let t = this.cellAt(gx, gy); + if (this.clear[t] < req) { + t = this.nearestPassable(this.cellX(gx), this.cellY(gy), req, 30); + if (t < 0) return null; + const tx = t % cw; + goalX = (tx + 0.5) * CELL; + goalY = ((t - tx) / cw + 0.5) * CELL; + } + this.lastExpanded = 0; + if (s === t || this.lineClear(sx, sy, goalX, goalY, req)) return [goalX, goalY]; + + const gen = ++this.gen; + const g = this.g; + const parent = this.parent; + const seen = this.seen; + const closed = this.closed; + const tx = t % cw; + const ty = (t - tx) / cw; + const heur = (i: number) => { + const x = i % cw; + const y = (i - x) / cw; + const dx = x > tx ? x - tx : tx - x; + const dy = y > ty ? y - ty : ty - y; + return dx > dy ? dx + (SQRT2 - 1) * dy : dy + (SQRT2 - 1) * dx; + }; + this.heapLen = 0; + g[s] = 0; + seen[s] = gen; + parent[s] = -1; + const h0 = heur(s); + this.heapPush(s, h0 * HEUR_W, h0); + let best = s; + let bestH = h0; + let found = false; + let expanded = 0; + while (this.heapLen > 0) { + const c = this.heapPop(); + if (closed[c] === gen) continue; + closed[c] = gen; + if (c === t) { + found = true; + break; + } + if (++expanded > maxNodes) break; + const cx = c % cw; + const cy = (c - cx) / cw; + const gc = g[c]; + for (let dir = 0; dir < 8; dir++) { + const dx = dir === 0 || dir === 4 || dir === 5 ? 1 : dir === 1 || dir === 6 || dir === 7 ? -1 : 0; + const dy = dir === 2 || dir === 4 || dir === 6 ? 1 : dir === 3 || dir === 5 || dir === 7 ? -1 : 0; + const nx = cx + dx; + const ny = cy + dy; + if (nx < 0 || ny < 0 || nx >= cw || ny >= this.ch) continue; + const n = ny * cw + nx; + if (this.clear[n] < req || closed[n] === gen) continue; + let cost = 1; + if (dx !== 0 && dy !== 0) { + if (this.clear[cy * cw + nx] < req || this.clear[ny * cw + cx] < req) continue; + cost = SQRT2; + } + const ng = gc + cost; + if (seen[n] !== gen || ng < g[n]) { + seen[n] = gen; + g[n] = ng; + parent[n] = c; + const h = heur(n); + if (h < bestH) { + bestH = h; + best = n; + } + this.heapPush(n, ng + h * HEUR_W, h); + } + } + } + this.lastExpanded = expanded; + const end = found ? t : best; + if (end === s) return null; + // reconstruct + const cells: number[] = []; + for (let c = end; c !== -1 && c !== s; c = parent[c]) cells.push(c); + cells.reverse(); + const pts: number[] = []; + for (const c of cells) { + const x = c % cw; + pts.push((x + 0.5) * CELL, ((c - x) / cw + 0.5) * CELL); + } + if (found) { + pts[pts.length - 2] = goalX; + pts[pts.length - 1] = goalY; + } + return this.smooth(sx, sy, pts, req); + } + + /** Greedy string-pulling using line-of-sight checks. */ + private smooth(sx: number, sy: number, pts: number[], req: number): number[] { + const n = pts.length / 2; + const out: number[] = []; + let curX = sx; + let curY = sy; + let i = -1; + while (i < n - 1) { + let j = i + 1; + while (j + 1 < n && this.lineClear(curX, curY, pts[(j + 1) * 2], pts[(j + 1) * 2 + 1], req)) j++; + curX = pts[j * 2]; + curY = pts[j * 2 + 1]; + out.push(curX, curY); + i = j; + } + return out; + } +} + +/** Clearance (in cells) a unit of radius r needs. */ +export function clearanceFor(radius: number): number { + return radius <= 11 ? 1 : radius <= 24 ? 2 : 3; +} diff --git a/gh-pages/bear-war/src/game/map/builder.ts b/gh-pages/bear-war/src/game/map/builder.ts new file mode 100644 index 00000000..51c74b77 --- /dev/null +++ b/gh-pages/bear-war/src/game/map/builder.ts @@ -0,0 +1,417 @@ +// Declarative, symmetric map construction. Shapes are described once in "canonical" +// space; the builder evaluates every tile through each symmetry transform so maps are +// perfectly fair (rotational or mirror symmetry). + +import { GameMap, F_BUILD, F_CLIFF, F_EDGE, F_RAMP, F_ROCK, F_ROUGH, F_WALK, F_WATER, type ResourceSpawn, type Tileset } from './gamemap.ts'; +import { dcos, dsin, datan2 } from '../math.ts'; +import { GEYSER_AMOUNT, MINERAL_AMOUNT, MINERAL_SMALL } from '../constants.ts'; + +export type Sym = 'none' | 'rot2' | 'rot4' | 'mirrorX' | 'mirrorY'; +type P = [number, number]; +interface Xf { + f(x: number, y: number): P; + inv(x: number, y: number): P; + /** true if axis-aligned rectangles keep their orientation (no 90° turn) */ + keepsAxes: boolean; +} +export type Shape = (x: number, y: number) => boolean; + +export interface BaseOpts { + start?: boolean; + minerals?: number; + geysers?: number; + small?: boolean; +} + +interface CanonBase { + cx: number; + cy: number; + angle: number; + opts: BaseOpts; +} + +// ---------------------------------------------------------------- shapes + +export function disc(cx: number, cy: number, r: number): Shape { + const r2 = r * r; + return (x, y) => (x - cx) * (x - cx) + (y - cy) * (y - cy) <= r2; +} + +export function ellipse(cx: number, cy: number, rx: number, ry: number): Shape { + return (x, y) => ((x - cx) * (x - cx)) / (rx * rx) + ((y - cy) * (y - cy)) / (ry * ry) <= 1; +} + +export function rect(x0: number, y0: number, x1: number, y1: number): Shape { + return (x, y) => x >= x0 && x <= x1 && y >= y0 && y <= y1; +} + +export function poly(pts: P[]): Shape { + return (x, y) => { + let inside = false; + for (let i = 0, j = pts.length - 1; i < pts.length; j = i++) { + const [xi, yi] = pts[i]; + const [xj, yj] = pts[j]; + if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) inside = !inside; + } + return inside; + }; +} + +/** A thick line segment (stadium shape). */ +export function capsule(x0: number, y0: number, x1: number, y1: number, width: number): Shape { + const dx = x1 - x0; + const dy = y1 - y0; + const len2 = dx * dx + dy * dy; + const hw2 = (width / 2) * (width / 2); + return (x, y) => { + let t = len2 > 0 ? ((x - x0) * dx + (y - y0) * dy) / len2 : 0; + t = t < 0 ? 0 : t > 1 ? 1 : t; + const px = x0 + dx * t - x; + const py = y0 + dy * t - y; + return px * px + py * py <= hw2; + }; +} + +export function union(...shapes: Shape[]): Shape { + return (x, y) => shapes.some((s) => s(x, y)); +} + +export function minus(a: Shape, b: Shape): Shape { + return (x, y) => a(x, y) && !b(x, y); +} + +/** Domain-warp a shape so its edges look organic. amp is in tiles. */ +export function warp(s: Shape, amp: number, seed = 1): Shape { + const s1 = seed * 1.7; + const s2 = seed * 2.3; + return (x, y) => { + const nx = dsin(x * 0.31 + y * 0.17 + s1) * 0.6 + dsin(x * 0.73 - y * 0.41 + s2) * 0.4; + const ny = dcos(y * 0.29 - x * 0.19 + s2) * 0.6 + dsin(y * 0.67 + x * 0.37 + s1) * 0.4; + return s(x + nx * amp, y + ny * amp); + }; +} + +// ---------------------------------------------------------------- builder + +export class MapBuilder { + id: string; + name: string; + desc = ''; + w: number; + h: number; + tileset: Tileset; + seed: number; + xfs: Xf[]; + elev: Uint8Array; + rock: Uint8Array; + water: Uint8Array; + ramp: Uint8Array; + rough: Uint8Array; + bases: CanonBase[] = []; + warnings: string[] = []; + + constructor(id: string, name: string, w: number, h: number, sym: Sym, tileset: Tileset, seed = 1) { + this.id = id; + this.name = name; + this.w = w; + this.h = h; + this.tileset = tileset; + this.seed = seed; + const n = w * h; + this.elev = new Uint8Array(n); + this.rock = new Uint8Array(n); + this.water = new Uint8Array(n); + this.ramp = new Uint8Array(n); + this.rough = new Uint8Array(n); + const W = w; + const H = h; + const id2: Xf = { f: (x, y) => [x, y], inv: (x, y) => [x, y], keepsAxes: true }; + const r180: Xf = { f: (x, y) => [W - x, H - y], inv: (x, y) => [W - x, H - y], keepsAxes: true }; + switch (sym) { + case 'none': + this.xfs = [id2]; + break; + case 'rot2': + this.xfs = [id2, r180]; + break; + case 'rot4': { + if (W !== H) throw new Error('rot4 symmetry needs a square map'); + const r90: Xf = { f: (x, y) => [W - y, x], inv: (x, y) => [y, W - x], keepsAxes: false }; + const r270: Xf = { f: (x, y) => [y, W - x], inv: (x, y) => [W - y, x], keepsAxes: false }; + // order: P1 canonical, P2 opposite corner, then the other two + this.xfs = [id2, r180, r90, r270]; + break; + } + case 'mirrorX': + this.xfs = [id2, { f: (x, y) => [W - x, y], inv: (x, y) => [W - x, y], keepsAxes: true }]; + break; + case 'mirrorY': + this.xfs = [id2, { f: (x, y) => [x, H - y], inv: (x, y) => [x, H - y], keepsAxes: true }]; + break; + } + } + + private paint(shape: Shape, fn: (i: number) => void): void { + const { w, h } = this; + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const cx = tx + 0.5; + const cy = ty + 0.5; + for (const xf of this.xfs) { + const [ux, uy] = xf.inv(cx, cy); + if (shape(ux, uy)) { + fn(ty * w + tx); + break; + } + } + } + } + } + + fillElev(e: number): this { + this.elev.fill(e); + return this; + } + setElev(shape: Shape, e: number): this { + this.paint(shape, (i) => (this.elev[i] = e)); + return this; + } + addRock(shape: Shape): this { + this.paint(shape, (i) => (this.rock[i] = 1)); + return this; + } + clearRock(shape: Shape): this { + this.paint(shape, (i) => (this.rock[i] = 0)); + return this; + } + addWater(shape: Shape): this { + this.paint(shape, (i) => (this.water[i] = 1)); + return this; + } + addRamp(shape: Shape): this { + this.paint(shape, (i) => (this.ramp[i] = 1)); + return this; + } + addRough(shape: Shape): this { + this.paint(shape, (i) => (this.rough[i] = 1)); + return this; + } + /** cx, cy: town hall center in tiles (use x.5 values). angle: direction the minerals lie in. */ + base(cx: number, cy: number, angle: number, opts: BaseOpts = {}): this { + this.bases.push({ cx, cy, angle, opts }); + return this; + } + + build(): GameMap { + const { w, h } = this; + const map = new GameMap(this.id, this.name, w, h, this.tileset, this.seed); + map.desc = this.desc; + const elev = this.elev; + const blocked = (i: number) => this.rock[i] || this.water[i]; + + // cliffs: a tile is a cliff if a neighbor is lower, unless both are ramp tiles + const cliff = new Uint8Array(w * h); + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const i = ty * w + tx; + const e = elev[i]; + let isCliff = false; + for (let dy = -1; dy <= 1 && !isCliff; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const nx = tx + dx; + const ny = ty + dy; + if (nx < 0 || ny < 0 || nx >= w || ny >= h) continue; + const j = ny * w + nx; + if (elev[j] < e && !(this.ramp[i] && this.ramp[j])) { + isCliff = true; + break; + } + } + } + cliff[i] = isCliff ? 1 : 0; + } + } + + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const i = ty * w + tx; + let f = 0; + const edge = tx === 0 || ty === 0 || tx === w - 1 || ty === h - 1; + if (edge) f |= F_EDGE; + if (cliff[i]) f |= F_CLIFF; + if (this.rock[i]) f |= F_ROCK; + if (this.water[i]) f |= F_WATER; + if (this.ramp[i]) f |= F_RAMP; + if (this.rough[i]) f |= F_ROUGH; + if (!edge && !cliff[i] && !blocked(i)) { + f |= F_WALK; + if (!this.ramp[i] && !this.rough[i]) f |= F_BUILD; + } + map.flags[i] = f; + } + } + map.elev.set(elev); + + // bases and resources + let startCount = 0; + for (const b of this.bases) if (b.opts.start) startCount++; + map.maxPlayers = Math.max(1, startCount * this.xfs.length); + const canonStarts = this.bases.filter((b) => b.opts.start); + this.xfs.forEach((xf, t) => { + for (const b of this.bases) { + const [X, Y] = xf.f(b.cx, b.cy); + const [X2, Y2] = xf.f(b.cx + dcos(b.angle), b.cy + dsin(b.angle)); + const ang = datan2(Y2 - Y, X2 - X); + const htx = Math.round(X - 2.5); + const hty = Math.round(Y - 2.5); + const start = b.opts.start ? t * canonStarts.length + canonStarts.indexOf(b) : -1; + map.bases.push({ tx: htx, ty: hty, start }); + let res: ResourceSpawn[]; + if (t === 0 || !xf.keepsAxes) { + res = layoutResources(htx, hty, ang, b.opts); + } else { + // map the canonical layout exactly, so mirrored bases are pixel-identical + const c = layoutResources(Math.round(b.cx - 2.5), Math.round(b.cy - 2.5), b.angle, b.opts); + res = c.map((r) => { + const sw = r.kind === 'mineral' ? 2 : 3; + const sh = r.kind === 'mineral' ? 1 : 3; + const [ax, ay] = xf.f(r.tx, r.ty); + const [bx, by] = xf.f(r.tx + sw, r.ty + sh); + return { ...r, tx: Math.round(Math.min(ax, bx)), ty: Math.round(Math.min(ay, by)) }; + }); + } + map.resources.push(...res); + } + }); + + this.verify(map); + return map; + } + + private verify(map: GameMap): void { + const { w } = map; + for (const [bi, b] of map.bases.entries()) { + for (let y = b.ty; y < b.ty + 5; y++) { + for (let x = b.tx; x < b.tx + 5; x++) { + if (!map.buildable(x, y)) { + this.warnings.push(`${map.id}: base ${bi} hall tile ${x},${y} not buildable`); + y = 1e9; + break; + } + } + } + } + for (const r of map.resources) { + const sw = r.kind === 'mineral' ? 2 : 3; + const sh = r.kind === 'mineral' ? 1 : 3; + for (let y = r.ty; y < r.ty + sh; y++) { + for (let x = r.tx; x < r.tx + sw; x++) { + if (!map.walkable(x, y)) this.warnings.push(`${map.id}: ${r.kind} at ${r.tx},${r.ty} on unwalkable tile`); + } + } + } + // connectivity between all bases + const seen = new Uint8Array(map.w * map.h); + const b0 = map.bases[0]; + if (!b0) return; + const q: number[] = [b0.ty * w + b0.tx]; + seen[q[0]] = 1; + while (q.length) { + const i = q.pop()!; + const x = i % w; + const y = (i - x) / w; + for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]]) { + const nx = x + dx; + const ny = y + dy; + if (!map.walkable(nx, ny)) continue; + const j = ny * w + nx; + if (!seen[j]) { + seen[j] = 1; + q.push(j); + } + } + } + for (const [bi, b] of map.bases.entries()) { + if (!seen[b.ty * w + b.tx]) this.warnings.push(`${map.id}: base ${bi} at ${b.tx},${b.ty} unreachable from base 0`); + } + } +} + +// ---------------------------------------------------------------- resources + +function overlap(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): boolean { + return ax < bx + bw && bx < ax + aw && ay < by + bh && by < ay + ah; +} + +/** Chebyshev gap in tiles between two rects (0 if touching/overlapping). */ +function gapBetween(ax: number, ay: number, aw: number, ah: number, bx: number, by: number, bw: number, bh: number): number { + const gx = Math.max(bx - (ax + aw), ax - (bx + bw), 0); + const gy = Math.max(by - (ay + ah), ay - (by + bh), 0); + return Math.max(gx, gy); +} + +/** + * Lay out a mineral line and geysers around a 5x5 town hall at (hx, hy), facing `angle`. + * Resources must stay 3 tiles away from the hall footprint (so the hall can be rebuilt). + */ +export function layoutResources(hx: number, hy: number, angle: number, opts: BaseOpts = {}): ResourceSpawn[] { + const nMin = opts.minerals ?? 8; + const nGas = opts.geysers ?? 2; + const amount = opts.small ? MINERAL_SMALL : MINERAL_AMOUNT; + const cx = hx + 2.5; + const cy = hy + 2.5; + const dx = dcos(angle); + const dy = dsin(angle); + const zx = hx - 3; + const zy = hy - 3; + const out: ResourceSpawn[] = []; + + const cands: { tx: number; ty: number; score: number }[] = []; + for (let ty = hy - 7; ty <= hy + 11; ty++) { + for (let tx = hx - 8; tx <= hx + 11; tx++) { + if (overlap(tx, ty, 2, 1, zx, zy, 11, 11)) continue; + const gap = gapBetween(tx, ty, 2, 1, zx, zy, 11, 11); + if (gap > 1) continue; + const mx = tx + 1 - cx; + const my = ty + 0.5 - cy; + const len = Math.sqrt(mx * mx + my * my); + const cos = (mx * dx + my * dy) / len; + if (cos < 0.25) continue; + cands.push({ tx, ty, score: (1 - cos) * 10 + gap * 1.6 }); + } + } + cands.sort((a, b) => a.score - b.score || a.ty - b.ty || a.tx - b.tx); + const mins: { tx: number; ty: number }[] = []; + for (const c of cands) { + if (mins.length >= nMin) break; + if (mins.some((m) => overlap(c.tx, c.ty, 2, 1, m.tx, m.ty, 2, 1))) continue; + mins.push(c); + out.push({ kind: 'mineral', tx: c.tx, ty: c.ty, amount }); + } + + const sides = nGas >= 2 ? [1, -1] : nGas === 1 ? [1] : []; + for (const s of sides) { + const ga = angle + s * 1.35; + const gdx = dcos(ga); + const gdy = dsin(ga); + let best: { tx: number; ty: number; score: number } | null = null; + for (let ty = hy - 8; ty <= hy + 10; ty++) { + for (let tx = hx - 8; tx <= hx + 10; tx++) { + if (overlap(tx, ty, 3, 3, zx, zy, 11, 11)) continue; + const gap = gapBetween(tx, ty, 3, 3, zx, zy, 11, 11); + if (gap > 1) continue; + if (mins.some((m) => gapBetween(tx, ty, 3, 3, m.tx, m.ty, 2, 1) < 1)) continue; + if (out.some((r) => r.kind === 'geyser' && gapBetween(tx, ty, 3, 3, r.tx, r.ty, 3, 3) < 1)) continue; + const mx = tx + 1.5 - cx; + const my = ty + 1.5 - cy; + const len = Math.sqrt(mx * mx + my * my); + const cos = (mx * gdx + my * gdy) / len; + const score = (1 - cos) * 10 + gap * 2; + if (!best || score < best.score) best = { tx, ty, score }; + } + } + if (best) out.push({ kind: 'geyser', tx: best.tx, ty: best.ty, amount: GEYSER_AMOUNT }); + } + return out; +} diff --git a/gh-pages/bear-war/src/game/map/gamemap.ts b/gh-pages/bear-war/src/game/map/gamemap.ts new file mode 100644 index 00000000..ac0a181d --- /dev/null +++ b/gh-pages/bear-war/src/game/map/gamemap.ts @@ -0,0 +1,76 @@ +// Static map data: terrain, elevation, base locations and resources. + +export const F_WALK = 1; // ground units can walk here +export const F_BUILD = 2; // structures can be placed here +export const F_RAMP = 4; +export const F_CLIFF = 8; +export const F_WATER = 16; +export const F_ROCK = 32; +export const F_EDGE = 64; +export const F_ROUGH = 128; // walkable, not buildable (decorative rubble) + +export type Tileset = 'badlands' | 'frost' | 'ash' | 'verdant'; + +export interface ResourceSpawn { + kind: 'mineral' | 'geyser'; + tx: number; + ty: number; + amount: number; +} + +export interface BaseSpot { + /** town hall top-left tile */ + tx: number; + ty: number; + /** start location index, or -1 for an expansion */ + start: number; +} + +export class GameMap { + id: string; + name: string; + desc: string; + w: number; + h: number; + tileset: Tileset; + maxPlayers: number; + elev: Uint8Array; + flags: Uint8Array; + bases: BaseSpot[] = []; + resources: ResourceSpawn[] = []; + /** seed for purely visual variation */ + seed: number; + + constructor(id: string, name: string, w: number, h: number, tileset: Tileset, seed: number) { + this.id = id; + this.name = name; + this.desc = ''; + this.w = w; + this.h = h; + this.tileset = tileset; + this.maxPlayers = 2; + this.seed = seed; + this.elev = new Uint8Array(w * h); + this.flags = new Uint8Array(w * h); + } + + inBounds(tx: number, ty: number): boolean { + return tx >= 0 && ty >= 0 && tx < this.w && ty < this.h; + } + idx(tx: number, ty: number): number { + return ty * this.w + tx; + } + walkable(tx: number, ty: number): boolean { + return this.inBounds(tx, ty) && (this.flags[ty * this.w + tx] & F_WALK) !== 0; + } + buildable(tx: number, ty: number): boolean { + return this.inBounds(tx, ty) && (this.flags[ty * this.w + tx] & F_BUILD) !== 0; + } + elevAt(tx: number, ty: number): number { + if (!this.inBounds(tx, ty)) return 0; + return this.elev[ty * this.w + tx]; + } + get starts(): BaseSpot[] { + return this.bases.filter((b) => b.start >= 0).sort((a, b) => a.start - b.start); + } +} diff --git a/gh-pages/bear-war/src/game/map/maps.ts b/gh-pages/bear-war/src/game/map/maps.ts new file mode 100644 index 00000000..3bc281cf --- /dev/null +++ b/gh-pages/bear-war/src/game/map/maps.ts @@ -0,0 +1,104 @@ +import { GameMap } from './gamemap.ts'; +import { MapBuilder, capsule, disc, ellipse, poly, rect, union, warp } from './builder.ts'; + +const NW = -2.356194490192345; // minerals toward the top-left corner + +function twinCraters(): MapBuilder { + const b = new MapBuilder('twin-craters', 'Twin Craters', 96, 96, 'rot2', 'badlands', 7); + b.desc = '1v1. Elevated mains overlook a shared shelf with two expansions and two exits.'; + b.fillElev(0); + // mid-level shelf around the main + b.setElev(warp(poly([[-2, -2], [60, -2], [60, 16], [45, 30], [30, 45], [16, 60], [-2, 60]]), 1.6, 3), 1); + // main plateau + b.setElev(warp(poly([[-2, -2], [33, -2], [33, 17], [27, 27], [17, 33], [-2, 33]]), 1.2, 5), 2); + // main ramp, down onto the shelf + b.addRamp(capsule(24.5, 24.5, 30, 30, 5)); + // shelf exits to the low ground + b.addRamp(capsule(42, 27, 49, 34, 5)); + b.addRamp(capsule(27, 42, 34, 49, 5)); + // centre lake and rock formations (each is mirrored for the other side) + b.addWater(warp(ellipse(48, 48, 7, 5), 1.5, 11)); + b.addRock(warp(disc(40, 62, 3), 1, 13)); + b.addRock(warp(disc(70, 30, 2.6), 1, 17)); + b.addRock(warp(ellipse(20, 72, 2.5, 3.5), 1, 23)); + // bases (hall centres); the builder mirrors everything for the second player + b.base(14.5, 14.5, NW, { start: true }); + b.base(46.5, 10.5, -1.25); + b.base(10.5, 46.5, 2.82); + b.base(83.5, 12.5, -0.785); + return b; +} + +function frostline(): MapBuilder { + const b = new MapBuilder('frostline', 'Frostline', 112, 80, 'rot2', 'frost', 21); + b.desc = '1v1. A wide frozen front: the central lake splits the battlefield into a northern and southern lane.'; + b.fillElev(0); + // natural shelf south-east of the main, running to the map edge (painted first) + b.setElev(warp(poly([[22, 50], [44, 48], [54, 52], [56, 62], [54, 84], [22, 84]]), 1.2, 32), 1); + // main plateau on the west edge + b.setElev(warp(poly([[-2, 12], [24, 12], [31, 24], [31, 56], [26, 64], [-2, 64]]), 1.3, 31), 2); + b.addRamp(capsule(26, 58, 31.5, 63, 5)); + b.addRamp(capsule(51, 64, 59, 64, 5)); + // frozen lake in the centre and lane rocks + b.addWater(warp(ellipse(56, 40, 6.5, 8), 1.4, 33)); + b.addRock(warp(disc(46, 26, 2.6), 1, 34)); + // bases + b.base(12.5, 38.5, Math.PI, { start: true }); + b.base(38.5, 71.5, 1.57); + b.base(40.5, 11.5, -1.57); + return b; +} + +function fourCorners(): MapBuilder { + const b = new MapBuilder('four-corners', 'Four Corners', 128, 128, 'rot4', 'ash', 41); + b.desc = '4 players (FFA or 2v2). Corner fortresses, edge expansions and a rocky crater in the middle.'; + b.fillElev(0); + b.setElev(warp(poly([[-2, -2], [31, -2], [31, 18], [25, 27], [16, 31], [-2, 31]]), 1.2, 42), 2); + b.setElev(warp(union(disc(45, 14, 11.5), rect(33, -3, 57, 14)), 1.2, 43), 1); + b.addRamp(capsule(28.5, 15.5, 35, 17, 5)); + b.addRamp(capsule(49, 23, 54, 30, 5)); + // central crater + b.addRock(warp(disc(64, 64, 5), 1.2, 44)); + b.addRock(warp(disc(52, 52, 2.4), 0.8, 45)); + b.addRough(warp(disc(64, 64, 9), 1, 46)); + b.base(13.5, 13.5, NW, { start: true }); + b.base(46.5, 10.5, -1.4); + b.base(36.5, 42.5, -2.4); + b.base(72.5, 8.5, -1.57, { small: true }); + return b; +} + +export interface MapInfo { + id: string; + name: string; + players: number; + desc: string; + build: () => MapBuilder; +} + +export const MAPS: MapInfo[] = [ + { id: 'twin-craters', name: 'Twin Craters', players: 2, desc: '1v1: elevated mains, shared shelf expansions.', build: twinCraters }, + { id: 'frostline', name: 'Frostline', players: 2, desc: '1v1: a frozen lake splits the front into two lanes.', build: frostline }, + { id: 'four-corners', name: 'Four Corners', players: 4, desc: '4 players: free-for-all or 2v2 from four corner fortresses.', build: fourCorners }, +]; + +const cache = new Map(); + +export function loadMap(id: string): GameMap { + let m = cache.get(id); + if (!m) { + const info = MAPS.find((x) => x.id === id) ?? MAPS[0]; + const b = info.build(); + m = b.build(); + for (const w of b.warnings) console.warn(w); + cache.set(id, m); + } + return m; +} + +export function buildMapWithWarnings(id: string): { map: GameMap; warnings: string[] } { + const info = MAPS.find((x) => x.id === id) ?? MAPS[0]; + const b = info.build(); + const map = b.build(); + return { map, warnings: b.warnings }; +} diff --git a/gh-pages/bear-war/src/game/math.ts b/gh-pages/bear-war/src/game/math.ts new file mode 100644 index 00000000..5dbb2117 --- /dev/null +++ b/gh-pages/bear-war/src/game/math.ts @@ -0,0 +1,96 @@ +// Deterministic math helpers. +// +// IEEE-754 +, -, *, / and sqrt are exactly specified and give identical results in +// every JS engine, but Math.sin/cos/atan2/pow/exp/log are "implementation-approximated" +// and can differ in the last bits between Chrome, Safari and Firefox. That would desync a +// lockstep game, so the simulation uses these polynomial versions instead. + +export const PI = 3.141592653589793; +export const TAU = 6.283185307179586; +export const HALF_PI = 1.5707963267948966; + +export function dist(ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax; + const dy = by - ay; + return Math.sqrt(dx * dx + dy * dy); +} + +export function dist2(ax: number, ay: number, bx: number, by: number): number { + const dx = bx - ax; + const dy = by - ay; + return dx * dx + dy * dy; +} + +export function clamp(v: number, lo: number, hi: number): number { + return v < lo ? lo : v > hi ? hi : v; +} + +/** Wrap an angle to (-PI, PI]. */ +export function wrapAngle(a: number): number { + while (a > PI) a -= TAU; + while (a <= -PI) a += TAU; + return a; +} + +/** Deterministic sine (max error ~1e-9 on the wrapped range). */ +export function dsin(a: number): number { + a = wrapAngle(a); + // use symmetry to reduce to [-PI/2, PI/2] + if (a > HALF_PI) a = PI - a; + else if (a < -HALF_PI) a = -PI - a; + const a2 = a * a; + // Taylor series up to x^15, plenty for |x| <= PI/2 + return ( + a * + (1 - + (a2 / 6) * + (1 - + (a2 / 20) * + (1 - (a2 / 42) * (1 - (a2 / 72) * (1 - (a2 / 110) * (1 - (a2 / 156) * (1 - a2 / 210))))))) + ); +} + +export function dcos(a: number): number { + return dsin(a + HALF_PI); +} + +/** Deterministic atan2 (max error ~1e-5 rad), good enough for facing and steering. */ +export function datan2(y: number, x: number): number { + if (x === 0 && y === 0) return 0; + const ax = x < 0 ? -x : x; + const ay = y < 0 ? -y : y; + const swap = ay > ax; + const t = swap ? ax / ay : ay / ax; // 0..1 + // minimax-ish polynomial for atan on [0,1] + const t2 = t * t; + let r = + t * + (0.99997726 + + t2 * (-0.33262347 + t2 * (0.19354346 + t2 * (-0.11643287 + t2 * (0.05265332 + t2 * -0.0117212))))); + if (swap) r = HALF_PI - r; + if (x < 0) r = PI - r; + if (y < 0) r = -r; + return r; +} + +/** Rotate `from` toward `to` by at most `step` radians. */ +export function turnToward(from: number, to: number, step: number): number { + const d = wrapAngle(to - from); + if (d > step) return wrapAngle(from + step); + if (d < -step) return wrapAngle(from - step); + return to; +} + +/** Distance from point to an axis-aligned rectangle (0 if inside). */ +export function distToRect(px: number, py: number, x0: number, y0: number, x1: number, y1: number): number { + const dx = px < x0 ? x0 - px : px > x1 ? px - x1 : 0; + const dy = py < y0 ? y0 - py : py > y1 ? py - y1 : 0; + return Math.sqrt(dx * dx + dy * dy); +} + +/** Integer hash (deterministic), useful for per-entity jitter. */ +export function hash32(n: number): number { + n = Math.imul(n ^ (n >>> 16), 0x45d9f3b); + n = Math.imul(n ^ (n >>> 16), 0x45d9f3b); + return (n ^ (n >>> 16)) >>> 0; +} diff --git a/gh-pages/bear-war/src/game/player.ts b/gh-pages/bear-war/src/game/player.ts new file mode 100644 index 00000000..d51ea72d --- /dev/null +++ b/gh-pages/bear-war/src/game/player.ts @@ -0,0 +1,87 @@ +import type { RaceId } from './data/types.ts'; +import { STARTING_GAS, STARTING_MINERALS } from './constants.ts'; + +export type AIDifficulty = 'easy' | 'normal' | 'hard' | 'brutal'; + +export interface PlayerSetup { + name: string; + race: RaceId; + color: number; + team: number; + ai?: AIDifficulty | null; + /** preferred start location index; -1 or undefined = random */ + start?: number; +} + +export interface PlayerStats { + mineralsGathered: number; + gasGathered: number; + mineralsSpent: number; + gasSpent: number; + unitsProduced: number; + unitsLost: number; + unitsKilled: number; + buildingsBuilt: number; + buildingsLost: number; + buildingsKilled: number; + commands: number; +} + +export class Player { + index: number; + name: string; + race: RaceId; + color: number; + team: number; + ai: AIDifficulty | null; + minerals = STARTING_MINERALS; + gas = STARTING_GAS; + /** mining income multiplier (Brutal AI bonus) */ + incomeMult = 1; + supplyUsed = 0; + supplyMax = 0; + /** completed research levels */ + upgrades: Record = {}; + /** research currently in progress (id -> count) */ + researching: Record = {}; + /** completed buildings per tech id (includes `counts` aliases) */ + tech: Record = {}; + defeated = false; + left = false; + /** tick since which the player has had no town hall (-1 = has one) */ + noHallSince = -1; + /** players without a town hall for 30s have their structures revealed */ + revealed = false; + startBase = 0; + lastAttackAlert = -100000; + lastSupplyAlert = -100000; + stats: PlayerStats = { + mineralsGathered: 0, + gasGathered: 0, + mineralsSpent: 0, + gasSpent: 0, + unitsProduced: 0, + unitsLost: 0, + unitsKilled: 0, + buildingsBuilt: 0, + buildingsLost: 0, + buildingsKilled: 0, + commands: 0, + }; + + constructor(index: number, setup: PlayerSetup) { + this.index = index; + this.name = setup.name; + this.race = setup.race; + this.color = setup.color; + this.team = setup.team; + this.ai = setup.ai ?? null; + } + + level(upgrade: string): number { + return this.upgrades[upgrade] ?? 0; + } + has(research: string): boolean { + return (this.upgrades[research] ?? 0) > 0; + } +} diff --git a/gh-pages/bear-war/src/game/rng.ts b/gh-pages/bear-war/src/game/rng.ts new file mode 100644 index 00000000..2492aeed --- /dev/null +++ b/gh-pages/bear-war/src/game/rng.ts @@ -0,0 +1,31 @@ +/** Small deterministic PRNG (mulberry32). Only integer ops, identical on every engine. */ +export class Rng { + s: number; + constructor(seed: number) { + this.s = seed >>> 0 || 0x9e3779b9; + } + next(): number { + let t = (this.s = (this.s + 0x6d2b79f5) | 0); + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + } + int(n: number): number { + return Math.floor(this.next() * n); + } + range(a: number, b: number): number { + return a + (b - a) * this.next(); + } + pick(arr: readonly T[]): T { + return arr[this.int(arr.length)]; + } + shuffle(arr: T[]): T[] { + for (let i = arr.length - 1; i > 0; i--) { + const j = this.int(i + 1); + const t = arr[i]; + arr[i] = arr[j]; + arr[j] = t; + } + return arr; + } +} diff --git a/gh-pages/bear-war/src/game/spatial.ts b/gh-pages/bear-war/src/game/spatial.ts new file mode 100644 index 00000000..4d119cdf --- /dev/null +++ b/gh-pages/bear-war/src/game/spatial.ts @@ -0,0 +1,62 @@ +import type { Entity } from './entity.ts'; + +/** Uniform bucket grid for fast proximity queries. Rebuilt every tick for units. */ +export class SpatialHash { + size: number; + cols: number; + rows: number; + head: Int32Array; + next: Int32Array; + items: Entity[] = []; + + constructor(worldW: number, worldH: number, size = 64) { + this.size = size; + this.cols = Math.ceil(worldW / size) + 1; + this.rows = Math.ceil(worldH / size) + 1; + this.head = new Int32Array(this.cols * this.rows).fill(-1); + this.next = new Int32Array(1024); + } + + clear(): void { + this.head.fill(-1); + this.items.length = 0; + } + + insert(e: Entity): void { + const idx = this.items.length; + this.items.push(e); + if (idx >= this.next.length) { + const n = new Int32Array(this.next.length * 2); + n.set(this.next); + this.next = n; + } + let bx = Math.floor(e.x / this.size); + let by = Math.floor(e.y / this.size); + if (bx < 0) bx = 0; + else if (bx >= this.cols) bx = this.cols - 1; + if (by < 0) by = 0; + else if (by >= this.rows) by = this.rows - 1; + const b = by * this.cols + bx; + this.next[idx] = this.head[b]; + this.head[b] = idx; + } + + /** Push every entity whose centre lies in buckets overlapping the circle. Caller filters exactly. */ + query(x: number, y: number, r: number, out: Entity[]): Entity[] { + const s = this.size; + let x0 = Math.floor((x - r) / s); + let x1 = Math.floor((x + r) / s); + let y0 = Math.floor((y - r) / s); + let y1 = Math.floor((y + r) / s); + if (x0 < 0) x0 = 0; + if (y0 < 0) y0 = 0; + if (x1 >= this.cols) x1 = this.cols - 1; + if (y1 >= this.rows) y1 = this.rows - 1; + for (let by = y0; by <= y1; by++) { + for (let bx = x0; bx <= x1; bx++) { + for (let i = this.head[by * this.cols + bx]; i !== -1; i = this.next[i]) out.push(this.items[i]); + } + } + return out; + } +} diff --git a/gh-pages/bear-war/src/game/stats.ts b/gh-pages/bear-war/src/game/stats.ts new file mode 100644 index 00000000..dbf5dbfc --- /dev/null +++ b/gh-pages/bear-war/src/game/stats.ts @@ -0,0 +1,108 @@ +// Derived unit statistics: upgrades and research modify the base data. + +import type { Entity } from './entity.ts'; +import type { Player } from './player.ts'; +import type { WeaponDef } from './data/types.ts'; +import { TICK_RATE, TILE } from './constants.ts'; + +export function maxHp(e: Entity, p: Player | undefined): number { + let hp = e.def.hp; + if (p && e.def.id === 'ranger' && p.has('thickfur')) hp += 10; + return hp; +} + +export function maxShields(e: Entity): number { + return e.def.shields ?? 0; +} + +export function armorOf(e: Entity, p: Player | undefined): number { + let a = e.def.armor; + if (p) { + if (e.def.armorUpg) a += p.level(e.def.armorUpg); + if (e.def.id === 'cavetitan' && p.has('combarmor')) a += 2; + } + return a; +} + +export function shieldArmorOf(e: Entity, p: Player | undefined): number { + return p && e.def.race === 'starborn' ? p.level('aurora') : 0; +} + +/** Movement speed in world px per tick (without terrain bonuses). */ +export function baseSpeed(e: Entity, p: Player | undefined): number { + let s = e.def.speed ?? 0; + if (p) { + switch (e.def.id) { + case 'scrapper': + if (p.has('zoomies')) s *= 1.6; + break; + case 'bumblebear': + if (p.has('biggerwings')) s = 2.63; + break; + case 'buzzbomb': + if (p.has('busybees')) s = 4.13; + break; + case 'stinger': + if (p.has('boundinggait')) s *= 1.25; + break; + case 'spiritbear': + if (p.has('pounce')) s = 4.72; + break; + } + } + if (e.rushT > 0) s *= 1.5; + if (e.slowT > 0) s *= 1 - e.slowPct; + return (s * TILE) / TICK_RATE; +} + +export function weaponRangePx(e: Entity, w: WeaponDef, p: Player | undefined): number { + let r = w.range; + if (p) { + if (e.def.id === 'stinger' && p.has('longstingers')) r += 1; + if (e.def.id === 'polarcolossus' && p.has('longgaze')) r += 2; + } + if (e.blindT > 0 && !w.melee) r = Math.min(r, 1); + return r * TILE; +} + +/** Damage per hit of weapon `w` (owned by player p) against target t. */ +export function weaponDamage(p: Player | undefined, w: WeaponDef, t: Entity): number { + const lvl = p && w.upg ? p.level(w.upg) : 0; + let dmg = w.dmg + lvl * (w.upgDmg ?? 1); + if (w.bonus) { + for (const a of t.def.attrs) { + const b = w.bonus[a]; + if (b) dmg += b + lvl * (w.upgBonus ?? 0); + } + } + return dmg; +} + +export function cooldownTicks(e: Entity, w: WeaponDef): number { + let cd = w.cd * TICK_RATE; + if (e.rushT > 0) cd /= 1.5; + return Math.max(1, Math.round(cd)); +} + +export function canTarget(w: WeaponDef, t: Entity): boolean { + if (w.unitsOnly && t.isBuilding) return false; + if (w.targets === 'both') return true; + return w.targets === 'air' ? t.air : !t.air; +} + +/** Edge-to-edge distance between two entities (buildings use their footprint). */ +export function edgeDist(a: Entity, b: Entity): number { + if (b.isBuilding || b.isResource) { + const dx = a.x < b.x0 ? b.x0 - a.x : a.x > b.x1 ? a.x - b.x1 : 0; + const dy = a.y < b.y0 ? b.y0 - a.y : a.y > b.y1 ? a.y - b.y1 : 0; + return Math.sqrt(dx * dx + dy * dy) - a.r; + } + if (a.isBuilding) { + const dx = b.x < a.x0 ? a.x0 - b.x : b.x > a.x1 ? b.x - a.x1 : 0; + const dy = b.y < a.y0 ? a.y0 - b.y : b.y > a.y1 ? b.y - a.y1 : 0; + return Math.sqrt(dx * dx + dy * dy) - b.r; + } + const dx = b.x - a.x; + const dy = b.y - a.y; + return Math.sqrt(dx * dx + dy * dy) - a.r - b.r; +} diff --git a/gh-pages/bear-war/src/game/systems/abilities.ts b/gh-pages/bear-war/src/game/systems/abilities.ts new file mode 100644 index 00000000..17ca86fc --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/abilities.ts @@ -0,0 +1,368 @@ +import { TICK_RATE, TILE } from '../constants.ts'; +import { ABILITY_DEFS } from '../data/index.ts'; +import type { AbilityDef } from '../data/types.ts'; +import type { Entity } from '../entity.ts'; +import type { Game } from '../game.ts'; +import { datan2, dist } from '../math.ts'; +import type { AreaEffect } from '../types.ts'; +import { applyHit, canSee, dealDamage } from './combat.ts'; +import { clearanceFor } from '../grid.ts'; +import { finishMorph } from './structures.ts'; + +const SPIRIT_SPEED = (5.6 * TILE) / TICK_RATE; +const SNOWBALL_SPEED = (4.5 * TILE) / TICK_RATE; +const LOCK_TICKS = 12 * TICK_RATE; +const LOCK_LEASH = 14 * TILE; + +function checkCost(g: Game, e: Entity, ab: AbilityDef): string | null { + const p = g.players[e.owner]; + if (ab.requires && !p.has(ab.requires)) return `Requires ${ab.name} research`; + if (!g.techMet(p, ab.needs)) return `Requires ${g.missingTech(p, ab.needs)}`; + if (ab.energy && e.energy < ab.energy) return 'Not enough energy'; + if (e.cooldowns[ab.id] > 0) return 'Ability is cooling down'; + return null; +} + +function announce(g: Game, e: Entity, ab: string, tx = e.x, ty = e.y): void { + g.events.push({ k: 'ability', owner: e.owner, ab, x: e.x, y: e.y, id: e.id, tx, ty }); +} + +/** Start switching e into another mode; instant modes switch right away. */ +function startMode(g: Game, e: Entity, ab: AbilityDef): void { + const m = ab.mode!; + if (ab.energy) e.energy -= ab.energy; + e.morphTo = m.to; + e.morphCost = null; + e.orders = []; + e.path = null; + e.target = 0; + if (m.time > 0) { + e.morphT = 0; + e.morphTotal = Math.round(m.time * TICK_RATE); + } else finishMorph(g, e, g.players[e.owner]); + announce(g, e, ab.id); +} + +/** Abilities without a target (honey rush, mode switches, smash, royal jelly). */ +export function useInstant(g: Game, e: Entity, abId: string): string | null { + const ab = ABILITY_DEFS[abId]; + if (ab.mode) { + if (e.morphTo || e.def.id === ab.mode.to) return null; + const err = checkCost(g, e, ab); + if (!err) startMode(g, e, ab); + return err; + } + switch (abId) { + case 'honeyrush': { + if (!g.players[e.owner].has('honeyrush')) return 'Requires Honey Rush research'; + const cost = e.def.id === 'grizzly' ? 20 : 10; + if (e.hp <= cost) return null; + e.hp -= cost; + e.rushT = 11 * TICK_RATE; + announce(g, e, abId); + return null; + } + case 'smash': { + const w = e.def.weapons?.[0]; + if (!w) return null; + applyHit(g, e.owner, w, undefined, e, e.x, e.y, 1); + g.kill(e); + return null; + } + case 'royaljelly': { + if (!e.built) return 'Structure is not finished'; + if (e.jellyT > 0) return 'Royal Jelly is already working'; + if (e.energy < 25) return 'Not enough energy'; + e.energy -= 25; + e.jellyT = 29 * TICK_RATE; + announce(g, e, abId); + return null; + } + } + return null; +} + +/** Targeted abilities, executed once the caster is in range. */ +export function castAbility(g: Game, e: Entity, ab: AbilityDef, tx: number, ty: number, t: Entity | undefined): void { + const err = checkCost(g, e, ab); + if (err) { + g.error(e.owner, err); + return; + } + const team = g.players[e.owner].team; + const fx = (kind: AreaEffect['kind'], r: number, dur: number, target = 0, x = tx, y = ty): AreaEffect => { + const a: AreaEffect = { id: g.nextFxId++, kind, x, y, r, owner: e.owner, team, t: dur, total: dur, src: e.id, target }; + g.effects.push(a); + return a; + }; + const ticks = (s: number | undefined) => Math.round((s ?? 0) * TICK_RATE); + const enemyUnit = (air?: boolean) => { + if (t && t.isUnit && g.isEnemy(e.owner, t.owner) && (air === undefined || t.air === air)) return true; + g.error(e.owner, air ? 'Must target an enemy flyer' : 'Must target an enemy unit'); + return false; + }; + if (ab.mode) { + if (e.morphTo) return; + e.zoneX = tx; + e.zoneY = ty; + startMode(g, e, ab); + return; + } + switch (ab.id) { + case 'sniff': + case 'revelation': + fx('sniff', (ab.radius ?? 13) * TILE, ticks(ab.duration)); + break; + case 'bubble': + if (!t || !g.isAlly(e.owner, t.owner) || t.isBuilding) { + g.error(e.owner, 'Must target a friendly unit'); + return; + } + t.bubble = 100; + t.bubbleT = ticks(ab.duration); + break; + case 'staticburst': { + const r = (ab.radius ?? 1.5) * TILE; + for (const u of g.unitsNear(tx, ty, r, g.tmp2)) { + if (!g.isEnemy(e.owner, u.owner)) continue; + u.shields = Math.max(0, u.shields - 100); + u.energy = Math.max(0, u.energy - 100); + } + fx('staticburst', r, 12); + break; + } + case 'grandcannon': + if (!t || !g.isEnemy(e.owner, t.owner)) { + g.error(e.owner, 'Must target an enemy'); + return; + } + e.channelT = 2 * TICK_RATE; + fx('grandshot', 0, 2 * TICK_RATE, t.id); + break; + case 'lockon': + if (!enemyUnit()) return; + e.lockT = LOCK_TICKS; + e.lockTarget = t!.id; + break; + case 'beecloud': + case 'stingswarm': + case 'starfall': + case 'pollen': + fx(ab.id, (ab.radius ?? 2) * TILE, ticks(ab.duration)); + break; + case 'hornetbomb': + if (!enemyUnit(true)) return; + fx('hornets', (ab.radius ?? 3) * TILE, ticks(ab.duration), t!.id, t!.x, t!.y); + break; + case 'scaldinghoney': + fx('honeyglob', (ab.radius ?? 0.75) * TILE, ticks(ab.duration)); + break; + case 'yank': { + if (!t || !t.isUnit || t === e || t.def.kind !== 'unit') { + g.error(e.owner, 'Must target a unit'); + return; + } + const d = dist(e.x, e.y, t.x, t.y) || 1; + let nx = e.x + ((t.x - e.x) / d) * (e.r + t.r + 4); + let ny = e.y + ((t.y - e.y) / d) * (e.r + t.r + 4); + if (!t.air) { + const p = g.grid.nearestFreePoint(nx, ny, clearanceFor(t.r), 12); + if (!p) { + g.error(e.owner, "Can't pull that unit here"); + return; + } + nx = p.x; + ny = p.y; + } + g.events.push({ k: 'fire', id: e.id, tid: t.id, fx: 'tongue', x: e.x, y: e.y, tx: t.x, ty: t.y, owner: e.owner, air: t.air }); + t.x = nx; + t.y = ny; + t.path = null; + break; + } + case 'releasecubs': + for (let i = 0; i < 2; i++) { + const c = g.spawnUnit('wildcub', e.owner, e.x + (i ? 10 : -10), e.y + e.r); + c.orders = [{ t: 'amove', x: tx, y: ty }]; + } + break; + case 'spiritwalk': { + const path = g.grid.findPath(e.x, e.y, tx, ty, clearanceFor(e.r)) ?? []; + fx('spirit', e.r, ticks(ab.duration), 0, e.x, e.y).path = path; + break; + } + case 'snowball': { + const d = dist(e.x, e.y, tx, ty); + const range = (ab.range ?? 9) * TILE; + const k = d > range ? range / d : 1; + fx('snowball', (ab.radius ?? 1.5) * TILE, 2 * TICK_RATE, 0, e.x, e.y).path = [e.x + (tx - e.x) * k, e.y + (ty - e.y) * k]; + break; + } + case 'haste': + if (!t || !t.isBuilding || t.owner !== e.owner || !t.built) { + g.error(e.owner, 'Must target one of your structures'); + return; + } + t.hasteT = ticks(ab.duration); + break; + case 'moonstep': { + const range = (ab.range ?? 8) * TILE; + let dx = tx - e.x; + let dy = ty - e.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d > range) { + dx = (dx / d) * range; + dy = (dy / d) * range; + } + const dest = g.grid.nearestFreePoint(e.x + dx, e.y + dy, clearanceFor(e.r), 6); + if (!dest) { + g.error(e.owner, "Can't step there"); + return; + } + teleport(g, e, dest.x, dest.y); + break; + } + default: + return; + } + if (ab.energy) e.energy -= ab.energy; + if (ab.cooldown) e.cooldowns[ab.id] = ticks(ab.cooldown); + announce(g, e, ab.id, tx, ty); +} + +function teleport(g: Game, e: Entity, x: number, y: number): void { + g.events.push({ k: 'blink', id: e.id, x0: e.x, y0: e.y, x1: x, y1: y, owner: e.owner }); + e.x = e.px = x; + e.y = e.py = y; + e.path = null; +} + +/** Tracker lock-on: steady damage while the target stays close and in sight. */ +export function lockUpdate(g: Game, e: Entity): void { + const t = g.get(e.lockTarget); + if (!t || t.inside || dist(e.x, e.y, t.x, t.y) > LOCK_LEASH || !canSee(g, g.players[e.owner].team, t)) { + e.lockT = 0; + return; + } + e.lockT--; + e.turret = datan2(t.y - e.y, t.x - e.x); + dealDamage(g, t, 300 / LOCK_TICKS, 1, e, e.owner, false); + if (e.lockT % 6 === 0) g.events.push({ k: 'fire', id: e.id, tid: t.id, fx: 'lockon', x: e.x, y: e.y, tx: t.x, ty: t.y, owner: e.owner, air: t.air }); +} + +/** Move a travelling effect along its waypoints at speed `spd` (px per tick). */ +function travel(fx: AreaEffect, spd: number): void { + const p = fx.path; + let left = spd; + while (p && p.length && left > 0) { + const dx = p[0] - fx.x; + const dy = p[1] - fx.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d <= left) { + fx.x = p[0]; + fx.y = p[1]; + p.splice(0, 2); + left -= d; + } else { + fx.x += (dx / d) * left; + fx.y += (dy / d) * left; + left = 0; + } + } +} + +export function updateEffects(g: Game): void { + let w = 0; + for (const fx of g.effects) { + fx.t--; + switch (fx.kind) { + case 'stingswarm': { + const per = 120 / (8 * TICK_RATE); + const victims = [...g.unitsNear(fx.x, fx.y, fx.r, g.tmp2), ...g.structuresNear(fx.x, fx.y, fx.r, [])]; + for (const v of victims) { + if (!v.alive || v.isResource || !g.isEnemy(fx.owner, v.owner)) continue; + if (v.shields > 0) v.shields = Math.max(0, v.shields - per); + else if (v.hp > 1) v.hp = Math.max(1, v.hp - per); + v.lastHit = g.tick; + } + break; + } + case 'starfall': { + const per = 80 / fx.total; + for (const v of g.unitsNear(fx.x, fx.y, fx.r, g.tmp2)) { + if (!v.alive || v.isBuilding || v.inside || v.owner < 0) continue; + if (dist(v.x, v.y, fx.x, fx.y) > fx.r + v.r * 0.5) continue; + dealDamage(g, v, per, 1, g.get(fx.src), fx.owner, true); + } + break; + } + case 'pollen': + for (const v of g.unitsNear(fx.x, fx.y, fx.r, g.tmp2)) if (!v.air && g.isEnemy(fx.owner, v.owner)) v.blindT = 2; + break; + case 'hornets': { + const host = g.get(fx.target); + if (!host || host.inside) { + fx.t = 0; + break; + } + fx.x = host.x; + fx.y = host.y; + for (const v of g.unitsNear(fx.x, fx.y, fx.r, g.tmp2)) { + if (v.air && g.isEnemy(fx.owner, v.owner)) dealDamage(g, v, 120 / fx.total, 1, g.get(fx.src), fx.owner, true); + } + break; + } + case 'honeyglob': + if (fx.t <= 0) { + g.events.push({ k: 'splash', fx: 'honey', x: fx.x, y: fx.y, r: fx.r }); + const victims = [...g.unitsNear(fx.x, fx.y, fx.r, g.tmp2), ...g.structuresNear(fx.x, fx.y, fx.r, [])]; + for (const v of victims) if (!v.inside && !v.isResource && g.isEnemy(fx.owner, v.owner)) dealDamage(g, v, 60, 1, g.get(fx.src), fx.owner, false); + } + break; + case 'spirit': { + travel(fx, SPIRIT_SPEED); + const e = g.get(fx.src); + if (!e || e.inside || e.morphTo) fx.t = 0; + else if (fx.t <= 0) { + const p = g.grid.nearestFreePoint(fx.x, fx.y, clearanceFor(e.r), 6); + if (p) teleport(g, e, p.x, p.y); + } + break; + } + case 'snowball': + travel(fx, SNOWBALL_SPEED); + if (fx.t <= 0) { + g.events.push({ k: 'splash', fx: 'snowball', x: fx.x, y: fx.y, r: fx.r }); + for (const v of g.unitsNear(fx.x, fx.y, fx.r, g.tmp2)) { + if (v.air || v.inside || v.id === fx.src || v.owner < 0 || v.def.kind !== 'unit') continue; + v.shields = Math.max(0, v.shields - 55); + dealDamage(g, v, 145, 1, g.get(fx.src), fx.owner, false); + } + } + break; + case 'grandshot': + if (fx.t <= 0) { + const src = g.get(fx.src); + const t = g.get(fx.target); + if (src && t) { + g.events.push({ k: 'fire', id: src.id, tid: t.id, fx: 'grandshot', x: src.x, y: src.y, tx: t.x, ty: t.y, owner: src.owner, air: t.air }); + dealDamage(g, t, 240, 1, src, src.owner, true); + g.events.push({ k: 'hit', fx: 'grandshot', x: t.x, y: t.y, tid: t.id, shield: false }); + } + } + break; + } + if (fx.t > 0) g.effects[w++] = fx; + } + g.effects.length = w; +} + +/** Seer Sunlance: drain energy while lit, douse at zero. */ +export function drainUpdate(g: Game, e: Entity): void { + e.energy -= e.def.drain! / TICK_RATE; + if (e.energy > 0 || e.morphTo) return; + e.energy = 0; + e.morphTo = e.def.base!; + finishMorph(g, e, g.players[e.owner]); +} + diff --git a/gh-pages/bear-war/src/game/systems/cargo.ts b/gh-pages/bear-war/src/game/systems/cargo.ts new file mode 100644 index 00000000..ddd3843c --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/cargo.ts @@ -0,0 +1,252 @@ +// Transports and bunkers: boarding, riding, unloading, and firing out of bunkers. +// +// A carried unit stays in the entity list with `inside` set (like a worker in a +// refinery), so it keeps counting for supply, but nothing can see, hit or collide +// with it. Its position follows the carrier so it reappears in the right place. + +import { ACQUIRE_INTERVAL, CELL, TICK_RATE, TILE } from '../constants.ts'; +import type { Entity } from '../entity.ts'; +import type { Game } from '../game.ts'; +import { clearanceFor } from '../grid.ts'; +import { dist } from '../math.ts'; +import { edgeDist, weaponRangePx } from '../stats.ts'; +import { findTarget, fireWeapon, isValidTarget, weaponFor } from './combat.ts'; +import { done, moveTo } from './units.ts'; + +/** How close (edge to edge, px) a unit and its carrier must be to board. */ +const BOARD_REACH = 10; +/** Ticks between units dropped by a transport. */ +const DROP_TICKS = 3; + +/** Slots taken by the units inside carrier c. */ +export function cargoUsed(g: Game, c: Entity): number { + let n = 0; + for (const id of c.cargo) n += g.get(id)?.def.cargoSize ?? 0; + return n; +} + +/** Can c carry units right now? (Bumblebears need Cargo Pouch; bunkers must be finished.) */ +export function canCarry(g: Game, c: Entity): boolean { + if (!c.alive || !c.def.cargo || c.owner < 0 || c.morphTo) return false; + if (c.isBuilding && !c.built) return false; + return !c.def.cargoReq || g.players[c.owner].has(c.def.cargoReq); +} + +/** Could unit u board carrier c? With `room`, also checks that it fits right now. */ +export function canBoard(g: Game, u: Entity, c: Entity, room = true): boolean { + if (u === c || !u.alive || u.inside || !u.isUnit || u.air || !u.def.cargoSize || u.morphTo) return false; + if (u.owner !== c.owner || !canCarry(g, c)) return false; + if (c.def.cargoBio && !u.def.attrs.includes('bio')) return false; + return !room || cargoUsed(g, c) + u.def.cargoSize <= c.def.cargo!; +} + +function board(g: Game, u: Entity, c: Entity): void { + const r = g.byId.get(u.res); + if (r && r.occupiedBy === u.id) r.occupiedBy = 0; + u.inside = true; + u.carrier = c.id; + u.orders = []; + u.ost = 0; + u.path = null; + u.target = 0; + u.holdPos = false; + u.wantMove = false; + u.vx = 0; + u.vy = 0; + u.x = c.x; + u.y = c.y; + c.cargo.push(u.id); + g.events.push({ k: 'cargo', id: u.id, carrier: c.id, owner: u.owner, x: c.x, y: c.y, load: true }); +} + +/** Put u (carried by c) back on the ground near (x,y). False if there is no room within maxR px. */ +function dropUnit(g: Game, c: Entity, u: Entity, x: number, y: number, maxR: number): boolean { + const pt = g.grid.nearestFreePoint(x, y, clearanceFor(u.r), Math.ceil(maxR / CELL) + 2); + if (!pt || dist(pt.x, pt.y, x, y) > maxR) return false; + const i = c.cargo.indexOf(u.id); + if (i >= 0) c.cargo.splice(i, 1); + u.inside = false; + u.carrier = 0; + u.x = u.px = u.lastProgressX = pt.x; + u.y = u.py = u.lastProgressY = pt.y; + u.orders = []; + u.ost = 0; + u.path = null; + u.target = 0; + u.stuckT = 0; + u.idleT = 0; + g.events.push({ k: 'cargo', id: u.id, carrier: c.id, owner: u.owner, x: pt.x, y: pt.y, load: false }); + return true; +} + +/** Bunkers let units out along their south wall; transports drop them right below. */ +function exitPoint(c: Entity, u: Entity, k: number): [number, number] { + if (c.isBuilding) return [c.x + ((k % 4) - 1.5) * 16, c.y1 + u.r + 4]; + // spread drops a little so units don't all land on one spot + return [c.x + ((k % 3) - 1) * 12, c.y + (((k / 3) | 0) % 3 - 1) * 12]; +} + +/** Unload one specific unit right now. */ +export function unloadOne(g: Game, c: Entity, u: Entity): boolean { + const [x, y] = exitPoint(c, u, c.cargo.length); + return dropUnit(g, c, u, x, y, c.isBuilding ? 6 * TILE : 2 * TILE); +} + +/** Bunker: everybody out at once. */ +export function unloadAll(g: Game, c: Entity): void { + let k = 0; + for (const id of c.cargo.slice()) { + const u = g.get(id); + if (!u) continue; + const [x, y] = exitPoint(c, u, k++); + dropUnit(g, c, u, x, y, 6 * TILE); + } +} + +/** The carrier is gone: bunkers (and anything removed quietly) let their units out; transports take them down. */ +export function releaseCargo(g: Game, c: Entity, killer: Entity | undefined, destroyed: boolean): void { + let k = 0; + for (const id of c.cargo.slice()) { + const u = g.get(id); + if (!u) continue; + if (destroyed && !c.isBuilding) { + u.x = c.x; + u.y = c.y; + g.kill(u, killer); + continue; + } + const [x, y] = exitPoint(c, u, k++); + if (!dropUnit(g, c, u, x, y, 12 * TILE)) g.kill(u, killer); + } + c.cargo.length = 0; +} + +// ------------------------------------------------------------------ orders + +/** Unit order: walk to the transport/bunker and get in. */ +export function doLoad(g: Game, e: Entity, targetId: number): void { + const c = g.get(targetId); + if (!c || !canBoard(g, e, c)) { + done(e); + return; + } + if (edgeDist(e, c) <= BOARD_REACH) { + board(g, e, c); + return; + } + // an idle transport comes over to meet its passenger + if (!c.isBuilding && c.orders.length === 0) c.orders.push({ t: 'pickup', target: e.id }); + // after reaching the closest reachable point, wait a second before walking again + if (e.ost > 0 && e.ost < TICK_RATE) { + e.ost++; + return; + } + e.ost = moveTo(g, e, c.x, c.y, 0) ? 1 : 0; +} + +/** Transport order: fly to a friendly unit and pick it up. */ +export function doPickup(g: Game, c: Entity, targetId: number): void { + const u = g.get(targetId); + if (!u || !canBoard(g, u, c)) { + done(c); + return; + } + if (edgeDist(c, u) <= BOARD_REACH) { + board(g, u, c); + done(c); + return; + } + moveTo(g, c, u.x, u.y, 0); +} + +/** Transport order: fly to (x,y), then drop the cargo one unit at a time. */ +export function doUnload(g: Game, c: Entity, o: { x: number; y: number }): void { + if (!c.cargo.length) { + done(c); + return; + } + if (!moveTo(g, c, o.x, o.y, 3)) return; + if (c.dropT > 0) { + c.dropT--; + return; + } + const u = g.get(c.cargo[0]); + if (!u) { + c.cargo.shift(); + return; + } + if (!unloadOne(g, c, u)) { + g.error(c.owner, 'Unable to unload here'); + done(c); + return; + } + c.dropT = DROP_TICKS; + if (!c.cargo.length) done(c); +} + +// ------------------------------------------------------------------ riding + +/** Per-tick update of a unit inside a transport or bunker. */ +export function cargoUpdate(g: Game, e: Entity): void { + const c = g.get(e.carrier); + if (!c) { + // carrier vanished without letting us out (shouldn't happen): climb out here + e.inside = false; + e.carrier = 0; + const pt = g.grid.nearestFreePoint(e.x, e.y, clearanceFor(e.r)); + if (pt) { + e.x = e.px = pt.x; + e.y = e.py = pt.y; + } + return; + } + e.x = c.x; + e.y = c.y; + for (let i = 0; i < e.cds.length; i++) if (e.cds[i] > 0) e.cds[i]--; + if (c.def.bunker && e.def.weapons) bunkerFire(g, e, c); +} + +/** Ranged units inside a bunker shoot out of it, with range measured from its walls. */ +function bunkerFire(g: Game, e: Entity, c: Entity): void { + const p = g.players[e.owner]; + const ws = e.def.weapons!; + const extra = c.def.bunker! * TILE + c.r - e.r; + const usable = (t: Entity): number => { + const wi = weaponFor(e, t); + return wi >= 0 && !ws[wi].melee ? wi : -1; + }; + const inRange = (t: Entity, wi: number) => edgeDist(e, t) <= weaponRangePx(e, ws[wi], p) + extra + 2; + let t = g.get(e.target); + if (t && (!isValidTarget(g, e, t) || usable(t) < 0 || !inRange(t, usable(t)))) { + e.target = 0; + t = undefined; + } + if (!t && (g.tick + e.id) % ACQUIRE_INTERVAL === 0) { + let maxR = 0; + for (const w of ws) if (!w.melee) maxR = Math.max(maxR, weaponRangePx(e, w, p)); + if (maxR <= 0) return; + const nt = findTarget(g, e, maxR + extra); + if (nt && usable(nt) >= 0 && inRange(nt, usable(nt))) { + t = nt; + e.target = nt.id; + } + } + if (!t) return; + const wi = usable(t); + if (e.cds[wi] > 0) return; + // shoot from the wall facing the target + const d = dist(c.x, c.y, t.x, t.y) || 1; + e.x = c.x + ((t.x - c.x) / d) * c.r * 0.8; + e.y = c.y + ((t.y - c.y) / d) * c.r * 0.8; + fireWeapon(g, e, wi, t); + c.lastAttackTick = g.tick; +} + +/** Point every unit in a bunker at target t. */ +export function focusBunker(g: Game, b: Entity, t: Entity | undefined): void { + for (const id of b.cargo) { + const u = g.get(id); + if (!u) continue; + u.target = t && isValidTarget(g, u, t) ? t.id : 0; + } +} diff --git a/gh-pages/bear-war/src/game/systems/comb.ts b/gh-pages/bear-war/src/game/systems/comb.ts new file mode 100644 index 00000000..7311fb38 --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/comb.ts @@ -0,0 +1,125 @@ +// Honeycomb. Spreads gradually from Horde structures and recedes when they die. Horde +// structures must be placed on honeycomb; other clans cannot build on it. + +import { TILE } from '../constants.ts'; +import type { Game } from '../game.ts'; +import { F_WALK } from '../map/gamemap.ts'; + +export const COMB_CHUNK = 8; // tiles per render chunk side + +export class Comb { + w: number; + h: number; + grid: Uint8Array; + target: Uint8Array; + dirty = true; + version = 0; + /** incremented whenever a tile in that chunk changes (for render caches) */ + chunkVer: Uint32Array; + cw: number; + + constructor(w: number, h: number) { + this.w = w; + this.h = h; + this.grid = new Uint8Array(w * h); + this.target = new Uint8Array(w * h); + this.cw = Math.ceil(w / COMB_CHUNK); + this.chunkVer = new Uint32Array(this.cw * Math.ceil(h / COMB_CHUNK)); + } + + has(tx: number, ty: number): boolean { + if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return false; + return this.grid[ty * this.w + tx] !== 0; + } + hasAt(x: number, y: number): boolean { + return this.has(Math.floor(x / TILE), Math.floor(y / TILE)); + } + + recomputeTarget(g: Game): void { + const { w, h, target } = this; + target.fill(0); + for (const e of g.entities) { + if (!e.alive || !e.isBuilding || !e.def.combRadius || !e.built) continue; + const r = e.def.combRadius; + const cx = e.x / TILE; + const cy = e.y / TILE; + const R = Math.ceil(r) + 3; + const x0 = Math.max(0, Math.floor(cx - R)); + const x1 = Math.min(w - 1, Math.ceil(cx + R)); + const y0 = Math.max(0, Math.floor(cy - R)); + const y1 = Math.min(h - 1, Math.ceil(cy + R)); + // footprints are large, so the radius is measured from the footprint edge + const hw = e.tw / 2; + const hh = e.th / 2; + for (let ty = y0; ty <= y1; ty++) { + for (let tx = x0; tx <= x1; tx++) { + const dx = Math.max(Math.abs(tx + 0.5 - cx) - hw, 0); + const dy = Math.max(Math.abs(ty + 0.5 - cy) - hh, 0); + if (dx * dx + dy * dy > r * r * 0.55) continue; + if (!(g.map.flags[ty * w + tx] & F_WALK) && !inside(tx, ty, e.tx, e.ty, e.tw, e.th)) continue; + target[ty * w + tx] = 1; + } + } + } + // keep comb under existing hive structures (they don't die instantly off comb) + this.dirty = false; + } + + fillToTarget(): void { + for (let i = 0; i < this.grid.length; i++) this.grid[i] = this.target[i]; + this.chunkVer.fill(1); + this.version++; + } + + private touch(tx: number, ty: number): void { + this.chunkVer[Math.floor(ty / COMB_CHUNK) * this.cw + Math.floor(tx / COMB_CHUNK)]++; + } + + update(g: Game): void { + if (this.dirty) this.recomputeTarget(g); + const { w, h, grid, target } = this; + const rng = g.rng; + let changed = false; + // Two passes so growth is one ring per step. Collect changes first. + const add: number[] = []; + const rem: number[] = []; + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const i = ty * w + tx; + const t = target[i]; + const c = grid[i]; + if (t === c) continue; + if (t && !c) { + if ( + (tx > 0 && grid[i - 1]) || (tx < w - 1 && grid[i + 1]) || + (ty > 0 && grid[i - w]) || (ty < h - 1 && grid[i + w]) + ) { + if (rng.next() < 0.45) add.push(i); + } + } else if (!t && c) { + if ( + (tx > 0 && !grid[i - 1]) || (tx < w - 1 && !grid[i + 1]) || + (ty > 0 && !grid[i - w]) || (ty < h - 1 && !grid[i + w]) + ) { + if (rng.next() < 0.3) rem.push(i); + } + } + } + } + for (const i of add) { + grid[i] = 1; + this.touch(i % w, Math.floor(i / w)); + changed = true; + } + for (const i of rem) { + grid[i] = 0; + this.touch(i % w, Math.floor(i / w)); + changed = true; + } + if (changed) this.version++; + } +} + +function inside(tx: number, ty: number, bx: number, by: number, bw: number, bh: number): boolean { + return tx >= bx && ty >= by && tx < bx + bw && ty < by + bh; +} diff --git a/gh-pages/bear-war/src/game/systems/combat.ts b/gh-pages/bear-war/src/game/systems/combat.ts new file mode 100644 index 00000000..d01d51dc --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/combat.ts @@ -0,0 +1,315 @@ +import { TICK_RATE, TILE } from '../constants.ts'; +import type { WeaponDef } from '../data/types.ts'; +import type { Entity } from '../entity.ts'; +import type { Game } from '../game.ts'; +import { datan2, dcos, dist, dsin } from '../math.ts'; +import { + armorOf, canTarget, cooldownTicks, edgeDist, shieldArmorOf, weaponDamage, weaponRangePx, +} from '../stats.ts'; +import type { Projectile } from '../types.ts'; + +const ALERT_COOLDOWN = 15 * TICK_RATE; + +/** Can team `team` currently see (and, if cloaked, detect) entity e? */ +export function canSee(g: Game, team: number, e: Entity): boolean { + if (!e.alive || e.inside) return false; + if (e.owner >= 0 && g.players[e.owner].team === team) return true; + if (e.isBuilding || e.isResource) { + // visible if any corner or the centre is visible + const v = g.vision; + if ( + !v.visibleAt(team, e.x, e.y) && !v.visibleAt(team, e.x0 + 4, e.y0 + 4) && !v.visibleAt(team, e.x1 - 4, e.y0 + 4) && + !v.visibleAt(team, e.x0 + 4, e.y1 - 4) && !v.visibleAt(team, e.x1 - 4, e.y1 - 4) + ) return false; + return true; + } + if (!g.vision.visibleAt(team, e.x, e.y)) return false; + if (e.def.cloaked && !g.vision.detectedAt(team, e.x, e.y)) return false; + return true; +} + +/** Index of the first weapon of attacker `e` able to hit `t`, or -1. */ +export function weaponFor(e: Entity, t: Entity): number { + const ws = e.def.weapons; + if (!ws) return -1; + for (let i = 0; i < ws.length; i++) if (canTarget(ws[i], t)) return i; + return -1; +} + +export function isValidTarget(g: Game, e: Entity, t: Entity | undefined): t is Entity { + if (!t || !t.alive || t.inside || t.isResource) return false; + if (!g.isEnemy(e.owner, t.owner)) return false; + if (!canSee(g, g.players[e.owner].team, t)) return false; + return weaponFor(e, t) >= 0; +} + +function priorityOf(t: Entity): number { + const d = t.def; + if (d.priority !== undefined) return d.priority; + if (t.isBuilding) return d.weapons ? 15 : 6; + if (d.worker) return 17; + if (d.weapons || d.energy) return 20; + return 12; +} + +/** + * Find the best enemy target within `radius` (px, edge distance) of e. + * Prefers higher priority, then targets already in weapon range, then closest. + */ +export function findTarget(g: Game, e: Entity, radius: number): Entity | undefined { + const team = g.players[e.owner].team; + let best: Entity | undefined; + let bestScore = -1e18; + const p = g.players[e.owner]; + const consider = (t: Entity) => { + if (t === e || !t.alive || t.inside || t.isResource || t.owner < 0) return; + if (!g.isEnemy(e.owner, t.owner)) return; + const wi = weaponFor(e, t); + if (wi < 0) return; + const d = edgeDist(e, t); + if (d > radius) return; + const w = e.def.weapons![wi]; + if (w.minRange && d < w.minRange * TILE) return; + if (!inZone(e, w, t)) return; + if (!canSee(g, team, t)) return; + const inRange = d <= weaponRangePx(e, w, p) ? 1 : 0; + const score = priorityOf(t) * 100000 + inRange * 50000 - d * 10 - (t.hp + t.shields) * 0.01; + if (score > bestScore || (score === bestScore && best && t.id < best.id)) { + bestScore = score; + best = t; + } + }; + for (const t of g.unitsNear(e.x, e.y, radius + e.r, g.tmp)) consider(t); + for (const t of g.structuresNear(e.x, e.y, radius + e.r, g.tmp)) consider(t); + return best; +} + +export function acquireRadius(e: Entity, g: Game): number { + const ws = e.def.weapons; + if (!ws) return 0; + const p = g.players[e.owner]; + let maxR = 0; + for (const w of ws) maxR = Math.max(maxR, weaponRangePx(e, w, p)); + if (e.isBuilding) return maxR; + // units notice enemies within most of their sight range (melee units included) + return Math.max(maxR + 1.5 * TILE, e.def.sight * TILE * 0.8); +} + +/** Anchored weapons only reach targets inside the anchor zone. */ +function inZone(e: Entity, w: WeaponDef, t: Entity): boolean { + return !w.zone || dist(e.zoneX, e.zoneY, t.x, t.y) <= w.zone * TILE + t.r * 0.5; +} + +/** Is target within range of weapon wi? */ +export function inWeaponRange(g: Game, e: Entity, t: Entity, wi: number): boolean { + const w = e.def.weapons![wi]; + if (w.zone) return inZone(e, w, t); + const d = edgeDist(e, t); + const range = weaponRangePx(e, w, g.players[e.owner]) + (w.melee ? 6 : 2); + if (w.minRange && d < w.minRange * TILE) return false; + return d <= range; +} + +/** Fire weapon wi at target t (assumes in range and ready). */ +export function fireWeapon(g: Game, e: Entity, wi: number, t: Entity): void { + const w = e.def.weapons![wi]; + e.cds[wi] = cooldownTicks(e, w); + e.lastAttackTick = g.tick; + const ang = datan2(t.y - e.y, t.x - e.x); + e.turret = ang; + if (!e.isBuilding && !e.def.turret) e.angle = ang; + if (w.line) { + lineHit(g, e, w, ang); + } else if (w.proj) { + const speed = ((w.projSpeed ?? 15) * TILE) / TICK_RATE; + const pr: Projectile = { + id: g.nextFxId++, x: e.x, y: e.y, px: e.x, py: e.y, tx: t.x, ty: t.y, target: t.id, speed, + owner: e.owner, src: e.id, w, mult: 1, bounces: w.bounce?.n ?? 0, hit: [], air: t.air, alive: true, age: 0, + }; + g.projectiles.push(pr); + g.events.push({ k: 'fire', id: e.id, tid: t.id, fx: w.fx, x: e.x, y: e.y, tx: t.x, ty: t.y, owner: e.owner, air: t.air }); + } else { + g.events.push({ k: 'fire', id: e.id, tid: t.id, fx: w.fx, x: e.x, y: e.y, tx: t.x, ty: t.y, owner: e.owner, air: t.air }); + applyHit(g, e.owner, w, t, e, t.x, t.y, 1); + } + if (w.suicide) g.kill(e); +} + +/** Spines along a line out to full range: every enemy ground unit and structure on it is hit. */ +function lineHit(g: Game, e: Entity, w: WeaponDef, ang: number): void { + const len = weaponRangePx(e, w, g.players[e.owner]) + e.r; + const dx = dcos(ang); + const dy = dsin(ang); + const ex = e.x + dx * len; + const ey = e.y + dy * len; + g.events.push({ k: 'fire', id: e.id, tid: 0, fx: w.fx, x: e.x, y: e.y, tx: ex, ty: ey, owner: e.owner, air: false }); + const mx = (e.x + ex) / 2; + const my = (e.y + ey) / 2; + const victims = [...g.unitsNear(mx, my, len / 2, g.tmp2), ...g.structuresNear(mx, my, len / 2, [])]; + for (const v of victims) { + if (!v.alive || v.inside || v.isResource || !g.isEnemy(e.owner, v.owner) || !canTarget(w, v)) continue; + const along = Math.max(0, Math.min(len, (v.x - e.x) * dx + (v.y - e.y) * dy)); + const off = dist(e.x + dx * along, e.y + dy * along, v.x, v.y) - (v.isBuilding ? Math.min(v.tw, v.th) * TILE * 0.5 : v.r); + if (off > w.line! * TILE) continue; + dealDamage(g, v, weaponDamage(g.players[e.owner], w, v), w.hits ?? 1, e, e.owner, false); + g.events.push({ k: 'hit', fx: w.fx, x: v.x, y: v.y, tid: v.id, shield: v.shields > 0 }); + } +} + +/** Is a ground unit currently protected by Bee Cloud? */ +function inSpores(g: Game, t: Entity): boolean { + if (t.air || t.isBuilding) return false; + for (const fx of g.effects) { + if (fx.kind !== 'beecloud') continue; + const dx = t.x - fx.x; + const dy = t.y - fx.y; + if (dx * dx + dy * dy <= fx.r * fx.r) return true; + } + return false; +} + +/** Apply a weapon hit on t at (ix,iy), including splash. */ +export function applyHit(g: Game, owner: number, w: WeaponDef, t: Entity | undefined, attacker: Entity | undefined, ix: number, iy: number, mult: number): void { + const p = g.players[owner]; + if (t && t.alive) { + const blocked = !w.melee && inSpores(g, t); + if (!blocked) { + const dmg = weaponDamage(p, w, t) * mult; + dealDamage(g, t, dmg, w.hits ?? 1, attacker, owner, false); + if (w.slow && t.alive && !t.isBuilding) { + t.slowT = Math.round(w.slow.dur * TICK_RATE); + t.slowPct = w.slow.pct; + } + } + g.events.push({ k: 'hit', fx: w.fx, x: t.x, y: t.y, tid: t.id, shield: t.shields > 0 }); + } + if (w.splash) { + const maxR = w.splash.r[w.splash.r.length - 1] * TILE; + g.events.push({ k: 'splash', fx: w.fx, x: ix, y: iy, r: maxR }); + const victims: Entity[] = []; + for (const v of g.unitsNear(ix, iy, maxR, g.tmp2)) victims.push(v); + for (const v of g.structuresNear(ix, iy, maxR, g.tmp2)) victims.push(v); + for (const v of victims) { + if (v === t || v === attacker || !v.alive || v.isResource || v.inside) continue; + if (!canTarget(w, v)) continue; + const enemy = g.isEnemy(owner, v.owner); + if (!enemy && !(w.splash.friendly && v.owner >= 0)) continue; + // distance from impact point to the victim's edge + let d: number; + if (v.isBuilding) { + const dx = ix < v.x0 ? v.x0 - ix : ix > v.x1 ? ix - v.x1 : 0; + const dy = iy < v.y0 ? v.y0 - iy : iy > v.y1 ? iy - v.y1 : 0; + d = Math.sqrt(dx * dx + dy * dy); + } else { + const dx = v.x - ix; + const dy = v.y - iy; + d = Math.max(0, Math.sqrt(dx * dx + dy * dy) - v.r * 0.5); + } + let pct = 0; + for (let i = 0; i < w.splash.r.length; i++) { + if (d <= w.splash.r[i] * TILE) { + pct = w.splash.pct[i]; + break; + } + } + if (pct <= 0) continue; + dealDamage(g, v, weaponDamage(p, w, v) * pct * mult, w.hits ?? 1, attacker, owner, false); + } + } +} + +/** + * Deal damage. Each of `hits` hits is reduced by armor (minimum 0.5). + * Shields absorb damage first (reduced by shield armor). `pure` ignores armor. + */ +export function dealDamage(g: Game, t: Entity, dmg: number, hits: number, attacker: Entity | undefined, owner: number, pure: boolean): void { + if (!t.alive || dmg <= 0) return; + const tp = g.players[t.owner]; + const armor = pure ? 0 : armorOf(t, tp); + const sArmor = pure ? 0 : shieldArmorOf(t, tp); + for (let h = 0; h < hits; h++) { + let d = dmg; + if (t.bubble > 0) { + const a = Math.min(t.bubble, d); + t.bubble -= a; + d -= a; + if (d <= 0) continue; + } + if (t.shields > 0) { + const sd = Math.max(0.5, d - sArmor); + if (sd <= t.shields) { + t.shields -= sd; + continue; + } + d = sd - t.shields; + t.shields = 0; + t.hp -= pure ? d : Math.max(0.5, d - armor); + } else { + t.hp -= Math.max(0.5, d - armor); + } + } + t.lastHit = g.tick; + if (t.owner >= 0 && owner >= 0 && g.isEnemy(owner, t.owner)) { + const tp2 = g.players[t.owner]; + if (g.tick - tp2.lastAttackAlert > ALERT_COOLDOWN) { + tp2.lastAttackAlert = g.tick; + g.events.push({ k: 'alert', owner: t.owner, kind: t.isBuilding || t.def.worker ? 'base' : 'attack', x: t.x, y: t.y }); + } + // idle units fight back + if (attacker && attacker.alive && !t.isBuilding && t.orders.length === 0 && !t.target && t.def.weapons) { + if (isValidTarget(g, t, attacker)) t.target = attacker.id; + } + } + if (t.hp <= 0.001) g.kill(t, attacker); +} + +export function updateProjectiles(g: Game): void { + for (const pr of g.projectiles) { + if (!pr.alive) continue; + pr.age++; + const t = g.get(pr.target); + if (t && !t.inside) { + pr.tx = t.x; + pr.ty = t.y; + } + const dx = pr.tx - pr.x; + const dy = pr.ty - pr.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d <= pr.speed || pr.age > 200) { + pr.x = pr.tx; + pr.y = pr.ty; + pr.alive = false; + impact(g, pr, t); + } else { + pr.x += (dx / d) * pr.speed; + pr.y += (dy / d) * pr.speed; + } + } +} + +function impact(g: Game, pr: Projectile, t: Entity | undefined): void { + const src = g.get(pr.src); + applyHit(g, pr.owner, pr.w, t && !t.inside ? t : undefined, src, pr.x, pr.y, pr.mult); + if (pr.bounces > 0 && t) { + pr.hit.push(t.id); + const b = pr.w.bounce!; + const team = g.players[pr.owner].team; + let next: Entity | undefined; + let bestD = 1e18; + for (const c of g.unitsNear(t.x, t.y, b.range * TILE, g.tmp)) { + if (pr.hit.includes(c.id) || !g.isEnemy(pr.owner, c.owner) || !canTarget(pr.w, c) || !canSee(g, team, c)) continue; + const dd = (c.x - t.x) * (c.x - t.x) + (c.y - t.y) * (c.y - t.y); + if (dd < bestD) { + bestD = dd; + next = c; + } + } + if (next) { + g.projectiles.push({ + id: g.nextFxId++, x: t.x, y: t.y, px: t.x, py: t.y, tx: next.x, ty: next.y, target: next.id, speed: pr.speed, + owner: pr.owner, src: pr.src, w: pr.w, mult: pr.mult / b.falloff, bounces: pr.bounces - 1, hit: pr.hit, air: next.air, + alive: true, age: 0, + }); + } + } +} diff --git a/gh-pages/bear-war/src/game/systems/movement.ts b/gh-pages/bear-war/src/game/systems/movement.ts new file mode 100644 index 00000000..64f83ad4 --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/movement.ts @@ -0,0 +1,167 @@ +// Movement integration and collision resolution. Units push each other apart (idle +// units yield to moving ones, units holding position stand firm) and slide along +// terrain and structures. Mining workers phase through other units, like in classic RTS. + +import { CELL, TILE } from '../constants.ts'; +import type { Entity } from '../entity.ts'; +import type { Game } from '../game.ts'; +import { datan2, dcos, dsin, turnToward } from '../math.ts'; +import { done } from './units.ts'; + +const near: Entity[] = []; + +export function moveUnits(g: Game): void { + const ents = g.entities; + const grid = g.grid; + const W = g.map.w * TILE; + const H = g.map.h * TILE; + // 1. integrate desired velocities + for (let i = 0; i < ents.length; i++) { + const e = ents[i]; + if (!e.alive || !e.wantMove || e.isBuilding || e.isResource || e.inside) continue; + const nx = e.x + e.vx; + const ny = e.y + e.vy; + if (e.air) { + e.x = nx; + e.y = ny; + } else if (grid.passableAt(nx, ny, 1)) { + e.x = nx; + e.y = ny; + } else if (grid.passableAt(nx, e.y, 1)) { + e.x = nx; + } else if (grid.passableAt(e.x, ny, 1)) { + e.y = ny; + } + e.angle = turnToward(e.angle, datan2(e.vy, e.vx), 0.55); + } + + // 2. stuck detection + if (g.tick % 10 === 0) { + for (const e of ents) { + if (!e.alive || !e.isUnit) continue; + const moved = Math.abs(e.x - e.lastProgressX) + Math.abs(e.y - e.lastProgressY); + if (e.wantMove && moved < 3) e.stuckT += 10; + else e.stuckT = 0; + e.lastProgressX = e.x; + e.lastProgressY = e.y; + } + } + + // 3. unit-unit separation + for (let i = 0; i < ents.length; i++) { + const a = ents[i]; + if (!a.alive || !a.isUnit || a.inside || a.def.burrowed || phasing(a)) continue; + g.unitsNear(a.x, a.y, a.r + 26, near); + for (const b of near) { + if (b.id <= a.id || b.inside || b.air !== a.air || b.def.burrowed || phasing(b)) continue; + let dx = b.x - a.x; + let dy = b.y - a.y; + const rr = a.air ? (a.r + b.r) * 0.75 : a.r + b.r; + const d2 = dx * dx + dy * dy; + if (d2 >= rr * rr) continue; + let d = Math.sqrt(d2); + if (d < 0.01) { + const ang = ((a.id * 7919 + b.id * 104729) % 628) / 100; + dx = dcos(ang); + dy = dsin(ang); + d = 0; + } else { + dx /= d; + dy /= d; + } + // group arrival: moving units stop when they bump into a groupmate that has arrived + if (a.group && a.group === b.group) { + if (arrivedCheck(a, b)) done(a); + else if (arrivedCheck(b, a)) done(b); + } + const wa = weight(g, a); + const wb = weight(g, b); + const sum = wa + wb; + if (sum <= 0) continue; + const overlap = (rr - d) * (a.air ? 0.2 : 0.6); + a.x -= dx * overlap * (wa / sum); + a.y -= dy * overlap * (wa / sum); + b.x += dx * overlap * (wb / sum); + b.y += dy * overlap * (wb / sum); + } + } + + // 4. keep ground units out of terrain/structures, everyone inside the map + for (let i = 0; i < ents.length; i++) { + const e = ents[i]; + if (!e.alive || !e.isUnit || e.inside) continue; + if (!e.air) pushOut(g, e); + if (e.x < e.r) e.x = e.r; + else if (e.x > W - e.r) e.x = W - e.r; + if (e.y < e.r) e.y = e.r; + else if (e.y > H - e.r) e.y = H - e.r; + } +} + +function phasing(e: Entity): boolean { + if (!e.def.worker) return false; + const o = e.orders[0]; + return !!o && (o.t === 'gather' || o.t === 'return'); +} + +/** How easily a unit is pushed (0 = immovable). Bigger units are harder to push. */ +function weight(g: Game, e: Entity): number { + if (e.def.kind === 'cocoon' || e.morphTo || (e.def.speed ?? 0) <= 0) return 0; + let w: number; + const o = e.orders[0]; + if (e.holdPos || (o && o.t === 'hold')) w = 0.05; + else if (!o) w = g.tick - e.lastAttackTick < 15 ? 0.15 : 1; + else if (!e.wantMove) w = 0.2; + else w = 0.6; + return (w * 100) / (e.r * e.r); +} + +/** a (moving) arrives if it touches b (stopped, same group) near a's destination. */ +function arrivedCheck(a: Entity, b: Entity): boolean { + const o = a.orders[0]; + if (!o || (o.t !== 'move' && o.t !== 'amove') || o.t === 'move' && o.target) return false; + if (b.orders.length > 0 && !b.arrived) return false; + const gx = o.x; + const gy = o.y; + const dx = gx - a.x; + const dy = gy - a.y; + const r = Math.sqrt(a.groupN) * a.r * 1.6 + 16; + if (dx * dx + dy * dy > r * r) return false; + a.arrived = true; + return true; +} + +function pushOut(g: Game, e: Entity): void { + const grid = g.grid; + if (!grid.passableAt(e.x, e.y, 1)) { + const p = grid.nearestFreePoint(e.x, e.y, 1, 30); + if (p) { + e.x = p.x; + e.y = p.y; + e.path = null; + } + return; + } + const r = e.r * 0.9; + const cx0 = Math.floor((e.x - r) / CELL); + const cx1 = Math.floor((e.x + r) / CELL); + const cy0 = Math.floor((e.y - r) / CELL); + const cy1 = Math.floor((e.y + r) / CELL); + for (let cy = cy0; cy <= cy1; cy++) { + for (let cx = cx0; cx <= cx1; cx++) { + if (grid.passable(cx, cy, 1)) continue; + const bx0 = cx * CELL; + const by0 = cy * CELL; + const qx = e.x < bx0 ? bx0 : e.x > bx0 + CELL ? bx0 + CELL : e.x; + const qy = e.y < by0 ? by0 : e.y > by0 + CELL ? by0 + CELL : e.y; + const dx = e.x - qx; + const dy = e.y - qy; + const d2 = dx * dx + dy * dy; + if (d2 >= r * r || d2 === 0) continue; + const d = Math.sqrt(d2); + const push = r - d; + e.x += (dx / d) * push; + e.y += (dy / d) * push; + } + } +} diff --git a/gh-pages/bear-war/src/game/systems/placement.ts b/gh-pages/bear-war/src/game/systems/placement.ts new file mode 100644 index 00000000..33e8015d --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/placement.ts @@ -0,0 +1,78 @@ +import { CELLS_PER_TILE, TILE, TOWNHALL_RESOURCE_GAP } from '../constants.ts'; +import { DEFS } from '../data/index.ts'; +import type { Game } from '../game.ts'; + +/** + * Can `owner` place structure `defId` with its top-left tile at (tx, ty)? + * Returns an error message, or null if the placement is valid. + */ +export function canPlace(g: Game, owner: number, defId: string, tx: number, ty: number, ignoreId = 0): string | null { + const d = DEFS[defId]; + if (!d || !d.size) return 'Invalid structure'; + const [w, h] = d.size; + const map = g.map; + if (tx < 1 || ty < 1 || tx + w > map.w - 1 || ty + h > map.h - 1) return "Can't build there"; + const p = g.players[owner]; + const team = p.team; + const explored = g.vision.explored[team]; + + if (d.refinery) { + for (const s of g.structuresNear((tx + 1.5) * TILE, (ty + 1.5) * TILE, 8, g.tmp2)) { + if (s.def.kind === 'geyser' && s.tx === tx && s.ty === ty) { + if (s.link && g.get(s.link)) return 'This geyser already has a refinery'; + if (!explored[ty * map.w + tx]) return 'Location not explored'; + return null; + } + } + return 'Must be placed on a gas geyser'; + } + + const isHorde = d.race === 'horde'; + let anyComb = false; + let allComb = true; + for (let y = ty; y < ty + h; y++) { + for (let x = tx; x < tx + w; x++) { + if (!map.buildable(x, y)) return "Can't build there"; + const i = y * map.w + x; + if (!explored[i]) return 'Location not explored'; + // structures/resources occupying the tile + const ci = y * CELLS_PER_TILE * g.grid.cw + x * CELLS_PER_TILE; + if (g.grid.occ[ci] || g.grid.occ[ci + 1] || g.grid.occ[ci + g.grid.cw] || g.grid.occ[ci + g.grid.cw + 1]) return "Can't build there"; + if (g.comb.grid[i]) anyComb = true; + else allComb = false; + } + } + if (d.needsComb && !allComb) return 'Must be built on honeycomb'; + if (!isHorde && anyComb) return "Can't build on honeycomb"; + + if (d.townHall) { + const x0 = tx - TOWNHALL_RESOURCE_GAP; + const y0 = ty - TOWNHALL_RESOURCE_GAP; + const x1 = tx + w + TOWNHALL_RESOURCE_GAP; + const y1 = ty + h + TOWNHALL_RESOURCE_GAP; + for (const s of g.structuresNear((tx + w / 2) * TILE, (ty + h / 2) * TILE, 10 * TILE, g.tmp2)) { + if (!s.isResource) continue; + if (s.tx < x1 && s.tx + s.tw > x0 && s.ty < y1 && s.ty + s.th > y0) return 'Too close to resources'; + } + } + + const cx = (tx + w / 2) * TILE; + const cy = (ty + h / 2) * TILE; + if (d.needsPower && !g.hasPower(owner, cx, cy)) return 'Must be placed in a Star Totem\'s glow'; + + // ground units in the way (own units step aside; enemies and cocoons block) + const rx0 = tx * TILE; + const ry0 = ty * TILE; + const rx1 = (tx + w) * TILE; + const ry1 = (ty + h) * TILE; + for (const u of g.unitsNear(cx, cy, Math.max(w, h) * TILE, g.tmp2)) { + if (u.air || u.id === ignoreId || u.inside) continue; + const qx = u.x < rx0 ? rx0 : u.x > rx1 ? rx1 : u.x; + const qy = u.y < ry0 ? ry0 : u.y > ry1 ? ry1 : u.y; + const dx = u.x - qx; + const dy = u.y - qy; + if (dx * dx + dy * dy >= u.r * u.r * 0.8) continue; + if (u.owner !== owner || u.def.kind === 'cocoon' || u.morphTo) return 'Something is in the way'; + } + return null; +} diff --git a/gh-pages/bear-war/src/game/systems/structures.ts b/gh-pages/bear-war/src/game/systems/structures.ts new file mode 100644 index 00000000..c14a6aec --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/structures.ts @@ -0,0 +1,368 @@ +import { + BURN_RATE, ENERGY_REGEN, HORDE_REGEN, CUB_CAP, CUB_INTERVAL, CUB_MAX, SHIELD_DELAY, SHIELD_REGEN, TICK_RATE, TILE, CELL, +} from '../constants.ts'; +import { DEFS, RESEARCH_DEFS } from '../data/index.ts'; +import type { Entity, QueueItem } from '../entity.ts'; +import { spawnCubFor, type Game } from '../game.ts'; +import { clearanceFor } from '../grid.ts'; +import type { Player } from '../player.ts'; +import { maxHp, maxShields } from '../stats.ts'; +import { dist } from '../math.ts'; +import { drainUpdate } from './abilities.ts'; + +const SUPPLY_ALERT_COOLDOWN = 10 * TICK_RATE; + +export function updateStructures(g: Game): void { + const ents = g.entities; + // iterate by index: spawning appends to the array; new entities start next tick + const n = ents.length; + for (let i = 0; i < n; i++) { + const e = ents[i]; + if (!e.alive || e.isResource) continue; + const p = g.players[e.owner]; + regen(g, e, p); + if (e.isBuilding) updateBuilding(g, e, p); + else updateUnitTimers(g, e, p); + } +} + +function regen(g: Game, e: Entity, p: Player | undefined): void { + const d = e.def; + if (d.energy && (e.built || !e.isBuilding)) { + e.energy += ENERGY_REGEN; + if (e.energy > d.energy) e.energy = d.energy; + } + if (d.shields && e.shields < d.shields && g.tick - e.lastHit > SHIELD_DELAY && (e.built || !e.isBuilding)) { + e.shields += SHIELD_REGEN; + if (e.shields > d.shields) e.shields = d.shields; + } + if (d.race === 'horde' && (e.built || !e.isBuilding)) { + const mh = maxHp(e, p); + if (e.hp < mh) e.hp = Math.min(mh, e.hp + HORDE_REGEN); + } +} + +export function advanceConstruction(g: Game, e: Entity, amount: number): void { + if (e.built) return; + const total = e.def.time * TICK_RATE; + const before = e.progress; + e.progress = Math.min(1, e.progress + amount / total); + const p = g.players[e.owner]; + const mh = maxHp(e, p); + e.hp = Math.min(mh, e.hp + mh * 0.9 * (e.progress - before)); + const ms = maxShields(e); + if (ms) e.shields = Math.min(ms, e.shields + ms * 0.9 * (e.progress - before)); + if (e.progress >= 1) completeBuilding(g, e, false); +} + +export function completeBuilding(g: Game, e: Entity, silent: boolean): void { + e.built = true; + e.progress = 1; + e.builder = 0; + const p = g.players[e.owner]; + if (p) { + p.stats.buildingsBuilt += silent ? 0 : 1; + for (const id of [e.def.id, ...(e.def.counts ?? [])]) p.tech[id] = (p.tech[id] ?? 0) + 1; + } + if (e.def.combRadius) g.comb.dirty = true; + g.powerDirty = true; + if (e.def.townHall && !e.rally) { + // default rally: nearest mineral, so new workers mine automatically + let best: Entity | undefined; + let bd = 12 * TILE; + for (const m of g.entities) { + if (!m.alive || m.def.kind !== 'mineral') continue; + const d = dist(m.x, m.y, e.x, e.y); + if (d < bd) { + bd = d; + best = m; + } + } + if (best) e.rally = { x: best.x, y: best.y, target: best.id }; + } + if (!silent) g.events.push({ k: 'built', id: e.id, owner: e.owner, def: e.def.id, x: e.x, y: e.y }); +} + +function updateBuilding(g: Game, e: Entity, p: Player | undefined): void { + if (!p) return; + if (!e.built) { + // ironpaw structures only progress while an hardhat works on them + if (e.def.race !== 'ironpaw') advanceConstruction(g, e, 1); + return; + } + // burning (ironpaw) + if (e.def.race === 'ironpaw') { + const mh = maxHp(e, p); + if (e.hp < mh / 3) { + e.hp -= BURN_RATE; + if (e.hp <= 0) { + g.kill(e); + return; + } + } + } + if (e.hasteT > 0) e.hasteT--; + if (e.def.spawnsCubs) updateCubs(g, e); + if (e.def.needsPower && !e.powered) return; + // tower weapons are handled in units.ts (buildings with weapons) + const item = e.queue[0]; + if (!item) return; + if (!item.started) { + if (item.kind === 'unit') { + const ud = DEFS[item.id]; + const sup = (ud.supply ?? 0) * (ud.count ?? 1); + if (sup > 0 && p.supplyUsed + sup > p.supplyMax) { + if (g.tick - p.lastSupplyAlert > SUPPLY_ALERT_COOLDOWN) { + p.lastSupplyAlert = g.tick; + g.events.push({ k: 'alert', owner: p.index, kind: 'supply', x: e.x, y: e.y }); + } + return; + } + p.supplyUsed += sup; + } + item.started = true; + } + item.progress += e.hasteT > 0 ? 1.5 : 1; + if (item.progress >= item.total) { + e.queue.shift(); + finishItem(g, e, item, p); + } +} + +function updateCubs(g: Game, e: Entity): void { + if (e.cubs.length < CUB_MAX) { + e.cubT++; + if (e.cubT >= CUB_INTERVAL) { + e.cubT = 0; + spawnCubFor(g, e); + } + } else e.cubT = 0; + if (e.jellyT > 0) { + e.jellyT--; + if (e.jellyT === 0) for (let i = 0; i < 3 && e.cubs.length < CUB_CAP; i++) spawnCubFor(g, e); + } +} + +function finishItem(g: Game, e: Entity, item: QueueItem, p: Player): void { + if (item.kind === 'unit') { + const ud = DEFS[item.id]; + for (let i = 0; i < (ud.count ?? 1); i++) { + const u = spawnNear(g, e, item.id); + applyRally(g, e, u); + g.events.push({ k: 'trained', id: u.id, owner: p.index, def: ud.id, x: u.x, y: u.y }); + } + p.stats.unitsProduced += ud.count ?? 1; + } else if (item.kind === 'research') { + p.upgrades[item.id] = item.level; + p.researching[item.id] = Math.max(0, (p.researching[item.id] ?? 1) - 1); + if (item.id === 'thickfur') { + for (const u of g.entities) if (u.alive && u.owner === p.index && u.def.id === 'ranger') u.hp += 10; + } + g.events.push({ k: 'researched', owner: p.index, id: item.id, level: item.level }); + } else if (item.kind === 'morph') { + const oldMax = maxHp(e, p); + e.def = DEFS[item.id]; + const newMax = maxHp(e, p); + e.hp += newMax - oldMax; + if (e.def.combRadius) g.comb.dirty = true; + for (const id of e.def.counts ?? []) p.tech[id] = p.tech[id] ?? 1; + p.tech[e.def.id] = (p.tech[e.def.id] ?? 0) + 1; + g.events.push({ k: 'built', id: e.id, owner: p.index, def: e.def.id, x: e.x, y: e.y }); + } +} + +/** Spawn a unit next to a structure, on the side facing its rally point. */ +export function spawnNear(g: Game, b: Entity, defId: string): Entity { + const def = DEFS[defId]; + if (def.air) { + return g.spawnUnit(defId, b.owner, b.x + (b.id % 3 - 1) * 6, b.y + (g.tick % 3 - 1) * 6); + } + const req = clearanceFor(def.radius ?? 8); + const tx = b.rally ? b.rally.x : b.x; + const ty = b.rally ? b.rally.y : b.y1 + 64; + // candidate points around the footprint perimeter + let best: { x: number; y: number } | null = null; + let bestScore = 1e18; + const r = def.radius ?? 8; + const off = Math.max(r + 2, (req - 0.5) * CELL + 4); + const x0 = b.x0 - off; + const x1 = b.x1 + off; + const y0 = b.y0 - off; + const y1 = b.y1 + off; + const step = 8; + const pts: number[] = []; + for (let x = x0; x <= x1; x += step) pts.push(x, y0, x, y1); + for (let y = y0 + step; y < y1; y += step) pts.push(x0, y, x1, y); + for (let i = 0; i < pts.length; i += 2) { + const px = pts[i]; + const py = pts[i + 1]; + if (!g.grid.passableAt(px, py, req)) continue; + let crowd = 0; + for (const u of g.unitsNear(px, py, r + 2, g.tmp)) if (!u.air) crowd++; + const score = dist(px, py, tx, ty) + crowd * 40; + if (score < bestScore) { + bestScore = score; + best = { x: px, y: py }; + } + } + const sx = best ? best.x : b.x; + const sy = best ? best.y : b.y1 + r; + return g.spawnUnit(defId, b.owner, sx, sy); +} + +export function applyRally(g: Game, b: Entity, u: Entity): void { + const r = b.rally; + if (!r) return; + const t = g.get(r.target); + if (u.def.worker && t && (t.def.kind === 'mineral' || (t.def.refinery && t.owner === u.owner && t.built))) { + u.orders = [{ t: 'gather', target: t.id }]; + u.ost = 0; + return; + } + if (t && t !== b) u.orders = [{ t: 'move', x: t.x, y: t.y, target: t.isUnit ? t.id : 0 }]; + else u.orders = [{ t: 'move', x: r.x, y: r.y, target: 0 }]; + u.ost = 0; +} + +function updateUnitTimers(g: Game, e: Entity, p: Player | undefined): void { + if (e.rushT > 0) e.rushT--; + if (e.slowT > 0) e.slowT--; + if (e.bubbleT > 0) { + e.bubbleT--; + if (e.bubbleT === 0) e.bubble = 0; + } + for (const k in e.cooldowns) if (e.cooldowns[k] > 0) e.cooldowns[k]--; + if (e.blindT > 0) e.blindT--; + if (e.def.drain) drainUpdate(g, e); + if (e.lifeT > 0 && --e.lifeT === 0) { + g.events.push({ k: 'death', id: e.id, def: e.def.id, owner: e.owner, x: e.x, y: e.y, air: e.air, building: false, r: e.r }); + g.removeSilently(e); + return; + } + if (e.morphTo) { + e.morphT++; + if (e.morphT >= e.morphTotal) finishMorph(g, e, p); + } +} + +export function finishMorph(g: Game, e: Entity, p: Player | undefined): void { + const to = e.morphTo!; + const td = DEFS[to]; + if (e.def.kind === 'cocoon') { + const hive = g.get(e.hive); + for (let i = 0; i < (td.count ?? 1); i++) { + const u = g.spawnUnit(to, e.owner, e.x + (i ? 8 : 0), e.y + (i ? 4 : 0)); + if (hive) applyRally(g, hive, u); + g.events.push({ k: 'trained', id: u.id, owner: e.owner, def: to, x: u.x, y: u.y }); + } + if (p) p.stats.unitsProduced += td.count ?? 1; + g.removeSilently(e); + return; + } + const oldMax = maxHp(e, p); + const ratio = e.hp / Math.max(1, oldMax); + e.def = td; + e.r = td.radius ?? e.r; + e.air = !!td.air; + e.hp = Math.max(1, ratio * maxHp(e, p)); + e.morphTo = null; + e.morphCost = null; + e.cds = [0, 0]; + if (td.morphFrom) g.events.push({ k: 'trained', id: e.id, owner: e.owner, def: to, x: e.x, y: e.y }); +} + +/** Queue production/research/morph on a building. Returns an error message or null. */ +export function enqueue(g: Game, b: Entity, kind: QueueItem['kind'], id: string): string | null { + const p = g.players[b.owner]; + if (!b.built) return 'Structure is not finished'; + if (b.queue.length >= 5) return 'Queue is full'; + let cost: { m: number; g: number }; + let time: number; + let level = 0; + if (kind === 'unit') { + const d = DEFS[id]; + if (!g.techMet(p, d.requires)) return `Requires ${g.missingTech(p, d.requires)}`; + cost = d.cost; + time = d.time; + const sup = (d.supply ?? 0) * (d.count ?? 1); + if (b.queue.length === 0 && sup > 0 && p.supplyUsed + sup > p.supplyMax) { + return supplyError(g, p); + } + } else if (kind === 'research') { + const r = RESEARCH_DEFS[id]; + const done = p.level(id); + const inProgress = p.researching[id] ?? 0; + level = done + inProgress + 1; + if (level > r.costs.length) return 'Already researched'; + if (inProgress > 0) return 'Already researching'; + const reqs = r.reqs?.[level - 1]; + if (reqs && !g.techMet(p, reqs)) return `Requires ${g.missingTech(p, reqs)}`; + cost = r.costs[level - 1]; + time = r.times[level - 1]; + } else { + const d = DEFS[id]; + if (!g.techMet(p, d.requires)) return `Requires ${g.missingTech(p, d.requires)}`; + if (b.queue.some((q) => q.kind === 'morph')) return 'Already evolving'; + cost = d.cost; + time = d.time; + } + const err = g.affordError(p, cost); + if (err) return err; + g.spend(p, cost); + if (kind === 'research') p.researching[id] = (p.researching[id] ?? 0) + 1; + b.queue.push({ kind, id, level, progress: 0, total: Math.round(time * TICK_RATE), started: false, cost }); + return null; +} + +export function supplyError(g: Game, p: Player): string { + void g; + switch (p.race) { + case 'ironpaw': + return 'Not enough supply: build more Larders'; + case 'horde': + return 'Not enough supply: raise more Bumblebears'; + default: + return 'Not enough supply: call down more Star Totems'; + } +} + +/** Cancel a queue slot (refund). slot -1 cancels construction / morph in progress. */ +export function cancelSlot(g: Game, b: Entity, slot: number): void { + const p = g.players[b.owner]; + if (slot < 0) { + if (!b.built) { + const cost = b.def.cost; + g.refund(p, cost, 0.75); + if (b.fromWorker) { + const d = g.spawnUnit('forager', b.owner, b.x, b.y); + d.orders = []; + } + g.removeSilently(b); + g.events.push({ k: 'death', id: b.id, def: b.def.id, owner: b.owner, x: b.x, y: b.y, air: false, building: true, r: b.r }); + return; + } + if (b.morphTo && b.morphCost) { + g.refund(p, b.morphCost, 1); + if (b.def.kind === 'cocoon') { + // the cub returns to its hive (and dies with it if the hive is gone) + b.def = DEFS.cub; + b.morphTo = null; + b.morphCost = null; + b.r = DEFS.cub.radius ?? 8; + b.hp = DEFS.cub.hp; + const hive = g.get(b.hive); + if (hive) hive.cubs.push(b.id); + else g.removeSilently(b); + return; + } + b.morphTo = null; + b.morphCost = null; + } + return; + } + const item = b.queue[slot]; + if (!item) return; + b.queue.splice(slot, 1); + g.refund(p, item.cost, 1); + if (item.kind === 'research') p.researching[item.id] = Math.max(0, (p.researching[item.id] ?? 1) - 1); +} diff --git a/gh-pages/bear-war/src/game/systems/units.ts b/gh-pages/bear-war/src/game/systems/units.ts new file mode 100644 index 00000000..e192c379 --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/units.ts @@ -0,0 +1,778 @@ +import { ACQUIRE_INTERVAL, COMB_SPEED_BONUS, GAS_TICKS, GAS_TRIP, MINE_TICKS, MINERAL_TRIP, TICK_RATE, TILE } from '../constants.ts'; +import { ABILITY_DEFS, DEFS } from '../data/index.ts'; +import type { Entity } from '../entity.ts'; +import type { Game } from '../game.ts'; +import { clearanceFor } from '../grid.ts'; +import { datan2, dist, distToRect } from '../math.ts'; +import type { Player } from '../player.ts'; +import { baseSpeed, edgeDist, maxHp } from '../stats.ts'; +import { + acquireRadius, findTarget, fireWeapon, inWeaponRange, isValidTarget, weaponFor, canSee, +} from './combat.ts'; +import { advanceConstruction } from './structures.ts'; +import { canPlace } from './placement.ts'; +import { castAbility, lockUpdate } from './abilities.ts'; +import { cargoUpdate, doLoad, doPickup, doUnload } from './cargo.ts'; + +export function updateUnits(g: Game): void { + const ents = g.entities; + const n = ents.length; + for (let i = 0; i < n; i++) { + const e = ents[i]; + if (!e.alive || e.isResource) continue; + if (e.isBuilding) { + if (e.built && e.def.weapons && (!e.def.needsPower || e.powered)) towerUpdate(g, e); + continue; + } + e.wantMove = false; + if (e.inside) { + if (e.carrier) cargoUpdate(g, e); + else insideUpdate(g, e); + continue; + } + unitUpdate(g, e); + } +} + +// ------------------------------------------------------------------ helpers + +export function done(e: Entity): void { + e.orders.shift(); + e.ost = 0; + e.path = null; + e.target = 0; + e.stuckT = 0; +} + +export function unitSpeed(g: Game, e: Entity): number { + let s = baseSpeed(e, g.players[e.owner]); + if (!e.air && e.def.race === 'horde' && g.comb.hasAt(e.x, e.y)) s *= COMB_SPEED_BONUS; + return s; +} + +function steer(e: Entity, tx: number, ty: number, spd: number): void { + const dx = tx - e.x; + const dy = ty - e.y; + const d = Math.sqrt(dx * dx + dy * dy); + if (d < 0.01) return; + const s = spd < d ? spd : d; + e.vx = (dx / d) * s; + e.vy = (dy / d) * s; + e.wantMove = true; +} + +/** Move toward (x,y). Returns true once within `arrive` px (or no closer point is reachable). */ +export function moveTo(g: Game, e: Entity, x: number, y: number, arrive: number): boolean { + const dx = x - e.x; + const dy = y - e.y; + if (dx * dx + dy * dy <= arrive * arrive) { + e.path = null; + return true; + } + const spd = unitSpeed(g, e); + if (spd <= 0) return false; + if (e.air) { + steer(e, x, y, spd); + return false; + } + if (e.stuckT >= 80) { + // genuinely stuck: give up on this destination + e.stuckT = 0; + e.path = null; + return true; + } + if (e.repathT > 0) e.repathT--; + const stale = + !e.path || + Math.abs(e.pathGoalX - x) > 12 || + Math.abs(e.pathGoalY - y) > 12 || + (e.pathVer !== g.grid.version && ((g.tick + e.id) & 7) === 0) || + (e.stuckT >= 30 && e.stuckT % 30 === 0); + if (stale && (e.repathT <= 0 || !e.path)) { + const req = clearanceFor(e.r); + let path = sharedPath(g, e, x, y, req); + if (!path) { + if (g.pathBudget > 0 || !e.path) { + if (g.pathBudget > 0) { + path = g.grid.findPath(e.x, e.y, x, y, req); + g.pathBudget -= g.grid.lastExpanded + 40; + if (path && path.length > 2) { + const key = pathKey(g, x, y, req); + g.pathCache.set(key, { pts: path, sx: e.x, sy: e.y, gx: x, gy: y, tick: g.tick, ver: g.grid.version }); + } + } else { + // out of budget this tick: head straight for the goal and try again shortly + path = [x, y]; + e.repathT = 2 + (e.id % 3); + } + } + } + if (path || !e.path) { + e.path = path ?? []; + e.pathI = 0; + e.pathGoalX = x; + e.pathGoalY = y; + e.pathVer = g.grid.version; + if (e.repathT <= 0) e.repathT = 6; + } + } + const path = e.path!; + if (path.length === 0) return true; // nowhere closer to go + const n = path.length >> 1; + while (e.pathI < n) { + const wx = path[e.pathI * 2]; + const wy = path[e.pathI * 2 + 1]; + const ddx = wx - e.x; + const ddy = wy - e.y; + const last = e.pathI === n - 1; + const reach = last ? Math.max(arrive, 2) : Math.max(6, spd); + if (ddx * ddx + ddy * ddy <= reach * reach) { + if (last) { + e.path = null; + return true; + } + e.pathI++; + } else break; + } + steer(e, path[e.pathI * 2], path[e.pathI * 2 + 1], spd); + return false; +} + +function pathKey(g: Game, x: number, y: number, req: number): number { + // goals are bucketed into 4x4-cell regions so units moving in formation share paths + const rx = g.grid.cellX(x) >> 2; + const ry = g.grid.cellY(y) >> 2; + return (ry * ((g.grid.cw >> 2) + 1) + rx) * 4 + req; +} + +/** + * Reuse a path another unit computed recently towards the same area, if this unit + * started nearby and can see onto it. Turns 100 searches for a big army into a handful. + */ +function sharedPath(g: Game, e: Entity, x: number, y: number, req: number): number[] | null { + const c = g.pathCache.get(pathKey(g, x, y, req)); + if (!c || c.ver !== g.grid.version || g.tick - c.tick > 60) return null; + const dx = e.x - c.sx; + const dy = e.y - c.sy; + if (dx * dx + dy * dy > 200 * 200) return null; + const n = c.pts.length >> 1; + const lx = c.pts[c.pts.length - 2]; + const ly = c.pts[c.pts.length - 1]; + const reached = lx === c.gx && ly === c.gy; + for (let i = Math.min(n - 1, 5); i >= 0; i--) { + if (!g.grid.lineClear(e.x, e.y, c.pts[i * 2], c.pts[i * 2 + 1], req)) continue; + const out = c.pts.slice(i * 2); + if (reached) { + // finish at this unit's own goal when the last leg is open + const px = out.length >= 4 ? out[out.length - 4] : e.x; + const py = out.length >= 4 ? out[out.length - 3] : e.y; + if (!g.grid.lineClear(px, py, x, y, req)) return null; + out[out.length - 2] = x; + out[out.length - 1] = y; + } + return out; + } + return null; +} + +function face(e: Entity, t: { x: number; y: number }): void { + if (t.x !== e.x || t.y !== e.y) e.angle = datan2(t.y - e.y, t.x - e.x); +} + +function canMove(g: Game, e: Entity): boolean { + return (e.def.speed ?? 0) > 0 && !e.holdPos && unitSpeed(g, e) > 0; +} + +/** Attack t if in range, else (optionally) approach. Returns true if busy with t. */ +function engage(g: Game, e: Entity, t: Entity, chase: boolean): boolean { + const wi = weaponFor(e, t); + if (wi < 0) return false; + if (inWeaponRange(g, e, t, wi)) { + e.path = null; + if (e.cds[wi] <= 0) fireWeapon(g, e, wi, t); + else if (!e.def.turret) face(e, t); + e.turret = datan2(t.y - e.y, t.x - e.x); + return true; + } + if (!chase || !canMove(g, e)) return false; + moveTo(g, e, t.x, t.y, 0); + return true; +} + +function resumeGather(g: Game, e: Entity): void { + if (e.orders.length > 0 || !e.def.worker || !e.res) return; + const r = g.get(e.res); + if (r && (r.def.kind === 'mineral' || (r.def.refinery && r.owner === e.owner))) e.orders.push({ t: 'gather', target: r.id }); +} + +// ------------------------------------------------------------------ structures with weapons + +function towerUpdate(g: Game, e: Entity): void { + for (let i = 0; i < e.cds.length; i++) if (e.cds[i] > 0) e.cds[i]--; + let t = g.get(e.target); + if (t && (!isValidTarget(g, e, t) || !inWeaponRange(g, e, t, weaponFor(e, t)))) { + e.target = 0; + t = undefined; + } + if (!t && (g.tick + e.id) % ACQUIRE_INTERVAL === 0) { + t = findTarget(g, e, acquireRadius(e, g)); + if (t) e.target = t.id; + } + if (t) { + const wi = weaponFor(e, t); + e.turret = datan2(t.y - e.y, t.x - e.x); + if (e.cds[wi] <= 0 && inWeaponRange(g, e, t, wi)) fireWeapon(g, e, wi, t); + } +} + +// ------------------------------------------------------------------ units + +function unitUpdate(g: Game, e: Entity): void { + const p = g.players[e.owner]; + for (let i = 0; i < e.cds.length; i++) if (e.cds[i] > 0) e.cds[i]--; + if (e.lockT > 0) lockUpdate(g, e); + const kind = e.def.kind; + if (kind === 'cub') { + cubWander(g, e); + return; + } + if (kind === 'cocoon' || e.morphTo) return; + if (e.channelT > 0) { + e.channelT--; + return; + } + const o = e.orders[0]; + if (e.res && o?.t !== 'gather') { + // a worker pulled off mining frees its patch for others + const r = g.byId.get(e.res); + if (r && r.occupiedBy === e.id) r.occupiedBy = 0; + } + if (!o) { + e.idleT++; + idle(g, e, p); + return; + } + e.idleT = 0; + switch (o.t) { + case 'move': + return doMove(g, e, o); + case 'amove': + return doAttackMove(g, e, p, o.x, o.y, false); + case 'patrol': + return doAttackMove(g, e, p, o.x, o.y, true); + case 'attack': + return doAttack(g, e, o.target); + case 'hold': + e.holdPos = true; + idle(g, e, p); + return; + case 'gather': + return doGather(g, e, p, o); + case 'return': + return doReturn(g, e, p); + case 'build': + return doBuild(g, e, p, o); + case 'construct': + return doConstruct(g, e, o.target); + case 'repair': + return doRepair(g, e, p, o.target); + case 'ability': + return doAbility(g, e, o); + case 'load': + return doLoad(g, e, o.target); + case 'pickup': + return doPickup(g, e, o.target); + case 'unload': + return doUnload(g, e, o); + } +} + +function idle(g: Game, e: Entity, p: Player): void { + if (e.def.id === 'medic') { + medicHeal(g, e, !e.holdPos); + return; + } + if (!e.def.weapons) return; + let t = g.get(e.target); + if (t && !isValidTarget(g, e, t)) { + e.target = 0; + t = undefined; + } + if (!t && !e.def.worker && (g.tick + e.id) % ACQUIRE_INTERVAL === 0) { + const radius = e.holdPos ? weaponMaxRange(g, e) : acquireRadius(e, g); + t = findTarget(g, e, radius); + if (t) e.target = t.id; + } + if (t) { + if (!engage(g, e, t, !e.holdPos && canMove(g, e))) e.target = 0; + } + void p; +} + +function weaponMaxRange(g: Game, e: Entity): number { + let r = 0; + for (let i = 0; i < (e.def.weapons?.length ?? 0); i++) { + const w = e.def.weapons![i]; + r = Math.max(r, w.range * TILE + (w.melee ? 6 : 2)); + } + void g; + return r; +} + +function doMove(g: Game, e: Entity, o: { x: number; y: number; target: number }): void { + if (o.target) { + const t = g.get(o.target); + if (!t) { + done(e); + return; + } + const arrived = moveTo(g, e, t.x, t.y, e.r + t.r + 12); + if (arrived && (t.isBuilding || t.isResource || t.owner !== e.owner)) done(e); + return; + } + if (moveTo(g, e, o.x, o.y, e.groupN > 1 ? 3 : 2)) { + done(e); + e.arrived = true; + } +} + +function doAttackMove(g: Game, e: Entity, p: Player, x: number, y: number, patrol: boolean): void { + if (e.def.id === 'medic' && medicHeal(g, e, true)) return; + if (e.def.weapons) { + let t = g.get(e.target); + if (t && (!isValidTarget(g, e, t) || edgeDist(e, t) > acquireRadius(e, g) * 1.6)) { + e.target = 0; + t = undefined; + } + if ((g.tick + e.id) % ACQUIRE_INTERVAL === 0) { + // re-evaluate: switch to a better target if the current one isn't in range + const cur = t; + if (!cur || !inWeaponRange(g, e, cur, weaponFor(e, cur))) { + const nt = findTarget(g, e, acquireRadius(e, g)); + if (nt) { + t = nt; + e.target = nt.id; + } + } + } + if (t && engage(g, e, t, true)) return; + } + if (moveTo(g, e, x, y, e.groupN > 1 ? 3 : 2)) { + const o = e.orders[0]; + if (patrol && o && o.t === 'patrol') { + const nx = o.x2; + const ny = o.y2; + o.x2 = o.x; + o.y2 = o.y; + o.x = nx; + o.y = ny; + e.path = null; + } else done(e); + } + void p; +} + +function doAttack(g: Game, e: Entity, targetId: number): void { + const t = g.get(targetId); + if (!t) { + done(e); + return; + } + const team = g.players[e.owner].team; + if (!canSee(g, team, t)) { + // chase into the fog briefly, then give up + e.ost++; + if (e.ost > 3 * TICK_RATE || !canMove(g, e) || moveTo(g, e, t.x, t.y, 4)) done(e); + return; + } + e.ost = 0; + e.target = t.id; + if (weaponFor(e, t) < 0 || !g.isEnemy(e.owner, t.owner)) { + if (canMove(g, e)) moveTo(g, e, t.x, t.y, e.r + t.r + 8); + return; + } + if (!engage(g, e, t, true)) done(e); +} + +// ------------------------------------------------------------------ economy + +function nearestMineral(g: Game, x: number, y: number, maxD: number, exclude = 0, freeOnly = false): Entity | undefined { + let best: Entity | undefined; + let bd = maxD; + for (const m of g.structuresNear(x, y, maxD, g.tmp)) { + if (m.def.kind !== 'mineral' || m.id === exclude) continue; + if (freeOnly && m.occupiedBy && g.get(m.occupiedBy)) continue; + const d = dist(m.x, m.y, x, y); + if (d < bd || (d === bd && best && m.id < best.id)) { + bd = d; + best = m; + } + } + return best; +} + +function deposit(g: Game, e: Entity, p: Player): void { + const amt = e.carry * p.incomeMult; + if (e.carryKind === 1) { + p.minerals += amt; + p.stats.mineralsGathered += amt; + } else if (e.carryKind === 2) { + p.gas += amt; + p.stats.gasGathered += amt; + } + e.carry = 0; + e.carryKind = 0; +} + +function doGather(g: Game, e: Entity, p: Player, o: { t: 'gather'; target: number }): void { + if (!e.def.worker) { + done(e); + return; + } + let r = g.get(o.target); + if (r && r.def.kind !== 'mineral' && r.def.kind !== 'geyser' && !r.def.refinery) { + done(e); + return; + } + if (r && r.def.kind === 'geyser') { + const ref = g.get(r.link); + if (ref && ref.owner === e.owner) { + o.target = ref.id; + r = ref; + } else { + done(e); + return; + } + } + if (!r) { + // resource depleted: find another mineral near where we were working + const last = g.byId.get(o.target); + const nx = last ? last.x : e.x; + const ny = last ? last.y : e.y; + const alt = nearestMineral(g, nx, ny, 12 * TILE); + if (!alt) { + done(e); + return; + } + o.target = alt.id; + r = alt; + e.ost = 0; + } + const isGas = !!r.def.refinery; + if (isGas && (!r.built || r.owner !== e.owner)) { + done(e); + return; + } + if (e.res !== r.id) { + const old = g.byId.get(e.res); + if (old && old.occupiedBy === e.id) old.occupiedBy = 0; + e.res = r.id; + } + if (e.ost === 0 && e.carry > 0 && (e.carryKind !== (isGas ? 2 : 1) || e.carry >= (isGas ? GAS_TRIP : MINERAL_TRIP))) e.ost = 2; + switch (e.ost) { + case 0: { + const d = edgeDist(e, r); + if (d <= 5) { + const occ = g.get(r.occupiedBy); + if (!occ || occ === e) { + r.occupiedBy = e.id; + e.path = null; + if (isGas) { + e.inside = true; + e.mineT = GAS_TICKS; + } else { + e.mineT = MINE_TICKS; + e.ost = 1; + face(e, r); + } + } else if (!isGas) { + const alt = nearestMineral(g, e.x, e.y, 5 * TILE, r.id, true); + if (alt && dist(alt.x, alt.y, r.x, r.y) < 7 * TILE) { + o.target = alt.id; + e.res = alt.id; + } + } + } else moveTo(g, e, r.x, r.y, 0); + break; + } + case 1: { + e.mineT--; + if (e.mineT <= 0) { + const amt = Math.min(MINERAL_TRIP, r.amount); + r.amount -= amt; + e.carry = amt; + e.carryKind = 1; + r.occupiedBy = 0; + if (r.amount <= 0 && r.def.kind === 'mineral') g.kill(r); + e.ost = 2; + } + break; + } + case 2: { + if (e.carry <= 0) { + e.ost = 0; + break; + } + const depot = g.nearestDepot(e.owner, e.x, e.y); + if (!depot) break; + if (edgeDist(e, depot) <= 5) { + deposit(g, e, p); + e.ost = 0; + e.path = null; + } else moveTo(g, e, depot.x, depot.y, 0); + break; + } + } +} + +function insideUpdate(g: Game, e: Entity): void { + e.mineT--; + const ref = g.get(e.res); + if (!ref) { + e.inside = false; + return; + } + if (e.mineT > 0) return; + const gey = g.get(ref.link); + const amt = gey ? Math.min(GAS_TRIP, gey.amount) : 0; + if (gey) gey.amount -= amt; + e.carry = amt; + e.carryKind = amt > 0 ? 2 : 0; + e.inside = false; + ref.occupiedBy = 0; + // pop out on the side facing the nearest depot + const depot = g.nearestDepot(e.owner, ref.x, ref.y); + const tx = depot ? depot.x : ref.x; + const ty = depot ? depot.y : ref.y + 100; + const d = dist(ref.x, ref.y, tx, ty) || 1; + const ex = ref.x + ((tx - ref.x) / d) * (ref.r + e.r + 4); + const ey = ref.y + ((ty - ref.y) / d) * (ref.r + e.r + 4); + const free = g.grid.nearestFreePoint(ex, ey, 1); + e.x = e.px = free ? free.x : ex; + e.y = e.py = free ? free.y : ey; + e.ost = 2; + if (amt === 0 && e.orders[0]?.t === 'gather') done(e); +} + +function doReturn(g: Game, e: Entity, p: Player): void { + if (e.carry <= 0) { + done(e); + resumeGather(g, e); + return; + } + const depot = g.nearestDepot(e.owner, e.x, e.y); + if (!depot) { + done(e); + return; + } + if (edgeDist(e, depot) <= 5) { + deposit(g, e, p); + done(e); + resumeGather(g, e); + } else moveTo(g, e, depot.x, depot.y, 0); +} + +function doBuild(g: Game, e: Entity, p: Player, o: { def: string; tx: number; ty: number }): void { + const d = DEFS[o.def]; + const [w, h] = d.size!; + const cx = (o.tx + w / 2) * TILE; + const cy = (o.ty + h / 2) * TILE; + const race = d.race; + const siteDist = () => distToRect(e.x, e.y, o.tx * TILE, o.ty * TILE, (o.tx + w) * TILE, (o.ty + h) * TILE); + let arrived: boolean; + if (race === 'horde' && !d.refinery) { + // the forager becomes the building, so it has to stand on the site + if (!moveTo(g, e, cx, cy, 8)) return; + arrived = siteDist() <= 4; + } else { + arrived = siteDist() - e.r <= (race === 'starborn' ? 2.5 * TILE : 6); + if (!arrived) { + if (!moveTo(g, e, cx, cy, 0)) return; + arrived = siteDist() - e.r <= 3 * TILE; + } + } + if (!arrived) { + // moveTo stopped at the closest reachable point: retry a couple of times, then give up + if (++e.ost < 3) { + e.path = null; + return; + } + g.error(e.owner, "Can't reach the build site"); + done(e); + resumeGather(g, e); + return; + } + const err = canPlace(g, e.owner, d.id, o.tx, o.ty, e.id) ?? g.affordError(p, d.cost) ?? (g.techMet(p, d.requires) ? null : `Requires ${g.missingTech(p, d.requires)}`); + if (err) { + g.error(e.owner, err); + done(e); + resumeGather(g, e); + return; + } + g.spend(p, d.cost); + const b = g.placeBuilding(d.id, e.owner, o.tx, o.ty, false); + g.events.push({ k: 'placed', id: b.id, owner: e.owner, def: d.id, x: b.x, y: b.y }); + if (race === 'ironpaw') { + e.orders[0] = { t: 'construct', target: b.id }; + e.ost = 0; + e.path = null; + b.builder = e.id; + } else if (race === 'horde') { + b.fromWorker = true; + g.removeSilently(e); + } else { + done(e); + resumeGather(g, e); + } +} + +function doConstruct(g: Game, e: Entity, targetId: number): void { + const b = g.get(targetId); + if (!b || b.built || b.owner !== e.owner) { + done(e); + resumeGather(g, e); + return; + } + const other = g.get(b.builder); + if (other && other !== e && other.orders[0]?.t === 'construct' && (other.orders[0] as { target: number }).target === b.id) { + done(e); + return; + } + if (edgeDist(e, b) > 6) { + moveTo(g, e, b.x, b.y, 0); + return; + } + e.path = null; + b.builder = e.id; + face(e, b); + advanceConstruction(g, b, 1); + if ((g.tick + e.id) % 5 === 0) g.events.push({ k: 'spark', x: e.x + (b.x - e.x) * 0.25, y: e.y + (b.y - e.y) * 0.25, id: e.id }); + if (b.built) { + done(e); + resumeGather(g, e); + } +} + +export function canRepairTarget(g: Game, e: Entity, t: Entity): boolean { + if (!g.isAlly(e.owner, t.owner)) return false; + if (!t.def.attrs.includes('mech')) return false; + if (t.isBuilding && (!t.built || t.def.race !== 'ironpaw')) return false; + return t !== e; +} + +function doRepair(g: Game, e: Entity, p: Player, targetId: number): void { + const t = g.get(targetId); + if (!t || !canRepairTarget(g, e, t)) { + done(e); + return; + } + const tp = g.players[t.owner]; + const mh = maxHp(t, tp); + if (t.hp >= mh) { + done(e); + resumeGather(g, e); + return; + } + if (edgeDist(e, t) > 6) { + moveTo(g, e, t.x, t.y, 0); + return; + } + e.path = null; + face(e, t); + const rate = Math.min(mh - t.hp, mh / Math.max(1, t.def.time * TICK_RATE)); + const cm = (t.def.cost.m * 0.25 * rate) / mh; + const cg = (t.def.cost.g * 0.25 * rate) / mh; + if (p.minerals < cm || p.gas < cg) { + g.error(e.owner, 'Not enough resources to repair'); + done(e); + return; + } + p.minerals -= cm; + p.gas -= cg; + t.hp += rate; + if ((g.tick + e.id) % 5 === 0) g.events.push({ k: 'spark', x: (e.x + t.x) / 2, y: (e.y + t.y) / 2, id: e.id }); +} + +function doAbility(g: Game, e: Entity, o: { ab: string; x: number; y: number; target: number }): void { + const ab = ABILITY_DEFS[o.ab]; + let tx = o.x; + let ty = o.y; + let t: Entity | undefined; + if (o.target) { + t = g.get(o.target); + if (!t) { + done(e); + return; + } + tx = t.x; + ty = t.y; + } + const range = ab.range ? ab.range * TILE : 0; + if (range > 0) { + const d = t ? edgeDist(e, t) : dist(e.x, e.y, tx, ty); + if (d > range) { + if (!canMove(g, e) || moveTo(g, e, tx, ty, range * 0.9)) { + if ((t ? edgeDist(e, t) : dist(e.x, e.y, tx, ty)) > range) { + done(e); + return; + } + } else return; + } + } + e.path = null; + face(e, { x: tx, y: ty }); + castAbility(g, e, ab, tx, ty, t); + done(e); +} + +// ------------------------------------------------------------------ medic + +/** Heal the most hurt biological ally nearby. Returns true while healing/approaching. */ +function medicHeal(g: Game, e: Entity, approach: boolean): boolean { + if (e.energy < 0.5) return false; + const reach = 4 * TILE; + const look = approach ? 7 * TILE : reach; + let best: Entity | undefined; + let bestScore = 1e18; + for (const u of g.unitsNear(e.x, e.y, look, g.tmp)) { + if (u === e || u.air || !g.isAlly(e.owner, u.owner) || !u.def.attrs.includes('bio') || u.def.kind !== 'unit') continue; + const mh = maxHp(u, g.players[u.owner]); + if (u.hp >= mh - 0.01) continue; + const score = u.hp / mh + dist(u.x, u.y, e.x, e.y) / (20 * TILE); + if (score < bestScore) { + bestScore = score; + best = u; + } + } + if (!best) return false; + if (edgeDist(e, best) > reach) { + if (!approach) return false; + moveTo(g, e, best.x, best.y, 0); + return true; + } + e.path = null; + face(e, best); + const mh = maxHp(best, g.players[best.owner]); + const amt = Math.min(12.6 / TICK_RATE, mh - best.hp, e.energy * 3); + best.hp += amt; + e.energy -= amt / 3; + if ((g.tick + e.id) % 4 === 0) g.events.push({ k: 'heal', id: e.id, tid: best.id, x: e.x, y: e.y, tx: best.x, ty: best.y }); + return true; +} + +// ------------------------------------------------------------------ cubs + +function cubWander(g: Game, e: Entity): void { + const hive = g.get(e.hive); + if (!hive) return; + if (e.wanderT <= 0) { + e.wanderT = 30 + g.rng.int(70); + e.pathGoalX = hive.x + (g.rng.next() - 0.5) * hive.tw * TILE * 0.8; + e.pathGoalY = hive.y1 + 4 + g.rng.next() * 14; + } + e.wanderT--; + const spd = ((e.def.speed ?? 0.5) * TILE) / TICK_RATE; + const dx = e.pathGoalX - e.x; + const dy = e.pathGoalY - e.y; + if (dx * dx + dy * dy > 4) steer(e, e.pathGoalX, e.pathGoalY, spd); +} diff --git a/gh-pages/bear-war/src/game/systems/vision.ts b/gh-pages/bear-war/src/game/systems/vision.ts new file mode 100644 index 00000000..8d5ddd04 --- /dev/null +++ b/gh-pages/bear-war/src/game/systems/vision.ts @@ -0,0 +1,131 @@ +// Fog of war. Per-team visibility at tile resolution, recomputed every few ticks. +// Ground units cannot see up cliffs: a tile is only visible if its elevation is not +// higher than the viewer's. Air units, scans and detection ignore elevation. + +import { TILE } from '../constants.ts'; +import type { Game } from '../game.ts'; + +export class Vision { + w: number; + h: number; + teams: number; + vis: Uint8Array[]; + explored: Uint8Array[]; + det: Uint8Array[]; + version = 0; + private circles = new Map(); + + constructor(w: number, h: number, teams: number) { + this.w = w; + this.h = h; + this.teams = teams; + this.vis = []; + this.explored = []; + this.det = []; + for (let t = 0; t < teams; t++) { + this.vis.push(new Uint8Array(w * h)); + this.explored.push(new Uint8Array(w * h)); + this.det.push(new Uint8Array(w * h)); + } + } + + private circle(r: number): Int16Array { + const key = Math.round(r * 4); + let c = this.circles.get(key); + if (!c) { + const pts: number[] = []; + const rr = (key / 4) * (key / 4); + const R = Math.ceil(key / 4); + for (let dy = -R; dy <= R; dy++) for (let dx = -R; dx <= R; dx++) if (dx * dx + dy * dy <= rr) pts.push(dx, dy); + c = new Int16Array(pts); + this.circles.set(key, c); + } + return c; + } + + /** Stamp a vision circle (radius in tiles) centred at world (x, y). */ + stamp(team: number, x: number, y: number, r: number, elev: number, ignoreElev: boolean, detect: boolean, detectR: number, elevMap: Uint8Array): void { + const cx = Math.floor(x / TILE); + const cy = Math.floor(y / TILE); + const vis = this.vis[team]; + const exp = this.explored[team]; + const c = this.circle(r); + const w = this.w; + const h = this.h; + for (let i = 0; i < c.length; i += 2) { + const tx = cx + c[i]; + const ty = cy + c[i + 1]; + if (tx < 0 || ty < 0 || tx >= w || ty >= h) continue; + const t = ty * w + tx; + if (!ignoreElev && elevMap[t] > elev) continue; + vis[t] = 1; + exp[t] = 1; + } + if (detect) { + const det = this.det[team]; + const d = this.circle(detectR); + for (let i = 0; i < d.length; i += 2) { + const tx = cx + d[i]; + const ty = cy + d[i + 1]; + if (tx < 0 || ty < 0 || tx >= w || ty >= h) continue; + det[ty * w + tx] = 1; + } + } + } + + update(g: Game): void { + for (let t = 0; t < this.teams; t++) { + this.vis[t].fill(0); + this.det[t].fill(0); + } + const elev = g.map.elev; + const w = this.w; + for (const e of g.entities) { + if (!e.alive || e.owner < 0 || e.inside) continue; + const team = g.players[e.owner].team; + let sight = e.def.sight; + if (e.isBuilding && !e.built) sight = Math.min(sight, 5); + const tx = Math.floor(e.x / TILE); + const ty = Math.floor(e.y / TILE); + const el = tx >= 0 && ty >= 0 && tx < w && ty < this.h ? elev[ty * w + tx] : 0; + const isDet = !!e.def.detector && (e.built || !e.isBuilding); + this.stamp(team, e.x, e.y, sight, el, e.air, isDet, e.def.detector ?? 0, elev); + } + for (const fx of g.effects) { + if (fx.kind === 'sniff') this.stamp(fx.team, fx.x, fx.y, fx.r / TILE, 0, true, true, fx.r / TILE, elev); + else if (fx.kind === 'spirit') this.stamp(fx.team, fx.x, fx.y, 4, 0, true, false, 0, elev); + } + // players without a town hall are revealed to all + for (const p of g.players) { + if (!p.revealed || p.defeated) continue; + for (const e of g.entities) { + if (!e.alive || e.owner !== p.index || !e.isBuilding) continue; + for (let t = 0; t < this.teams; t++) if (t !== p.team) this.stamp(t, e.x, e.y, 3, 0, true, false, 0, elev); + } + } + this.version++; + } + + visibleTile(team: number, tx: number, ty: number): boolean { + if (team < 0) return true; + if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return false; + return this.vis[team][ty * this.w + tx] !== 0; + } + visibleAt(team: number, x: number, y: number): boolean { + return this.visibleTile(team, Math.floor(x / TILE), Math.floor(y / TILE)); + } + exploredAt(team: number, x: number, y: number): boolean { + const tx = Math.floor(x / TILE); + const ty = Math.floor(y / TILE); + if (team < 0) return true; + if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return false; + return this.explored[team][ty * this.w + tx] !== 0; + } + detectedAt(team: number, x: number, y: number): boolean { + const tx = Math.floor(x / TILE); + const ty = Math.floor(y / TILE); + if (tx < 0 || ty < 0 || tx >= this.w || ty >= this.h) return false; + const i = ty * this.w + tx; + return this.det[team][i] !== 0 && this.vis[team][i] !== 0; + } +} diff --git a/gh-pages/bear-war/src/game/types.ts b/gh-pages/bear-war/src/game/types.ts new file mode 100644 index 00000000..d51ff134 --- /dev/null +++ b/gh-pages/bear-war/src/game/types.ts @@ -0,0 +1,96 @@ +import type { WeaponDef } from './data/types.ts'; + +/** Player commands. These are what travels over the network in lockstep. */ +export type Command = + | { c: 'move'; ids: number[]; x: number; y: number; q?: boolean } + | { c: 'smart'; ids: number[]; x: number; y: number; t?: number; q?: boolean } + | { c: 'amove'; ids: number[]; x: number; y: number; q?: boolean } + | { c: 'attack'; ids: number[]; t: number; q?: boolean } + | { c: 'patrol'; ids: number[]; x: number; y: number; q?: boolean } + | { c: 'stop'; ids: number[] } + | { c: 'hold'; ids: number[] } + | { c: 'gather'; ids: number[]; t: number; q?: boolean } + | { c: 'return'; ids: number[]; q?: boolean } + | { c: 'build'; ids: number[]; d: string; tx: number; ty: number; q?: boolean } + | { c: 'repair'; ids: number[]; t: number; q?: boolean } + | { c: 'train'; ids: number[]; d: string } + | { c: 'research'; ids: number[]; r: string } + | { c: 'morph'; ids: number[]; d: string } + | { c: 'cancel'; id: number; slot: number } + | { c: 'rally'; ids: number[]; x: number; y: number; t?: number } + | { c: 'ability'; ids: number[]; a: string; x?: number; y?: number; t?: number; q?: boolean } + | { c: 'autocast'; ids: number[]; on: boolean } + /** board transport/bunker t, or (transports selected) pick up unit t */ + | { c: 'load'; ids: number[]; t: number; q?: boolean } + /** unload everything at (x,y) (in place if omitted), or only unit u right here */ + | { c: 'unload'; ids: number[]; x?: number; y?: number; u?: number; q?: boolean } + | { c: 'surrender' } + | { c: 'leave' }; + +export interface TickCommand { + p: number; + c: Command; +} + +export type GameEvent = + | { k: 'fire'; id: number; tid: number; fx: string; x: number; y: number; tx: number; ty: number; owner: number; air: boolean } + | { k: 'hit'; fx: string; x: number; y: number; tid: number; shield: boolean } + | { k: 'splash'; fx: string; x: number; y: number; r: number } + | { k: 'death'; id: number; def: string; owner: number; x: number; y: number; air: boolean; building: boolean; r: number } + | { k: 'built'; id: number; owner: number; def: string; x: number; y: number } + | { k: 'placed'; id: number; owner: number; def: string; x: number; y: number } + | { k: 'trained'; id: number; owner: number; def: string; x: number; y: number } + | { k: 'researched'; owner: number; id: string; level: number } + | { k: 'error'; owner: number; msg: string } + | { k: 'alert'; owner: number; kind: 'attack' | 'supply' | 'base'; x: number; y: number } + | { k: 'ability'; owner: number; ab: string; x: number; y: number; id: number; tx: number; ty: number } + | { k: 'blink'; id: number; x0: number; y0: number; x1: number; y1: number; owner: number } + | { k: 'heal'; id: number; tid: number; x: number; y: number; tx: number; ty: number } + | { k: 'spark'; x: number; y: number; id: number } + | { k: 'cargo'; id: number; carrier: number; owner: number; x: number; y: number; load: boolean } + | { k: 'defeat'; player: number } + | { k: 'revealed'; player: number } + | { k: 'gameover'; winners: number[] }; + +export interface Projectile { + id: number; + x: number; + y: number; + px: number; + py: number; + tx: number; + ty: number; + target: number; + speed: number; + owner: number; + src: number; + w: WeaponDef; + /** damage multiplier (bounces) */ + mult: number; + bounces: number; + hit: number[]; + air: boolean; + alive: boolean; + age: number; +} + +export type EffectKind = + | 'sniff' | 'beecloud' | 'stingswarm' | 'starfall' | 'grandshot' | 'staticburst' + | 'pollen' | 'hornets' | 'honeyglob' | 'spirit' | 'snowball'; + +export interface AreaEffect { + id: number; + kind: EffectKind; + x: number; + y: number; + /** radius in px */ + r: number; + owner: number; + team: number; + t: number; + total: number; + src: number; + target: number; + /** moving effects (spirits, snowballs): waypoints still to visit, flat [x0,y0,x1,y1,...] */ + path?: number[]; +} diff --git a/gh-pages/bear-war/src/main.ts b/gh-pages/bear-war/src/main.ts new file mode 100644 index 00000000..4c1a8933 --- /dev/null +++ b/gh-pages/bear-war/src/main.ts @@ -0,0 +1,87 @@ +import './style.css'; +import { boot } from './app/app.ts'; +import { openMultiplayer } from './app/lobby.ts'; +import { saveSettings } from './app/settings.ts'; +import { RACE_IDS } from './game/data/index.ts'; +import type { RaceId } from './game/data/types.ts'; +import { Game } from './game/game.ts'; +import { MAPS, loadMap } from './game/map/maps.ts'; +import type { AIDifficulty } from './game/player.ts'; +import { LocalSession } from './net/session.ts'; + +const root = document.getElementById('app')!; +const app = boot(root); +app.mp = openMultiplayer; + +// expose for debugging in the console +(window as unknown as { bearwar: unknown }).bearwar = app; + +// quick start for development / sharing: ?quick&race=horde&vs=starborn&ai=hard&map=twin-craters +const q = new URLSearchParams(location.search); +if (q.get('server')) { + app.settings.server = q.get('server')!; + saveSettings(app.settings); +} +if (q.has('join')) { + app.multiplayerMenu(); +} else if (q.has('quick')) { + const race = (q.get('race') ?? 'ironpaw') as RaceId; + const vs = (q.get('vs') ?? 'random') as RaceId | 'random'; + const ai = (q.get('ai') ?? 'normal') as AIDifficulty; + app.skirmish.slots[0].race = race; + app.skirmish.slots[1].race = vs; + app.skirmish.slots[1].ai = ai; + if (q.get('map')) app.skirmish.map = q.get('map')!; + app.startSkirmish(); +} else if (q.has('attract')) { + document.body.classList.add('attract'); + app.settings.master = 0; + app.ingameMenu = () => {}; + app.endScreen = () => location.reload(); + const pick = (a: T[]) => a[Math.floor(Math.random() * a.length)]; + const players = [0, 1].map((i) => ({ name: 'AI', race: pick(RACE_IDS), color: i, team: i, ai: 'hard' as const })); + app.launch(new LocalSession(new Game({ map: loadMap(pick(MAPS).id), players, seed: (Math.random() * 2 ** 31) | 0 }), -1)); + const cam = app.game!.renderer.cam; + let to = { x: cam.centerX, y: cam.centerY }; + let userUntil = 0; + let team = -1; + for (const ev of ['pointerdown', 'wheel', 'keydown']) addEventListener(ev, () => (userUntil = performance.now() + 20000), { capture: true, passive: true }); + const retarget = () => { + const g = app.game!.session.game; + const mine = g.entities.filter((e) => e.alive && e.owner >= 0 && (team < 0 || g.players[e.owner].team === team)); + const army = mine.filter((e) => !e.isBuilding && !e.def.worker); + const fighting = army.filter((e) => e.cds.some((c) => c > 0)); + to = pick(fighting.length ? fighting : army.length ? army : mine) ?? to; + }; + setInterval(retarget, 8000); + const glide = () => { + if (app.game!.ctl.view.team !== team) { + team = app.game!.ctl.view.team; + userUntil = 0; + retarget(); + } + if (performance.now() > userUntil) cam.centerOn(cam.centerX + (to.x - cam.centerX) * 0.02, cam.centerY + (to.y - cam.centerY) * 0.02); + requestAnimationFrame(glide); + }; + glide(); + document.querySelector('.spec-auto')?.click(); +} + +if (!q.has('attract')) { + // AdSense H5 Games Ads: the preroll is a no-op until game ads are enabled for the site + ((window as unknown as { adsbygoogle?: object[] }).adsbygoogle ??= []).push({ type: 'preroll' }); + const ads = Object.assign(document.createElement('script'), { async: true, crossOrigin: 'anonymous', src: 'https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-8496762857844623' }); + if (import.meta.env.DEV) ads.dataset.adbreakTest = 'on'; + document.head.append(ads); +} + +// offline support: cache the whole game once it has been loaded (production builds only) +if ('serviceWorker' in navigator && import.meta.env.PROD) { + window.addEventListener('load', () => { + navigator.serviceWorker.register('./sw.js').catch((err) => console.warn('service worker registration failed', err)); + }); +} + +// prevent pinch-zoom / double-tap zoom of the page itself on iOS +document.addEventListener('gesturestart', (e) => e.preventDefault()); +document.addEventListener('dblclick', (e) => e.preventDefault(), { passive: false }); diff --git a/gh-pages/bear-war/src/net/client.ts b/gh-pages/bear-war/src/net/client.ts new file mode 100644 index 00000000..54cc8f21 --- /dev/null +++ b/gh-pages/bear-war/src/net/client.ts @@ -0,0 +1,114 @@ +import { PROTOCOL_VERSION, type C2S, type S2C } from './protocol.ts'; + +/** Thin WebSocket wrapper with JSON messages and RTT measurement. */ +export class NetClient { + ws: WebSocket | null = null; + id = ''; + url = ''; + rtt = 0; + motd = ''; + private handlers = new Set<(m: S2C) => void>(); + private closeHandlers = new Set<() => void>(); + private pingTimer = 0; + + connect(url: string, name: string): Promise { + this.url = url; + return new Promise((resolve, reject) => { + let settled = false; + let ws: WebSocket; + try { + ws = new WebSocket(url); + } catch (e) { + reject(e); + return; + } + this.ws = ws; + const timer = window.setTimeout(() => { + if (!settled) { + settled = true; + ws.close(); + reject(new Error('Connection timed out')); + } + }, 8000); + ws.onopen = () => this.send({ t: 'hello', name, v: PROTOCOL_VERSION, sim: __SIM_HASH__ }); + ws.onmessage = (ev) => { + let m: S2C; + try { + m = JSON.parse(String(ev.data)); + } catch { + return; + } + if (m.t === 'error' && !settled) { + // refused during the handshake (e.g. version mismatch) + settled = true; + window.clearTimeout(timer); + reject(new Error(m.msg)); + ws.close(); + return; + } + if (m.t === 'welcome') { + this.id = m.id; + this.motd = m.motd ?? ''; + if (!settled) { + settled = true; + window.clearTimeout(timer); + resolve(); + } + } + if (m.t === 'pong') this.rtt = performance.now() - m.at; + for (const h of this.handlers) h(m); + }; + ws.onerror = () => { + if (!settled) { + settled = true; + window.clearTimeout(timer); + reject(new Error('Could not connect to server')); + } + }; + ws.onclose = () => { + window.clearInterval(this.pingTimer); + if (!settled) { + settled = true; + window.clearTimeout(timer); + reject(new Error('Connection closed')); + } + for (const h of this.closeHandlers) h(); + }; + this.pingTimer = window.setInterval(() => this.send({ t: 'ping', at: performance.now() }), 2000); + }); + } + + get open(): boolean { + return !!this.ws && this.ws.readyState === WebSocket.OPEN; + } + + send(m: C2S): void { + if (this.ws && this.ws.readyState === WebSocket.OPEN) this.ws.send(JSON.stringify(m)); + } + + on(fn: (m: S2C) => void): () => void { + this.handlers.add(fn); + return () => this.handlers.delete(fn); + } + + onClose(fn: () => void): () => void { + this.closeHandlers.add(fn); + return () => this.closeHandlers.delete(fn); + } + + close(): void { + window.clearInterval(this.pingTimer); + this.handlers.clear(); + this.closeHandlers.clear(); + this.ws?.close(); + this.ws = null; + } +} + +/** Default server: same host as the page (the bundled server), or a build-time override. */ +export function defaultServerUrl(): string { + const env = (import.meta as unknown as { env?: Record }).env?.VITE_SERVER_URL; + if (env) return env; + const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + return `${proto}//${location.host}/ws`; +} diff --git a/gh-pages/bear-war/src/net/netsession.ts b/gh-pages/bear-war/src/net/netsession.ts new file mode 100644 index 00000000..cc1a5bd0 --- /dev/null +++ b/gh-pages/bear-war/src/net/netsession.ts @@ -0,0 +1,201 @@ +import { TICK_MS } from '../game/constants.ts'; +import type { Game } from '../game/game.ts'; +import type { Command, GameEvent, TickCommand } from '../game/types.ts'; +import { NetClient } from './client.ts'; +import { HASH_EVERY, TURN_TICKS, type S2C } from './protocol.ts'; +import type { Session } from './session.ts'; + +/** + * Lockstep session: the simulation only advances through turns received from the + * server, so every client executes exactly the same commands on exactly the same tick. + */ +export class NetSession implements Session { + game: Game; + localPlayer: number; + readonly online = true; + speed: number; + paused = false; + status = ''; + log: [number, number, Command][] = []; + ping = 0; + onChat: ((from: string, text: string, sys: boolean) => void) | null = null; + private client: NetClient; + private turns = new Map(); + private maxTurn = -1; + private next = 0; + private sub = 0; + private acc = 0; + private events: GameEvent[] = []; + private pending: Command[] = []; + private unsub: () => void; + private unsubClose: () => void; + private starving = 0; + private desyncMsg = 0; + private waitMsg = ''; + private disconnected = false; + + private rejoinInfo: { code: string; token: string; name: string } | null; + private reconnecting = false; + private disposed = false; + private rejoinError = ''; + private leaveSent = false; + + constructor(client: NetClient, game: Game, you: number, speed: number, rejoin?: { code: string; token: string; name: string }) { + this.client = client; + this.game = game; + this.localPlayer = you; + this.speed = speed; + this.rejoinInfo = rejoin && rejoin.token ? rejoin : null; + this.unsub = client.on((m) => this.onMessage(m)); + this.unsubClose = client.onClose(() => this.onDisconnect()); + } + + private onDisconnect(): void { + this.disconnected = true; + if (!this.disposed && !this.game.over && this.rejoinInfo) void this.reconnect(); + } + + /** Mobile connections drop (app switch, network change): reconnect and resume where we were. */ + private async reconnect(): Promise { + if (this.reconnecting || !this.rejoinInfo) return; + this.reconnecting = true; + const url = this.client.url; + const deadline = performance.now() + 40000; + while (!this.disposed && performance.now() < deadline && !this.rejoinError) { + const c = new NetClient(); + try { + await c.connect(url, this.rejoinInfo.name); + } catch { + await new Promise((r) => window.setTimeout(r, 2000)); + continue; + } + if (this.disposed) { + c.close(); + break; + } + this.unsub(); + this.unsubClose(); + this.client = c; + this.unsub = c.on((m) => this.onMessage(m)); + this.unsubClose = c.onClose(() => this.onDisconnect()); + c.send({ t: 'rejoin', code: this.rejoinInfo.code, token: this.rejoinInfo.token, from: this.next }); + this.reconnecting = false; + return; + } + this.reconnecting = false; + } + + private onMessage(m: S2C): void { + switch (m.t) { + case 'turn': + this.turns.set(m.n, m.c ?? []); + if (m.n > this.maxTurn) this.maxTurn = m.n; + break; + case 'pause': + this.paused = true; + this.waitMsg = `Waiting for ${m.waiting.join(', ') || 'players'}…`; + break; + case 'resume': + this.paused = false; + this.waitMsg = ''; + break; + case 'desync': + this.desyncMsg = performance.now() + 8000; + break; + case 'chat': + this.onChat?.(m.from, m.text, !!m.sys); + break; + case 'rejoined': + for (const [n, c] of m.turns) { + if (n < this.next) continue; + this.turns.set(n, c ?? []); + if (n > this.maxTurn) this.maxTurn = n; + } + this.disconnected = false; + break; + case 'error': + if (this.disconnected) this.rejoinError = m.msg; + break; + } + } + + issue(cmd: Command): void { + if (this.localPlayer < 0) return; + this.pending.push(cmd); + } + + chat(text: string): void { + const t = text.trim().slice(0, 200); + if (t) this.client.send({ t: 'chat', text: t }); + } + + flush(): void { + if (this.pending.length) { + this.client.send({ t: 'cmd', c: this.pending }); + this.pending = []; + } + } + + update(dtMs: number): number { + this.flush(); + this.ping = Math.round(this.client.rtt); + const tickMs = TICK_MS / this.speed; + const bufferedTicks = (this.maxTurn - this.next + 1) * TURN_TICKS - this.sub; + // catch up smoothly when we have fallen behind (e.g. after the tab was hidden) + const mult = bufferedTicks > 6 ? 1 + Math.min(8, (bufferedTicks - 6) / 4) : 1; + this.acc += Math.min(dtMs, 1000) * mult; + let steps = 0; + while (this.acc >= tickMs && steps < 60) { + const turn = this.turns.get(this.next); + if (!turn) { + this.acc = Math.min(this.acc, tickMs); + this.starving += dtMs; + break; + } + this.starving = 0; + let cmds: TickCommand[] = []; + if (this.sub === 0 && turn.length) { + cmds = turn.map(([p, c]) => ({ p, c })); + for (const tc of cmds) this.log.push([this.game.tick, tc.p, tc.c]); + } + this.game.step(cmds); + for (const ev of this.game.events) this.events.push(ev); + this.acc -= tickMs; + steps++; + this.sub++; + if (this.sub >= TURN_TICKS) { + this.sub = 0; + this.turns.delete(this.next); + const done = this.next; + this.next++; + if (done % HASH_EVERY === 0) this.client.send({ t: 'ack', turn: this.next, hash: this.game.hash(), htick: this.game.tick }); + else if (done % 5 === 0) this.client.send({ t: 'ack', turn: this.next }); + } + } + // once defeated, tell the server so the game doesn't wait for us if we close the tab + if (!this.leaveSent && this.localPlayer >= 0 && this.game.players[this.localPlayer]?.defeated && !this.game.over) { + this.leaveSent = true; + this.client.send({ t: 'cmd', c: [{ c: 'leave' }] }); + } + if (this.disconnected) this.status = this.rejoinError ? `Disconnected: ${this.rejoinError}` : this.reconnecting ? 'Connection lost, reconnecting…' : 'Disconnected from server'; + else if (this.waitMsg) this.status = this.waitMsg; + else if (performance.now() < this.desyncMsg) this.status = 'Desync detected, results may differ between players'; + else if (this.starving > 1500 && !this.game.over) this.status = 'Waiting for server…'; + else this.status = ''; + return Math.min(0.999, this.acc / tickMs); + } + + drainEvents(): GameEvent[] { + const e = this.events; + this.events = []; + return e; + } + + dispose(): void { + this.disposed = true; + this.flush(); + this.unsub(); + this.unsubClose(); + this.client.close(); + } +} diff --git a/gh-pages/bear-war/src/net/protocol.ts b/gh-pages/bear-war/src/net/protocol.ts new file mode 100644 index 00000000..bc1b7f0a --- /dev/null +++ b/gh-pages/bear-war/src/net/protocol.ts @@ -0,0 +1,81 @@ +// Wire protocol shared by the browser client and the Node relay server. +// +// Multiplayer uses deterministic lockstep: the server never simulates the game. It +// collects player commands, groups them into numbered "turns" (one every 100 ms) and +// broadcasts each turn to every client, which all execute identical simulations. + +import type { Command } from '../game/types.ts'; +import type { AIDifficulty, PlayerSetup } from '../game/player.ts'; +import type { RaceId } from '../game/data/types.ts'; + +export const PROTOCOL_VERSION = 2; +export const TURN_TICKS = 2; +export const BASE_TURN_MS = 100; +/** clients send a state hash every N turns for desync detection */ +export const HASH_EVERY = 50; +/** pause the game when a client falls this many turns behind */ +export const MAX_LAG_TURNS = 30; +/** drop a client after the game has waited this long for it */ +export const DROP_AFTER_MS = 45000; + +export type RacePick = RaceId | 'random'; +export type SlotKind = 'open' | 'closed' | 'human' | 'ai'; + +export interface Slot { + kind: SlotKind; + id?: string; + name?: string; + race: RacePick; + color: number; + team: number; + ai?: AIDifficulty; +} + +export interface RoomInfo { + code: string; + name: string; + map: string; + players: number; + max: number; + started: boolean; +} + +export interface RoomState { + code: string; + name: string; + host: string; + map: string; + speed: number; + public: boolean; + slots: Slot[]; + started: boolean; +} + +export type C2S = + | { t: 'hello'; name: string; v: number; sim?: string } + | { t: 'list' } + | { t: 'create'; name: string; map: string; maxPlayers: number; public: boolean } + | { t: 'join'; code: string } + | { t: 'leaveRoom' } + | { t: 'slot'; slot: number; race?: RacePick; color?: number; team?: number; kind?: SlotKind; ai?: AIDifficulty } + | { t: 'settings'; map?: string; maxPlayers?: number; speed?: number } + | { t: 'chat'; text: string } + | { t: 'start' } + | { t: 'cmd'; c: Command[] } + | { t: 'ack'; turn: number; hash?: number; htick?: number } + | { t: 'rejoin'; code: string; token: string; from: number } + | { t: 'ping'; at: number }; + +export type S2C = + | { t: 'welcome'; id: string; v: number; motd?: string } + | { t: 'rooms'; rooms: RoomInfo[] } + | { t: 'room'; room: RoomState | null } + | { t: 'chat'; from: string; text: string; sys?: boolean } + | { t: 'error'; msg: string } + | { t: 'start'; seed: number; map: string; players: PlayerSetup[]; you: number; speed: number; code: string; token: string } + | { t: 'rejoined'; turns: [number, [number, Command][] | null][] } + | { t: 'turn'; n: number; c?: [number, Command][] } + | { t: 'pause'; waiting: string[] } + | { t: 'resume' } + | { t: 'desync'; tick: number } + | { t: 'pong'; at: number }; diff --git a/gh-pages/bear-war/src/net/replay.ts b/gh-pages/bear-war/src/net/replay.ts new file mode 100644 index 00000000..ee2547fe --- /dev/null +++ b/gh-pages/bear-war/src/net/replay.ts @@ -0,0 +1,113 @@ +// Replays: because the simulation is deterministic, a whole game is just its setup plus +// the command log. ReplaySession re-simulates it with full vision and speed control. + +import { TICK_MS } from '../game/constants.ts'; +import { Game } from '../game/game.ts'; +import { loadMap } from '../game/map/maps.ts'; +import type { PlayerSetup } from '../game/player.ts'; +import type { Command, GameEvent, TickCommand } from '../game/types.ts'; +import type { Session } from './session.ts'; + +export const REPLAY_VERSION = 1; + +export interface ReplayFile { + format: 'bear-war-replay'; + version: number; + gameVersion: string; + date: string; + map: string; + seed: number; + players: PlayerSetup[]; + /** [tick, player, command] */ + log: [number, number, Command][]; + ticks: number; + winners: number[]; +} + +export function makeReplay(game: Game, players: PlayerSetup[], log: [number, number, Command][], gameVersion: string): ReplayFile { + return { + format: 'bear-war-replay', + version: REPLAY_VERSION, + gameVersion, + date: new Date().toISOString(), + map: game.map.id, + seed: game.seed, + players, + log, + ticks: game.tick, + winners: game.winners, + }; +} + +export function parseReplay(text: string): ReplayFile { + const r = JSON.parse(text) as ReplayFile; + if (r.format !== 'bear-war-replay' || !Array.isArray(r.log) || !Array.isArray(r.players)) throw new Error('Not an Bear War replay file'); + if (r.version > REPLAY_VERSION) throw new Error('This replay was made with a newer version of the game'); + return r; +} + +export class ReplaySession implements Session { + game: Game; + readonly localPlayer = -1; + readonly online = false; + speed = 1; + paused = false; + status = ''; + log: [number, number, Command][]; + replay: ReplayFile; + private idx = 0; + private acc = 0; + private events: GameEvent[] = []; + + constructor(replay: ReplayFile) { + this.replay = replay; + this.log = replay.log; + this.game = new Game({ map: loadMap(replay.map), players: replay.players, seed: replay.seed }); + } + + issue(): void {} + + update(dtMs: number): number { + if (this.paused) return 0; + if (this.game.tick >= this.replay.ticks) { + this.status = 'Replay finished'; + return 0; + } + this.acc += Math.min(dtMs, 250) * this.speed; + let steps = 0; + while (this.acc >= TICK_MS && steps < 200 && this.game.tick < this.replay.ticks) { + const cmds: TickCommand[] = []; + while (this.idx < this.log.length && this.log[this.idx][0] === this.game.tick) { + const [, p, c] = this.log[this.idx++]; + cmds.push({ p, c }); + } + this.game.step(cmds); + for (const ev of this.game.events) this.events.push(ev); + this.acc -= TICK_MS; + steps++; + } + this.status = ''; + return Math.min(0.999, this.acc / TICK_MS); + } + + drainEvents(): GameEvent[] { + const e = this.events; + this.events = []; + return e; + } + + dispose(): void {} +} + +/** Offer a file to the user. */ +export function downloadText(name: string, text: string): void { + const blob = new Blob([text], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = name; + document.body.appendChild(a); + a.click(); + a.remove(); + window.setTimeout(() => URL.revokeObjectURL(url), 5000); +} diff --git a/gh-pages/bear-war/src/net/room.ts b/gh-pages/bear-war/src/net/room.ts new file mode 100644 index 00000000..735b4f7f --- /dev/null +++ b/gh-pages/bear-war/src/net/room.ts @@ -0,0 +1,412 @@ +// Room + lockstep turn clock. Pure TypeScript (no Node or DOM APIs) so it can run in +// the relay server or, in the future, inside a browser acting as host. + +import type { Command } from '../game/types.ts'; +import type { PlayerSetup } from '../game/player.ts'; +import { RACE_IDS } from '../game/data/index.ts'; +import { + BASE_TURN_MS, DROP_AFTER_MS, MAX_LAG_TURNS, type RoomInfo, type RoomState, type S2C, type Slot, type C2S, +} from './protocol.ts'; + +export interface RoomClient { + id: string; + name: string; + send(msg: S2C): void; +} + +const DEFAULT_COLORS = [1, 0, 2, 3, 4, 5, 6, 7]; + +/** Unguessable rejoin token (crypto RNG: Math.random's state can be reconstructed). */ +function randomToken(): string { + const bytes = new Uint8Array(18); + globalThis.crypto.getRandomValues(bytes); + let s = ''; + for (const b of bytes) s += b.toString(16).padStart(2, '0'); + return s; +} +const MAX_CMDS_PER_TURN = 64; +/** turns kept for reconnecting players (2 minutes at normal speed) */ +const HISTORY_TURNS = 1200; +const REJOIN_COOLDOWN_MS = 2000; + +const COMMANDS = new Set([ + 'move', 'smart', 'amove', 'attack', 'patrol', 'stop', 'hold', 'gather', 'return', 'build', 'repair', 'train', + 'research', 'morph', 'cancel', 'rally', 'ability', 'autocast', 'load', 'unload', 'surrender', 'leave', +]); +const NUM_FIELDS = ['x', 'y', 't', 'tx', 'ty', 'id', 'slot', 'u']; +const STR_FIELDS = ['d', 'r', 'a']; + +/** + * Rebuild a command from untrusted input with only known fields of the right types, so + * relayed turns stay small. The simulation still validates every command itself. + */ +export function sanitizeCommand(raw: unknown): Command | null { + if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null; + const r = raw as Record; + if (typeof r.c !== 'string' || !COMMANDS.has(r.c)) return null; + const out: Record = { c: r.c }; + if (Array.isArray(r.ids)) out.ids = r.ids.slice(0, 400).filter((v) => typeof v === 'number' && Number.isFinite(v)); + for (const k of NUM_FIELDS) if (typeof r[k] === 'number' && Number.isFinite(r[k])) out[k] = r[k]; + for (const k of STR_FIELDS) if (typeof r[k] === 'string' && (r[k] as string).length <= 40) out[k] = r[k]; + if (r.q === true) out.q = true; + if (r.c === 'autocast') out.on = r.on === true; + return out as unknown as Command; +} + +export class Room { + code: string; + name: string; + host: string; + map: string; + speed = 1; + isPublic: boolean; + slots: Slot[] = []; + members = new Map(); + started = false; + onEmpty: () => void = () => {}; + + // in-game state + private playerOf = new Map(); + private gone = new Set(); + private turn = 0; + private queue: [number, Command][] = []; + private cmdCount = new Map(); + private startTime = 0; + private pausedAt = 0; + private pauseTotal = 0; + private paused = false; + private acks = new Map(); + private hashes = new Map>(); + private desynced = false; + /** secret per-player tokens for reconnecting */ + private tokens = new Map(); + /** players that disconnected without leaving (player index -> name) */ + private away = new Map(); + /** recent turns (for players who reconnect) */ + private history: [number, [number, Command][] | null][] = []; + private lastRejoin = new Map(); + + constructor(code: string, host: RoomClient, name: string, map: string, maxPlayers: number, isPublic: boolean) { + this.code = code; + this.host = host.id; + this.name = name.slice(0, 32) || `${host.name}'s game`; + this.map = map; + this.isPublic = isPublic; + this.resize(maxPlayers); + this.slots[0] = { kind: 'human', id: host.id, name: host.name, race: 'random', color: DEFAULT_COLORS[0], team: 0 }; + this.members.set(host.id, host); + } + + private resize(n: number): void { + n = Math.max(2, Math.min(8, n | 0)); + while (this.slots.length < n) { + const i = this.slots.length; + this.slots.push({ kind: i === 1 ? 'open' : 'closed', race: 'random', color: DEFAULT_COLORS[i], team: i }); + } + while (this.slots.length > n) { + const s = this.slots.pop()!; + if (s.kind === 'human' && s.id) { + // kicked by shrinking: move them to a free slot if possible + const free = this.slots.findIndex((x) => x.kind === 'open' || x.kind === 'closed'); + if (free >= 0) this.slots[free] = { ...s, color: this.slots[free].color, team: this.slots[free].team }; + } + } + } + + info(): RoomInfo { + return { + code: this.code, name: this.name, map: this.map, started: this.started, + players: this.slots.filter((s) => s.kind === 'human').length, max: this.slots.filter((s) => s.kind !== 'closed').length, + }; + } + + state(): RoomState { + return { code: this.code, name: this.name, host: this.host, map: this.map, speed: this.speed, public: this.isPublic, slots: this.slots, started: this.started }; + } + + broadcast(msg: S2C): void { + for (const c of this.members.values()) c.send(msg); + } + + private sync(): void { + this.broadcast({ t: 'room', room: this.state() }); + } + + sys(text: string): void { + this.broadcast({ t: 'chat', from: '', text, sys: true }); + } + + // ------------------------------------------------------------------ lobby + + join(c: RoomClient): string | null { + if (this.started) return 'Game already started'; + if (this.members.has(c.id)) return null; + const i = this.slots.findIndex((s) => s.kind === 'open'); + if (i < 0) return 'Room is full'; + this.slots[i] = { ...this.slots[i], kind: 'human', id: c.id, name: c.name }; + this.members.set(c.id, c); + this.sys(`${c.name} joined`); + this.sync(); + return null; + } + + leave(c: RoomClient): void { + if (!this.members.has(c.id)) return; + this.members.delete(c.id); + if (this.started) { + const p = this.playerOf.get(c.id); + this.playerOf.delete(c.id); + if (p !== undefined && !this.gone.has(p)) { + // unexpected disconnect: the turn clock pauses the game and drops the player + // if they don't reconnect in time (explicit quits send a 'leave' command first) + this.away.set(p, c.name); + this.sys(`${c.name} lost connection…`); + } + } else { + const i = this.slots.findIndex((s) => s.id === c.id); + if (i >= 0) this.slots[i] = { kind: 'open', race: 'random', color: this.slots[i].color, team: this.slots[i].team }; + this.sys(`${c.name} left`); + } + if (this.host === c.id) { + const next = [...this.members.values()][0]; + if (next) { + this.host = next.id; + this.sys(`${next.name} is now the host`); + } + } + if (this.members.size === 0) this.onEmpty(); + else if (!this.started) this.sync(); + } + + setSlot(c: RoomClient, m: Extract): void { + if (this.started || !this.members.has(c.id)) return; + if (!Number.isInteger(m.slot) || m.slot < 0 || m.slot >= this.slots.length) return; + const s = this.slots[m.slot]; + if (!s) return; + const isHost = c.id === this.host; + const own = s.id === c.id; + if (!own && !isHost) return; + if (m.race !== undefined && (m.race === 'random' || RACE_IDS.includes(m.race))) s.race = m.race; + if (m.color !== undefined && Number.isInteger(m.color) && m.color >= 0 && m.color < 8) { + if (!this.slots.some((x, i) => i !== m.slot && x.kind !== 'closed' && x.kind !== 'open' && x.color === m.color)) s.color = m.color; + } + if (m.team !== undefined && Number.isInteger(m.team) && m.team >= 0 && m.team < 8) s.team = m.team; + if (isHost && m.kind !== undefined && s.kind !== 'human') { + if (m.kind === 'open' || m.kind === 'closed' || m.kind === 'ai') { + s.kind = m.kind; + if (m.kind === 'ai') { + s.ai = s.ai ?? 'normal'; + s.name = 'Computer'; + } else { + delete s.ai; + delete s.name; + } + } + } + if (isHost && m.kind !== undefined && s.kind === 'human' && s.id !== this.host && (m.kind === 'open' || m.kind === 'closed')) { + // kick + const victim = this.members.get(s.id!); + this.slots[m.slot] = { kind: m.kind, race: 'random', color: s.color, team: s.team }; + if (victim) { + this.members.delete(victim.id); + victim.send({ t: 'room', room: null }); + victim.send({ t: 'error', msg: 'You were removed from the room' }); + } + } + if (isHost && m.ai !== undefined && s.kind === 'ai' && ['easy', 'normal', 'hard', 'brutal'].includes(m.ai)) s.ai = m.ai; + this.sync(); + } + + settings(c: RoomClient, m: Extract, maxForMap: (map: string) => number): void { + if (this.started || c.id !== this.host) return; + if (m.map !== undefined) { + this.map = m.map; + this.resize(maxForMap(m.map)); + } + if (m.speed !== undefined && [0.75, 1, 1.4].includes(m.speed)) this.speed = m.speed; + this.sync(); + } + + chat(c: RoomClient, text: string): void { + if (!this.members.has(c.id)) return; + const t = String(text).replace(/[\u0000-\u001f]/g, '').slice(0, 200).trim(); + if (t) this.broadcast({ t: 'chat', from: c.name, text: t }); + } + + start(c: RoomClient, now: number): string | null { + if (c.id !== this.host) return 'Only the host can start'; + if (this.started) return 'Already started'; + const occ = this.slots.filter((s) => s.kind === 'human' || s.kind === 'ai'); + if (occ.length < 2) return 'Need at least two players'; + if (new Set(occ.map((s) => s.team)).size < 2) return 'Need at least two teams'; + if (new Set(occ.map((s) => s.color)).size < occ.length) return 'Players need different colours'; + const players: PlayerSetup[] = occ.map((s) => ({ + name: s.kind === 'ai' ? `Computer (${s.ai})` : s.name ?? 'Player', + race: s.race === 'random' ? RACE_IDS[Math.floor(Math.random() * RACE_IDS.length)] : s.race, + color: s.color, + team: s.team, + ai: s.kind === 'ai' ? s.ai ?? 'normal' : null, + })); + this.playerOf.clear(); + occ.forEach((s, i) => { + if (s.kind === 'human' && s.id) this.playerOf.set(s.id, i); + }); + const seed = Math.floor(Math.random() * 2 ** 31); + this.started = true; + this.turn = 0; + this.queue = []; + this.startTime = now + 600; + this.pauseTotal = 0; + this.paused = false; + this.acks.clear(); + for (const p of this.playerOf.values()) this.acks.set(p, -1); + this.history = []; + this.lastRejoin.clear(); + this.tokens.clear(); + for (const m of this.members.values()) { + const you = this.playerOf.get(m.id) ?? -1; + let token = ''; + if (you >= 0) { + token = randomToken(); + this.tokens.set(token, you); + } + m.send({ t: 'start', seed, map: this.map, players, you, speed: this.speed, code: this.code, token }); + } + return null; + } + + /** + * A player whose connection dropped comes back: resume their turn stream from turn + * `from`. A rejoin also takes over from an older connection the server still thinks + * is alive (half-open socket, second tab). + */ + rejoin(c: RoomClient, token: string, from: number, now = Date.now()): string | null { + if (!this.started) return 'Game has not started'; + const p = this.tokens.get(String(token)); + if (p === undefined) return 'Invalid rejoin token'; + if (this.gone.has(p)) return 'You were dropped from this game'; + if (now - (this.lastRejoin.get(p) ?? -1e15) < REJOIN_COOLDOWN_MS) return 'Reconnecting too fast, try again'; + const first = this.history.length ? this.history[0][0] : this.turn; + const start = Math.min(this.turn, Math.floor(Number(from) || 0)); + if (start < first) return 'Too far behind to rejoin this game'; + this.lastRejoin.set(p, now); + for (const [id, idx] of [...this.playerOf]) { + if (idx !== p || id === c.id) continue; + this.playerOf.delete(id); + const old = this.members.get(id); + this.members.delete(id); + old?.send({ t: 'error', msg: 'This game was resumed from another connection' }); + old?.send({ t: 'room', room: null }); + } + this.playerOf.set(c.id, p); + this.members.set(c.id, c); + this.away.delete(p); + c.send({ t: 'rejoined', turns: this.history.slice(start - first) }); + this.sys(`${c.name} reconnected`); + return null; + } + + // ------------------------------------------------------------------ in game + + command(c: RoomClient, cmds: unknown): void { + if (!this.started || !Array.isArray(cmds)) return; + const p = this.playerOf.get(c.id); + if (p === undefined || this.gone.has(p)) return; + let n = this.cmdCount.get(p) ?? 0; + for (const raw of cmds) { + if (n >= MAX_CMDS_PER_TURN) break; + const cmd = sanitizeCommand(raw); + if (!cmd) continue; + this.queue.push([p, cmd]); + n++; + // a player who quit or surrendered no longer holds up the game + if (cmd.c === 'leave' || cmd.c === 'surrender') { + this.gone.add(p); + break; + } + } + this.cmdCount.set(p, n); + } + + ack(c: RoomClient, turn: number, hash?: number, htick?: number): void { + const p = this.playerOf.get(c.id); + if (p === undefined || !Number.isFinite(turn)) return; + this.acks.set(p, Math.max(this.acks.get(p) ?? -1, turn)); + if (hash !== undefined && htick !== undefined && !this.desynced) { + let m = this.hashes.get(htick); + if (!m) { + m = new Map(); + this.hashes.set(htick, m); + } + m.set(p, hash); + const live = [...this.acks.keys()].filter((q) => !this.gone.has(q)); + if (live.every((q) => m!.has(q))) { + const vals = new Set(live.map((q) => m!.get(q))); + if (vals.size > 1) { + this.desynced = true; + this.broadcast({ t: 'desync', tick: htick }); + this.sys('Desync detected, the simulation diverged between players'); + } + this.hashes.delete(htick); + } + if (this.hashes.size > 20) this.hashes.delete(this.hashes.keys().next().value!); + } + } + + /** Called frequently by the host process; emits turns on schedule. */ + tickClock(now: number): void { + if (!this.started) return; + const live = [...this.acks.entries()].filter(([p]) => !this.gone.has(p)); + const minAck = live.length ? Math.min(...live.map(([, a]) => a)) : this.turn; + const lag = this.turn - minAck; + if (!this.paused && lag > MAX_LAG_TURNS && live.length) { + this.paused = true; + this.pausedAt = now; + const waiting = live.filter(([, a]) => this.turn - a > MAX_LAG_TURNS / 2).map(([p]) => this.nameOf(p)); + this.broadcast({ t: 'pause', waiting }); + } + if (this.paused) { + if (lag <= MAX_LAG_TURNS / 3 || !live.length) { + this.paused = false; + this.pauseTotal += now - this.pausedAt; + this.broadcast({ t: 'resume' }); + } else if (now - this.pausedAt > DROP_AFTER_MS) { + for (const [p, a] of live) { + if (this.turn - a > MAX_LAG_TURNS / 2) { + this.gone.add(p); + this.away.delete(p); + this.queue.push([p, { c: 'leave' }]); + this.sys(`${this.nameOf(p)} was dropped (not responding)`); + } + } + this.paused = false; + this.pauseTotal += now - this.pausedAt; + this.broadcast({ t: 'resume' }); + } else return; + } + const turnMs = BASE_TURN_MS / this.speed; + const target = Math.floor((now - this.startTime - this.pauseTotal) / turnMs); + let guard = 0; + while (this.turn <= target && guard++ < 50) { + const c = this.queue.length ? this.queue : undefined; + this.broadcast(c ? { t: 'turn', n: this.turn, c } : { t: 'turn', n: this.turn }); + this.history.push([this.turn, c ?? null]); + if (this.history.length > HISTORY_TURNS + 200) this.history.splice(0, this.history.length - HISTORY_TURNS); + this.queue = []; + this.cmdCount.clear(); + this.turn++; + } + } + + private nameOf(p: number): string { + for (const [id, idx] of this.playerOf) if (idx === p) return this.members.get(id)?.name ?? `Player ${p + 1}`; + return this.away.get(p) ?? `Player ${p + 1}`; + } + + /** All humans gone (left, dropped or disconnected with nobody left to wait for them)? */ + get abandoned(): boolean { + if (!this.started) return false; + const humans = [...this.tokens.values()]; + return humans.every((p) => this.gone.has(p) || this.away.has(p)) && this.members.size === 0; + } +} diff --git a/gh-pages/bear-war/src/net/session.ts b/gh-pages/bear-war/src/net/session.ts new file mode 100644 index 00000000..0be31cac --- /dev/null +++ b/gh-pages/bear-war/src/net/session.ts @@ -0,0 +1,79 @@ +import { TICK_MS, TICKS_PER_TURN } from '../game/constants.ts'; +import type { Game } from '../game/game.ts'; +import type { Command, GameEvent, TickCommand } from '../game/types.ts'; + +/** Drives a Game: decides when ticks run and which commands they contain. */ +export interface Session { + game: Game; + localPlayer: number; + /** spectators have no player */ + readonly online: boolean; + speed: number; + paused: boolean; + /** human-readable status (e.g. "Waiting for Bob…"), empty when running normally */ + status: string; + issue(cmd: Command): void; + /** Advance by real elapsed ms. Returns interpolation alpha in [0,1). */ + update(dtMs: number): number; + drainEvents(): GameEvent[]; + /** command log for replays: [tick, player, command] */ + log: [number, number, Command][]; + dispose(): void; + /** ping in ms (online) */ + ping?: number; +} + +/** Offline game: commands apply on the next tick; supports pause and speed. */ +export class LocalSession implements Session { + game: Game; + localPlayer: number; + readonly online = false; + speed = 1; + paused = false; + status = ''; + log: [number, number, Command][] = []; + private pending: Command[] = []; + private acc = 0; + private events: GameEvent[] = []; + + constructor(game: Game, localPlayer: number) { + this.game = game; + this.localPlayer = localPlayer; + } + + issue(cmd: Command): void { + if (this.localPlayer < 0) return; + this.pending.push(cmd); + } + + update(dtMs: number): number { + if (this.paused || this.game.over) return 0; + this.acc += Math.min(dtMs, 250) * this.speed; + let steps = 0; + while (this.acc >= TICK_MS && steps < 200) { + const cmds: TickCommand[] = this.pending.map((c) => ({ p: this.localPlayer, c })); + for (const tc of cmds) this.log.push([this.game.tick, tc.p, tc.c]); + this.pending = []; + this.stepOnce(cmds); + this.acc -= TICK_MS; + steps++; + } + if (steps >= 200) this.acc = 0; + return this.acc / TICK_MS; + } + + private stepOnce(cmds: TickCommand[]): void { + this.game.step(cmds); + for (const ev of this.game.events) this.events.push(ev); + } + + drainEvents(): GameEvent[] { + const e = this.events; + this.events = []; + return e; + } + + dispose(): void {} +} + +export { TICKS_PER_TURN }; diff --git a/gh-pages/bear-war/src/render/art/bear.ts b/gh-pages/bear-war/src/render/art/bear.ts new file mode 100644 index 00000000..31ea0a25 --- /dev/null +++ b/gh-pages/bear-war/src/render/art/bear.ts @@ -0,0 +1,98 @@ +// Top-down bears shared by every faction. Sprite-local space: (0,0) is the centre and the +// bear faces +x. `s` scales the whole animal; s = 1 fits a unit of radius 10. + +import { circle, dome, ellipse, line, rgba, tone, type Ctx } from './kit.ts'; + +export interface Fur { + fur: string; + /** paws, ears and other dark points */ + dark: string; + muzzle: string; + nose?: string; + /** inner ear */ + ear?: string; +} + +export const FUR: Record = { + brown: { fur: '#8a5a33', dark: '#4a2d17', muzzle: '#c49a6c' }, + grizzly: { fur: '#9c7448', dark: '#4d3520', muzzle: '#d2b28a' }, + kodiak: { fur: '#6e4526', dark: '#3a2312', muzzle: '#a57a52' }, + black: { fur: '#2b2522', dark: '#151110', muzzle: '#b08a63' }, + sun: { fur: '#2a2320', dark: '#141010', muzzle: '#e0b05a' }, + sloth: { fur: '#231d1b', dark: '#0f0c0b', muzzle: '#d9cbb6' }, + spectacled: { fur: '#2e2621', dark: '#171210', muzzle: '#e6d3a8' }, + honey: { fur: '#c98d3c', dark: '#6a4516', muzzle: '#ecc98c' }, + cave: { fur: '#5b4634', dark: '#2c2118', muzzle: '#8f7357' }, + polar: { fur: '#eef0ea', dark: '#9aa3a6', muzzle: '#d9dcd4', nose: '#1d1d22' }, + spirit: { fur: '#f3eedf', dark: '#b3a98f', muzzle: '#e2d8bf', nose: '#3a3330' }, + glacier: { fur: '#7d8c99', dark: '#3e4852', muzzle: '#b6c1c9' }, + moon: { fur: '#1f1c22', dark: '#0d0b0f', muzzle: '#9b8b7a' }, + panda: { fur: '#f2f0ea', dark: '#1b1a1d', muzzle: '#f7f5ef' }, +}; + +/** Head with round ears, snout, eyes and nose, centred at (x, 0). */ +export function bearHead(ctx: Ctx, x: number, r: number, f: Fur, y = 0): void { + for (const sy of [-1, 1]) { + const ex = x - r * 0.45; + const ey = y + sy * r * 0.78; + circle(ctx, ex, ey, r * 0.42, f.dark); + circle(ctx, ex + r * 0.05, ey, r * 0.22, f.ear ?? tone(f.dark, 0.25)); + } + circle(ctx, x, y, r, dome(ctx, x, y, r, f.fur, 0.35), rgba('#000000', 0.45), 0.6); + ellipse(ctx, x + r * 0.72, y, r * 0.5, r * 0.38, 0, dome(ctx, x + r * 0.72, y, r * 0.5, f.muzzle, 0.3)); + ellipse(ctx, x + r * 1.12, y, r * 0.17, r * 0.22, 0, f.nose ?? '#141010'); + for (const sy of [-1, 1]) circle(ctx, x + r * 0.32, y + sy * r * 0.36, r * 0.11, '#0b0908'); +} + +/** A bear on all fours. `stride` is the paw swing; frame drives the diagonal gait. */ +export function quadBear(ctx: Ctx, frame: number, s: number, f: Fur, o: { long?: number; wide?: number; hump?: boolean; shag?: boolean } = {}): void { + const L = 22 * s * (o.long ?? 1); + const W = 13 * s * (o.wide ?? 1); + const ph = [0, 1, 0, -1][frame % 4] * 2.2 * s; + const paws: [number, number, number][] = [ + [L * 0.3, -W * 0.48, ph], [L * 0.3, W * 0.48, -ph], [-L * 0.32, -W * 0.44, -ph], [-L * 0.32, W * 0.44, ph], + ]; + for (const [px, py, d] of paws) { + line(ctx, px * 0.7, py * 0.6, px + d, py, f.dark, 3.6 * s); + ellipse(ctx, px + d, py, 2.4 * s, 2 * s, 0, f.dark); + } + circle(ctx, -L * 0.52, 0, 1.8 * s, f.dark); + ellipse(ctx, -L * 0.05, 0, L * 0.5, W * 0.5, 0, dome(ctx, -L * 0.05, 0, L * 0.5, f.fur, 0.3), rgba('#000000', 0.45), 0.7); + if (o.hump) ellipse(ctx, L * 0.14, 0, L * 0.18, W * 0.36, 0, rgba(tone(f.fur, 0.25), 0.8)); + ctx.strokeStyle = rgba(o.shag ? tone(f.fur, 0.35) : '#000000', o.shag ? 0.55 : 0.18); + ctx.lineWidth = 0.7 * s; + for (let i = 0; i < (o.shag ? 9 : 5); i++) { + const x = -L * 0.4 + (L * 0.65 * i) / (o.shag ? 8 : 4); + for (const sy of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(x, sy * W * 0.18); + ctx.lineTo(x - 2 * s, sy * W * 0.42); + ctx.stroke(); + } + } + bearHead(ctx, L * 0.48, 5 * s, f); +} + +/** A bear standing upright, seen from above: shoulders, arms reaching forward, head. */ +export function bipedBear(ctx: Ctx, frame: number, s: number, f: Fur): void { + const ph = [0, 1, 0, -1][frame % 4] * 2.4 * s; + ellipse(ctx, ph, -4 * s, 3 * s, 2.2 * s, 0, f.dark); + ellipse(ctx, -ph, 4 * s, 3 * s, 2.2 * s, 0, f.dark); + ellipse(ctx, -1.5 * s, 0, 5.6 * s, 7.6 * s, 0, dome(ctx, -1.5 * s, 0, 7.6 * s, f.fur, 0.3), rgba('#000000', 0.45), 0.7); + bipedArms(ctx, s, f); + bearHead(ctx, 1.6 * s, 4 * s, f); +} + +/** The two forward-reaching arms of an upright bear; drawn again on top of held gear. */ +export function bipedArms(ctx: Ctx, s: number, f: Fur, reach = 1): void { + for (const sy of [-1, 1]) { + line(ctx, -0.5 * s, sy * 5.6 * s, 5 * s * reach, sy * 3.6 * s, f.fur, 3.4 * s); + circle(ctx, 5.2 * s * reach, sy * 3.6 * s, 1.9 * s, f.dark); + } +} + +/** Paw-print emblem (pad plus four toes), for team badges on buildings and gear. */ +export function pawPrint(ctx: Ctx, x: number, y: number, r: number, col: string): void { + ellipse(ctx, x, y + r * 0.25, r * 0.55, r * 0.45, 0, col); + for (const [dx, dy] of [[-0.62, -0.25], [-0.22, -0.62], [0.22, -0.62], [0.62, -0.25]]) circle(ctx, x + dx * r, y + dy * r, r * 0.22, col); +} diff --git a/gh-pages/bear-war/src/render/art/horde.ts b/gh-pages/bear-war/src/render/art/horde.ts new file mode 100644 index 00000000..b571b0e3 --- /dev/null +++ b/gh-pages/bear-war/src/render/art/horde.ts @@ -0,0 +1,1078 @@ +// Honey Horde art: feral bears living alongside giant bees. Amber wax, honeycomb, +// bark and dark wood; team colour shows on honey sacs, stripes, straps and banners. + +import type { ArtDef, TeamPal } from './index.ts'; +import { FUR, bearHead, bipedArms, bipedBear, pawPrint, quadBear, type Fur } from './bear.ts'; +import { circle, dome, ellipse, glow, line, poly, rgba, rnd, rrect, lit, tone, type Ctx, TAU } from './kit.ts'; + +const WAX = '#e0a42e'; +const WAX_L = '#ffd56a'; +const WAX_D = '#7e4f10'; +const HONEY = '#f29a1a'; +const WOOD = '#6b4526'; +const WOOD_D = '#2f1d0f'; +const BARK = '#4f3520'; +const EARTH = '#5e4630'; +const BONE = '#e2d6b4'; +const BEE_Y = '#f5c730'; +const BEE_K = '#1d1712'; +const PAPER = '#b5a486'; +const STONE = '#7a6c5c'; + +/** Noisy closed blob path. */ +function blob(ctx: Ctx, cx: number, cy: number, rx: number, ry: number, seed: number, amp = 0.12, n = 18): void { + const r = rnd(seed); + const offs: number[] = []; + for (let i = 0; i < n; i++) offs.push(1 + (r() - 0.5) * 2 * amp); + ctx.beginPath(); + for (let i = 0; i <= n; i++) { + const a0 = (i / n) * TAU; + const k = offs[i % n]; + const x = cx + Math.cos(a0) * rx * k; + const y = cy + Math.sin(a0) * ry * k; + if (i === 0) ctx.moveTo(x, y); + else { + const am = ((i - 0.5) / n) * TAU; + const km = (offs[(i - 1) % n] + k) / 2; + ctx.quadraticCurveTo(cx + Math.cos(am) * rx * km * 1.04, cy + Math.sin(am) * ry * km * 1.04, x, y); + } + } + ctx.closePath(); +} + +function fillBlob(ctx: Ctx, cx: number, cy: number, rx: number, ry: number, seed: number, fill: string | CanvasGradient, stroke = WOOD_D, amp = 0.12): void { + blob(ctx, cx, cy, rx, ry, seed, amp); + ctx.fillStyle = fill; + ctx.fill(); + if (stroke) { + ctx.strokeStyle = stroke; + ctx.lineWidth = 1; + ctx.stroke(); + } +} + +function hexPath(ctx: Ctx, x: number, y: number, r: number): void { + ctx.beginPath(); + for (let i = 0; i < 6; i++) { + const a = (i / 6) * TAU; + if (i === 0) ctx.moveTo(x + Math.cos(a) * r, y + Math.sin(a) * r); + else ctx.lineTo(x + Math.cos(a) * r, y + Math.sin(a) * r); + } + ctx.closePath(); +} + +/** Honeycomb grid over a box; the caller clips. Some cells hold honey, some are empty. */ +function combCells(ctx: Ctx, cx: number, cy: number, rx: number, ry: number, r: number, seed: number, base = WAX, honey = 0.25, empty = 0.12): void { + const R = rnd(seed); + const dx = r * 1.5; + const dy = r * Math.sqrt(3); + const ni = Math.ceil(rx / dx) + 1; + const nj = Math.ceil(ry / dy) + 1; + ctx.lineWidth = Math.max(0.5, r * 0.2); + ctx.strokeStyle = tone(base, -0.55); + for (let i = -ni; i <= ni; i++) { + const x = cx + i * dx; + const off = i & 1 ? dy / 2 : 0; + for (let j = -nj; j <= nj; j++) { + const y = cy + j * dy + off; + const k = R(); + hexPath(ctx, x, y, r * 0.92); + if (k < honey) { + ctx.fillStyle = dome(ctx, x, y, r, HONEY, 0.5); + } else if (k < honey + empty) { + ctx.fillStyle = tone(base, -0.6); + } else { + ctx.fillStyle = tone(base, (R() - 0.5) * 0.25); + } + ctx.fill(); + ctx.stroke(); + if (k < honey) circle(ctx, x - r * 0.3, y - r * 0.3, r * 0.22, rgba('#ffffff', 0.55)); + } + } +} + +/** Blob filled with honeycomb and a soft dome shade on top. */ +function combBlob(ctx: Ctx, cx: number, cy: number, rx: number, ry: number, seed: number, cell: number, base = WAX, honey = 0.25, amp = 0.12): void { + ctx.save(); + blob(ctx, cx, cy, rx, ry, seed, amp); + ctx.clip(); + combCells(ctx, cx, cy, rx * 1.2, ry * 1.2, cell, seed + 1, base, honey); + const g = ctx.createRadialGradient(cx - rx * 0.3, cy - ry * 0.3, 0, cx, cy, Math.max(rx, ry) * 1.15); + g.addColorStop(0, 'rgba(255,240,190,0.25)'); + g.addColorStop(0.6, 'rgba(0,0,0,0)'); + g.addColorStop(1, 'rgba(40,20,0,0.5)'); + ctx.fillStyle = g; + ctx.fillRect(cx - rx * 1.3, cy - ry * 1.3, rx * 2.6, ry * 2.6); + ctx.restore(); + blob(ctx, cx, cy, rx, ry, seed, amp); + ctx.strokeStyle = WAX_D; + ctx.lineWidth = 1.2; + ctx.stroke(); +} + +/** The spreading honeycomb every Horde structure stands on. */ +function combGround(ctx: Ctx, w: number, h: number, seed: number, base = '#a8742a'): void { + ctx.save(); + ctx.filter = 'blur(3px)'; + fillBlob(ctx, 3, 5, w / 2, h / 2, seed, 'rgba(0,0,0,0.35)', '', 0.1); + ctx.restore(); + combBlob(ctx, 0, 0, w / 2, h / 2, seed, Math.max(3.5, w / 26), base, 0.1, 0.1); +} + +/** Wax drips hanging below an edge. */ +function drips(ctx: Ctx, cx: number, cy: number, rx: number, ry: number, n: number, seed: number, col = WAX): void { + const R = rnd(seed); + for (let i = 0; i < n; i++) { + const a = 0.25 + (i / Math.max(1, n - 1)) * (Math.PI - 0.5) + (R() - 0.5) * 0.15; + const x = cx + Math.cos(a) * rx; + const y = cy + Math.sin(a) * ry; + const l = 3 + R() * 5; + ctx.fillStyle = tone(col, 0.1); + ctx.beginPath(); + ctx.moveTo(x - 2, y - 1); + ctx.lineTo(x + 2, y - 1); + ctx.quadraticCurveTo(x + 1.6, y + l * 0.6, x, y + l); + ctx.quadraticCurveTo(x - 1.6, y + l * 0.6, x - 2, y - 1); + ctx.fill(); + circle(ctx, x - 0.5, y + l * 0.55, 0.7, 'rgba(255,255,255,0.5)'); + } +} + +/** Team-coloured honey sac. */ +function sac(ctx: Ctx, x: number, y: number, r: number, c: TeamPal): void { + circle(ctx, x, y, r, dome(ctx, x, y, r, c.main, 0.55), tone(c.dark, -0.35), 0.6); + circle(ctx, x + r * 0.15, y + r * 0.2, r * 0.55, rgba(HONEY, 0.35)); + circle(ctx, x - r * 0.35, y - r * 0.35, r * 0.26, rgba('#ffffff', 0.55)); +} + +function spikes(ctx: Ctx, cx: number, cy: number, r: number, n: number, len: number, seed: number, col = BONE, w = 2.4): void { + const R = rnd(seed); + for (let i = 0; i < n; i++) { + const a = (i / n) * TAU + R() * 0.3; + const l = len * (0.7 + R() * 0.6); + const bx = cx + Math.cos(a) * r; + const by = cy + Math.sin(a) * r; + const nx = Math.cos(a + Math.PI / 2) * w; + const ny = Math.sin(a + Math.PI / 2) * w; + poly(ctx, [bx + nx, by + ny, bx + Math.cos(a) * l, by + Math.sin(a) * l, bx - nx, by - ny], col, tone(col, -0.5), 0.6); + } +} + +/** Bee-striped stinger spike from (x0,y0) to the point (x1,y1). */ +function stingerSpike(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, w: number): void { + const a = Math.atan2(y1 - y0, x1 - x0); + const nx = Math.cos(a + Math.PI / 2) * w; + const ny = Math.sin(a + Math.PI / 2) * w; + const at = (t: number, k: number) => [x0 + (x1 - x0) * t + nx * k, y0 + (y1 - y0) * t + ny * k]; + poly(ctx, [...at(0, 1), x1, y1, ...at(0, -1)], BEE_Y, BEE_K, 0.6); + poly(ctx, [...at(0.25, 0.75), ...at(0.42, 0.58), ...at(0.42, -0.58), ...at(0.25, -0.75)], BEE_K); + poly(ctx, [...at(0.62, 0.38), x1, y1, ...at(0.62, -0.38)], BEE_K); +} + +/** Translucent insect wing rooted at (x,y), pointing along `ang`. */ +function wing(ctx: Ctx, x: number, y: number, len: number, wid: number, ang: number, tint = '#ffffff', a = 0.45): void { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(ang); + ellipse(ctx, len / 2, 0, len / 2, wid / 2, 0, rgba(tint, a), rgba('#2a1a08', 0.55), 0.6); + ctx.strokeStyle = rgba('#2a1a08', 0.3); + ctx.lineWidth = 0.5; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.lineTo(len * 0.92, 0); + ctx.moveTo(len * 0.3, 0); + ctx.lineTo(len * 0.7, -wid * 0.3); + ctx.moveTo(len * 0.4, 0); + ctx.lineTo(len * 0.75, wid * 0.28); + ctx.stroke(); + ellipse(ctx, len * 0.4, -wid * 0.15, len * 0.18, wid * 0.12, 0, rgba('#ffffff', 0.35)); + ctx.restore(); +} + +/** Small honeybee facing `a`. */ +function bee(ctx: Ctx, x: number, y: number, s: number, a: number): void { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(a); + ellipse(ctx, 0, 0, 1.4 * s, 0.85 * s, 0, BEE_Y, BEE_K, 0.3 * s); + line(ctx, -0.2 * s, -0.75 * s, -0.2 * s, 0.75 * s, BEE_K, 0.4 * s); + line(ctx, -0.85 * s, -0.5 * s, -0.85 * s, 0.5 * s, BEE_K, 0.35 * s); + circle(ctx, 1.3 * s, 0, 0.55 * s, BEE_K); + ellipse(ctx, -0.1 * s, -1 * s, 0.9 * s, 0.5 * s, -0.5, 'rgba(255,255,255,0.7)'); + ellipse(ctx, -0.1 * s, 1 * s, 0.9 * s, 0.5 * s, 0.5, 'rgba(255,255,255,0.7)'); + ctx.restore(); +} + +/** Bees circling (cx, cy) on a wobbly ring. */ +function orbitBees(ctx: Ctx, cx: number, cy: number, r: number, n: number, t: number, seed: number, s = 1.6, speed = 1.6): void { + for (let i = 0; i < n; i++) { + const dir = i % 2 ? -1 : 1; + const a = dir * t * speed * (0.8 + (i % 3) * 0.15) + (i / n) * TAU + seed; + const rr = r * (0.85 + 0.15 * Math.sin(t * 3 + i * 1.7)); + bee(ctx, cx + Math.cos(a) * rr, cy + Math.sin(a) * rr * 0.9, s, a + (dir * Math.PI) / 2); + } +} + +/** Top-down straw skep: concentric coils, optionally bee-striped. */ +function skep(ctx: Ctx, x: number, y: number, r: number, striped: boolean, band?: string): void { + circle(ctx, x, y, r, dome(ctx, x, y, r, striped ? BEE_Y : '#d9a441', 0.45), WOOD_D, 0.8); + const n = Math.max(3, Math.round(r / 2.2)); + for (let i = 1; i < n; i++) { + const rr = (r * i) / n; + const odd = i % 2 === 1; + if (striped && odd) circle(ctx, x - (r - rr) * 0.08, y - (r - rr) * 0.08, rr + r / n / 2, null, rgba(BEE_K, 0.85), r / n); + else circle(ctx, x - (r - rr) * 0.08, y - (r - rr) * 0.08, rr, null, rgba(WAX_D, 0.6), 0.7); + } + if (band) circle(ctx, x, y, r * 0.82, null, band, r * 0.14); + circle(ctx, x - r * 0.3, y - r * 0.32, r * 0.25, rgba('#fff6cf', 0.35)); +} + +/** Stretches the front paws of a quadBear into visible claws. */ +function frontClaws(ctx: Ctx, frame: number, s: number, long: number, wide: number, len: number, col = BONE): void { + const L = 22 * s * long; + const W = 13 * s * wide; + const ph = [0, 1, 0, -1][frame % 4] * 2.2 * s; + for (const [py, d] of [[-W * 0.48, ph], [W * 0.48, -ph]]) { + const px = L * 0.3 + d + 1.6 * s; + for (const k of [-1, 0, 1]) line(ctx, px, py + k * 0.9 * s, px + len, py + k * 1.3 * s, col, 0.55 * s); + } +} + +function headX(s: number, long = 1): number { + return 22 * s * long * 0.48; +} + +/** Wooden bee box with a stacked look. */ +function beeBox(ctx: Ctx, x: number, y: number, w: number, h: number, stack: number, c: TeamPal): void { + for (let i = 0; i < stack; i++) { + const yy = y - i * 4; + rrect(ctx, x, yy + 4, w, h - 4, 2, tone(WOOD, -0.35), WOOD_D, 0.8); + rrect(ctx, x + w * 0.3, yy + h - 2.5, w * 0.4, 1.6, 0.6, BEE_K); + } + const top = y - (stack - 1) * 4; + rrect(ctx, x - 1, top - 1, w + 2, h - 3, 2, lit(ctx, x, top, x + w, top + h, '#b8854c', 0.25), WOOD_D, 0.9); + for (let i = 1; i < 4; i++) line(ctx, x + 1, top + (h - 4) * (i / 4), x + w - 1, top + (h - 4) * (i / 4), 'rgba(60,30,10,0.35)', 0.6); + rrect(ctx, x + 2, top + (h - 4) * 0.4, w - 4, 3, 1, c.main, tone(c.dark, -0.3), 0.5); +} + +function honeyPot(ctx: Ctx, x: number, y: number, r: number, c: TeamPal): void { + circle(ctx, x + 1, y + 1.5, r, 'rgba(0,0,0,0.3)'); + circle(ctx, x, y, r, dome(ctx, x, y, r, '#a8642c', 0.4), '#3a1f0a', 0.8); + circle(ctx, x, y, r * 0.62, dome(ctx, x, y, r * 0.62, HONEY, 0.5)); + circle(ctx, x, y, r * 0.62, null, c.main, 1.4); + circle(ctx, x - r * 0.2, y - r * 0.2, r * 0.15, 'rgba(255,255,255,0.6)'); +} + +function banner(ctx: Ctx, x: number, y: number, c: TeamPal, h = 12): void { + circle(ctx, x + 1, y + 2, 2.6, 'rgba(0,0,0,0.3)'); + poly(ctx, [x, y - h, x + 7, y - h + 1, x + 5.5, y - h + 4, x + 7, y - h + 7, x, y - h + 7], c.main, tone(c.dark, -0.3), 0.6); + line(ctx, x, y, x, y - h - 1, WOOD_D, 1.6); + circle(ctx, x, y, 2, dome(ctx, x, y, 2, WOOD, 0.4)); +} + +// ---------------------------------------------------------------- unit bodies + +function curledCub(ctx: Ctx, c: TeamPal, w: number, f: Fur): void { + ctx.rotate(w * 0.18); + for (const sy of [-1, 1]) ellipse(ctx, -2.6, sy * 2.2, 1.2, 1, 0, f.dark); + circle(ctx, -0.8, 0, 3.4, dome(ctx, -0.8, 0, 3.4, f.fur, 0.35), rgba('#000000', 0.45), 0.5); + ellipse(ctx, 1.3, 0, 0.9, 2.3, 0, c.main); + bearHead(ctx, 2.6, 2.3, f, w * 0.4); + for (const sy of [-1, 1]) circle(ctx, 1.8, sy * 2.6 + w * 0.3, 0.9, f.dark); +} + +function shaggyTufts(ctx: Ctx, rx: number, ry: number, cx: number, n: number, col: string, seed: number): void { + const R = rnd(seed); + ctx.fillStyle = col; + for (let i = 0; i < n; i++) { + const a = (i / n) * TAU + R() * 0.2; + if (Math.abs(Math.cos(a)) > 0.85 && Math.cos(a) > 0) continue; + const x = cx + Math.cos(a) * rx; + const y = Math.sin(a) * ry; + const l = 1 + R() * 1.4; + ctx.beginPath(); + ctx.moveTo(x - Math.sin(a) * 0.9, y + Math.cos(a) * 0.9); + ctx.lineTo(x + Math.cos(a - 0.5) * l, y + Math.sin(a - 0.5) * l); + ctx.lineTo(x + Math.sin(a) * 0.9, y - Math.cos(a) * 0.9); + ctx.fill(); + } +} + +export const HORDE_ART: Record = { + // ================================================================ units + cub: { + size: 16, + frames: 4, + draw(ctx, c, f) { + curledCub(ctx, c, [0, 1, 0, -1][f], FUR.honey); + }, + }, + cocoon: { + size: 28, + frames: 2, + draw(ctx, c, f) { + const s = f ? 1.05 : 1; + ellipse(ctx, 1.5, 2, 8 * s, 9.5 * s, 0, 'rgba(0,0,0,0.3)'); + ellipse(ctx, 0, 0, 7.6 * s, 9.2 * s, 0, dome(ctx, 0, 0, 9.5, '#e8a32a', 0.5), WAX_D, 0.9); + ctx.save(); + ctx.beginPath(); + ctx.ellipse(0, 0, 7.6 * s, 9.2 * s, 0, 0, TAU); + ctx.clip(); + ctx.globalAlpha = 0.35; + combCells(ctx, 0, 0, 10, 11, 2.2, 7, WAX, 0, 0); + ctx.globalAlpha = 1; + // the sleeping cub inside + const sil = rgba('#4a2306', 0.55); + circle(ctx, 0, 1.8, 4.2 * s, sil); + circle(ctx, 0, -3.4, 2.8 * s, sil); + circle(ctx, -2.2, -5.2, 1.2 * s, sil); + circle(ctx, 2.2, -5.2, 1.2 * s, sil); + ctx.restore(); + glow(ctx, 0, 0, 7 * s, c.light, f ? 0.45 : 0.3); + for (const sy of [-1, 1]) { + ctx.strokeStyle = c.main; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.ellipse(0, sy * 3.5, 7.2 * s, 2.2, 0, 0, Math.PI); + ctx.stroke(); + } + ellipse(ctx, -2.6, -4.5, 1.6, 2.6, -0.4, 'rgba(255,248,210,0.5)'); + }, + }, + forager: { + size: 26, + frames: 4, + draw(ctx, c, f) { + quadBear(ctx, f, 0.62, FUR.honey); + line(ctx, 0.5, -4.2, 0.5, 4.2, c.main, 1.3); + // pollen basket + ellipse(ctx, -3, 0, 3.8, 3.3, 0, dome(ctx, -3, 0, 3.8, '#8f6432', 0.35), c.dark, 1); + ctx.strokeStyle = 'rgba(50,25,5,0.5)'; + ctx.lineWidth = 0.5; + for (const x of [-5, -3.5, -2, -0.5]) { + ctx.beginPath(); + ctx.moveTo(x, -2.6); + ctx.lineTo(x, 2.6); + ctx.stroke(); + } + for (const [x, y] of [[-3.8, -1], [-2.2, 1], [-3.2, 1.4], [-2, -1.4]]) circle(ctx, x, y, 1.1, dome(ctx, x, y, 1.1, '#ffd23a', 0.45)); + }, + }, + bumblebear: { + size: 64, + frames: 4, + draw(ctx, c, f) { + const flap = [1, 0.7, 0.4, 0.7][f]; + // stubby legs and team pollen sacs + for (const sy of [-1, 1]) { + ellipse(ctx, 7, sy * 13.5, 3, 2.4, 0, BEE_K); + line(ctx, -6, sy * 12, -10, sy * 17, BEE_K, 2.2); + sac(ctx, -10, sy * 18, 4.6, c); + } + // fuzzy striped body + const bx = -2; + const R = rnd(4); + for (let i = 0; i < 44; i++) { + const a = (i / 44) * TAU; + const x = bx + Math.cos(a) * 15; + const y = Math.sin(a) * 15; + const band = Math.floor((x + 16) / 6.5) % 2 === 1; + circle(ctx, x, y, 2 + R() * 0.8, band ? '#26201a' : '#e8b828'); + } + circle(ctx, bx, 0, 15, dome(ctx, bx, 0, 15, '#f2c230', 0.35)); + ctx.save(); + ctx.beginPath(); + ctx.arc(bx, 0, 15.5, 0, TAU); + ctx.clip(); + for (const x0 of [-15.5, -2.5]) { + ctx.fillStyle = '#231c16'; + ctx.fillRect(bx + x0 + 3, -16, 6.5, 32); + } + ctx.fillStyle = rgba('#000000', 0.25); + ctx.beginPath(); + ctx.arc(bx + 4, 4, 15, 0, TAU); + ctx.arc(bx - 2, -2, 15, 0, TAU, true); + ctx.fill(); + ctx.restore(); + circle(ctx, bx - 5, -6, 4, 'rgba(255,250,220,0.25)'); + // antennae + for (const sy of [-1, 1]) { + ctx.strokeStyle = BEE_K; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(15, sy * 3); + ctx.quadraticCurveTo(21, sy * 4, 23, sy * 8); + ctx.stroke(); + circle(ctx, 23, sy * 8, 1.4, BEE_K); + } + bearHead(ctx, 13.5, 6.2, { fur: '#2b2420', dark: '#151110', muzzle: '#e6b866' }); + // four flapping wings over the back + for (const sy of [-1, 1]) { + wing(ctx, 3, sy * 8, 16 * flap + 6, 10, sy * (Math.PI / 2 - 0.1), '#eaf6ff', 0.3); + wing(ctx, -3, sy * 8, 12 * flap + 4, 7.5, sy * (Math.PI / 2 + 0.55), '#eaf6ff', 0.28); + } + }, + }, + scrapper: { + size: 18, + frames: 4, + draw(ctx, c, f) { + frontClaws(ctx, f, 0.45, 1.15, 1, 2.2, '#f4eee0'); + quadBear(ctx, f, 0.45, FUR.brown, { long: 1.15, shag: true }); + line(ctx, -3.4, 0, 1.6, 0, c.main, 1.3); + for (const sy of [-1, 1]) line(ctx, -1.2, sy * 0.8, -2.4, sy * 2.2, c.main, 0.7); + }, + }, + buzzbomb: { + size: 30, + frames: 4, + draw(ctx, c, f) { + ctx.save(); + ctx.translate(-4, 0); + quadBear(ctx, f, 0.5, FUR.brown, { shag: true }); + ctx.restore(); + skep(ctx, 6.5, 0, 6.2, true, c.main); + ellipse(ctx, 12, 0, 1.2, 1.8, 0, '#140a04'); + // arms hugging the hive + for (const sy of [-1, 1]) { + ctx.strokeStyle = FUR.brown.fur; + ctx.lineWidth = 2; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(0, sy * 3.4); + ctx.quadraticCurveTo(5, sy * 7.5, 8.5, sy * 4.6); + ctx.stroke(); + circle(ctx, 8.8, sy * 4.4, 1.3, FUR.brown.dark); + } + }, + overlay(ctx, _c, s) { + const p = 0.5 + 0.5 * Math.sin(s.t * 7 + s.seed); + glow(ctx, 6.5, 0, 9 + p * 3, HONEY, 0.22 + p * 0.25); + orbitBees(ctx, 6.5, 0, 8, 2, s.t * 2, s.seed, 1); + }, + }, + stinger: { + size: 36, + frames: 4, + draw(ctx, c, f) { + quadBear(ctx, f, 0.9, FUR.grizzly, { shag: true }); + // quiver of giant stingers + for (const [y, l] of [[-4.5, 11], [-1.5, 13], [1.5, 12.5], [4.5, 10.5]] as [number, number][]) stingerSpike(ctx, -2, y * 0.8, -2 - l, y * 1.5, 1.3); + ctx.save(); + ctx.translate(-4, 0); + ctx.rotate(-0.15); + rrect(ctx, -4, -4.5, 8, 9, 2, lit(ctx, -4, -4.5, 4, 4.5, '#7a4a22', 0.3), WOOD_D, 0.7); + line(ctx, 0, -4.5, 0, 4.5, c.main, 1.6); + ctx.restore(); + }, + }, + bombardier: { + size: 46, + frames: 4, + draw(ctx, c, f) { + const s = 1.15; + quadBear(ctx, f, s, FUR.kodiak, { wide: 1.35, hump: true }); + for (const [x, y, r] of [[3, -6.5, 3.6], [3, 6.5, 3.6], [-5, -4.5, 4.6], [-5, 4.5, 4.6], [-11.5, 0, 3.8]] as [number, number, number][]) sac(ctx, x, y, r, c); + // wide honey-dripping mouth + const hx = headX(s); + const r = 5 * s; + ellipse(ctx, hx + r * 1.02, 0, r * 0.4, r * 0.62, 0, '#3a0d08', '#140404', 0.6); + ellipse(ctx, hx + r * 1.1, 0, r * 0.2, r * 0.4, 0, rgba(HONEY, 0.9)); + ellipse(ctx, hx + r * 0.7, 0, r * 0.16, r * 0.2, 0, '#141010'); + }, + overlay(ctx, c, s) { + glow(ctx, -3, 0, 13, c.light, 0.12 + 0.08 * Math.sin(s.t * 3 + s.seed)); + }, + }, + burrower: { + size: 46, + frames: 4, + draw(ctx, c, f) { + quadBear(ctx, f, 1.2, FUR.black, { wide: 1.15 }); + // bony back plates with spikes + for (let i = 0; i < 4; i++) { + const x = -10 + i * 4.6; + const w = 6.2 - Math.abs(i - 1.5) * 0.7; + for (const sy of [-1, 1]) poly(ctx, [x + 1, sy * w * 0.6, x - 4, sy * (w + 5), x - 1.5, sy * w * 0.4], BONE, tone(BONE, -0.55), 0.5); + poly(ctx, [x + 3, 0, x, -w, x - 3, -w * 0.8, x - 2, 0, x - 3, w * 0.8, x, w], dome(ctx, x, 0, w, '#cbbd97', 0.35), c.main, 1); + poly(ctx, [x + 1.5, 0, x - 4.5, -1.2, x - 4.5, 1.2], BONE, tone(BONE, -0.5), 0.5); + } + }, + }, + burrower_b: { + size: 46, + draw(ctx, c) { + ellipse(ctx, 2, 3, 17, 15, 0, 'rgba(0,0,0,0.3)'); + spikes(ctx, 0, 0, 11, 9, 19, 23, BONE, 2.2); + fillBlob(ctx, 0, 0, 15, 13.5, 21, dome(ctx, 0, 0, 15, EARTH, 0.35), '#22160c', 0.16); + ctx.save(); + blob(ctx, 0, 0, 15, 13.5, 21, 0.16); + ctx.clip(); + ctx.globalAlpha = 0.7; + combCells(ctx, 5, -4, 6, 5, 2.4, 3, WAX, 0.3); + ctx.globalAlpha = 1; + ctx.restore(); + const R = rnd(9); + for (let i = 0; i < 7; i++) circle(ctx, (R() - 0.5) * 22, (R() - 0.5) * 20, 1 + R() * 1.4, dome(ctx, 0, 0, 3, '#7a5c40', 0.3)); + for (const sy of [-1, 1]) { + ellipse(ctx, -3, sy * 2.5, 1.6, 0.8, 0, c.light); + glow(ctx, -3, sy * 2.5, 3.2, c.light, 0.6); + } + }, + }, + hornet: { + size: 52, + frames: 4, + draw(ctx, c, f) { + const flap = [1, 0.7, 0.45, 0.7][f]; + // striped stinger abdomen + ctx.save(); + ctx.beginPath(); + ctx.ellipse(-13, 0, 9, 5, 0, 0, TAU); + ctx.fillStyle = dome(ctx, -13, 0, 9, BEE_Y, 0.4); + ctx.fill(); + ctx.clip(); + ctx.fillStyle = BEE_K; + for (const x of [-20, -15.5, -11]) ctx.fillRect(x, -6, 2.2, 12); + ctx.restore(); + ellipse(ctx, -13, 0, 9, 5, 0, null, BEE_K, 0.7); + poly(ctx, [-21.5, -1.4, -26, 0, -21.5, 1.4], BEE_K); + ellipse(ctx, -4.2, 0, 1.8, 3.4, 0, c.main, tone(c.dark, -0.3), 0.6); + // bear torso with dangling paws + for (const sy of [-1, 1]) { + circle(ctx, 4, sy * 5.6, 1.8, FUR.brown.dark); + circle(ctx, -2, sy * 5, 1.6, FUR.brown.dark); + } + ellipse(ctx, 0.5, 0, 6.5, 5.4, 0, dome(ctx, 0.5, 0, 6.5, FUR.brown.fur, 0.3), rgba('#000000', 0.45), 0.6); + bearHead(ctx, 7.5, 4.2, FUR.brown); + // narrow wasp wings + for (const sy of [-1, 1]) { + wing(ctx, 1, sy * 3, 20 * flap + 3, 6, sy * (Math.PI / 2 + 0.55), '#ffcf7a', 0.4); + wing(ctx, -1, sy * 3, 15 * flap + 2, 4.6, sy * (Math.PI / 2 + 0.95), '#ffcf7a', 0.36); + } + }, + }, + denmother: { + size: 58, + frames: 4, + draw(ctx, c, f) { + const s = 1.45; + quadBear(ctx, f, s, FUR.grizzly, { hump: true }); + line(ctx, -2, -9.5, -2, 9.5, c.main, 2.4); + line(ctx, -10, -8.5, -10, 8.5, c.main, 2.4); + // the den on her back + fillBlob(ctx, -6, 0, 10.5, 9, 31, dome(ctx, -6, 0, 10.5, '#7a5530', 0.35), WOOD_D, 0.1); + ctx.save(); + blob(ctx, -6, 0, 10.5, 9, 31, 0.1); + ctx.clip(); + combCells(ctx, -6, 0, 10, 9, 2.6, 33, WAX, 0.2, 0); + ctx.fillStyle = 'rgba(40,20,0,0.25)'; + ctx.fillRect(-17, 2, 22, 10); + ctx.restore(); + for (const [x, y, a] of [[-11, -4, -2.4], [-2, 4.5, 1.2], [-9, 5, 2.2], [-1, -4, -1]] as [number, number, number][]) { + circle(ctx, x, y, 2.4, '#1c0e04', WOOD_D, 0.6); + ctx.save(); + ctx.translate(x, y); + ctx.rotate(a); + bearHead(ctx, 0.6, 1.3, FUR.honey); + ctx.restore(); + } + }, + }, + wildcub: { + size: 18, + frames: 4, + draw(ctx, c, f) { + const s = 0.42; + shaggyTufts(ctx, 5, 3.4, -0.5, 16, FUR.black.fur, 5 + f); + quadBear(ctx, f, s, FUR.black, { shag: true }); + for (const sy of [-1, 1]) line(ctx, -3, sy * 0.6, 0.5, sy * 1.6, c.main, 0.7); + const hx = headX(s); + const r = 5 * s; + for (const sy of [-1, 1]) { + glow(ctx, hx + r * 0.32, sy * r * 0.36, 2.6, c.light, 0.9); + circle(ctx, hx + r * 0.32, sy * r * 0.36, 0.55, '#ffffff'); + } + }, + }, + longtongue: { + size: 58, + frames: 4, + draw(ctx, c, f) { + const flap = [1, 0.7, 0.4, 0.7][f]; + const curl = [0, 1, 2, 1][f] * 0.6; + const sun = FUR.sun; + for (const sy of [-1, 1]) { + ellipse(ctx, 5, sy * 6.2, 2.4, 2, 0, sun.dark); + ellipse(ctx, -7, sy * 5.5, 2.4, 2, 0, sun.dark); + } + ellipse(ctx, -1, 0, 9, 6.6, 0, dome(ctx, -1, 0, 9, sun.fur, 0.35), rgba('#000000', 0.5), 0.6); + // the sun bear's crescent, in team colour + ctx.strokeStyle = c.main; + ctx.lineWidth = 1.8; + ctx.beginPath(); + ctx.arc(1, 0, 4.4, -1.2, 1.2); + ctx.stroke(); + circle(ctx, -9.5, 0, 1.4, sun.dark); + // the tongue + ctx.strokeStyle = '#d9667a'; + ctx.lineCap = 'round'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(12, 0); + ctx.bezierCurveTo(18, -1, 22, 2 + curl, 22.5, 6 + curl); + ctx.bezierCurveTo(23, 9 + curl, 19, 10.5 + curl, 18, 8 + curl); + ctx.bezierCurveTo(17.4, 6.5 + curl, 19.5, 5.6 + curl, 20, 7 + curl); + ctx.stroke(); + ctx.strokeStyle = '#f39aa8'; + ctx.lineWidth = 0.6; + ctx.beginPath(); + ctx.moveTo(13, -0.4); + ctx.bezierCurveTo(18, -1.2, 21.5, 1.6 + curl, 22, 5 + curl); + ctx.stroke(); + bearHead(ctx, 7.5, 4.6, sun); + // round bee wings + for (const sy of [-1, 1]) { + wing(ctx, 0, sy * 5.5, 15 * flap + 5, 10, sy * (Math.PI / 2 + 0.15), '#fff4d8', 0.3); + wing(ctx, -4, sy * 5, 10 * flap + 4, 7, sy * (Math.PI / 2 + 0.75), '#fff4d8', 0.28); + } + }, + }, + cavetitan: { + size: 116, + frames: 4, + draw(ctx, c, f) { + const s = 2.8; + const long = 1.1; + const wide = 1.3; + const L = 22 * s * long; + const W = 13 * s * wide; + const ph = [0, 1, 0, -1][f] * 2.2 * s; + const cave = FUR.cave; + // long pillar legs with huge claws + for (const [px, sy, d] of [[L * 0.3, -1, ph], [L * 0.3, 1, -ph], [-L * 0.32, -1, -ph], [-L * 0.32, 1, ph]]) { + const fx = px + d; + const fy = sy * (W * 0.5 + 6); + line(ctx, px * 0.8, sy * W * 0.3, fx, fy, tone(cave.fur, -0.15), 11); + line(ctx, px * 0.8, sy * W * 0.3, fx, fy, rgba('#000000', 0.25), 3); + for (const k of [-1.5, -0.5, 0.5, 1.5]) { + ctx.strokeStyle = BONE; + ctx.lineWidth = 1.8; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(fx + 4, fy + k * 2.4); + ctx.quadraticCurveTo(fx + 11, fy + k * 2.6, fx + 13, fy + k * 3.4 + sy * 2); + ctx.stroke(); + } + ellipse(ctx, fx, fy, 6.8, 5.6, 0, dome(ctx, fx, fy, 6.8, '#3d2e22', 0.3), '#000', 0.6); + } + quadBear(ctx, f, s, cave, { long, wide, hump: true, shag: true }); + // honeycomb armor plates over the back + ctx.save(); + ctx.beginPath(); + ctx.ellipse(-6, 0, 24, 17, 0, 0, TAU); + ctx.clip(); + combCells(ctx, -6, 0, 27, 20, 4.4, 61, WAX, 0.22, 0.05); + ctx.restore(); + ctx.save(); + ctx.beginPath(); + ctx.ellipse(-6, 0, 24, 17, 0, 0, TAU); + ctx.strokeStyle = WAX_D; + ctx.lineWidth = 2; + ctx.stroke(); + ctx.restore(); + for (const [x, y] of [[-6, 0], [-12.6, -3.8], [0.6, 3.8], [-19.2, 0], [7.2, 0]]) { + hexPath(ctx, x, y, 4); + ctx.fillStyle = c.main; + ctx.fill(); + ctx.strokeStyle = tone(c.dark, -0.4); + ctx.lineWidth = 0.8; + ctx.stroke(); + } + spikes(ctx, 14, 0, 8, 5, 8, 63, BONE, 2); + bearHead(ctx, headX(s, long), 5 * s, cave); + // old scars + line(ctx, headX(s, long) - 6, -9, headX(s, long) + 2, -3, rgba('#e8d2b0', 0.5), 1.2); + line(ctx, headX(s, long) - 8, -6, headX(s, long), 0, rgba('#e8d2b0', 0.5), 1.2); + }, + }, + swarmcaller: { + size: 50, + frames: 4, + draw(ctx, c, f) { + const s = 1.25; + const sp = FUR.spectacled; + // team-colour cape + ellipse(ctx, -4, 0, 7.5, 11, 0, dome(ctx, -4, 0, 11, c.main, 0.35), tone(c.dark, -0.35), 0.7); + for (const y of [-6, 0, 6]) line(ctx, -9, y, -11.5, y * 1.1, c.dark, 1); + bipedBear(ctx, f, s, sp); + // hive staff + line(ctx, -5, 7.5, 15, 6, WOOD_D, 1.8); + skep(ctx, 15, 6, 3.6, true); + bipedArms(ctx, s, sp); + // wax mask + const hx = 1.6 * s; + const r = 4 * s; + ellipse(ctx, hx + r * 0.45, 0, r * 0.8, r * 0.9, 0, dome(ctx, hx + r * 0.45, 0, r, WAX_L, 0.4), WAX_D, 0.7); + for (const sy of [-1, 1]) { + ellipse(ctx, hx + r * 0.45, sy * r * 0.38, r * 0.14, r * 0.22, 0, '#1a0c02'); + glow(ctx, hx + r * 0.45, sy * r * 0.38, 2.2, c.light, 0.7); + } + line(ctx, hx + r * 0.85, -r * 0.3, hx + r * 0.85, r * 0.3, WAX_D, 0.6); + poly(ctx, [hx + r * 1.15, -1, hx + r * 1.55, 0, hx + r * 1.15, 1], WAX_D); + }, + overlay(ctx, _c, s) { + orbitBees(ctx, 0, 0, 17, 6, s.t, s.seed, 1.5, 2.2); + }, + }, + + // ================================================================ structures + hive: { + pad: 10, + draw(ctx, c, _f, def) { + const tier = def.id === 'elderhive' ? 2 : def.id === 'royalhive' ? 1 : 0; + combGround(ctx, 174, 168, 5); + if (tier === 2) { + // gnarled roots reaching across the comb + const R = rnd(17); + for (let i = 0; i < 9; i++) { + const a = (i / 9) * TAU + R() * 0.3; + ctx.strokeStyle = '#2e2015'; + ctx.lineCap = 'round'; + ctx.lineWidth = 7 - R() * 2; + ctx.beginPath(); + ctx.moveTo(Math.cos(a) * 50, Math.sin(a) * 48); + ctx.quadraticCurveTo(Math.cos(a + 0.25) * 66, Math.sin(a + 0.25) * 64, Math.cos(a + 0.1) * 80, Math.sin(a + 0.1) * 77); + ctx.stroke(); + ctx.strokeStyle = 'rgba(120,100,60,0.35)'; + ctx.lineWidth = 1.5; + ctx.stroke(); + } + } + // tree stump + const bark = tier === 2 ? '#3a2a1c' : BARK; + fillBlob(ctx, 0, 2, 66, 62, 9, dome(ctx, 0, 2, 66, bark, 0.3), WOOD_D, 0.08); + for (let i = 0; i < 40; i++) { + const a = (i / 40) * TAU; + line(ctx, Math.cos(a) * 56, 2 + Math.sin(a) * 52, Math.cos(a) * 64, 2 + Math.sin(a) * 60, 'rgba(15,8,2,0.45)', 1.2); + } + if (tier === 2) { + for (const [x, y, r] of [[-50, -26, 9], [44, 34, 8], [-30, 48, 7], [52, -14, 6]] as [number, number, number][]) fillBlob(ctx, x, y, r, r * 0.8, x + 99, rgba('#6a7a2e', 0.75), '', 0.3); + } + // wild comb on the stump, then the great skep + combBlob(ctx, 0, -2, 56, 52, 11, 5, WAX, 0.25, 0.1); + drips(ctx, 0, -2, 54, 50, 9, 40); + const skeps = [[38], [40, 27], [41, 30, 19]][tier]; + skeps.forEach((r, i) => { + const y = -6 - i * 6; + circle(ctx, 3, y + 5, r, 'rgba(0,0,0,0.35)'); + skep(ctx, 0, y, r, false, i === 0 ? c.main : undefined); + if (i > 0) drips(ctx, 0, y, r * 0.96, r * 0.92, 5, 44 + i); + }); + // entrance + ellipse(ctx, 0, 22, 12, 6.5, 0, '#241004', WOOD_D, 1); + ellipse(ctx, 0, 23, 7.5, 3.6, 0, rgba(HONEY, 0.6)); + // team honey sacs hanging around the rim + for (const [x, y] of [[-46, -22], [46, -20], [-42, 30], [44, 28], [0, -50]]) sac(ctx, x, y, 7 + tier, c); + if (tier >= 1) { + // royal crown of golden comb points + const cy = -6 - tier * 6; + const n = tier === 2 ? 9 : 7; + for (let i = 0; i < n; i++) { + const a = (i / n) * TAU - Math.PI / 2; + const r0 = 8 + tier * 2; + const r1 = r0 + 7 + tier * 3; + poly(ctx, [Math.cos(a - 0.22) * r0, cy + Math.sin(a - 0.22) * r0, Math.cos(a) * r1, cy + Math.sin(a) * r1, Math.cos(a + 0.22) * r0, cy + Math.sin(a + 0.22) * r0], dome(ctx, 0, cy, r1, WAX_L, 0.5), WAX_D, 0.8); + } + circle(ctx, 0, cy, 8 + tier * 2, dome(ctx, 0, cy, 10, WAX_L, 0.5), WAX_D, 1); + circle(ctx, 0, cy, 5 + tier, dome(ctx, 0, cy, 6, c.main, 0.55), tone(c.dark, -0.4), 1); + } + if (tier === 2) { + for (const [x, y, r] of [[-44, -6, 5], [42, 4, 4], [-30, 36, 4], [30, -36, 5]] as [number, number, number][]) { + hexPath(ctx, x, y, r); + ctx.fillStyle = dome(ctx, x, y, r, '#ffb02e', 0.6); + ctx.fill(); + ctx.strokeStyle = '#6a3a04'; + ctx.stroke(); + } + } + }, + overlay(ctx, c, s, def) { + const tier = def.id === 'elderhive' ? 2 : def.id === 'royalhive' ? 1 : 0; + const p = 0.35 + 0.2 * Math.sin(s.t * 2 + s.seed); + glow(ctx, 0, 23, 18, HONEY, p); + if (tier === 2) glow(ctx, 0, -10, 46, '#ffb02e', 0.12 + 0.06 * Math.sin(s.t * 1.3)); + if (tier >= 1) glow(ctx, 0, -6 - tier * 6, 12, c.light, p * 0.8); + orbitBees(ctx, 0, 4, 62, 4 + tier * 2, s.t * 0.7, s.seed, 2.2); + }, + }, + nectartap: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 13); + // log ring + for (let i = 0; i < 9; i++) { + const a = (i / 9) * TAU; + ctx.save(); + ctx.translate(Math.cos(a) * 28, Math.sin(a) * 27); + ctx.rotate(a + Math.PI / 2); + rrect(ctx, -9, -4, 18, 8, 3.5, lit(ctx, -9, -4, 9, 4, WOOD, 0.3), WOOD_D, 0.9); + circle(ctx, 9, 0, 3.6, '#b98a55', WOOD_D, 0.6); + circle(ctx, 9, 0, 1.6, null, 'rgba(60,30,10,0.5)', 0.5); + ctx.restore(); + } + // wax funnel and nectar pool + circle(ctx, 0, 0, 20, dome(ctx, 0, 0, 20, WAX, 0.4), WAX_D, 1.2); + drips(ctx, 0, 0, 19, 18, 7, 14); + circle(ctx, 0, 0, 11, '#5a2c06'); + circle(ctx, 0, 0, 9, dome(ctx, -1, -1, 9, HONEY, 0.5)); + circle(ctx, -3, -3, 2, 'rgba(255,255,255,0.5)'); + // spigots to barrels + for (const [x, y] of [[-36, -34], [36, 34]]) { + line(ctx, x * 0.45, y * 0.45, x, y, WOOD_D, 3); + circle(ctx, x, y, 8, dome(ctx, x, y, 8, '#8a5a2c', 0.35), WOOD_D, 1); + circle(ctx, x, y, 6, null, c.main, 1.6); + circle(ctx, x, y, 3, rgba(HONEY, 0.8)); + } + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 16, HONEY, 0.3 + 0.2 * Math.sin(s.t * 2.5 + s.seed)); + }, + }, + cubden: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 21); + fillBlob(ctx, 0, 0, 38, 34, 22, dome(ctx, -4, -6, 40, '#7a5533', 0.35), WOOD_D, 0.1); + combBlob(ctx, -4, -8, 24, 18, 24, 4, WAX, 0.25); + drips(ctx, -4, -8, 23, 17, 7, 25); + pawPrint(ctx, -4, -10, 9, c.main); + // den mouths with cubs peeking out + for (const [x, y, r, hr] of [[6, 18, 11, 4.4], [-22, 12, 7, 2.8], [26, 0, 6.5, 2.6]] as [number, number, number, number][]) { + ellipse(ctx, x, y, r, r * 0.72, 0, '#1a0c04', WOOD_D, 1); + ctx.save(); + ctx.translate(x, y + r * 0.15); + ctx.rotate(Math.PI / 2); + bearHead(ctx, 0, hr, FUR.honey); + ctx.restore(); + } + banner(ctx, 30, -22, c); + }, + }, + honeycellar: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 31); + // dug-in cellar frame and trapdoors + rrect(ctx, -26, -22, 52, 42, 5, '#2e1a0b', WOOD_D, 1); + rrect(ctx, -23, -19, 23, 36, 2, lit(ctx, -23, -19, 0, 17, '#8f6236', 0.25), WOOD_D, 0.8); + rrect(ctx, 0, -19, 23, 36, 2, lit(ctx, 0, -19, 23, 17, '#8f6236', 0.25), WOOD_D, 0.8); + for (const x of [-17, -11, -5, 6, 12, 18]) line(ctx, x, -18, x, 16, 'rgba(40,20,5,0.45)', 0.7); + for (const y of [-12, 10]) line(ctx, -22, y, 22, y, '#4a3018', 2.4); + pawPrint(ctx, 0, -1, 9, c.main); + circle(ctx, -3, 4, 1.6, null, '#c9a46a', 1); + circle(ctx, 3, 4, 1.6, null, '#c9a46a', 1); + for (const [x, y, r] of [[-36, -26, 7], [-38, 4, 6], [-30, 30, 7], [34, -28, 6], [37, 2, 7], [30, 30, 6], [0, 32, 6]] as [number, number, number][]) honeyPot(ctx, x, y, r, c); + }, + }, + thornstump: { + pad: 6, + turretSize: 58, + draw(ctx) { + combGround(ctx, 70, 68, 41); + spikes(ctx, 0, 0, 20, 12, 9, 42, '#3a2414', 2); + circle(ctx, 0, 0, 22, dome(ctx, 0, 0, 22, BARK, 0.35), WOOD_D, 1.2); + circle(ctx, 0, 0, 17, dome(ctx, -3, -3, 17, '#b98a55', 0.25), '#5a3a1c', 0.8); + for (const r of [5, 9, 13]) circle(ctx, 0.5, 0.5, r, null, 'rgba(90,50,20,0.5)', 0.7); + }, + turret(ctx, c) { + // barbed bramble spike + poly(ctx, [-6, -5, 26, 0, -6, 5], dome(ctx, 6, 0, 18, '#5a3418', 0.35), WOOD_D, 1); + for (let i = 0; i < 4; i++) { + const x = -1 + i * 6; + const w = 4.3 - i * 1; + for (const sy of [-1, 1]) poly(ctx, [x, sy * w * 0.8, x - 3, sy * (w + 3.5), x + 2, sy * w * 0.6], BONE, tone(BONE, -0.5), 0.5); + } + poly(ctx, [20, -1.2, 27, 0, 20, 1.2], BONE); + circle(ctx, -5, 0, 6.5, dome(ctx, -5, 0, 6.5, WOOD, 0.4), WOOD_D, 1); + circle(ctx, -5, 0, 3.6, dome(ctx, -5, 0, 3.6, c.main, 0.5), tone(c.dark, -0.3), 0.6); + }, + }, + stingtower: { + pad: 6, + turretSize: 48, + draw(ctx) { + combGround(ctx, 70, 68, 51); + fillBlob(ctx, 0, 0, 22, 21, 52, dome(ctx, 0, 0, 22, WAX, 0.4), WAX_D, 0.12); + drips(ctx, 0, 0, 21, 20, 6, 53); + }, + turret(ctx, c) { + poly(ctx, [6, -4.5, 18, -2.6, 18, 2.6, 6, 4.5], lit(ctx, 6, -4, 18, 4, WAX_L, 0.3), WAX_D, 0.8); + ellipse(ctx, 18, 0, 1.4, 2.4, 0, '#1a0c02'); + skep(ctx, 0, 0, 11, true, c.main); + circle(ctx, 0, 0, 3, BEE_K); + }, + overlay(ctx, _c, s) { + orbitBees(ctx, 0, 0, 19, 3, s.t, s.seed, 1.6, 2.4); + }, + }, + apiary: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 61); + beeBox(ctx, -36, -32, 28, 24, 2, c); + beeBox(ctx, 6, -36, 28, 24, 3, c); + beeBox(ctx, -14, -2, 28, 24, 3, c); + beeBox(ctx, 18, 10, 24, 22, 1, c); + beeBox(ctx, -40, 16, 22, 20, 1, c); + skep(ctx, 34, -6, 8, false, c.main); + }, + overlay(ctx, _c, s) { + orbitBees(ctx, 0, -2, 34, 6, s.t * 0.9, s.seed, 1.8); + }, + }, + stingerden: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 71); + fillBlob(ctx, 0, 0, 30, 27, 72, dome(ctx, 0, 0, 30, '#6e4a2a', 0.35), WOOD_D, 0.1); + const R = rnd(73); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU + 0.2 + R() * 0.2; + const l = 38 + R() * 8; + stingerSpike(ctx, Math.cos(a) * 14, Math.sin(a) * 13, Math.cos(a) * l, Math.sin(a) * l * 0.95, 4.2); + } + combBlob(ctx, 0, 0, 18, 16, 74, 3.6, WAX, 0.3); + circle(ctx, 0, 2, 8, '#1a0c04', WOOD_D, 1); + circle(ctx, 0, 2, 10, null, c.main, 2.4); + }, + }, + burrowden: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 81, '#a8772a'); + fillBlob(ctx, 0, 0, 40, 36, 82, dome(ctx, 0, 0, 40, EARTH, 0.3), '#22160c', 0.14); + const R = rnd(83); + for (let i = 0; i < 14; i++) { + const a = R() * TAU; + const d = 26 + R() * 12; + circle(ctx, Math.cos(a) * d, Math.sin(a) * d * 0.9, 2 + R() * 2.5, dome(ctx, 0, 0, 4, '#7a5c40', 0.3), '#2e1e10', 0.5); + } + const g = ctx.createRadialGradient(0, 2, 2, 0, 2, 24); + g.addColorStop(0, '#050201'); + g.addColorStop(0.7, '#1e120a'); + g.addColorStop(1, '#3d2a1a'); + fillBlob(ctx, 0, 2, 24, 21, 84, g, '#140a04', 0.1); + // bone spines ringing the pit, pointing inward + for (let i = 0; i < 11; i++) { + const a = (i / 11) * TAU; + const bx = Math.cos(a) * 26; + const by = 2 + Math.sin(a) * 23; + const nx = -Math.sin(a) * 2.6; + const ny = Math.cos(a) * 2.6; + poly(ctx, [bx + nx, by + ny, Math.cos(a) * 13, 2 + Math.sin(a) * 11, bx - nx, by - ny], BONE, tone(BONE, -0.5), 0.6); + } + for (const sy of [-1, 1]) { + ellipse(ctx, sy * 4, 3, 2, 1, 0, c.light); + glow(ctx, sy * 4, 3, 4, c.light, 0.7); + } + sac(ctx, -38, -20, 5, c); + sac(ctx, 38, 20, 5, c); + }, + }, + hornetnest: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 91); + // layered paper nest + fillBlob(ctx, 2, 3, 38, 35, 92, 'rgba(0,0,0,0.35)', '', 0.1); + fillBlob(ctx, 0, 0, 38, 35, 92, dome(ctx, 0, 0, 38, PAPER, 0.35), '#4a3e2c', 0.1); + for (let i = 0; i < 7; i++) { + const r = 34 - i * 4.6; + ctx.save(); + ctx.beginPath(); + ctx.ellipse(-i * 0.8, -i * 0.8, r, r * 0.93, 0, 0, TAU); + ctx.clip(); + ctx.strokeStyle = i % 3 === 1 ? rgba(c.main, 0.75) : i % 2 ? '#8e7f66' : '#d1c3a6'; + ctx.lineWidth = 3; + for (let k = 0; k < 12; k++) { + const a = (k / 12) * TAU + i * 0.4; + ctx.beginPath(); + ctx.arc(-i * 0.8 + Math.cos(a) * r, -i * 0.8 + Math.sin(a) * r * 0.93, 6, 0, TAU); + ctx.stroke(); + } + ctx.restore(); + ctx.beginPath(); + ctx.ellipse(-i * 0.8, -i * 0.8, r, r * 0.93, 0, 0, TAU); + ctx.strokeStyle = 'rgba(60,48,30,0.45)'; + ctx.lineWidth = 0.8; + ctx.stroke(); + } + ellipse(ctx, 4, 20, 8, 5.5, 0, '#1a1008', '#3a2e1c', 1); + ellipse(ctx, 4, 20.5, 4.5, 2.6, 0, rgba(HONEY, 0.45)); + }, + overlay(ctx, _c, s) { + orbitBees(ctx, 0, 0, 42, 3, s.t * 1.2, s.seed, 2); + }, + }, + swarmpit: { + pad: 8, + draw(ctx, c) { + combGround(ctx, 104, 100, 101); + combBlob(ctx, 0, 0, 36, 33, 102, 4.2, '#d39a30', 0.3); + const g = ctx.createRadialGradient(0, 0, 2, 0, 0, 24); + g.addColorStop(0, '#000000'); + g.addColorStop(0.6, '#1c0e04'); + g.addColorStop(1, '#5a3410'); + fillBlob(ctx, 0, 0, 24, 22, 103, g, WAX_D, 0.08); + drips(ctx, 0, -2, 23, 20, 8, 104, WAX); + for (const [x, y] of [[-38, -32], [38, -32], [-38, 34], [38, 34]]) banner(ctx, x, y, c, 14); + }, + overlay(ctx, c, s) { + glow(ctx, 0, 0, 22, c.light, 0.15 + 0.08 * Math.sin(s.t * 1.7)); + orbitBees(ctx, 0, 0, 16, 5, s.t * 1.8, s.seed, 1.5); + orbitBees(ctx, 0, 0, 28, 6, -s.t * 1.1, s.seed + 1, 1.8); + }, + }, + titancavern: { + pad: 10, + draw(ctx, c) { + combGround(ctx, 108, 104, 111); + // rocky hill of boulders + const R = rnd(112); + for (let i = 0; i < 9; i++) { + const a = (i / 9) * TAU + 0.3; + const x = Math.cos(a) * 28; + const y = Math.sin(a) * 24 - 4; + const r = 12 + R() * 6; + fillBlob(ctx, x, y, r, r * 0.85, 113 + i, dome(ctx, x, y, r, ['#7a6c5c', '#6e6050', '#857766'][i % 3], 0.35), '#2c241c', 0.18); + } + fillBlob(ctx, 0, -6, 30, 24, 130, dome(ctx, -4, -12, 30, '#8a7a66', 0.35), '#2c241c', 0.12); + // giant claw marks + for (const k of [-1, 0, 1]) line(ctx, -14 + k * 5, -24, -2 + k * 5, -8, 'rgba(20,14,8,0.7)', 2.2); + // cave mouth glowing with honey + fillBlob(ctx, 0, 16, 20, 13, 131, '#0e0804', '#2c241c', 0.12); + ellipse(ctx, 0, 18, 11, 6, 0, rgba(HONEY, 0.45)); + ctx.save(); + blob(ctx, 0, 16, 20, 13, 131, 0.12); + ctx.clip(); + combCells(ctx, 0, 8, 20, 6, 3, 132, WAX, 0.5, 0); + ctx.restore(); + // bones by the entrance + for (const [x, y, a] of [[-28, 34, 0.4], [26, 36, -0.5]]) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(a); + line(ctx, -7, 0, 7, 0, BONE, 2.4); + for (const sx of [-7, 7]) for (const sy of [-1, 1]) circle(ctx, sx, sy * 1.4, 1.6, BONE); + ctx.restore(); + } + // team hide banner draped over the top + poly(ctx, [-12, -32, 12, -32, 10, -20, 4, -16, 0, -20, -4, -16, -10, -20], c.main, tone(c.dark, -0.35), 1); + pawPrint(ctx, 0, -26, 4.5, tone(c.dark, -0.3)); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 16, 18, HONEY, 0.25 + 0.15 * Math.sin(s.t * 1.5 + s.seed)); + }, + }, +}; diff --git a/gh-pages/bear-war/src/render/art/index.ts b/gh-pages/bear-war/src/render/art/index.ts new file mode 100644 index 00000000..f995b538 --- /dev/null +++ b/gh-pages/bear-war/src/render/art/index.ts @@ -0,0 +1,89 @@ +import type { EntityDef } from '../../game/data/types.ts'; + +export interface TeamPal { + main: string; + light: string; + dark: string; + index: number; +} + +export type DrawFn = (ctx: CanvasRenderingContext2D, c: TeamPal, frame: number, def: EntityDef) => void; + +export interface OverlayState { + t: number; + built: boolean; + powered: boolean; + working: boolean; + seed: number; +} + +export interface ArtDef { + /** unit sprite box side (world px) */ + size?: number; + /** extra space around a structure footprint (world px) */ + pad?: number; + frames?: number; + draw: DrawFn; + turret?: DrawFn; + turretSize?: number; + /** cheap per-frame decoration in world px, centred on the entity (structures: unrotated) */ + overlay?: (ctx: CanvasRenderingContext2D, c: TeamPal, s: OverlayState, def: EntityDef) => void; +} + +const placeholderUnit: ArtDef = { + draw(ctx, c, _f, def) { + const r = def.radius ?? 10; + ctx.fillStyle = c.dark; + ctx.beginPath(); + ctx.arc(0, 0, r, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = c.main; + ctx.beginPath(); + ctx.arc(0, 0, r * 0.8, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = c.light; + ctx.beginPath(); + ctx.moveTo(r, 0); + ctx.lineTo(r * 0.2, -r * 0.45); + ctx.lineTo(r * 0.2, r * 0.45); + ctx.fill(); + }, +}; + +const placeholderBuilding: ArtDef = { + draw(ctx, c, _f, def) { + const [w, h] = def.size ?? [2, 2]; + const W = w * 32; + const H = h * 32; + ctx.fillStyle = '#555'; + ctx.fillRect(-W / 2 + 2, -H / 2 + 2, W - 4, H - 4); + ctx.strokeStyle = c.main; + ctx.lineWidth = 3; + ctx.strokeRect(-W / 2 + 3, -H / 2 + 3, W - 6, H - 6); + ctx.fillStyle = '#fff'; + ctx.font = 'bold 12px sans-serif'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(def.name.slice(0, 3).toUpperCase(), 0, 0); + }, +}; + +export const ART: Record = { + _unit: placeholderUnit, + _building: placeholderBuilding, +}; + +import { IRONPAW_ART } from './ironpaw.ts'; +import { HORDE_ART } from './horde.ts'; +import { STARBORN_ART } from './starborn.ts'; +import { NEUTRAL_ART } from './neutral.ts'; +Object.assign(ART, IRONPAW_ART, HORDE_ART, STARBORN_ART, NEUTRAL_ART); + +/** Register art definitions (called by the per-race art modules). */ +export function registerArt(defs: Record): void { + Object.assign(ART, defs); +} + +export function fallbackArt(def: EntityDef): ArtDef { + return def.kind === 'building' || def.kind === 'mineral' || def.kind === 'geyser' ? placeholderBuilding : placeholderUnit; +} diff --git a/gh-pages/bear-war/src/render/art/ironpaw.ts b/gh-pages/bear-war/src/render/art/ironpaw.ts new file mode 100644 index 00000000..7885ae02 --- /dev/null +++ b/gh-pages/bear-war/src/render/art/ironpaw.ts @@ -0,0 +1,1217 @@ +// Ironpaw art: industrial timber-country bears. Steel and log buildings, riveted steam +// machinery, brass fittings and team-coloured gear. + +import type { ArtDef, TeamPal } from './index.ts'; +import { FUR, bearHead, bipedBear, pawPrint, quadBear, type Fur } from './bear.ts'; +import { circle, dome, ellipse, glow, hazard, line, lit, poly, rivets, rrect, sym, tone, type Ctx, footShadow, rgba, TAU } from './kit.ts'; + +const STEEL = '#8a949e'; +const STEEL_D = '#4e565f'; +const GUN = '#2d3339'; +const GLASS = '#7fdcff'; +const HAZ = '#e2b33b'; +const BRASS = '#c9a24a'; +const COPPER = '#b5693a'; +const WOOD = '#8a6239'; +const WOOD_D = '#5a3d22'; +const DIRT = '#6b5136'; + +// ---------------------------------------------------------------- bear gear + +/** Ears of bearHead(x, r), redrawn so they poke out over a hat. */ +function ears(ctx: Ctx, x: number, r: number, f: Fur): void { + for (const sy of [-1, 1]) { + const ex = x - r * 0.45; + const ey = sy * r * 0.78; + circle(ctx, ex, ey, r * 0.42, f.dark); + circle(ctx, ex + r * 0.05, ey, r * 0.22, f.ear ?? tone(f.dark, 0.25)); + } +} + +/** Round hat seen from above: brim, crown and a team band. */ +function hat(ctx: Ctx, x: number, brim: number, crown: number, col: string, band: string): void { + circle(ctx, x, 0, brim, dome(ctx, x, 0, brim, tone(col, -0.12), 0.3), 'rgba(0,0,0,0.5)', 0.5); + circle(ctx, x, 0, crown + brim * 0.12, band); + circle(ctx, x, 0, crown, dome(ctx, x, 0, crown, col, 0.45), 'rgba(0,0,0,0.35)', 0.4); +} + +function shoulder(ctx: Ctx, x: number, y: number, rx: number, ry: number, c: TeamPal): void { + ellipse(ctx, x, y, rx, ry, 0, dome(ctx, x, y, Math.max(rx, ry), c.main, 0.5), tone(c.dark, -0.2), 0.6); +} + +/** Shaggy fur tufts around an ellipse (sloth bears). */ +function shag(ctx: Ctx, x: number, rx: number, ry: number, f: Fur, n = 16): void { + ctx.strokeStyle = tone(f.fur, 0.25); + ctx.lineWidth = 1; + ctx.lineCap = 'round'; + for (let i = 0; i < n; i++) { + const a = (i / n) * TAU; + ctx.beginPath(); + ctx.moveTo(x + Math.cos(a) * rx * 0.8, Math.sin(a) * ry * 0.8); + ctx.lineTo(x + Math.cos(a + 0.15) * rx * 1.12, Math.sin(a + 0.15) * ry * 1.12); + ctx.stroke(); + } +} + +/** Cream spectacle rings round the eyes of bearHead(x, r). */ +function spectacles(ctx: Ctx, x: number, r: number): void { + for (const sy of [-1, 1]) { + circle(ctx, x + r * 0.32, sy * r * 0.36, r * 0.2, null, '#ecd9a8', r * 0.13); + circle(ctx, x + r * 0.32, sy * r * 0.36, r * 0.11, '#0b0908'); + } + line(ctx, x + r * 0.45, -r * 0.2, x + r * 0.8, -r * 0.08, '#ecd9a8', r * 0.09); + line(ctx, x + r * 0.45, r * 0.2, x + r * 0.8, r * 0.08, '#ecd9a8', r * 0.09); +} + +/** Pilot head with brass flying goggles. */ +function pilot(ctx: Ctx, x: number, r: number, f: Fur, y = 0): void { + bearHead(ctx, x, r, f, y); + line(ctx, x - r * 0.3, y - r * 0.9, x - r * 0.3, y + r * 0.9, '#3a2a1c', r * 0.2); + for (const sy of [-1, 1]) circle(ctx, x + r * 0.32, y + sy * r * 0.38, r * 0.26, rgba(GLASS, 0.85), BRASS, r * 0.14); +} + +function paw(ctx: Ctx, x: number, y: number, s: number, f: Fur): void { + circle(ctx, x, y, 1.9 * s, f.dark); +} + +function keg(ctx: Ctx, x: number, y: number, w: number, h: number, c: TeamPal): void { + rrect(ctx, x - w / 2, y - h / 2, w, h, w * 0.35, lit(ctx, x - w / 2, y - h / 2, x + w / 2, y + h / 2, '#9a6a3a', 0.3), '#2a1a0e', 0.6); + for (const k of [-0.32, 0.32]) line(ctx, x - w / 2 + 0.4, y + k * h, x + w / 2 - 0.4, y + k * h, c.main, 1.1); + line(ctx, x - w / 2 + 0.4, y, x + w / 2 - 0.4, y, 'rgba(0,0,0,0.3)', 0.5); +} + +function wheel(ctx: Ctx, x: number, y: number, l: number, w: number, f: number): void { + rrect(ctx, x - l / 2, y - w / 2, l, w, w * 0.45, '#1f2226', '#0b0c0e', 0.5); + ctx.strokeStyle = 'rgba(255,255,255,0.14)'; + ctx.lineWidth = 0.7; + const o = (f % 2) * 1.3; + for (let k = -l / 2 + 1 + o; k < l / 2 - 0.5; k += 2.6) { + ctx.beginPath(); + ctx.moveTo(x + k, y - w / 2 + 0.4); + ctx.lineTo(x + k, y + w / 2 - 0.4); + ctx.stroke(); + } + circle(ctx, x, y, w * 0.18, STEEL); +} + +function puff(ctx: Ctx, x: number, y: number, r: number, t: number): void { + for (let i = 0; i < 3; i++) { + const k = (t * 1.2 + i / 3) % 1; + ctx.globalAlpha = 0.45 * (1 - k); + circle(ctx, x - k * r * 2.2, y + Math.sin(k * 5 + i) * r * 0.4, r * (0.5 + k), '#e8e4dc'); + } + ctx.globalAlpha = 1; +} + +/** Spinning rotor: blurred disc plus two blades. */ +function rotor(ctx: Ctx, x: number, y: number, r: number, a: number, blades = 2): void { + circle(ctx, x, y, r, 'rgba(220,225,230,0.12)', 'rgba(220,225,230,0.25)', 0.6); + for (let i = 0; i < blades; i++) { + const b = a + (i / blades) * TAU; + line(ctx, x, y, x + Math.cos(b) * r, y + Math.sin(b) * r, 'rgba(40,40,44,0.8)', 1.6); + } + circle(ctx, x, y, 1.6, BRASS, '#222', 0.4); +} + +/** Propeller seen from above: an edge-on blade sweeping along y. */ +function prop(ctx: Ctx, x: number, y: number, r: number, t: number): void { + ellipse(ctx, x, y, 1.2, r, 0, 'rgba(220,225,230,0.18)'); + const k = Math.cos(t * 45); + line(ctx, x, y - r * k, x, y + r * k, 'rgba(40,36,32,0.85)', 1.4); + circle(ctx, x, y, 1.3, BRASS); +} + +function flame(ctx: Ctx, x: number, y: number, t: number, r = 2.5): void { + const k = 0.7 + 0.3 * Math.sin(t * 23 + x); + glow(ctx, x, y, r * 2.2 * k, '#ff8a2a', 0.8); + circle(ctx, x, y, r * 0.45 * k, '#fff0b0'); +} + +// ---------------------------------------------------------------- buildings + +/** A building block with a visible south wall for depth. */ +function block(ctx: Ctx, x: number, y: number, w: number, h: number, col: string, depth = 5, r = 3): void { + rrect(ctx, x, y + depth, w, h - depth, r, tone(col, -0.45)); + rrect(ctx, x, y, w, h - depth, r, lit(ctx, x, y, x + w, y + h, col, 0.28), 'rgba(0,0,0,0.5)', 0.8); + line(ctx, x + r, y + 0.8, x + w - r, y + 0.8, 'rgba(255,255,255,0.35)', 0.8); +} + +/** Timber block: a block with plank seams running along x (or y). */ +function timber(ctx: Ctx, x: number, y: number, w: number, h: number, depth = 6, r = 4, gap = 6, vertical = false, col = WOOD): void { + block(ctx, x, y, w, h, col, depth, r); + ctx.strokeStyle = 'rgba(40,24,10,0.45)'; + ctx.lineWidth = 0.8; + if (vertical) { + for (let k = x + gap; k < x + w - 2; k += gap) { + ctx.beginPath(); + ctx.moveTo(k, y + 2); + ctx.lineTo(k, y + h - depth - 1); + ctx.stroke(); + } + } else { + for (let k = y + gap; k < y + h - depth - 1; k += gap) { + ctx.beginPath(); + ctx.moveTo(x + 2, k); + ctx.lineTo(x + w - 2, k); + ctx.stroke(); + } + } +} + +/** Stack of log ends (round cuts with rings). */ +function logs(ctx: Ctx, x: number, y: number, n: number, r = 3.5): void { + for (let i = 0; i < n; i++) { + const lx = x + (i % 3) * r * 2 + (Math.floor(i / 3) % 2) * r; + const ly = y - Math.floor(i / 3) * r * 1.7; + circle(ctx, lx, ly, r, '#b48a58', WOOD_D, 0.7); + circle(ctx, lx, ly, r * 0.5, null, 'rgba(90,60,30,0.6)', 0.5); + } +} + +function pad(ctx: Ctx, w: number, h: number): void { + footShadow(ctx, w - 6, h - 6, 4); + rrect(ctx, -w / 2 + 2, -h / 2 + 2, w - 4, h - 4, 5, lit(ctx, -w / 2, -h / 2, w / 2, h / 2, '#5b6168', 0.15), 'rgba(0,0,0,0.6)', 1); + ctx.strokeStyle = 'rgba(0,0,0,0.18)'; + ctx.lineWidth = 0.6; + for (let i = -w / 2 + 16; i < w / 2 - 4; i += 16) { + ctx.beginPath(); + ctx.moveTo(i, -h / 2 + 3); + ctx.lineTo(i, h / 2 - 3); + ctx.stroke(); + } +} + +function corners(ctx: Ctx, w: number, h: number): void { + const s = 7; + hazard(ctx, -w / 2 + 3, -h / 2 + 3, s, s); + hazard(ctx, w / 2 - 3 - s, -h / 2 + 3, s, s); + hazard(ctx, -w / 2 + 3, h / 2 - 3 - s, s, s); + hazard(ctx, w / 2 - 3 - s, h / 2 - 3 - s, s, s); +} + +function blink(ctx: Ctx, x: number, y: number, t: number, col: string, phase = 0): void { + const on = Math.sin(t * 3 + phase) > 0.2; + if (!on) return; + glow(ctx, x, y, 5, col, 0.9); + circle(ctx, x, y, 1, '#fff'); +} + +function engine(ctx: Ctx, x: number, y: number, r: number): void { + glow(ctx, x, y, r * 2.4, '#ff9a3a', 0.8); + circle(ctx, x, y, r * 0.6, '#ffe2b0'); +} + +/** Team paw badge on a dark roundel. */ +function badge(ctx: Ctx, x: number, y: number, r: number, c: TeamPal): void { + circle(ctx, x, y, r, dome(ctx, x, y, r, '#3a3f45', 0.3), '#15181b', 0.8); + pawPrint(ctx, x, y + r * 0.05, r * 0.7, c.main); +} + +function chimney(ctx: Ctx, x: number, y: number, r: number): void { + circle(ctx, x, y, r, dome(ctx, x, y, r, '#5a4a40', 0.3), '#15181b', 0.8); + circle(ctx, x, y, r * 0.55, '#121417'); +} + +// ---------------------------------------------------------------- units + +export const IRONPAW_ART: Record = { + hardhat: { + size: 26, + frames: 4, + draw(ctx, c, f) { + const s = 0.72; + const F = FUR.brown; + bipedBear(ctx, f, s, F); + // tool belt with pouches + line(ctx, -4.4 * s, -6.6 * s, -4.4 * s, 6.6 * s, '#4a3018', 1.3); + rrect(ctx, -6 * s, -7.8 * s, 3, 2.4, 0.6, c.main); + rrect(ctx, -6 * s, 6.6 * s, 3, 2.4, 0.6, '#6a4a28'); + // wrench in the right paw + line(ctx, 4.6 * s, 3.6 * s, 11 * s, 3.9 * s, '#aab3bb', 1.3); + circle(ctx, 11.4 * s, 3.9 * s, 1.5, '#aab3bb'); + circle(ctx, 12.2 * s, 3.9 * s, 0.7, '#3a2a1a'); + paw(ctx, 5.2 * s, 3.6 * s, s, F); + // hard hat with a team stripe + hat(ctx, 0.2 * s, 3.2 * s, 2.6 * s, '#f0c419', '#c79a10'); + ears(ctx, 1.6 * s, 4 * s, F); + circle(ctx, 0.2 * s, 0, 2.6 * s, dome(ctx, 0.2 * s, 0, 2.6 * s, '#f5cd2a', 0.45)); + line(ctx, -2.2 * s, 0, 2.6 * s, 0, c.main, 1.2 * s); + circle(ctx, 3.1 * s, 0, 0.7, '#fff6c0'); + }, + }, + ranger: { + size: 30, + frames: 4, + draw(ctx, c, f) { + const s = 0.82; + const F = FUR.black; + bipedBear(ctx, f, s, F); + // rifle held forward, stock at the right shoulder + rrect(ctx, -1 * s, 3.4 * s, 6 * s, 2.4 * s, 0.8, '#6a4526'); + rrect(ctx, 3 * s, 3.5 * s, 12 * s, 1.5 * s, 0.5, GUN); + rrect(ctx, 5 * s, 3.2 * s, 3.2 * s, 2.2 * s, 0.6, '#3d454d'); + paw(ctx, 5.2 * s, 3.6 * s, s, F); + paw(ctx, 4 * s, -2.8 * s, s, F); + // ranger campaign hat with a pinched crown + hat(ctx, 0.2 * s, 3.6 * s, 2.2 * s, '#b98f52', c.main); + ears(ctx, 1.6 * s, 4 * s, F); + circle(ctx, 0.2 * s, 0, 2.2 * s, dome(ctx, 0.2 * s, 0, 2.2 * s, '#a47a40', 0.4)); + line(ctx, -1.6 * s, 0, 2 * s, 0, 'rgba(60,38,16,0.7)', 0.7); + }, + }, + grizzly: { + size: 40, + frames: 4, + draw(ctx, c, f) { + const s = 1.2; + const F = FUR.grizzly; + bipedBear(ctx, f, s, F); + // shoulder hump + ellipse(ctx, -3 * s, 0, 2.6 * s, 4 * s, 0, rgba(tone(F.fur, 0.3), 0.85)); + // steel shoulder armor in team colour + for (const sy of [-1, 1]) { + shoulder(ctx, -1 * s, sy * 5.4 * s, 3.4 * s, 2.6 * s, c); + rivets(ctx, -3 * s, sy * 5.4 * s, 1.4 * s, sy * 5.4 * s, 3, 0.6); + } + rrect(ctx, -6 * s, -3 * s, 2.4 * s, 6 * s, 1, lit(ctx, -6 * s, -3 * s, -3.6 * s, 3 * s, STEEL, 0.3), '#1b1f23', 0.5); + // sap-shell launcher on the right arm + rrect(ctx, -1 * s, 3 * s, 14 * s, 4 * s, 1.5, lit(ctx, -1 * s, 3 * s, 13 * s, 7 * s, '#5d666f', 0.35), '#15181b', 0.7); + circle(ctx, 2.5 * s, 5 * s, 2.6 * s, dome(ctx, 2.5 * s, 5 * s, 2.6 * s, c.main, 0.5), tone(c.dark, -0.3), 0.6); + rrect(ctx, 12 * s, 2.6 * s, 2 * s, 4.8 * s, 0.8, GUN); + circle(ctx, 14 * s, 5 * s, 1.4 * s, '#e8a22a'); + paw(ctx, 5.2 * s, -3.6 * s, s, F); + bearHead(ctx, 1.6 * s, 4 * s, F); + }, + }, + medic: { + size: 30, + frames: 4, + draw(ctx, c, f) { + const s = 0.8; + const F = FUR.sun; + bipedBear(ctx, f, s, F); + // honey-pot satchel slung on the back + line(ctx, -5 * s, -5.5 * s, 0.5 * s, 5.5 * s, c.main, 1.4); + circle(ctx, -7 * s, -1 * s, 3.6 * s, dome(ctx, -7 * s, -1 * s, 3.6 * s, '#e0a33a', 0.45), '#5a3510', 0.6); + circle(ctx, -7 * s, -1 * s, 1.8 * s, '#7a4a18'); + circle(ctx, -7.4 * s, -1.4 * s, 0.9 * s, '#ffd76a'); + // honey jar in the left paw + circle(ctx, 6 * s, -3.8 * s, 1.9 * s, '#f0b840', '#7a4a18', 0.5); + paw(ctx, 5.2 * s, 3.6 * s, s, F); + // white cap with a green cross + hat(ctx, 0.2 * s, 3 * s, 2.5 * s, '#f4f5f2', '#d4d8d2'); + ears(ctx, 1.6 * s, 4 * s, F); + circle(ctx, 0.2 * s, 0, 2.5 * s, dome(ctx, 0.2 * s, 0, 2.5 * s, '#ffffff', 0.25)); + line(ctx, -1.4 * s, 0, 1.8 * s, 0, '#2fbf5a', 1.2); + line(ctx, 0.2 * s, -1.6 * s, 0.2 * s, 1.6 * s, '#2fbf5a', 1.2); + }, + }, + blazerider: { + size: 46, + frames: 4, + draw(ctx, c, f) { + const F = FUR.sloth; + wheel(ctx, -14, 0, 10, 4, f); + wheel(ctx, 14, 0, 9, 3.4, f); + // frame and front fork + line(ctx, -14, 0, 9, 0, '#3a4047', 2.6); + line(ctx, 8, -2, 14, -1, '#5d666f', 1.2); + line(ctx, 8, 2, 14, 1, '#5d666f', 1.2); + // boiler with a team stripe and a brass funnel + rrect(ctx, -12, -4.5, 9, 9, 3.5, lit(ctx, -12, -4.5, -3, 4.5, COPPER, 0.4), '#3a1e0e', 0.7); + rrect(ctx, -9.5, -4.5, 2, 9, 0, c.main); + circle(ctx, -13, -3.8, 1.8, BRASS, '#3a2a10', 0.5); + // flamer nozzle on the right of the fork + rrect(ctx, 6, 3.4, 12, 2.4, 0.8, GUN); + rrect(ctx, 16, 3, 3, 3.2, 0.8, '#3e464e'); + // handlebars + line(ctx, 7, -6.5, 7, 6.5, '#2a2f34', 1.6); + // shaggy rider + ellipse(ctx, -1, 0, 5, 6, 0, dome(ctx, -1, 0, 6, F.fur, 0.35), rgba('#000000', 0.45), 0.6); + shag(ctx, -1, 5, 6, F); + for (const sy of [-1, 1]) { + line(ctx, 0, sy * 4.5, 7, sy * 5.6, F.fur, 2.6); + circle(ctx, 7, sy * 5.6, 1.5, F.dark); + } + bearHead(ctx, 2.8, 3.6, F); + rrect(ctx, -3, -6.8, 3, 1.6, 0.6, c.main); + rrect(ctx, -3, 5.2, 3, 1.6, 0.6, c.main); + }, + overlay(ctx, _c, s) { + puff(ctx, -14, -3.8, 1.6, s.t); + flame(ctx, 19.5, 4.6, s.t, 1.4); + }, + }, + blazebrute: { + size: 40, + frames: 4, + draw(ctx, c, f) { + const s = 1.1; + const F = FUR.sloth; + // fuel tank on the back + for (const sy of [-1, 1]) rrect(ctx, -12 * s, sy * 3.2 * s - 2.6 * s, 6 * s, 5.2 * s, 2.4 * s, lit(ctx, -12 * s, -6 * s, -6 * s, 6 * s, COPPER, 0.4), '#3a1e0e', 0.6); + bipedBear(ctx, f, s, F); + shag(ctx, -1.5 * s, 5.6 * s, 7.6 * s, F, 18); + // steam armor: chest boiler and big pauldrons + rrect(ctx, -6 * s, -3.5 * s, 5 * s, 7 * s, 2, lit(ctx, -6 * s, -3.5 * s, -1 * s, 3.5 * s, STEEL, 0.35), '#15181b', 0.6); + rrect(ctx, -12 * s, -1 * s, 6 * s, 2 * s, 0, c.main); + for (const sy of [-1, 1]) { + shoulder(ctx, -1 * s, sy * 6 * s, 3.8 * s, 2.8 * s, c); + // forearm flamer with a hose back to the tank + line(ctx, -8 * s, sy * 3 * s, -1 * s, sy * 6.4 * s, '#2a2f34', 1); + rrect(ctx, 2 * s, sy * 3.6 * s - 1.6 * s, 9 * s, 3.2 * s, 1.2, lit(ctx, 2 * s, -2, 11 * s, 2, '#5d666f', 0.35), '#15181b', 0.6); + circle(ctx, 11 * s, sy * 3.6 * s, 1.3 * s, '#ff7a2a'); + } + bearHead(ctx, 1.6 * s, 4 * s, F); + }, + overlay(ctx, _c, s) { + flame(ctx, 12.4, -4, s.t, 1.6); + flame(ctx, 12.4, 4, s.t + 1.3, 1.6); + }, + }, + sapper: { + size: 26, + frames: 4, + draw(ctx, c, f) { + const s = 0.68; + const F = FUR.sun; + bipedBear(ctx, f, s, F); + // powder keg strapped on the back + keg(ctx, -6.5 * s, 0, 6 * s, 10 * s, c); + line(ctx, -4 * s, -5 * s, -4 * s, 5 * s, '#4a3018', 1); + ctx.strokeStyle = '#3a3026'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(-8.5 * s, -3 * s); + ctx.quadraticCurveTo(-12 * s, -4 * s, -12 * s, -7 * s); + ctx.stroke(); + }, + overlay(ctx, _c, s) { + flame(ctx, -12 * 0.68, -7 * 0.68, s.t, 1.1); + }, + }, + sapper_b: { + size: 26, + draw(ctx, c) { + const F = FUR.sun; + // dirt mound + ellipse(ctx, 0, 0, 10, 9, 0, dome(ctx, 0, 0, 10, DIRT, 0.35), '#2e2216', 0.7); + for (const [x, y, r] of [[-6, -5, 2.4], [4, -6, 2], [-5, 6, 2.2], [6, 5, 1.8], [-8, 1, 1.6]] as [number, number, number][]) circle(ctx, x, y, r, dome(ctx, x, y, r, tone(DIRT, 0.1), 0.3)); + // keg top and ears/eyes peeking out + circle(ctx, -4, 0, 3.4, dome(ctx, -4, 0, 3.4, '#9a6a3a', 0.35), '#2a1a0e', 0.6); + circle(ctx, -4, 0, 2.6, null, c.main, 0.9); + for (const sy of [-1, 1]) { + circle(ctx, 2.5, sy * 3.6, 1.8, F.dark); + circle(ctx, 2.6, sy * 3.6, 0.9, tone(F.dark, 0.25)); + circle(ctx, 5.4, sy * 1.6, 1.1, '#f4ecd0'); + circle(ctx, 5.7, sy * 1.6, 0.6, '#0b0908'); + } + line(ctx, -4, 0, -6.5, -2.5, '#3a3026', 0.8); + }, + overlay(ctx, _c, s) { + const k = Math.sin(s.t * 17) * 0.5 + Math.sin(s.t * 7.3) * 0.5; + glow(ctx, -6.8, -2.8, 3.4 + k, '#ff8a2a', 0.6 + 0.3 * k); + circle(ctx, -6.8, -2.8, 0.6, '#fff0b0'); + }, + }, + kodiak: { + size: 64, + frames: 4, + turretSize: 60, + draw(ctx, c, f) { + const s = 1.7; + ctx.save(); + ctx.translate(-3, 0); + quadBear(ctx, f, s, FUR.kodiak, { hump: true, wide: 1.1 }); + ctx.restore(); + // riveted flank plates + for (const sy of [-1, 1]) { + poly(ctx, [-18, sy * 4, 7, sy * 4, 5, sy * 10.5, -15, sy * 10.5], lit(ctx, -18, -10, 7, 10, c.main, 0.4), tone(c.dark, -0.35), 0.8); + rivets(ctx, -15, sy * 9, 4, sy * 9, 6, 0.7); + line(ctx, -6, sy * 4.5, -6, sy * 10, tone(c.dark, -0.3), 0.7); + } + // saddle ring under the turret + circle(ctx, 0, 0, 10, '#30373e', '#15181b', 0.8); + rivets(ctx, -16, 0, -12, 0, 2, 0.8); + }, + turret(ctx, c) { + circle(ctx, -2, 0, 8, dome(ctx, -2, 0, 8, STEEL, 0.5), '#1b1f23', 0.8); + for (const y of [-3.4, 1.2]) { + rrect(ctx, 3, y, 15, 2.2, 0.8, lit(ctx, 3, y, 18, y + 2.2, '#3e464e', 0.4), '#111', 0.4); + rrect(ctx, 16, y - 0.4, 2.6, 3, 0.8, GUN); + } + circle(ctx, -3, 0, 4, c.main, tone(c.dark, -0.3), 0.6); + pawPrint(ctx, -3, 0, 2.6, tone(c.light, 0.3)); + }, + }, + kodiak_b: { + size: 70, + turretSize: 76, + draw(ctx, c) { + const s = 1.7; + const F = FUR.kodiak; + // splayed legs with steel braces and claws dug in + for (const [x0, y0, x1, y1] of [[10, 9, 22, 22], [10, -9, 22, -22], [-12, 9, -24, 21], [-12, -9, -24, -21]]) { + line(ctx, x0, y0, x1, y1, F.dark, 6); + line(ctx, x0, y0, x1, y1, STEEL_D, 2.2); + rrect(ctx, x1 - 4, y1 - 3, 8, 6, 1.5, lit(ctx, x1 - 4, y1 - 3, x1 + 4, y1 + 3, STEEL, 0.35), '#15181b', 0.6); + const d = Math.sign(y1); + for (const k of [-2.5, 0, 2.5]) line(ctx, x1 + k, y1 + d * 2.5, x1 + k * 1.3, y1 + d * 5.5, '#d8d0c0', 1); + } + quadBear(ctx, 0, s, F, { hump: true, wide: 1.25, long: 1.05 }); + for (const sy of [-1, 1]) { + poly(ctx, [-19, sy * 4, 7, sy * 4, 5, sy * 11.5, -16, sy * 11.5], lit(ctx, -19, -11, 7, 11, c.main, 0.4), tone(c.dark, -0.35), 0.8); + rivets(ctx, -16, sy * 10, 4, sy * 10, 6, 0.7); + } + hazard(ctx, -20, -2, 6, 4); + circle(ctx, 0, 0, 11, '#30373e', '#15181b', 0.8); + }, + turret(ctx, c) { + circle(ctx, -2, 0, 9.5, dome(ctx, -2, 0, 9.5, STEEL, 0.5), '#1b1f23', 0.8); + rrect(ctx, 3, -2.6, 30, 5.2, 1, lit(ctx, 3, -2.6, 33, 2.6, '#3e464e', 0.4), '#111', 0.5); + rrect(ctx, 30, -3.6, 6, 7.2, 1, GUN); + for (const x of [12, 20]) line(ctx, x, -2.8, x, 2.8, BRASS, 1); + circle(ctx, -2, 0, 4.5, c.main, tone(c.dark, -0.3), 0.6); + hazard(ctx, -9, 5, 7, 2.5); + }, + }, + tracker: { + size: 44, + frames: 4, + turretSize: 36, + draw(ctx, c, f) { + for (const [x, y] of [[-9, -10], [-9, 10], [9, -10], [9, 10]]) wheel(ctx, x, y, 9, 4.5, f); + line(ctx, -9, -8, -9, 8, '#2a2f34', 2); + line(ctx, 9, -8, 9, 8, '#2a2f34', 2); + rrect(ctx, -15, -8, 30, 16, 4, lit(ctx, -15, -8, 15, 8, STEEL, 0.3), '#15181b', 0.9); + rrect(ctx, -13, -8, 24, 3, 1, c.main); + rrect(ctx, -13, 5, 24, 3, 1, c.main); + // rear boiler and funnel + rrect(ctx, -16, -5, 7, 10, 3, lit(ctx, -16, -5, -9, 5, COPPER, 0.4), '#3a1e0e', 0.6); + circle(ctx, -13, 0, 2, '#1a1a1a', BRASS, 0.8); + // front bumper + rrect(ctx, 14, -6, 3, 12, 1, GUN); + // driver up front + ellipse(ctx, 6, 0, 3.5, 4.5, 0, dome(ctx, 6, 0, 4.5, FUR.brown.fur, 0.3)); + for (const sy of [-1, 1]) { + line(ctx, 6, sy * 3.5, 11, sy * 3, FUR.brown.fur, 2); + circle(ctx, 11, sy * 3, 1.2, FUR.brown.dark); + } + pilot(ctx, 8.5, 3, FUR.brown); + }, + overlay(ctx, _c, s) { + puff(ctx, -13.5, 0, 1.8, s.t); + }, + turret(ctx, c) { + rrect(ctx, -9, -6, 11, 12, 2.5, lit(ctx, -9, -6, 2, 6, '#6d767f', 0.35), '#15181b', 0.8); + for (const y of [-3.4, 0, 3.4]) { + rrect(ctx, 0, y - 1.3, 5, 2.6, 1, GUN); + circle(ctx, 5, y, 1, '#e2e4e6'); + } + rrect(ctx, -9, -1.2, 9, 2.4, 0, c.main); + circle(ctx, -6.5, -4, 1.3, '#ff3a3a'); + }, + }, + ironhide: { + size: 54, + frames: 4, + draw(ctx, c, f) { + const F = FUR.spectacled; + const ph = [0, 1, 0, -1][f]; + for (const sy of [-1, 1]) { + const fx = sy * ph * 5; + line(ctx, -2, sy * 7, fx + 2, sy * 13, '#3a4148', 4); + circle(ctx, -2, sy * 7, 3, '#2a2f34'); + rrect(ctx, fx - 3, sy * 13 - 3, 10, 6, 2, lit(ctx, fx - 3, sy * 13 - 3, fx + 7, sy * 13 + 3, STEEL_D, 0.35), '#15181b', 0.6); + for (const k of [0, 2.5, 5]) line(ctx, fx + 6, sy * 13 - 2.2 + k * 0.9, fx + 8.5, sy * 13 - 2.2 + k * 0.9, '#c9c3b5', 0.9); + } + // hull with boiler at the back + rrect(ctx, -16, -5, 6, 10, 2.5, lit(ctx, -16, -5, -10, 5, COPPER, 0.4), '#3a1e0e', 0.6); + rrect(ctx, -11, -10, 21, 20, 6, lit(ctx, -11, -10, 10, 10, STEEL, 0.3), '#1b1f23', 0.9); + poly(ctx, [-8, -9.5, 4, -9.5, 7, -6, -8, -6], c.main); + poly(ctx, [-8, 9.5, 4, 9.5, 7, 6, -8, 6], c.main); + rivets(ctx, -9, -7.5, 4, -7.5, 5, 0.6); + rivets(ctx, -9, 7.5, 4, 7.5, 5, 0.6); + // autocannons on the right, missile pod on the left + rrect(ctx, 2, 8, 17, 2.4, 0.6, GUN); + rrect(ctx, 2, 11.4, 17, 2.4, 0.6, GUN); + rrect(ctx, -2, 7, 8, 8, 2, '#3c444c', '#15181b', 0.6); + rrect(ctx, -4, -17, 11, 8, 2, lit(ctx, -4, -17, 7, -9, '#59626b', 0.35), '#15181b', 0.6); + for (let i = 0; i < 3; i++) for (let j = 0; j < 2; j++) circle(ctx, -1.5 + i * 3, -15 + j * 3.5, 1, '#e2e4e6', '#333', 0.3); + // open cockpit with the spectacled bear pilot + circle(ctx, 1, 0, 7, '#1d2125', '#0d0f11', 0.6); + ellipse(ctx, -0.5, 0, 3.6, 5, 0, dome(ctx, -0.5, 0, 5, F.fur, 0.3)); + bearHead(ctx, 2.6, 4, F); + spectacles(ctx, 2.6, 4); + }, + }, + barnstormer: { + size: 50, + draw(ctx, c) { + const F = FUR.brown; + // tailplane and lower wing tips + poly(ctx, sym([-18, 0, -18, -8, -13, -8, -11, -2, -11, 0]), lit(ctx, -18, -8, -11, 8, '#c8b48a', 0.3), '#3a2c18', 0.7); + poly(ctx, [-18, -8, -15, -8, -15, -5.5, -18, -5.5], c.main); + poly(ctx, [-18, 8, -15, 8, -15, 5.5, -18, 5.5], c.main); + rrect(ctx, -3, -18, 6, 36, 2, tone('#b8a47a', -0.25)); + // fuselage + poly(ctx, sym([-19, 0, -16, -2, -2, -4.2, 9, -4, 13, -3, 13, 0]), lit(ctx, -19, -4, 13, 4, '#d1bd92', 0.3), '#3a2c18', 0.8); + line(ctx, -16, 0, -10, 0, c.main, 2.2); + // upper wing with struts and team tips + rrect(ctx, 0, -21, 8, 42, 2.5, lit(ctx, 0, -21, 8, 21, '#d8c69c', 0.3), '#3a2c18', 0.8); + rrect(ctx, 0, -21, 8, 5, 2, c.main); + rrect(ctx, 0, 16, 8, 5, 2, c.main); + ctx.strokeStyle = 'rgba(60,40,20,0.35)'; + ctx.lineWidth = 0.6; + for (const y of [-13, -7, 7, 13]) { + ctx.beginPath(); + ctx.moveTo(0.5, y); + ctx.lineTo(7.5, y); + ctx.stroke(); + } + for (const y of [-14, 14]) circle(ctx, 4, y, 0.9, '#3a2c18'); + // engine cowl + circle(ctx, 13, 0, 3.6, dome(ctx, 13, 0, 3.6, STEEL, 0.5), '#15181b', 0.6); + // cockpit and pilot with a trailing team scarf + ellipse(ctx, -6, 0, 4.6, 4, 0, '#2a1e14'); + ctx.strokeStyle = c.main; + ctx.lineWidth = 1.6; + ctx.beginPath(); + ctx.moveTo(-7, 1); + ctx.quadraticCurveTo(-11, 3, -14, 1.5); + ctx.stroke(); + pilot(ctx, -5.8, 3, F); + }, + overlay(ctx, _c, s) { + prop(ctx, 16, 0, 7, s.t); + }, + }, + hoverbear: { + size: 52, + draw(ctx, c) { + hoverHull(ctx, c, false); + }, + overlay(ctx, _c, s) { + rotor(ctx, -2, -13, 9, s.t * 14, 3); + rotor(ctx, -2, 13, 9, -s.t * 14, 3); + }, + }, + hoverbear_a: { + size: 56, + draw(ctx, c) { + // anchor hanging beneath, chain running under the hull + ctx.globalAlpha = 0.85; + for (let i = 0; i < 7; i++) ellipse(ctx, -4 - i * 2.4, 2 + i * 1.2, 1.4, 0.9, 0.4, null, '#5d666f', 0.8); + poly(ctx, [-22, 9, -20, 6.5, -18, 9, -19, 14, -20, 14.5, -21, 14], '#3a4148', '#15181b', 0.6); + line(ctx, -23.5, 12, -16.5, 12, '#3a4148', 1.4); + ctx.globalAlpha = 1; + hoverHull(ctx, c, true); + }, + overlay(ctx, _c, s) { + rotor(ctx, -2, -14, 10, s.t * 10, 3); + rotor(ctx, -2, 14, 10, -s.t * 10, 3); + if (s.working) glow(ctx, 15, 0, 6, '#ffcf6a', 0.6); + }, + }, + bearloon: { + size: 64, + frames: 4, + draw(ctx, c, f) { + const F = FUR.brown; + // wicker basket trailing beneath, with ropes + const bx = -17; + for (const sy of [-1, 1]) { + line(ctx, bx + 3, sy * 4, -6, sy * 12, '#6a5030', 0.7); + line(ctx, bx + 3, sy * 4, 0, sy * 6, '#6a5030', 0.7); + } + rrect(ctx, bx - 5, -5, 10, 10, 2, lit(ctx, bx - 5, -5, bx + 5, 5, '#b89058', 0.3), '#4a3018', 0.8); + ctx.strokeStyle = 'rgba(70,45,20,0.5)'; + ctx.lineWidth = 0.5; + for (let k = -3; k <= 3; k += 2) { + ctx.beginPath(); + ctx.moveTo(bx + k, -4.5); + ctx.lineTo(bx + k, 4.5); + ctx.moveTo(bx - 4.5, k); + ctx.lineTo(bx + 4.5, k); + ctx.stroke(); + } + rrect(ctx, bx - 5, -1, 10, 2, 0, c.main); + // bear-head envelope: ears, team gores, snout + const R = 17; + const x = 3; + for (const sy of [-1, 1]) { + circle(ctx, x - R * 0.5, sy * R * 0.8, R * 0.38, dome(ctx, x - R * 0.5, sy * R * 0.8, R * 0.38, F.fur, 0.35), rgba('#000000', 0.5), 0.7); + circle(ctx, x - R * 0.47, sy * R * 0.82, R * 0.2, c.main); + } + ctx.save(); + ctx.beginPath(); + ctx.arc(x, 0, R, 0, TAU); + ctx.clip(); + ctx.fillStyle = F.fur; + ctx.fillRect(x - R, -R, R * 2, R * 2); + const n = 12; + const rot = f * (TAU / n / 4); + for (let i = 0; i < n; i += 2) { + const a0 = rot + (i / n) * TAU; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.arc(x, 0, R, a0, a0 + TAU / n); + ctx.closePath(); + ctx.fillStyle = c.main; + ctx.fill(); + } + ctx.fillStyle = dome(ctx, x, 0, R, '#808080', 0.9); + ctx.globalCompositeOperation = 'overlay'; + ctx.fillRect(x - R, -R, R * 2, R * 2); + ctx.restore(); + circle(ctx, x, 0, R, null, rgba('#000000', 0.5), 0.8); + circle(ctx, x, 0, 3, tone(c.dark, -0.2), '#15181b', 0.5); + ellipse(ctx, x + R * 0.72, 0, R * 0.42, R * 0.33, 0, dome(ctx, x + R * 0.72, 0, R * 0.42, F.muzzle, 0.3), rgba('#000000', 0.35), 0.5); + ellipse(ctx, x + R * 1.04, 0, R * 0.13, R * 0.18, 0, '#141010'); + for (const sy of [-1, 1]) { + circle(ctx, x + R * 0.36, sy * R * 0.36, R * 0.11, '#0b0908'); + circle(ctx, x + R * 0.33, sy * R * 0.36 - 0.6, R * 0.04, '#ffffff'); + } + }, + }, + lookout: { + size: 52, + draw(ctx, c) { + const F = FUR.grizzly; + // keel, tail boom and fin + line(ctx, -18, 0, 8, 0, '#3a4047', 2); + poly(ctx, [-20, -6, -15, -6, -14, 6, -20, 6], c.main, tone(c.dark, -0.3), 0.6); + line(ctx, -14.5, -6, -14.5, 6, '#2a2f34', 1); + // pusher prop hub and engine + rrect(ctx, -11, -3, 6, 6, 2, lit(ctx, -11, -3, -5, 3, COPPER, 0.4), '#3a1e0e', 0.6); + // pod + poly(ctx, sym([-6, 0, -5, -5, 4, -5.5, 10, -3, 12, 0]), lit(ctx, -6, -5, 12, 5, STEEL, 0.35), '#15181b', 0.8); + poly(ctx, sym([4, -5.5, 10, -3, 12, 0]), c.main); + // pilot with a brass spyglass + ellipse(ctx, -0.5, 0, 3.4, 4.4, 0, dome(ctx, -0.5, 0, 4.4, F.fur, 0.3)); + rrect(ctx, 3, -5.8, 14, 2.6, 1.2, lit(ctx, 3, -5.8, 17, -3.2, BRASS, 0.45), '#4a3510', 0.5); + rrect(ctx, 15, -6.4, 3.4, 3.8, 1, tone(BRASS, -0.2)); + circle(ctx, 18.4, -4.5, 1.2, GLASS); + line(ctx, 0.5, -3.5, 6, -4.5, F.fur, 2); + line(ctx, 0.5, 3.5, 6.5, -2.8, F.fur, 2); + circle(ctx, 6, -4.5, 1.2, F.dark); + circle(ctx, 6.5, -2.8, 1.2, F.dark); + bearHead(ctx, 2.4, 3, F); + // rotor mast + circle(ctx, -1, 0, 1.6, '#2a2f34'); + }, + overlay(ctx, _c, s) { + prop(ctx, -12.5, 0, 5, s.t * 1.1); + ctx.save(); + ctx.translate(-1, 0); + const a = s.t * 9; + ctx.globalAlpha = 0.9; + circle(ctx, 0, 0, 21, 'rgba(220,225,230,0.05)'); + for (const k of [0, Math.PI]) line(ctx, 0, 0, Math.cos(a + k) * 21, Math.sin(a + k) * 21, 'rgba(40,40,44,0.75)', 1.8); + ctx.globalAlpha = 1; + circle(ctx, 0, 0, 1.8, BRASS, '#222', 0.4); + ctx.restore(); + }, + }, + ironmama: { + size: 118, + draw(ctx, c) { + const F = FUR.kodiak; + // rear propellers on outriggers + for (const y of [-14, 14]) { + line(ctx, -40, y * 0.6, -50, y, '#3a4047', 3); + circle(ctx, -50, y, 3.4, dome(ctx, -50, y, 3.4, STEEL_D, 0.4), '#15181b', 0.6); + } + // tail fins + poly(ctx, [-38, -10, -52, -22, -46, -22, -30, -12], lit(ctx, -52, -22, -30, -10, STEEL_D, 0.3), '#15181b', 0.8); + poly(ctx, [-38, 10, -52, 22, -46, 22, -30, 12], lit(ctx, -52, 10, -30, 22, STEEL_D, 0.3), '#15181b', 0.8); + // armored envelope + ellipse(ctx, -4, 0, 42, 21, 0, dome(ctx, -4, 0, 42, '#7c868f', 0.35), '#15181b', 1.2); + ctx.save(); + ctx.beginPath(); + ctx.ellipse(-4, 0, 42, 21, 0, 0, TAU); + ctx.clip(); + ctx.strokeStyle = 'rgba(0,0,0,0.3)'; + ctx.lineWidth = 0.8; + for (let x = -40; x < 36; x += 9) { + ctx.beginPath(); + ctx.moveTo(x, -22); + ctx.lineTo(x, 22); + ctx.stroke(); + } + for (const y of [-12, 12]) { + ctx.beginPath(); + ctx.moveTo(-46, y); + ctx.lineTo(40, y); + ctx.stroke(); + } + for (const x of [-26, 8]) { + ctx.fillStyle = c.main; + ctx.fillRect(x, -22, 5, 44); + ctx.fillStyle = tone(c.main, -0.3); + ctx.fillRect(x + 5, -22, 1.5, 44); + } + ctx.restore(); + rivets(ctx, -36, -16, 26, -16, 14); + rivets(ctx, -36, 16, 26, 16, 14); + // bridge with paw emblem + rrect(ctx, -14, -6, 16, 12, 4, lit(ctx, -14, -6, 2, 6, '#9aa3ac', 0.4), '#15181b', 0.8); + pawPrint(ctx, -6, 0, 4.2, c.main); + rrect(ctx, -1, -3, 2, 6, 1, GLASS); + // broadside batteries + for (const x of [-32, -16, 2, 18]) { + for (const sy of [-1, 1]) { + circle(ctx, x, sy * 17, 3, dome(ctx, x, sy * 17, 3, '#4a525a', 0.4), '#0d0f11', 0.5); + line(ctx, x, sy * 17, x + 1, sy * 22, GUN, 1.4); + } + } + // mama-bear figurehead with a steel ram snout + const hx = 40; + const R = 13; + ears(ctx, hx, R, F); + for (const sy of [-1, 1]) circle(ctx, hx - R * 0.45, sy * R * 0.78, R * 0.42, null, c.main, 1.2); + circle(ctx, hx, 0, R, dome(ctx, hx, 0, R, F.fur, 0.35), '#15181b', 0.9); + ctx.save(); + ctx.beginPath(); + ctx.arc(hx, 0, R, 0, TAU); + ctx.clip(); + ctx.fillStyle = rgba(c.main, 0.85); + ctx.fillRect(hx - R, -R, 4, R * 2); + ctx.restore(); + poly(ctx, sym([hx + 5, 0, hx + 6, -5.5, hx + 13, -4, hx + 22, 0]), lit(ctx, hx + 5, -5.5, hx + 22, 5.5, STEEL, 0.45), '#15181b', 0.8); + ellipse(ctx, hx + 15, 0, 2, 2.6, 0, '#141010'); + rivets(ctx, hx + 8, -3.5, hx + 8, 3.5, 3, 0.7); + for (const sy of [-1, 1]) { + circle(ctx, hx + R * 0.32, sy * R * 0.38, R * 0.13, '#0b0908'); + circle(ctx, hx + R * 0.3, sy * R * 0.38 - 0.6, R * 0.05, '#ffcf6a'); + } + }, + overlay(ctx, _c, s) { + prop(ctx, -54, -14, 7, s.t); + prop(ctx, -54, 14, 7, s.t + 0.37); + }, + }, + + // ================================================================ structures + lodge: { + pad: 8, + draw(ctx, c) { + const W = 160; + pad(ctx, W, W); + corners(ctx, W, W); + // octagonal log hall + const o = 64; + const k = 22; + poly(ctx, [-o + k, -o, o - k, -o, o, -o + k, o, o - k - 6, o - k, o - 6, -o + k, o - 6, -o, o - k - 6, -o, -o + k], tone(WOOD_D, -0.35)); + poly(ctx, [-o + k, -o, o - k, -o, o, -o + k, o, o - k - 12, o - k, o - 12, -o + k, o - 12, -o, o - k - 12, -o, -o + k], lit(ctx, -o, -o, o, o, WOOD, 0.25), '#2a1a0c', 1.2); + // log courses + ctx.save(); + poly(ctx, [-o + k, -o, o - k, -o, o, -o + k, o, o - k - 12, o - k, o - 12, -o + k, o - 12, -o, o - k - 12, -o, -o + k], null); + ctx.clip(); + ctx.strokeStyle = 'rgba(40,22,8,0.4)'; + ctx.lineWidth = 1; + for (let r = 40; r < 92; r += 7) { + ctx.beginPath(); + ctx.arc(0, -4, r, 0, TAU); + ctx.stroke(); + } + ctx.restore(); + // team colour band + ctx.lineWidth = 5; + ctx.strokeStyle = c.main; + ctx.beginPath(); + ctx.arc(0, -4, 44, 0, TAU); + ctx.stroke(); + // corner log piles and boiler silos + for (const [x, y] of [[-46, -46], [46, 38]]) { + circle(ctx, x, y, 11, dome(ctx, x, y, 11, '#a3acb5', 0.45), '#15181b', 0.8); + circle(ctx, x, y, 4, '#30373e'); + rivets(ctx, x - 8, y, x + 8, y, 5, 0.8); + } + logs(ctx, 36, -44, 6, 4); + logs(ctx, -54, 40, 6, 4); + // central steel dome with the paw crest + circle(ctx, 0, -4, 30, dome(ctx, 0, -4, 30, '#b9c2ca', 0.45), '#15181b', 1); + rivets(ctx, -26, -4, 26, -4, 9, 0.8); + circle(ctx, 0, -4, 20, dome(ctx, 0, -4, 20, '#5b6570', 0.4), '#15181b', 0.8); + pawPrint(ctx, 0, -4, 13, c.main); + circle(ctx, 0, -4, 15, null, 'rgba(226,179,59,0.6)', 1.2); + // south gate + rrect(ctx, -22, 50, 44, 10, 2, '#2a1a0c'); + for (let x = -18; x < 22; x += 6) line(ctx, x, 51, x, 59, 'rgba(120,80,40,0.6)', 1); + hazard(ctx, -22, 50, 44, 3); + // chimney and antenna + chimney(ctx, -30, -40, 6); + line(ctx, 20, -30, 34, -48, '#2c3238', 2); + circle(ctx, 34, -48, 2.5, '#d84a3a'); + }, + overlay(ctx, c, s) { + puff(ctx, -30, -40, 4, s.t * 0.6); + blink(ctx, 34, -48, s.t, '#ff4a3a'); + blink(ctx, -46, -46, s.t, '#5cff7a', 1); + blink(ctx, 46, 38, s.t, '#5cff7a', 2.5); + if (s.working) glow(ctx, 0, -4, 16, c.light, 0.35 + 0.15 * Math.sin(s.t * 4)); + }, + }, + larder: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 60, 58, 3); + timber(ctx, -29, -29, 58, 58, 6, 4, 6, true); + // roof ridge in team colour + rrect(ctx, -29, -6, 58, 6, 0, c.main); + line(ctx, -29, -6, 29, -6, 'rgba(255,255,255,0.3)', 0.8); + // crates + for (const [x, y] of [[-24, -25], [-13, -25]]) { + rrect(ctx, x, y, 10, 10, 1, lit(ctx, x, y, x + 10, y + 10, '#b48a58', 0.3), '#3a2410', 0.7); + line(ctx, x + 1, y + 1, x + 9, y + 9, '#6a4a28', 0.9); + line(ctx, x + 9, y + 1, x + 1, y + 9, '#6a4a28', 0.9); + } + // honey pots + for (const [x, y] of [[9, -19], [20, -20]]) { + circle(ctx, x, y, 5.2, dome(ctx, x, y, 5.2, '#e0a33a', 0.45), '#5a3510', 0.7); + circle(ctx, x, y, 3, '#f4c04a', '#7a4a18', 0.6); + circle(ctx, x - 0.8, y - 0.8, 1.2, '#fff0b0'); + ellipse(ctx, x + 2, y + 4.2, 1, 1.6, 0, '#e8a22a'); + } + // salmon on a drying rack + line(ctx, -24, 6, 24, 6, '#3a2410', 1.2); + for (const x of [-18, -6, 6, 18]) { + line(ctx, x, 6, x, 8, '#3a2410', 0.8); + ellipse(ctx, x, 12, 2.6, 4.6, 0, lit(ctx, x - 3, 7, x + 3, 17, '#e07a5a', 0.35), '#6a2e1e', 0.6); + poly(ctx, [x, 15.5, x - 3, 19.5, x + 3, 19.5], '#c0604a', '#6a2e1e', 0.5); + circle(ctx, x, 9, 0.7, '#1a1010'); + } + }, + }, + refinery: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + rrect(ctx, -46, -46, 92, 92, 8, lit(ctx, -46, -46, 46, 46, '#5c636a', 0.2), '#15181b', 1); + // timber walkway deck + timber(ctx, -40, -6, 80, 12, 2, 2, 5, true); + // pipes + line(ctx, -26, -24, 26, 24, '#3a4047', 6); + line(ctx, -26, 24, 26, -24, '#3a4047', 6); + for (const [x, y, r] of [[-24, -22, 16], [24, 22, 16], [24, -24, 11]] as [number, number, number][]) { + circle(ctx, x, y, r + 2, 'rgba(0,0,0,0.35)'); + circle(ctx, x, y, r, dome(ctx, x, y, r, '#b3bcc4', 0.45), '#15181b', 1); + circle(ctx, x, y, r * 0.55, null, c.main, 3); + rivets(ctx, x - r * 0.8, y, x + r * 0.8, y, 5, 0.7); + } + for (const [x, y] of [[-24, -22], [24, 22]]) circle(ctx, x, y, 3, BRASS, '#3a2a10', 0.6); + rrect(ctx, -34, 14, 22, 22, 3, '#3f464d', '#15181b', 0.8); + pawPrint(ctx, -23, 25, 7, c.main); + hazard(ctx, -34, 33, 22, 3); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 12, '#62ff7a', 0.4 + 0.2 * Math.sin(s.t * 3)); + puff(ctx, 24, -24, 3, s.t * 0.5); + }, + }, + bunkhouse: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + block(ctx, -46, -44, 92, 88, WOOD_D, 7, 5); + // gabled shingle roof: two panels + poly(ctx, [-42, -40, 42, -40, 42, -2, -42, -2], lit(ctx, -42, -40, 42, -2, '#9a7046', 0.3)); + poly(ctx, [-42, -2, 42, -2, 42, 32, -42, 32], lit(ctx, -42, -2, 42, 32, '#6e4c2c', 0.25)); + ctx.strokeStyle = 'rgba(40,22,8,0.4)'; + ctx.lineWidth = 0.8; + for (const y of [-34, -28, -22, -16, -10, 4, 10, 16, 22, 28]) { + ctx.beginPath(); + ctx.moveTo(-42, y); + ctx.lineTo(42, y); + ctx.stroke(); + } + line(ctx, -42, -2, 42, -2, 'rgba(255,255,255,0.4)', 1); + rrect(ctx, -42, -4, 84, 4, 0, c.main); + // log-end corners + for (const [x, y] of [[-42, -40], [42, -40], [-42, 32], [42, 32]]) circle(ctx, x, y, 3.5, '#b48a58', WOOD_D, 0.7); + // door + rrect(ctx, -14, 30, 28, 9, 2, '#1d2125'); + hazard(ctx, -14, 30, 28, 3); + // stovepipes and paw crest + for (const x of [-28, 28]) chimney(ctx, x, -22, 4.5); + badge(ctx, 0, 14, 8, c); + }, + overlay(ctx, _c, s) { + puff(ctx, -28, -22, 2.6, s.t * 0.6); + blink(ctx, -40, 34, s.t, '#ffcf4a'); + blink(ctx, 40, 34, s.t, '#ffcf4a', 3); + }, + }, + workshop: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + timber(ctx, -46, -44, 92, 88, 6, 6, 7); + circle(ctx, -6, -6, 30, dome(ctx, -6, -6, 30, '#b8c1c9', 0.45), '#15181b', 1); + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; + for (let a = 0; a < 6; a++) { + ctx.beginPath(); + ctx.moveTo(-6, -6); + ctx.lineTo(-6 + Math.cos(a) * 30, -6 + Math.sin(a) * 30); + ctx.stroke(); + } + rivets(ctx, -34, -6, 22, -6, 12, 0.7); + badge(ctx, -6, -6, 10, c); + // tool racks + for (const y of [-30, -16, -2, 12]) rrect(ctx, 28, y, 12, 9, 1.5, '#353c43', '#15181b', 0.6); + line(ctx, 30, -26, 38, -26, '#aab3bb', 1.4); + circle(ctx, 34, -12, 3, null, '#aab3bb', 1); + rrect(ctx, -40, 26, 50, 8, 2, c.main); + logs(ctx, -40, 22, 3, 3); + }, + }, + guntower: { + pad: 6, + turretSize: 48, + draw(ctx, c) { + footShadow(ctx, 58, 58, 3); + poly(ctx, [-20, -28, 20, -28, 28, -20, 28, 20, 20, 28, -20, 28, -28, 20, -28, -20], lit(ctx, -28, -28, 28, 28, WOOD, 0.25), '#2a1a0c', 1); + ctx.strokeStyle = 'rgba(40,22,8,0.4)'; + ctx.lineWidth = 0.8; + for (let y = -22; y <= 22; y += 6) { + ctx.beginPath(); + ctx.moveTo(-26, y); + ctx.lineTo(26, y); + ctx.stroke(); + } + circle(ctx, 0, 0, 17, lit(ctx, -17, -17, 17, 17, STEEL_D, 0.3), '#15181b', 0.8); + rivets(ctx, -14, 0, 14, 0, 2, 1); + for (const [x, y] of [[-22, -22], [22, -22], [-22, 22], [22, 22]]) circle(ctx, x, y, 2, c.main); + hazard(ctx, -24, 22, 48, 3); + }, + turret(ctx, c) { + circle(ctx, 0, 0, 11, dome(ctx, 0, 0, 11, STEEL, 0.5), '#15181b', 1); + rrect(ctx, 6, -4.5, 16, 3, 1, GUN); + rrect(ctx, 6, 1.5, 16, 3, 1, GUN); + rivets(ctx, -8, -6, -8, 6, 4, 0.7); + circle(ctx, -2, 0, 4.5, c.main, tone(c.dark, -0.3), 0.8); + }, + }, + bunker: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 94, 62, 3); + // sandbag berm + rrect(ctx, -47, -31, 94, 62, 16, lit(ctx, -47, -31, 47, 31, '#77705d', 0.2), '#2a2620', 1); + for (let x = -40; x <= 40; x += 8) { + ellipse(ctx, x, -27, 4.2, 2.6, 0, '#8c846d', 'rgba(0,0,0,0.35)', 0.5); + ellipse(ctx, x + 4, 27, 4.2, 2.6, 0, '#7f7863', 'rgba(0,0,0,0.35)', 0.5); + } + // log casemate + timber(ctx, -38, -23, 76, 46, 7, 9, 6); + for (const [x, y] of [[-38, -18], [-38, -6], [-38, 6], [38, -18], [38, -6], [38, 6]]) circle(ctx, x, y, 3, '#b48a58', WOOD_D, 0.6); + // firing slits + for (const x of [-24, -8, 8, 24]) { + rrect(ctx, x - 5, -20, 10, 3, 1, '#14171a'); + rrect(ctx, x - 5, 10, 10, 3, 1, '#14171a'); + } + // steel roof hatch + circle(ctx, 0, -4, 8, dome(ctx, 0, -4, 8, STEEL_D, 0.4), '#15181b', 0.8); + pawPrint(ctx, 0, -4, 5, c.main); + rrect(ctx, -30, 0, 14, 4, 1, c.main); + rrect(ctx, 16, 0, 14, 4, 1, c.main); + }, + overlay(ctx, _c, s) { + if (!s.working) return; + // muzzle flashes in the slits while the garrison is shooting + const k = Math.floor(s.t * 14) % 4; + const x = [-24, -8, 8, 24][k]; + glow(ctx, x, k % 2 ? 11.5 : -18.5, 6, '#ffcf6a', 0.9); + }, + }, + flakturret: { + pad: 6, + turretSize: 48, + draw(ctx, c) { + footShadow(ctx, 58, 58, 3); + circle(ctx, 0, 0, 28, lit(ctx, -28, -28, 28, 28, WOOD, 0.25), '#2a1a0c', 1); + ctx.save(); + ctx.beginPath(); + ctx.arc(0, 0, 27, 0, TAU); + ctx.clip(); + ctx.strokeStyle = 'rgba(40,22,8,0.4)'; + ctx.lineWidth = 0.8; + for (let x = -24; x <= 24; x += 6) { + ctx.beginPath(); + ctx.moveTo(x, -28); + ctx.lineTo(x, 28); + ctx.stroke(); + } + ctx.restore(); + circle(ctx, 0, 0, 18, '#30373e', '#15181b', 0.8); + circle(ctx, 0, 0, 22, null, c.main, 2); + hazard(ctx, -18, 20, 36, 3); + }, + turret(ctx, c) { + rrect(ctx, -9, -12, 18, 24, 4, lit(ctx, -9, -12, 9, 12, STEEL, 0.35), '#15181b', 1); + for (const [x, y] of [[-3, -7], [4, -7], [-3, 7], [4, 7]]) circle(ctx, x, y, 2.6, '#1b1f23', '#000', 0.4); + rrect(ctx, -9, -2, 18, 4, 0, c.main); + circle(ctx, -6, 0, 1.3, '#ff3a3a'); + }, + }, + academy: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + timber(ctx, -46, -44, 92, 88, 6, 6, 8, true); + rrect(ctx, -36, -34, 72, 54, 4, lit(ctx, -36, -34, 36, 20, '#9aa3ab', 0.25), '#15181b', 0.8); + rivets(ctx, -32, -31, 32, -31, 12, 0.7); + rivets(ctx, -32, 17, 32, 17, 12, 0.7); + // paw crest + circle(ctx, 0, -8, 19, null, c.main, 2.5); + pawPrint(ctx, 0, -8, 14, c.main); + for (const x of [-30, 30]) circle(ctx, x, 30, 6, dome(ctx, x, 30, 6, '#c9d0d6'), '#15181b', 0.6); + rrect(ctx, -10, 31, 20, 8, 2, '#1d2125'); + }, + }, + steamworks: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + block(ctx, -46, -44, 92, 88, '#6f7881', 8, 4); + // hangar roof arcs + for (let i = 0; i < 4; i++) { + const y = -36 + i * 16; + rrect(ctx, -40, y, 64, 13, 6, lit(ctx, -40, y, 24, y + 13, '#8c96a0', 0.35), '#15181b', 0.7); + rivets(ctx, -36, y + 6.5, 20, y + 6.5, 10, 0.6); + } + // smokestacks + for (const y of [-30, -12]) chimney(ctx, 34, y, 6); + rrect(ctx, 28, 2, 12, 26, 2, c.main); + pawPrint(ctx, 34, 15, 5, tone(c.light, 0.4)); + // big timber door + rrect(ctx, -30, 28, 48, 10, 2, WOOD_D); + for (let x = -26; x < 18; x += 6) line(ctx, x, 29, x, 37, 'rgba(0,0,0,0.35)', 1); + hazard(ctx, -30, 28, 48, 3); + }, + overlay(ctx, _c, s) { + puff(ctx, 34, -30, 3.5, s.t * 0.7); + puff(ctx, 34, -12, 3.5, s.t * 0.7 + 0.5); + if (s.working && Math.sin(s.t * 6) > 0.6) glow(ctx, -6, 34, 10, '#ff9a3a', 0.4); + blink(ctx, 40, 36, s.t, '#ffcf4a', 1); + }, + }, + armory: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + block(ctx, -46, -44, 92, 88, '#727b84', 6, 4); + timber(ctx, 18, -40, 26, 76, 2, 2, 6, false); + // gear emblem with a paw at the hub + const g = 18; + ctx.save(); + ctx.translate(-12, -4); + ctx.fillStyle = c.main; + ctx.beginPath(); + for (let i = 0; i < 16; i++) { + const a = (i / 16) * TAU; + const r = i % 2 ? g : g + 5; + ctx.lineTo(Math.cos(a) * r, Math.sin(a) * r); + } + ctx.closePath(); + ctx.fill(); + circle(ctx, 0, 0, 10, '#3a4148'); + pawPrint(ctx, 0, 0, 7, tone(c.light, 0.3)); + ctx.restore(); + for (const y of [-34, -20, -6, 8, 22]) rrect(ctx, 22, y, 18, 10, 1.5, lit(ctx, 22, y, 40, y + 10, '#9aa3ab'), '#15181b', 0.6); + rivets(ctx, -40, 34, 14, 34, 10, 0.8); + }, + }, + airfield: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + block(ctx, -46, -44, 92, 88, '#5f676f', 5, 6); + // timber landing deck with a paw marking + circle(ctx, -6, 0, 30, lit(ctx, -36, -30, 24, 30, WOOD, 0.2), '#15181b', 1); + ctx.save(); + ctx.beginPath(); + ctx.arc(-6, 0, 29, 0, TAU); + ctx.clip(); + ctx.strokeStyle = 'rgba(40,22,8,0.45)'; + ctx.lineWidth = 0.8; + for (let x = -34; x < 24; x += 5) { + ctx.beginPath(); + ctx.moveTo(x, -30); + ctx.lineTo(x, 30); + ctx.stroke(); + } + ctx.restore(); + circle(ctx, -6, 0, 25, null, HAZ, 1.5); + pawPrint(ctx, -6, 1, 15, HAZ); + // control tower with a windsock + rrect(ctx, 24, -38, 18, 28, 3, lit(ctx, 24, -38, 42, -10, '#a0a9b1', 0.35), '#15181b', 0.8); + rrect(ctx, 27, -34, 12, 6, 1.5, GLASS); + rrect(ctx, 24, -10, 18, 6, 0, c.main); + line(ctx, 33, 6, 33, 30, '#2c3238', 1.5); + poly(ctx, [33, 8, 42, 10, 42, 13, 33, 14], c.main, tone(c.dark, -0.3), 0.5); + }, + overlay(ctx, _c, s) { + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU; + const on = Math.floor(s.t * 6) % 8 === i; + if (on) glow(ctx, -6 + Math.cos(a) * 30, Math.sin(a) * 30, 5, '#ffcf6a', 0.9); + } + }, + }, + sciencelab: { + pad: 6, + draw(ctx, c) { + footShadow(ctx, 92, 90, 4); + timber(ctx, -46, -44, 92, 88, 6, 8, 7, true); + circle(ctx, 0, -4, 28, dome(ctx, 0, -4, 28, '#d9e0e6', 0.35), '#15181b', 1); + rivets(ctx, -24, -4, 24, -4, 2, 1); + circle(ctx, 0, -4, 18, rgba('#7fdcff', 0.6), BRASS, 1.5); + circle(ctx, 0, -4, 7, '#e8fbff'); + // brass telescope poking from the dome + rrect(ctx, 6, -24, 22, 5, 2, lit(ctx, 6, -24, 28, -19, BRASS, 0.45), '#4a3510', 0.6); + for (const [x, y] of [[-34, -34], [34, -34], [-34, 30], [34, 30]]) { + line(ctx, x, y, x * 0.6, y * 0.6, '#3a4148', 2); + circle(ctx, x, y, 4, c.main, '#15181b', 0.6); + } + pawPrint(ctx, 0, 30, 6, c.main); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, -4, 18, '#7fdcff', 0.35 + 0.2 * Math.sin(s.t * 2.5)); + }, + }, +}; + +function hoverHull(ctx: Ctx, c: TeamPal, anchored: boolean): void { + const F = FUR.brown; + const ry = anchored ? 14 : 13; + // rotor booms and housings + line(ctx, -2, -ry, -2, ry, '#2d3339', 3.5); + for (const sy of [-1, 1]) { + circle(ctx, -2, sy * ry, 9.5, null, STEEL_D, 2.4); + circle(ctx, -2, sy * ry, 10.6, null, c.main, 0.9); + } + // rocket pods + if (!anchored) { + for (const sy of [-1, 1]) { + rrect(ctx, 2, sy * 7 - 2.2, 10, 4.4, 1.8, '#3c444c', '#15181b', 0.6); + for (const k of [-1, 1]) circle(ctx, 12, sy * 7 + k * 1.1, 0.8, '#e2e4e6'); + } + } + // hull + poly(ctx, sym([-15, 0, -14, -4, -6, -6.5, 6, -6, 13, -3, 14, 0]), lit(ctx, -15, -6, 14, 6, STEEL, 0.35), '#15181b', 0.9); + poly(ctx, sym([-12, 0, -12, -4.5, -8, -5.5, -8, 0]), c.main); + rivets(ctx, -6, -5, 8, -4.5, 6, 0.6); + rivets(ctx, -6, 5, 8, 4.5, 6, 0.6); + engine(ctx, -15, 0, 1.8); + if (anchored) { + // heavy downward cannon slung under the nose + circle(ctx, 12, 0, 6.5, dome(ctx, 12, 0, 6.5, '#59626b', 0.45), '#0d0f11', 0.8); + circle(ctx, 14, 0, 3.4, '#121417', '#3e464e', 1.2); + hazard(ctx, 6, -7.5, 8, 2); + } + // pilot + circle(ctx, 2, 0, 5, rgba(GLASS, 0.35), '#15181b', 0.5); + pilot(ctx, 2, 3.2, F); +} diff --git a/gh-pages/bear-war/src/render/art/kit.ts b/gh-pages/bear-war/src/render/art/kit.ts new file mode 100644 index 00000000..a592275d --- /dev/null +++ b/gh-pages/bear-war/src/render/art/kit.ts @@ -0,0 +1,196 @@ +// Procedural drawing helpers shared by all sprite art. Coordinates are world px in +// sprite-local space: (0,0) is the centre, units face +x. + +export type Ctx = CanvasRenderingContext2D; + +export function hexToRgb(c: string): [number, number, number] { + const n = parseInt(c.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function rgba(c: string, a: number): string { + const [r, g, b] = hexToRgb(c); + return `rgba(${r},${g},${b},${a})`; +} + +/** Lighten (amt > 0) or darken (amt < 0) a hex colour. */ +export function tone(c: string, amt: number): string { + const [r, g, b] = hexToRgb(c); + const f = (v: number) => Math.round(Math.max(0, Math.min(255, amt >= 0 ? v + (255 - v) * amt : v * (1 + amt)))); + return `rgb(${f(r)},${f(g)},${f(b)})`; +} + +export function mixHex(a: string, b: string, t: number): string { + const A = hexToRgb(a); + const B = hexToRgb(b); + return `rgb(${Math.round(A[0] + (B[0] - A[0]) * t)},${Math.round(A[1] + (B[1] - A[1]) * t)},${Math.round(A[2] + (B[2] - A[2]) * t)})`; +} + +/** Linear gradient from light (top-left) to dark (bottom-right) across a box. */ +export function lit(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, base: string, spread = 0.35): CanvasGradient { + const g = ctx.createLinearGradient(x0, y0, x1, y1); + g.addColorStop(0, tone(base, spread)); + g.addColorStop(0.5, base); + g.addColorStop(1, tone(base, -spread)); + return g; +} + +/** Radial "dome" shading for round shapes: highlight near the top-left. */ +export function dome(ctx: Ctx, x: number, y: number, r: number, base: string, spread = 0.45): CanvasGradient { + const g = ctx.createRadialGradient(x - r * 0.35, y - r * 0.35, r * 0.05, x, y, r); + g.addColorStop(0, tone(base, spread)); + g.addColorStop(0.55, base); + g.addColorStop(1, tone(base, -spread)); + return g; +} + +export function path(ctx: Ctx, pts: number[], close = true): void { + ctx.beginPath(); + ctx.moveTo(pts[0], pts[1]); + for (let i = 2; i < pts.length; i += 2) ctx.lineTo(pts[i], pts[i + 1]); + if (close) ctx.closePath(); +} + +export function poly(ctx: Ctx, pts: number[], fill: string | CanvasGradient | null, stroke?: string | null, lw = 1): void { + path(ctx, pts); + if (fill) { + ctx.fillStyle = fill; + ctx.fill(); + } + if (stroke) { + ctx.strokeStyle = stroke; + ctx.lineWidth = lw; + ctx.lineJoin = 'round'; + ctx.stroke(); + } +} + +/** Mirror a half-polygon (given for y <= 0) across the x axis into a full outline. */ +export function sym(half: number[]): number[] { + const out = [...half]; + for (let i = half.length - 2; i >= 0; i -= 2) { + if (half[i + 1] === 0) continue; + out.push(half[i], -half[i + 1]); + } + return out; +} + +export function circle(ctx: Ctx, x: number, y: number, r: number, fill: string | CanvasGradient | null, stroke?: string | null, lw = 1): void { + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + if (fill) { + ctx.fillStyle = fill; + ctx.fill(); + } + if (stroke) { + ctx.strokeStyle = stroke; + ctx.lineWidth = lw; + ctx.stroke(); + } +} + +export function ellipse(ctx: Ctx, x: number, y: number, rx: number, ry: number, rot: number, fill: string | CanvasGradient | null, stroke?: string | null, lw = 1): void { + ctx.beginPath(); + ctx.ellipse(x, y, rx, ry, rot, 0, Math.PI * 2); + if (fill) { + ctx.fillStyle = fill; + ctx.fill(); + } + if (stroke) { + ctx.strokeStyle = stroke; + ctx.lineWidth = lw; + ctx.stroke(); + } +} + +export function rrect(ctx: Ctx, x: number, y: number, w: number, h: number, r: number, fill: string | CanvasGradient | null, stroke?: string | null, lw = 1): void { + ctx.beginPath(); + ctx.roundRect(x, y, w, h, r); + if (fill) { + ctx.fillStyle = fill; + ctx.fill(); + } + if (stroke) { + ctx.strokeStyle = stroke; + ctx.lineWidth = lw; + ctx.stroke(); + } +} + +export function line(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, color: string, lw = 1): void { + ctx.strokeStyle = color; + ctx.lineWidth = lw; + ctx.lineCap = 'round'; + ctx.beginPath(); + ctx.moveTo(x0, y0); + ctx.lineTo(x1, y1); + ctx.stroke(); +} + +/** Soft additive glow. */ +export function glow(ctx: Ctx, x: number, y: number, r: number, color: string, a = 1): void { + const g = ctx.createRadialGradient(x, y, 0, x, y, r); + g.addColorStop(0, rgba(color, a)); + g.addColorStop(0.4, rgba(color, a * 0.45)); + g.addColorStop(1, rgba(color, 0)); + const prev = ctx.globalCompositeOperation; + ctx.globalCompositeOperation = 'lighter'; + ctx.fillStyle = g; + ctx.fillRect(x - r, y - r, r * 2, r * 2); + ctx.globalCompositeOperation = prev; +} + +/** Diagonal warning stripes inside a rect. */ +export function hazard(ctx: Ctx, x: number, y: number, w: number, h: number, a = '#e2b33b', b = '#2a2620'): void { + ctx.save(); + ctx.beginPath(); + ctx.rect(x, y, w, h); + ctx.clip(); + ctx.fillStyle = b; + ctx.fillRect(x, y, w, h); + ctx.fillStyle = a; + const s = 4; + for (let i = -h; i < w + h; i += s * 2) { + ctx.beginPath(); + ctx.moveTo(x + i, y); + ctx.lineTo(x + i + s, y); + ctx.lineTo(x + i + s - h, y + h); + ctx.lineTo(x + i - h, y + h); + ctx.closePath(); + ctx.fill(); + } + ctx.restore(); +} + +/** Small round rivets/bolts along a line. */ +export function rivets(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, n: number, r = 0.7, color = 'rgba(0,0,0,0.45)'): void { + ctx.fillStyle = color; + for (let i = 0; i < n; i++) { + const t = n === 1 ? 0.5 : i / (n - 1); + ctx.beginPath(); + ctx.arc(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t, r, 0, Math.PI * 2); + ctx.fill(); + } +} + +/** Soft drop shadow under a structure footprint (drawn into the sprite). */ +export function footShadow(ctx: Ctx, w: number, h: number, off = 5): void { + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.32)'; + ctx.filter = 'blur(3px)'; + ctx.beginPath(); + ctx.roundRect(-w / 2 + off * 0.6, -h / 2 + off, w, h, 6); + ctx.fill(); + ctx.restore(); +} + +/** Deterministic pseudo-random for art. */ +export function rnd(seed: number): () => number { + let s = seed >>> 0 || 1; + return () => { + s = (s * 1664525 + 1013904223) >>> 0; + return s / 4294967296; + }; +} + +export const TAU = Math.PI * 2; diff --git a/gh-pages/bear-war/src/render/art/neutral.ts b/gh-pages/bear-war/src/render/art/neutral.ts new file mode 100644 index 00000000..d4679ba5 --- /dev/null +++ b/gh-pages/bear-war/src/render/art/neutral.ts @@ -0,0 +1,62 @@ +import type { ArtDef } from './index.ts'; +import { circle, dome, ellipse, glow, poly, rnd, tone, type Ctx } from './kit.ts'; + +function crystal(ctx: Ctx, x: number, y: number, h: number, w: number, lean: number): void { + // a hexagonal prism seen from above-ish: bright face + dark face + const tx = x + lean; + const ty = y - h; + poly(ctx, [x - w, y, x - w * 0.6, y - h * 0.8, tx, ty, x + w * 0.1, y - h * 0.2], '#bff3ff', '#1b5f86', 0.7); + poly(ctx, [x + w * 0.1, y - h * 0.2, tx, ty, x + w * 0.6, y - h * 0.75, x + w, y], '#3aa6dd', '#1b5f86', 0.7); + poly(ctx, [x - w, y, x + w * 0.1, y - h * 0.2, x + w, y, x, y + w * 0.4], '#1d78b3', '#123e5a', 0.7); +} + +export const NEUTRAL_ART: Record = { + mineral: { + pad: 8, + frames: 3, + draw(ctx, _c, f) { + ellipse(ctx, 2, 8, 32, 9, 0, 'rgba(0,0,0,0.35)'); + const r = rnd(7 + f); + const n = [7, 5, 3][f]; + const scale = [1, 0.8, 0.6][f]; + const pts: [number, number, number, number, number][] = []; + for (let i = 0; i < n; i++) { + const x = -24 + (48 * (i + 0.5)) / n + (r() - 0.5) * 6; + const y = 8 + (r() - 0.5) * 6; + pts.push([x, y, (14 + r() * 14) * scale, (4 + r() * 3) * scale, (r() - 0.5) * 8]); + } + pts.sort((a, b) => a[1] - b[1]); + for (const p of pts) crystal(ctx, p[0], p[1], p[2], p[3], p[4]); + glow(ctx, 0, -2, 22, '#7fdcff', 0.18); + }, + }, + geyser: { + pad: 8, + draw(ctx) { + ellipse(ctx, 3, 6, 46, 42, 0, 'rgba(0,0,0,0.35)'); + const r = rnd(3); + ctx.beginPath(); + for (let i = 0; i <= 16; i++) { + const a = (i / 16) * Math.PI * 2; + const k = 1 + (r() - 0.5) * 0.18; + const x = Math.cos(a) * 44 * k; + const y = Math.sin(a) * 40 * k; + if (i === 0) ctx.moveTo(x, y); + else ctx.lineTo(x, y); + } + ctx.closePath(); + ctx.fillStyle = dome(ctx, 0, 0, 44, '#6b6256', 0.35); + ctx.fill(); + ctx.strokeStyle = '#2b251e'; + ctx.lineWidth = 1.2; + ctx.stroke(); + circle(ctx, 0, 0, 26, dome(ctx, 4, 4, 26, tone('#4b443b', -0.1), -0.5), '#2b251e', 1); + ellipse(ctx, 0, 0, 14, 12, 0, '#183a20', '#0c1d10', 1); + glow(ctx, 0, 0, 22, '#5fe07a', 0.55); + for (let i = 0; i < 5; i++) { + const a = r() * Math.PI * 2; + circle(ctx, Math.cos(a) * 34, Math.sin(a) * 30, 3 + r() * 4, dome(ctx, Math.cos(a) * 34, Math.sin(a) * 30, 6, '#7b7264'), '#2b251e', 0.6); + } + }, + }, +}; diff --git a/gh-pages/bear-war/src/render/art/starborn.ts b/gh-pages/bear-war/src/render/art/starborn.ts new file mode 100644 index 00000000..199c9c15 --- /dev/null +++ b/gh-pages/bear-war/src/render/art/starborn.ts @@ -0,0 +1,821 @@ +// Starborn art: mystic polar, spirit, moon and panda bears who draw on starlight. Ivory fur, +// gold armor and gold-and-ivory star temples, cyan and aurora-green glows over deep night +// blue; team colour on gems and trims. + +import type { ArtDef, TeamPal } from './index.ts'; +import { FUR, bearHead, bipedArms, bipedBear, pawPrint, quadBear, type Fur } from './bear.ts'; +import { circle, dome, ellipse, footShadow, glow, line, lit, poly, rgba, rrect, tone, type Ctx, TAU } from './kit.ts'; + +const GOLD = '#d2ae5b'; +const GOLD_D = '#7d5f24'; +const IVORY = '#efe6cc'; +const NIGHT = '#1c2450'; +const CYAN = '#74e8ff'; +const AURORA = '#7dffc0'; +const STAR = '#fff4c2'; +const VIOLET = '#b07bff'; + +const SHADOW: Fur = { fur: '#221d2a', dark: '#0c0a10', muzzle: '#3a3344', nose: '#000000' }; + +function gem(ctx: Ctx, x: number, y: number, r: number, c: TeamPal): void { + poly(ctx, [x, y - r, x + r * 0.8, y, x, y + r, x - r * 0.8, y], dome(ctx, x, y, r, c.light, 0.5), tone(c.dark, -0.3), 0.6); + circle(ctx, x - r * 0.2, y - r * 0.3, r * 0.22, 'rgba(255,255,255,0.8)'); +} + +/** n-pointed star polygon. */ +function star(ctx: Ctx, x: number, y: number, r: number, fill: string | CanvasGradient, n = 4, inner = 0.38, rot = -Math.PI / 2, stroke?: string): void { + const pts: number[] = []; + for (let i = 0; i < n * 2; i++) { + const a = rot + (i / (n * 2)) * TAU; + const k = i % 2 ? r * inner : r; + pts.push(x + Math.cos(a) * k, y + Math.sin(a) * k); + } + poly(ctx, pts, fill, stroke, 0.6); +} + +/** Faint lines between points, then a bright star on each. */ +function constellation(ctx: Ctx, pts: number[][], links: number[][], col: string, r: number, lw = 0.7, a = 0.55): void { + for (const [i, j] of links) line(ctx, pts[i][0], pts[i][1], pts[j][0], pts[j][1], rgba(col, a), lw); + for (const p of pts) { + circle(ctx, p[0], p[1], r * 1.6, rgba(col, 0.25)); + star(ctx, p[0], p[1], r * 1.5, STAR, 4, 0.3); + circle(ctx, p[0], p[1], r * 0.55, '#ffffff'); + } +} + +/** Crescent: a disc with an offset disc cut away. */ +function crescent(ctx: Ctx, x: number, y: number, r: number, dx: number, dy: number, fill: string | CanvasGradient): void { + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r, 0, TAU); + ctx.clip(); + ctx.beginPath(); + ctx.rect(x - r - 1, y - r - 1, r * 2 + 2, r * 2 + 2); + ctx.arc(x + dx, y + dy, r * 0.86, 0, TAU); + ctx.fillStyle = fill; + ctx.fill('evenodd'); + ctx.restore(); +} + +function lightBlade(ctx: Ctx, x0: number, y0: number, x1: number, y1: number, col: string, w: number): void { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + line(ctx, x0, y0, x1, y1, rgba(col, 0.45), w * 2.6); + line(ctx, x0, y0, x1, y1, '#eafff6', w); + ctx.restore(); +} + +/** Front-facing carved bear face, for emblems on temples. */ +function bearFace(ctx: Ctx, x: number, y: number, r: number, col: string, deep: string): void { + for (const s of [-1, 1]) { + circle(ctx, x + s * r * 0.72, y - r * 0.72, r * 0.4, dome(ctx, x + s * r * 0.72, y - r * 0.72, r * 0.4, col, 0.4), deep, 0.6); + circle(ctx, x + s * r * 0.72, y - r * 0.72, r * 0.2, deep); + } + circle(ctx, x, y, r, dome(ctx, x, y, r, col, 0.4), deep, 0.8); + ellipse(ctx, x, y + r * 0.38, r * 0.48, r * 0.36, 0, tone(col, 0.25), deep, 0.5); + ellipse(ctx, x, y + r * 0.26, r * 0.18, r * 0.12, 0, deep); + for (const s of [-1, 1]) circle(ctx, x + s * r * 0.38, y - r * 0.12, r * 0.11, deep); +} + +function hexPlatform(ctx: Ctx, r: number, col: string): void { + const pts: number[] = []; + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU + Math.PI / 8; + pts.push(Math.cos(a) * r, Math.sin(a) * r); + } + poly(ctx, pts, lit(ctx, -r, -r, r, r, col, 0.3), GOLD_D, 1.2); +} + +function plinth(ctx: Ctx, w: number, h: number): void { + footShadow(ctx, w - 8, h - 8, 4); + rrect(ctx, -w / 2 + 4, -h / 2 + 6, w - 8, h - 10, 8, tone(IVORY, -0.55)); + rrect(ctx, -w / 2 + 4, -h / 2 + 4, w - 8, h - 12, 8, lit(ctx, -w / 2, -h / 2, w / 2, h / 2, IVORY, 0.25), GOLD_D, 1.2); +} + +/** Night-blue inlay with scattered tiny stars. */ +function nightInlay(ctx: Ctx, x: number, y: number, r: number, seed: number): void { + circle(ctx, x, y, r, dome(ctx, x, y, r, NIGHT, 0.35), GOLD_D, 1.2); + let s = seed; + for (let i = 0; i < Math.round(r / 2); i++) { + s = (s * 1664525 + 1013904223) >>> 0; + const a = (s / 4294967296) * TAU; + s = (s * 1664525 + 1013904223) >>> 0; + const d = Math.sqrt(s / 4294967296) * r * 0.85; + circle(ctx, x + Math.cos(a) * d, y + Math.sin(a) * d, i % 4 ? 0.5 : 0.9, rgba(STAR, 0.85)); + } +} + +function seerDisc(ctx: Ctx, c: TeamPal, lance: boolean): void { + circle(ctx, 0, 0, 14, dome(ctx, 0, 0, 14, GOLD, 0.4), GOLD_D, 1); + circle(ctx, 0, 0, 11, dome(ctx, 0, 0, 11, IVORY, 0.3), GOLD_D, 0.8); + star(ctx, 0, 0, 10.5, rgba(GOLD, 0.6), 8, 0.55, 0); + circle(ctx, 0, 0, 11, null, c.main, 1.2); + for (let i = 0; i < 4; i++) { + const a = (i / 4) * TAU + Math.PI / 4; + gem(ctx, Math.cos(a) * 12.5, Math.sin(a) * 12.5, 1.8, c); + } + rrect(ctx, 11, -2.2, 6, 4.4, 1.5, lit(ctx, 11, -2, 17, 2, GOLD, 0.4), GOLD_D, 0.6); + circle(ctx, 17, 0, 2, lance ? '#ffffff' : tone(CYAN, -0.4), GOLD_D, 0.5); + bipedBear(ctx, 0, 0.9, FUR.spectacled); + // glowing spectacles + const hx = 1.6 * 0.9; + const r = 4 * 0.9; + for (const s of [-1, 1]) circle(ctx, hx + r * 0.32, s * r * 0.38, r * 0.3, rgba(CYAN, 0.55), GOLD, 0.6); + line(ctx, hx + r * 0.32, -r * 0.1, hx + r * 0.32, r * 0.1, GOLD, 0.5); +} + +export const STARBORN_ART: Record = { + // ================================================================ units + snowcub: { + size: 26, + frames: 4, + draw(ctx, _c, f) { + quadBear(ctx, f, 0.55, FUR.polar); + star(ctx, 5.3, 0, 1.7, STAR, 4, 0.35, -Math.PI / 2, GOLD_D); + }, + overlay(ctx, c, s) { + glow(ctx, 5.3, 0, 4, STAR, 0.5); + const y = -8 + Math.sin(s.t * 3 + s.seed) * 0.8; + glow(ctx, -3, y, 4, CYAN, 0.5); + poly(ctx, [-3, y - 2.6, -1.4, y, -3, y + 2.6, -4.6, y], rgba('#d8fbff', 0.95), c.main, 0.6); + }, + }, + spiritbear: { + size: 40, + frames: 4, + draw(ctx, c, f) { + const s = 1.15; + for (const sy of [-1, 1]) { + for (const k of [-1.2, 0, 1.2]) lightBlade(ctx, 6, sy * 4.1 + k, 16, sy * (5.5 + k * 1.6), AURORA, 0.8); + } + bipedBear(ctx, f, s, FUR.spirit); + for (const sy of [-1, 1]) { + ellipse(ctx, -2, sy * 6.4, 4.2, 3.2, 0, dome(ctx, -2, sy * 6.4, 4.2, GOLD, 0.5), GOLD_D, 0.7); + ellipse(ctx, -3, sy * 8, 2.8, 1.1, 0, c.main); + circle(ctx, -1, sy * 6.2, 0.9, c.light); + } + }, + overlay(ctx, _c, s) { + const a = s.working ? 0.7 : 0.3; + for (const sy of [-1, 1]) glow(ctx, 14, sy * 5.5, 6, AURORA, a); + }, + }, + moonbear: { + size: 50, + frames: 4, + draw(ctx, c, f) { + quadBear(ctx, f, 1.25, FUR.moon); + crescent(ctx, -7, 0, 5.5, 2.6, 0, '#f4f2ff'); + // gold yoke with twin moon-orb emitters + ctx.strokeStyle = GOLD_D; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(-1, 0, 9, -1.1, 1.1); + ctx.stroke(); + ctx.strokeStyle = GOLD; + ctx.lineWidth = 1.6; + ctx.stroke(); + for (const sy of [-1, 1]) { + circle(ctx, 4, sy * 8, 3, dome(ctx, 4, sy * 8, 3, GOLD, 0.5), GOLD_D, 0.6); + circle(ctx, 4.5, sy * 8, 1.8, dome(ctx, 4.5, sy * 8, 1.8, '#eef6ff', 0.4)); + } + gem(ctx, 7.5, 0, 1.8, c); + }, + overlay(ctx, _c, s) { + glow(ctx, -7, 0, 8, '#d8e4ff', 0.25); + for (const sy of [-1, 1]) glow(ctx, 4.5, sy * 8, 6, '#e8f0ff', s.working ? 0.9 : 0.4); + }, + }, + glacierbear: { + size: 40, + frames: 4, + draw(ctx, c, f) { + bipedBear(ctx, f, 1, FUR.glacier); + line(ctx, 8, -11, 8, 11, GOLD_D, 2); + line(ctx, 8, -11, 8, 11, GOLD, 1); + for (const sy of [-1, 1]) { + ctx.beginPath(); + ctx.moveTo(7, sy * 10); + ctx.quadraticCurveTo(9, sy * 18, 17, sy * 14); + ctx.quadraticCurveTo(11, sy * 13.5, 9.5, sy * 9); + ctx.closePath(); + ctx.fillStyle = lit(ctx, 7, sy * 9, 17, sy * 17, '#bff4ff', 0.35); + ctx.fill(); + ctx.strokeStyle = '#2b6f86'; + ctx.lineWidth = 0.6; + ctx.stroke(); + } + gem(ctx, 8, 0, 1.6, c); + bipedArms(ctx, 1, FUR.glacier, 1.54); + }, + overlay(ctx, _c, s) { + for (const sy of [-1, 1]) glow(ctx, 12, sy * 13, 5, CYAN, s.working ? 0.7 : 0.25); + }, + }, + pandasage: { + size: 40, + draw(ctx, c) { + const f = FUR.panda; + // robe and crossed legs + circle(ctx, -1.5, 0, 8.5, dome(ctx, -1.5, 0, 8.5, c.main, 0.45), tone(c.dark, -0.3), 0.8); + circle(ctx, -1.5, 0, 8.5, null, GOLD, 1); + ellipse(ctx, 4.5, -2.5, 4.4, 2.1, 0.5, f.dark); + ellipse(ctx, 4.5, 2.5, 4.4, 2.1, -0.5, f.dark); + for (const sy of [-1, 1]) { + line(ctx, -1, sy * 6.5, 5, sy * 1.5, f.dark, 3); + circle(ctx, 5.5, sy * 1.2, 1.6, f.dark); + } + rrect(ctx, -7, -1, 7, 2, 1, GOLD_D); + bearHead(ctx, 0, 4.4, f); + for (const sy of [-1, 1]) { + ellipse(ctx, 1.4, sy * 1.6, 1.3, 0.95, sy * 0.5, f.dark); + circle(ctx, 1.6, sy * 1.6, 0.4, '#ffffff'); + } + }, + overlay(ctx, c, s) { + glow(ctx, 0, 0, 14, c.light, 0.15 + 0.08 * Math.sin(s.t * 3 + s.seed)); + for (let i = 0; i < 3; i++) { + const a = s.t * 1.6 + s.seed + (i / 3) * TAU; + const x = Math.cos(a) * 12; + const y = Math.sin(a) * 12; + glow(ctx, x, y, 4.5, i === 1 ? AURORA : CYAN, 0.8); + circle(ctx, x, y, 1.6, '#f4ffff'); + } + }, + }, + shadowpaw: { + size: 40, + frames: 4, + draw(ctx, _c, f) { + // smoky cloak trailing behind + const w = [0, 1, 0, -1][f % 4]; + ctx.fillStyle = rgba('#140f1c', 0.85); + ctx.beginPath(); + ctx.moveTo(2, -8); + ctx.quadraticCurveTo(-8, -12, -14, -8 + w); + ctx.quadraticCurveTo(-11, -4, -17, -1 - w); + ctx.quadraticCurveTo(-11, 1, -16, 6 + w); + ctx.quadraticCurveTo(-9, 7, -12, 11 - w); + ctx.quadraticCurveTo(-4, 12, 2, 8); + ctx.closePath(); + ctx.fill(); + bipedBear(ctx, f, 1, SHADOW); + ellipse(ctx, -3, 0, 4.5, 6.5, 0, rgba('#2c2238', 0.8)); + for (const sy of [-1, 1]) { + circle(ctx, 2.9, sy * 1.45, 0.6, VIOLET); + for (const k of [-1, 0, 1]) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + ctx.strokeStyle = rgba(VIOLET, 0.85); + ctx.lineWidth = 1.1; + ctx.beginPath(); + ctx.moveTo(5.5, sy * (3.6 + k * 0.9)); + ctx.quadraticCurveTo(12, sy * (4 + k * 1.2), 13 + k, sy * (10 + k * 1.4)); + ctx.stroke(); + ctx.restore(); + } + } + }, + overlay(ctx, _c, s) { + if (s.working) for (const sy of [-1, 1]) glow(ctx, 11, sy * 7, 6, VIOLET, 0.6); + }, + }, + littledipper: { + size: 30, + draw(ctx) { + ellipse(ctx, -1, 0, 8, 5.5, 0, rgba(NIGHT, 0.45)); + circle(ctx, 6, 0, 3.6, rgba(NIGHT, 0.45)); + const pts = [[9, 0], [6, 0], [4.5, -3.5], [4.5, 3.5], [1, -4.5], [1, 4.5], [3.5, -8], [3.5, 8], [-6, -4], [-6, 4], [-5, -8], [-5, 8], [-9.5, 0]]; + const links = [[0, 1], [1, 2], [1, 3], [1, 4], [1, 5], [4, 6], [5, 7], [4, 8], [5, 9], [8, 10], [9, 11], [8, 12], [9, 12]]; + constellation(ctx, pts, links, '#bfe6ff', 0.75, 0.6, 0.5); + }, + overlay(ctx, _c, s) { + for (const [x, y, k] of [[6, 0, 0], [-6, -4, 1], [1, 4.5, 2], [-9.5, 0, 3]]) { + glow(ctx, x, y, 3.5, '#cfeaff', 0.35 + 0.3 * Math.sin(s.t * 4 + k * 1.7 + s.seed)); + } + }, + }, + floebear: { + size: 60, + draw(ctx, c) { + poly(ctx, [-20, -6, -16, -13, -4, -15, 8, -13, 17, -9, 21, -1, 18, 8, 9, 14, -4, 15, -15, 12, -21, 5], rgba(tone(CYAN, -0.3), 0.9)); + poly(ctx, [-20, -7, -16, -14, -4, -16, 8, -14, 17, -10, 21, -2, 18, 7, 9, 13, -4, 14, -15, 11, -21, 4], lit(ctx, -20, -16, 20, 14, '#eaf8ff', 0.2), '#7bb4c8', 1); + ctx.strokeStyle = rgba('#5aa6c4', 0.6); + ctx.lineWidth = 0.7; + ctx.beginPath(); + ctx.moveTo(-8, -15); + ctx.lineTo(-3, -6); + ctx.lineTo(4, -4); + ctx.lineTo(9, 6); + ctx.moveTo(-3, -6); + ctx.lineTo(-10, 4); + ctx.moveTo(4, -4); + ctx.lineTo(16, -6); + ctx.stroke(); + for (const [x, y, r] of [[6, 6, 3], [10, -5, 2.4], [-2, 9, 2]]) circle(ctx, x, y, r, dome(ctx, x, y, r, '#ffffff', 0.25)); + // rudder and tiller at the stern + rrect(ctx, -25, -1.5, 6, 3, 1, GOLD_D); + line(ctx, -21, 0, -11, 3, GOLD_D, 1.6); + ctx.save(); + ctx.translate(-12, 0); + bipedBear(ctx, 0, 0.8, FUR.polar); + ctx.restore(); + // team banner at the bow + circle(ctx, 12, -9, 1.6, GOLD, GOLD_D, 0.5); + ctx.fillStyle = c.main; + ctx.beginPath(); + ctx.moveTo(12, -9); + ctx.quadraticCurveTo(6, -13, 0, -11); + ctx.quadraticCurveTo(5, -9, 2, -6); + ctx.quadraticCurveTo(7, -7, 12, -7.5); + ctx.closePath(); + ctx.fill(); + ctx.strokeStyle = c.dark; + ctx.lineWidth = 0.5; + ctx.stroke(); + star(ctx, 6.5, -9.5, 1.4, STAR, 4, 0.35); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 22, CYAN, 0.16 + 0.06 * Math.sin(s.t * 2 + s.seed)); + }, + }, + snowballer: { + size: 56, + frames: 4, + draw(ctx, c, f) { + ctx.save(); + ctx.translate(-8, 0); + quadBear(ctx, f, 1.05, FUR.polar); + ctx.restore(); + const x = 15; + const r = 10; + circle(ctx, x, 0, r, dome(ctx, x, 0, r, '#f4fbff', 0.35), '#7bb4c8', 1); + ctx.save(); + ctx.beginPath(); + ctx.arc(x, 0, r, 0, TAU); + ctx.clip(); + ctx.translate(x, 0); + ctx.rotate(f * 0.6); + for (let i = 0; i < 5; i++) { + const a = (i / 5) * TAU; + line(ctx, Math.cos(a) * 2, Math.sin(a) * 2, Math.cos(a + 0.6) * 9, Math.sin(a + 0.6) * 9, rgba(CYAN, 0.55), 1); + star(ctx, Math.cos(a + 1.2) * 6, Math.sin(a + 1.2) * 6, 1.3, STAR, 4, 0.35); + } + ctx.restore(); + circle(ctx, x, 0, 3, dome(ctx, x, 0, 3, c.light, 0.5)); + star(ctx, x, 0, 4, rgba('#ffffff', 0.9), 4, 0.3); + }, + overlay(ctx, _c, s) { + glow(ctx, 15, 0, 16, CYAN, 0.28 + 0.12 * Math.sin(s.t * 5 + s.seed)); + glow(ctx, 15, 0, 6, '#ffffff', 0.5); + }, + }, + polarcolossus: { + size: 104, + frames: 4, + draw(ctx, c, f) { + const s = 2.4; + quadBear(ctx, f, s, FUR.polar); + // starmetal back plates + ellipse(ctx, -8, 0, 17, 9.5, 0, lit(ctx, -25, -10, 9, 10, GOLD, 0.4), GOLD_D, 1.2); + ellipse(ctx, -8, 0, 12, 6, 0, null, rgba('#fff0b0', 0.5), 0.8); + for (const x of [-18, 2]) line(ctx, x, -8, x, 8, GOLD_D, 1); + rrect(ctx, -24, -1.8, 32, 3.6, 1.8, c.main, tone(c.dark, -0.3), 0.7); + for (const x of [-14, -2]) gem(ctx, x, 0, 2.8, c); + star(ctx, -8, 0, 4.5, STAR, 4, 0.3, -Math.PI / 2, GOLD_D); + // pauldrons + for (const sy of [-1, 1]) { + ellipse(ctx, 12, sy * 11.5, 6.5, 4.5, sy * 0.3, dome(ctx, 12, sy * 11.5, 6.5, GOLD, 0.5), GOLD_D, 1); + ellipse(ctx, 10.5, sy * 14.5, 4, 1.3, sy * 0.3, c.main); + } + // helm over the brow with beam emitters + const hx = 22 * s * 0.48; + const hr = 5 * s; + ctx.beginPath(); + ctx.arc(hx, 0, hr + 0.6, Math.PI * 0.55, Math.PI * 1.45); + ctx.lineTo(hx + hr * 0.3, -hr * 0.55); + ctx.lineTo(hx + hr * 0.3, hr * 0.55); + ctx.closePath(); + ctx.fillStyle = lit(ctx, hx - hr, -hr, hx + hr, hr, GOLD, 0.45); + ctx.fill(); + ctx.strokeStyle = GOLD_D; + ctx.lineWidth = 1; + ctx.stroke(); + for (const sy of [-1, 1]) { + circle(ctx, hx - hr * 0.45, sy * hr * 0.78, hr * 0.3, dome(ctx, hx - hr * 0.45, sy * hr * 0.78, hr * 0.3, GOLD, 0.5), GOLD_D, 0.6); + circle(ctx, hx + hr * 0.32, sy * hr * 0.36, 1.7, '#bff6ff', GOLD_D, 0.8); + } + circle(ctx, hx - hr * 0.1, 0, 2.6, '#e8fdff', GOLD_D, 1); + gem(ctx, hx - hr * 0.6, 0, 2.2, c); + }, + overlay(ctx, _c, s) { + const hx = 22 * 2.4 * 0.48; + const hr = 12; + const a = s.working ? 1 : 0.35 + 0.1 * Math.sin(s.t * 3 + s.seed); + for (const sy of [-1, 1]) glow(ctx, hx + hr * 0.32, sy * hr * 0.36, s.working ? 9 : 5, CYAN, a); + glow(ctx, hx - hr * 0.1, 0, s.working ? 8 : 5, '#e8fdff', a * 0.8); + glow(ctx, -8, 0, 8, STAR, 0.4); + }, + }, + starwing: { + size: 56, + draw(ctx, c) { + ctx.save(); + ctx.globalCompositeOperation = 'lighter'; + for (const sy of [-1, 1]) { + const g = ctx.createLinearGradient(0, 0, 0, sy * 25); + g.addColorStop(0, rgba('#ffffff', 0.9)); + g.addColorStop(0.5, rgba(CYAN, 0.7)); + g.addColorStop(1, rgba(AURORA, 0.45)); + ctx.fillStyle = g; + ctx.strokeStyle = rgba('#e8fdff', 0.9); + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(4, sy * 3); + ctx.quadraticCurveTo(10, sy * 20, -9, sy * 26); + ctx.quadraticCurveTo(-1, sy * 15, -7, sy * 4); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + for (const k of [0.35, 0.6, 0.85]) line(ctx, -2, sy * 5, 2 - k * 10, sy * (5 + k * 19), rgba('#ffffff', 0.5), 0.6); + } + ctx.restore(); + for (const sy of [-1, 1]) gem(ctx, -8.5, sy * 25, 1.8, c); + bipedBear(ctx, 0, 0.9, FUR.spirit); + for (const sy of [-1, 1]) ellipse(ctx, -1.5, sy * 5, 2.6, 2, 0, dome(ctx, -1.5, sy * 5, 2.6, GOLD, 0.5), GOLD_D, 0.5); + gem(ctx, -4, 0, 1.8, c); + }, + overlay(ctx, _c, s) { + const a = 0.25 + 0.12 * Math.sin(s.t * 4 + s.seed); + for (const sy of [-1, 1]) glow(ctx, -1, sy * 15, 12, CYAN, a); + }, + }, + seer: { + size: 44, + draw(ctx, c) { + seerDisc(ctx, c, false); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 16, CYAN, 0.12 + 0.06 * Math.sin(s.t * 2 + s.seed)); + for (const sy of [-1, 1]) glow(ctx, 2.6, sy * 1.4, 3, CYAN, 0.6); + }, + }, + seer_b: { + size: 44, + draw(ctx, c) { + seerDisc(ctx, c, true); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 16, '#ffe39a', 0.15); + for (const sy of [-1, 1]) glow(ctx, 2.6, sy * 1.4, 3, CYAN, 0.7); + glow(ctx, 17, 0, s.working ? 10 : 7, '#fff2c0', 0.9); + }, + }, + greatbear: { + size: 124, + draw(ctx) { + const body = () => { + ctx.beginPath(); + ctx.ellipse(-6, 0, 28, 19, 0, 0, TAU); + ctx.moveTo(26, 0); + ctx.ellipse(20, 0, 6, 9, 0, 0, TAU); + ctx.moveTo(42, 0); + ctx.arc(30, 0, 12, 0, TAU); + ctx.moveTo(48, 0); + ctx.ellipse(41, 0, 7, 6, 0, 0, TAU); + for (const sy of [-1, 1]) { + ctx.moveTo(29, sy * 11); + ctx.arc(24, sy * 11, 5, 0, TAU); + ctx.moveTo(23, sy * 17); + ctx.ellipse(15, sy * 17, 8, 7, 0, 0, TAU); + ctx.moveTo(24, sy * 23); + ctx.ellipse(18, sy * 23, 6, 4.5, 0, 0, TAU); + ctx.moveTo(-11, sy * 16); + ctx.ellipse(-19, sy * 16, 8, 7, 0, 0, TAU); + ctx.moveTo(-14, sy * 23); + ctx.ellipse(-20, sy * 23, 6, 4.5, 0, 0, TAU); + } + ctx.moveTo(-31, 0); + ctx.arc(-35, 0, 4, 0, TAU); + }; + body(); + ctx.strokeStyle = GOLD; + ctx.lineWidth = 3; + ctx.lineJoin = 'round'; + ctx.stroke(); + ctx.save(); + ctx.globalCompositeOperation = 'destination-out'; + body(); + ctx.fill(); + ctx.restore(); + body(); + const g = ctx.createRadialGradient(-6, -6, 4, 0, 0, 48); + g.addColorStop(0, rgba('#3a4fa8', 0.7)); + g.addColorStop(1, rgba(NIGHT, 0.55)); + ctx.fillStyle = g; + ctx.fill(); + let r = 99; + for (let i = 0; i < 40; i++) { + r = (r * 1664525 + 1013904223) >>> 0; + const x = -32 + (r / 4294967296) * 74; + r = (r * 1664525 + 1013904223) >>> 0; + const y = ((r / 4294967296) - 0.5) * 36; + circle(ctx, x, y, i % 5 ? 0.45 : 0.8, rgba(STAR, 0.7)); + } + // minor stars on head and paws + const minor = [[44, 0], [30, 0], [24, -11], [24, 11], [18, -23], [18, 23], [-20, -23], [-20, 23]]; + constellation(ctx, minor, [[0, 1], [1, 2], [1, 3]], '#cfe0ff', 0.9, 0.6, 0.35); + // the seven stars of the dipper along back and tail + const dip = [[-52, 8], [-41, 4], [-29, 2], [-17, -2], [1, -5], [2, 11], [-15, 10]]; + constellation(ctx, dip, [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 3]], '#ffe39a', 1.6, 1.1, 0.7); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 50, '#4a62d0', 0.12 + 0.05 * Math.sin(s.t * 1.5 + s.seed)); + const dip = [[-52, 8], [-41, 4], [-29, 2], [-17, -2], [1, -5], [2, 11], [-15, 10]]; + for (let i = 0; i < dip.length; i++) glow(ctx, dip[i][0], dip[i][1], 7, STAR, 0.45 + 0.3 * Math.sin(s.t * 3 + i * 1.3)); + if (s.working) glow(ctx, 44, 0, 12, '#ffe39a', 0.7); + }, + }, + + // ================================================================ structures + startemple: { + pad: 8, + draw(ctx, c) { + plinth(ctx, 164, 162); + hexPlatform(ctx, 66, '#e5dcc0'); + hexPlatform(ctx, 50, '#d9c79a'); + // constellation ring etched in the floor + const ring: number[][] = []; + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU; + ring.push([Math.cos(a) * 40, Math.sin(a) * 40]); + } + constellation(ctx, ring, ring.map((_, i) => [i, (i + 1) % 8]), GOLD_D, 0.9, 0.8, 0.6); + // four pylons + for (let i = 0; i < 4; i++) { + const a = (i / 4) * TAU + Math.PI / 4; + const x = Math.cos(a) * 54; + const y = Math.sin(a) * 54; + circle(ctx, x, y, 10, dome(ctx, x, y, 10, GOLD, 0.5), GOLD_D, 1); + star(ctx, x, y, 7, IVORY, 4, 0.4, -Math.PI / 2, GOLD_D); + gem(ctx, x, y, 3.5, c); + } + nightInlay(ctx, 0, 0, 30, 3); + circle(ctx, 0, 0, 30, null, c.main, 3); + bearFace(ctx, 0, 3, 15, GOLD, GOLD_D); + star(ctx, 0, -1, 5, '#bff6ff', 4, 0.4, -Math.PI / 2, '#2a7a8c'); + for (const [x, y] of [[0, -62], [0, 62], [-62, 0], [62, 0]]) pawPrint(ctx, x, y, 6, rgba(GOLD_D, 0.85)); + }, + overlay(ctx, c, s) { + glow(ctx, 0, -1, 26, CYAN, 0.3 + 0.12 * Math.sin(s.t * 2)); + ctx.save(); + ctx.rotate(s.t * 0.5); + ctx.strokeStyle = rgba(c.light, 0.7); + ctx.lineWidth = 1.5; + ctx.setLineDash([10, 8]); + ctx.beginPath(); + ctx.arc(0, 0, 34, 0, TAU); + ctx.stroke(); + ctx.restore(); + }, + }, + startotem: { + pad: 6, + draw(ctx, c) { + plinth(ctx, 62, 60); + circle(ctx, 0, 2, 20, dome(ctx, 0, 2, 20, GOLD, 0.4), GOLD_D, 1); + bearFace(ctx, 0, 8, 12, IVORY, GOLD_D); + star(ctx, 0, -6, 15, dome(ctx, -3, -10, 15, '#bff6ff', 0.4), 4, 0.36, -Math.PI / 2, '#2a7a8c'); + star(ctx, 0, -6, 15, 'rgba(255,255,255,0.22)', 2, 0.36, -Math.PI / 2); + gem(ctx, 0, -6, 3.5, c); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, -6, 22, CYAN, s.built ? 0.35 + 0.15 * Math.sin(s.t * 2.2 + s.seed) : 0.15); + }, + }, + dewshrine: { + pad: 6, + draw(ctx, c) { + plinth(ctx, 94, 92); + circle(ctx, 0, 0, 36, dome(ctx, 0, 0, 36, GOLD, 0.35), GOLD_D, 1.2); + circle(ctx, 0, 0, 30, dome(ctx, 0, 0, 30, IVORY, 0.35), GOLD_D, 1); + // dew pools in the shape of a great paw + ellipse(ctx, 0, 7, 13, 10, 0, rgba('#1d5a3a', 0.9), GOLD_D, 1.2); + ellipse(ctx, 0, 7, 10, 7.5, 0, rgba(AURORA, 0.75)); + for (const [dx, dy] of [[-0.62, -0.25], [-0.22, -0.62], [0.22, -0.62], [0.62, -0.25]]) { + const x = dx * 26; + const y = 3 + dy * 26; + circle(ctx, x, y, 5.5, rgba('#1d5a3a', 0.9), GOLD_D, 1); + circle(ctx, x, y, 4, rgba(AURORA, 0.75)); + } + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU + Math.PI / 2; + gem(ctx, Math.cos(a) * 33, Math.sin(a) * 33, 3.5, c); + } + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 4, 18, AURORA, 0.3 + 0.15 * Math.sin(s.t * 3)); + }, + }, + moongate: { + pad: 8, + draw(ctx, c) { + plinth(ctx, 94, 92); + // the gate ring wears bear ears + for (const sx of [-1, 1]) { + circle(ctx, sx * 25, -24, 10, dome(ctx, sx * 25, -24, 10, GOLD, 0.45), GOLD_D, 1.2); + circle(ctx, sx * 25, -24, 5.5, tone(GOLD_D, -0.2)); + gem(ctx, sx * 25, -24, 3, c); + } + circle(ctx, 0, 0, 34, dome(ctx, 0, 0, 34, GOLD, 0.4), GOLD_D, 1.5); + circle(ctx, 0, 0, 25, '#141226', GOLD_D, 1.2); + crescent(ctx, 0, 0, 22, 7, -4, rgba('#e8eeff', 0.85)); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU; + star(ctx, Math.cos(a) * 29.5, Math.sin(a) * 29.5, 2.6, i % 2 ? STAR : c.light, 4, 0.4); + } + }, + overlay(ctx, c, s) { + ctx.save(); + ctx.rotate(s.t * (s.working ? 2.5 : 0.8)); + ctx.globalCompositeOperation = 'lighter'; + for (let i = 0; i < 3; i++) { + ctx.strokeStyle = rgba(i === 1 ? c.light : CYAN, 0.55); + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(0, 0, 8 + i * 5, i * 2, i * 2 + 3.5); + ctx.stroke(); + } + ctx.restore(); + glow(ctx, 0, 0, 22, CYAN, s.powered ? 0.3 : 0.05); + }, + }, + starforge: { + pad: 6, + draw(ctx, c) { + plinth(ctx, 94, 92); + rrect(ctx, -32, -30, 64, 52, 10, lit(ctx, -32, -30, 32, 22, GOLD, 0.35), GOLD_D, 1.2); + // anvil hearth with a captive star + rrect(ctx, -22, -20, 44, 34, 8, '#0f1630', GOLD_D, 1.2); + for (const sx of [-1, 1]) rrect(ctx, sx * 26 - 3, -14, 6, 22, 2, lit(ctx, sx * 26 - 3, -14, sx * 26 + 3, 8, IVORY, 0.3), GOLD_D, 0.7); + star(ctx, 0, -3, 13, dome(ctx, 0, -3, 13, '#dffbff', 0.3), 4, 0.35); + star(ctx, 0, -3, 8, '#ffffff', 4, 0.35, -Math.PI / 4); + for (const [x, y] of [[-15, -14], [14, -13], [-14, 8], [15, 7]]) star(ctx, x, y, 2, STAR, 4, 0.35); + for (const sx of [-1, 1]) bearFace(ctx, sx * 30, 26, 6, IVORY, GOLD_D); + gem(ctx, 0, 30, 5, c); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, -3, 22, CYAN, s.powered ? 0.35 + 0.2 * Math.sin(s.t * 4) : 0.08); + }, + }, + starlantern: { + pad: 6, + turretSize: 44, + draw(ctx, c) { + plinth(ctx, 62, 60); + hexPlatform(ctx, 22, GOLD); + for (let i = 0; i < 4; i++) { + const a = (i / 4) * TAU + Math.PI / 4; + circle(ctx, Math.cos(a) * 24, Math.sin(a) * 24, 2.4, STAR, GOLD_D, 0.6); + } + gem(ctx, 0, 21, 3.5, c); + }, + turret(ctx, c) { + // lantern carved as a bear head, star lens in its muzzle + for (const sy of [-1, 1]) { + circle(ctx, -5, sy * 9.5, 4.2, dome(ctx, -5, sy * 9.5, 4.2, IVORY, 0.4), GOLD_D, 0.9); + circle(ctx, -5, sy * 9.5, 2.2, c.main); + } + circle(ctx, 0, 0, 11, dome(ctx, 0, 0, 11, IVORY, 0.4), GOLD_D, 1); + ellipse(ctx, 8, 0, 5.5, 4.5, 0, dome(ctx, 8, 0, 5.5, GOLD, 0.4), GOLD_D, 0.8); + circle(ctx, 0, 0, 5.5, dome(ctx, 0, 0, 5.5, '#9ff1ff', 0.5), '#1f6070', 0.8); + star(ctx, 0, 0, 4.5, '#ffffff', 4, 0.35); + circle(ctx, 11.5, 0, 2.2, '#dffbff', GOLD_D, 0.6); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 14, CYAN, s.powered ? 0.35 + 0.2 * Math.sin(s.t * 5) : 0.05); + }, + }, + spiritlodge: { + pad: 6, + draw(ctx, c) { + plinth(ctx, 94, 92); + hexPlatform(ctx, 40, '#e2d6b2'); + circle(ctx, 0, -6, 28, dome(ctx, 0, -6, 28, IVORY, 0.4), GOLD_D, 1.2); + for (let i = 0; i < 8; i++) { + const a = (i / 8) * TAU; + line(ctx, Math.cos(a) * 6, -6 + Math.sin(a) * 6, Math.cos(a) * 27, -6 + Math.sin(a) * 27, GOLD, 2.2); + } + circle(ctx, 0, -6, 8, dome(ctx, 0, -6, 8, GOLD, 0.5), GOLD_D, 1); + gem(ctx, 0, -6, 4.5, c); + // carved spirit bear over the door + bearFace(ctx, 0, 22, 9, IVORY, GOLD_D); + for (const sx of [-1, 1]) { + rrect(ctx, sx * 32 - 3, 12, 6, 18, 2, lit(ctx, sx * 32 - 3, 12, sx * 32 + 3, 30, GOLD, 0.4), GOLD_D, 0.8); + circle(ctx, sx * 32, 11, 3.2, AURORA, '#1d5a3a', 0.6); + } + }, + overlay(ctx, _c, s) { + const a = s.powered ? 0.4 + 0.15 * Math.sin(s.t * 2.5 + s.seed) : 0.06; + for (const sx of [-1, 1]) glow(ctx, sx * 32, 11, 9, AURORA, a); + glow(ctx, 0, -6, 18, AURORA, a * 0.5); + }, + }, + skyaltar: { + pad: 8, + draw(ctx, c) { + plinth(ctx, 94, 92); + for (let i = 0; i < 4; i++) { + const a = (i / 4) * TAU + Math.PI / 4; + poly(ctx, [Math.cos(a - 0.25) * 30, Math.sin(a - 0.25) * 30, Math.cos(a) * 45, Math.sin(a) * 45, Math.cos(a + 0.25) * 30, Math.sin(a + 0.25) * 30], lit(ctx, -45, -45, 45, 45, GOLD, 0.4), GOLD_D, 1); + circle(ctx, Math.cos(a) * 37, Math.sin(a) * 37, 2.6, c.light); + } + nightInlay(ctx, 0, 0, 34, 11); + circle(ctx, 0, 0, 24, null, c.main, 3); + const pts = [[-14, -8], [-4, -15], [8, -12], [14, 0], [6, 12], [-8, 13]]; + constellation(ctx, pts, [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 0]], '#cfe0ff', 1, 0.7, 0.5); + pawPrint(ctx, 0, 2, 9, rgba(IVORY, 0.9)); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 28, CYAN, s.working ? 0.4 : s.powered ? 0.18 : 0.04); + }, + }, + frostworks: { + pad: 6, + draw(ctx, c) { + plinth(ctx, 94, 92); + rrect(ctx, -34, -32, 68, 60, 6, lit(ctx, -34, -32, 34, 28, '#e3d6b0', 0.3), GOLD_D, 1.2); + rrect(ctx, -24, -22, 48, 40, 4, '#16324a', GOLD_D, 1); + // ice crystals growing in the work pit + for (const [x, y, h, a] of [[-14, 8, 14, -0.3], [-6, 10, 18, 0.1], [4, 9, 12, 0.4], [14, 6, 16, -0.2], [-16, -12, 9, 0.6], [15, -12, 10, -0.5]]) { + ctx.save(); + ctx.translate(x, y); + ctx.rotate(a); + poly(ctx, [-2.4, 0, -2.4, -h * 0.75, 0, -h, 2.4, -h * 0.75, 2.4, 0], lit(ctx, -3, -h, 3, 0, '#c8f6ff', 0.4), '#2b6f86', 0.6); + ctx.restore(); + } + for (const [x, y] of [[-29, -27], [29, -27], [-29, 23], [29, 23]]) gem(ctx, x, y, 4, c); + pawPrint(ctx, 0, 24, 3.5, GOLD_D); + }, + overlay(ctx, _c, s) { + ctx.save(); + ctx.translate(0, -2); + ctx.rotate(s.working ? s.t * 2 : 0.4); + for (let i = 0; i < 2; i++) { + ctx.rotate(Math.PI); + line(ctx, 0, 0, 17, 0, GOLD, 3); + circle(ctx, 17, 0, 3, CYAN); + } + circle(ctx, 0, 0, 4, GOLD, GOLD_D, 1); + ctx.restore(); + glow(ctx, 0, 0, 22, CYAN, s.powered ? 0.18 : 0.04); + }, + }, + sagelibrary: { + pad: 6, + draw(ctx, c) { + plinth(ctx, 94, 92); + hexPlatform(ctx, 38, '#ddd0a8'); + // ring of scroll shelves + for (let i = 0; i < 12; i++) { + const a = (i / 12) * TAU; + ctx.save(); + ctx.translate(Math.cos(a) * 29, Math.sin(a) * 29); + ctx.rotate(a); + rrect(ctx, -3, -4, 6, 8, 1.2, i % 3 === 0 ? c.main : i % 3 === 1 ? '#7a3a2a' : '#2e4a6a', GOLD_D, 0.6); + line(ctx, -3, 0, 3, 0, rgba(IVORY, 0.6), 0.6); + ctx.restore(); + } + circle(ctx, 0, 0, 20, dome(ctx, 0, 0, 20, IVORY, 0.35), GOLD_D, 1); + // black-and-white sage paw in the reading circle + pawPrint(ctx, 0, 1, 15, '#1b1a1d'); + ellipse(ctx, 0, 4.8, 4, 3, 0, '#f2f0ea'); + star(ctx, 0, 4.8, 3, c.light, 4, 0.4); + }, + overlay(ctx, c, s) { + glow(ctx, 0, 0, 24, c.light, s.powered ? 0.22 + 0.12 * Math.sin(s.t * 2) : 0.05); + if (s.powered) { + const a = s.t * 0.9; + glow(ctx, Math.cos(a) * 14, Math.sin(a) * 14, 5, CYAN, 0.7); + } + }, + }, + greatbeacon: { + pad: 8, + draw(ctx, c) { + plinth(ctx, 94, 92); + for (let i = 0; i < 3; i++) { + const a = (i / 3) * TAU - Math.PI / 2; + poly(ctx, [0, 0, Math.cos(a - 0.35) * 40, Math.sin(a - 0.35) * 40, Math.cos(a) * 46, Math.sin(a) * 46, Math.cos(a + 0.35) * 40, Math.sin(a + 0.35) * 40], lit(ctx, -46, -46, 46, 46, GOLD, 0.45), GOLD_D, 1); + gem(ctx, Math.cos(a) * 36, Math.sin(a) * 36, 3.5, c); + } + nightInlay(ctx, 0, 0, 24, 5); + const dip = [[-19, 9], [-13, 4], [-7, 3], [-1, 0], [10, -3], [11, 7], [1, 9]]; + constellation(ctx, dip, [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 6], [6, 3]], '#ffe39a', 1.2, 0.9, 0.8); + circle(ctx, 0, -14, 4, dome(ctx, 0, -14, 4, '#fff6d6', 0.4), GOLD_D, 0.8); + }, + overlay(ctx, _c, s) { + glow(ctx, 0, 0, 32, '#ffe39a', s.powered ? 0.28 + 0.15 * Math.sin(s.t * 1.5) : 0.05); + }, + }, +}; diff --git a/gh-pages/bear-war/src/render/camera.ts b/gh-pages/bear-war/src/render/camera.ts new file mode 100644 index 00000000..22b78f0b --- /dev/null +++ b/gh-pages/bear-war/src/render/camera.ts @@ -0,0 +1,80 @@ +export class Camera { + /** world coords of the view's top-left corner */ + x = 0; + y = 0; + zoom = 1; + minZoom = 0.5; + maxZoom = 3; + /** view size in CSS px */ + w = 800; + h = 600; + /** HUD insets in CSS px: the usable viewport (for centering) */ + insetTop = 0; + insetBottom = 0; + worldW = 1000; + worldH = 1000; + + toScreenX(wx: number): number { + return (wx - this.x) * this.zoom; + } + toScreenY(wy: number): number { + return (wy - this.y) * this.zoom; + } + toWorldX(sx: number): number { + return sx / this.zoom + this.x; + } + toWorldY(sy: number): number { + return sy / this.zoom + this.y; + } + get viewW(): number { + return this.w / this.zoom; + } + get viewH(): number { + return this.h / this.zoom; + } + + centerOn(wx: number, wy: number): void { + const usable = this.h - this.insetTop - this.insetBottom; + this.x = wx - this.viewW / 2; + this.y = wy - (this.insetTop + usable / 2) / this.zoom; + this.clamp(); + } + + /** world point at the centre of the usable view */ + get centerX(): number { + return this.x + this.viewW / 2; + } + get centerY(): number { + const usable = this.h - this.insetTop - this.insetBottom; + return this.y + (this.insetTop + usable / 2) / this.zoom; + } + + setZoom(z: number, anchorSx = this.w / 2, anchorSy = this.h / 2): void { + z = Math.max(this.minZoom, Math.min(this.maxZoom, z)); + const wx = this.toWorldX(anchorSx); + const wy = this.toWorldY(anchorSy); + this.zoom = z; + this.x = wx - anchorSx / z; + this.y = wy - anchorSy / z; + this.clamp(); + } + + pan(dsx: number, dsy: number): void { + this.x += dsx / this.zoom; + this.y += dsy / this.zoom; + this.clamp(); + } + + clamp(): void { + if (!Number.isFinite(this.zoom) || this.zoom <= 0) this.zoom = Math.max(0.1, this.minZoom || 1); + if (!Number.isFinite(this.x)) this.x = 0; + if (!Number.isFinite(this.y)) this.y = 0; + const margin = 64; + const minX = -margin; + const minY = -margin - this.insetTop / this.zoom; + const maxX = this.worldW + margin - this.viewW; + const maxY = this.worldH + margin + this.insetBottom / this.zoom - this.viewH; + this.x = maxX < minX ? (minX + maxX) / 2 : Math.max(minX, Math.min(maxX, this.x)); + this.y = maxY < minY ? (minY + maxY) / 2 : Math.max(minY, Math.min(maxY, this.y)); + } +} diff --git a/gh-pages/bear-war/src/render/effects.ts b/gh-pages/bear-war/src/render/effects.ts new file mode 100644 index 00000000..00c30151 --- /dev/null +++ b/gh-pages/bear-war/src/render/effects.ts @@ -0,0 +1,536 @@ +// Visual effects driven by simulation events: muzzle flashes, tracers, beams, +// explosions, splats, sparks and scorch decals. Purely cosmetic. + +import type { Game } from '../game/game.ts'; +import type { GameEvent } from '../game/types.ts'; +import { DEFS } from '../game/data/index.ts'; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + life: number; + max: number; + size: number; + grow: number; + r: number; + g: number; + b: number; + a: number; + /** 0 = soft dot (additive), 1 = smoke (normal), 2 = ring, 3 = spark streak, 4 = debris chunk, 5 = goo droplet */ + kind: number; + drag: number; + air: boolean; +} + +interface Beam { + x0: number; + y0: number; + x1: number; + y1: number; + color: string; + glow: string; + width: number; + life: number; + max: number; + air: boolean; +} + +interface Decal { + x: number; + y: number; + r: number; + life: number; + max: number; + kind: number; // 0 scorch, 1 goo, 2 blood + rot: number; +} + +const MAX_PARTICLES = 1800; + +const glowCache = new Map(); +/** Soft radial glow sprite, cached per (quantised) colour. */ +export function glowSprite(r: number, g: number, b: number): HTMLCanvasElement { + const key = ((r >> 3) << 10) | ((g >> 3) << 5) | (b >> 3); + let c = glowCache.get(key); + if (!c) { + c = document.createElement('canvas'); + c.width = c.height = 64; + const ctx = c.getContext('2d')!; + const gr = ctx.createRadialGradient(32, 32, 0, 32, 32, 32); + gr.addColorStop(0, `rgba(${r},${g},${b},1)`); + gr.addColorStop(0.35, `rgba(${r},${g},${b},0.55)`); + gr.addColorStop(1, `rgba(${r},${g},${b},0)`); + ctx.fillStyle = gr; + ctx.fillRect(0, 0, 64, 64); + glowCache.set(key, c); + } + return c; +} + +type C3 = [number, number, number]; +const FX_COLORS: Record = { + rifle: [255, 220, 120], + sap: [255, 170, 80], + flame: [255, 140, 40], + cannon: [255, 200, 120], + boulder: [255, 180, 90], + autocannon: [255, 220, 140], + missile: [255, 190, 100], + guns: [255, 70, 60], + broadside: [120, 180, 255], + prism: [110, 240, 255], + torch: [140, 220, 255], + claw: [200, 255, 160], + starspark: [140, 230, 255], + blade: [130, 210, 255], + scythe: [170, 120, 255], + maul: [220, 255, 170], + quill: [190, 230, 90], + glaive: [200, 120, 255], + sting: [150, 255, 90], + burst: [140, 255, 70], + thorn: [210, 190, 150], + moonorb: [120, 170, 255], + eyebeam: [255, 120, 50], + lantern: [120, 230, 255], + starbolt: [255, 215, 120], + grandshot: [140, 200, 255], + keg: [255, 170, 80], + honey: [255, 190, 60], + eruption: [210, 180, 120], + frost: [170, 230, 255], + sunlance: [255, 240, 150], + tongue: [255, 130, 170], + lockon: [255, 90, 70], + snowball: [190, 235, 255], +}; + +export class Effects { + parts: Particle[] = []; + beams: Beam[] = []; + decals: Decal[] = []; + /** id -> seconds of white hit-flash remaining */ + flash = new Map(); + shieldHits: { x: number; y: number; r: number; life: number; ang: number }[] = []; + lowFx = false; + + add(p: Partial & { x: number; y: number }): void { + if (this.parts.length >= MAX_PARTICLES) { + if (this.lowFx) return; + this.parts.shift(); + } + this.parts.push({ + vx: 0, vy: 0, life: 0.4, max: 0.4, size: 3, grow: 0, r: 255, g: 255, b: 255, a: 1, kind: 0, drag: 0.9, air: false, + ...p, + } as Particle); + } + + burst(x: number, y: number, n: number, c: C3, speed: number, size: number, life: number, kind = 0, air = false): void { + if (this.lowFx) n = Math.ceil(n / 2); + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const s = speed * (0.3 + Math.random() * 0.7); + const l = life * (0.6 + Math.random() * 0.6); + this.add({ x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s, life: l, max: l, size: size * (0.6 + Math.random() * 0.8), r: c[0], g: c[1], b: c[2], kind, air }); + } + } + + explosion(x: number, y: number, scale: number, air = false, scorch = true): void { + this.add({ x, y, life: 0.35, max: 0.35, size: 6 * scale, grow: 70 * scale, r: 255, g: 230, b: 170, kind: 2, air }); + this.burst(x, y, Math.round(10 * scale), [255, 190, 90], 90 * scale, 5 * scale, 0.45, 0, air); + this.burst(x, y, Math.round(6 * scale), [255, 120, 40], 60 * scale, 7 * scale, 0.6, 0, air); + for (let i = 0; i < Math.round(5 * scale); i++) { + const a = Math.random() * Math.PI * 2; + const l = 0.9 + Math.random() * 0.8; + this.add({ x: x + Math.cos(a) * 4 * scale, y: y + Math.sin(a) * 4 * scale, vx: Math.cos(a) * 15, vy: Math.sin(a) * 15 - 8, life: l, max: l, size: 6 * scale, grow: 10 * scale, r: 60, g: 55, b: 50, a: 0.55, kind: 1, drag: 0.95, air }); + } + this.burst(x, y, Math.round(4 * scale), [80, 70, 60], 140 * scale, 2.2, 0.5, 4, air); + if (scorch && !air) this.decals.push({ x, y, r: 10 * scale + 6, life: 25, max: 25, kind: 0, rot: Math.random() * 6 }); + } + + splat(x: number, y: number, scale: number, c: C3, decal: number): void { + this.burst(x, y, Math.round(10 * scale), c, 70 * scale, 3.5 * scale, 0.5, 5); + this.add({ x, y, life: 0.3, max: 0.3, size: 4 * scale, grow: 40 * scale, r: c[0], g: c[1], b: c[2], a: 0.6, kind: 2 }); + this.decals.push({ x, y, r: 7 * scale + 4, life: 18, max: 18, kind: decal, rot: Math.random() * 6 }); + } + + /** Feed simulation events. */ + onEvents(events: GameEvent[], g: Game): void { + for (const ev of events) this.onEvent(ev, g); + } + + private onEvent(ev: GameEvent, g: Game): void { + switch (ev.k) { + case 'fire': { + const c = FX_COLORS[ev.fx] ?? [255, 255, 255]; + const dx = ev.tx - ev.x; + const dy = ev.ty - ev.y; + const d = Math.sqrt(dx * dx + dy * dy) || 1; + const src = g.get(ev.id); + const r = src ? src.r : 8; + const mx = ev.x + (dx / d) * r * 0.9; + const my = ev.y + (dy / d) * r * 0.9; + const air = !!src?.air; + switch (ev.fx) { + case 'rifle': + case 'autocannon': + this.beams.push({ x0: mx, y0: my, x1: ev.tx + (Math.random() - 0.5) * 6, y1: ev.ty + (Math.random() - 0.5) * 6, color: 'rgba(255,240,180,0.95)', glow: 'rgba(255,200,90,0.35)', width: ev.fx === 'autocannon' ? 1.8 : 1.1, life: 0.06, max: 0.06, air: air || ev.air }); + this.add({ x: mx, y: my, life: 0.06, max: 0.06, size: ev.fx === 'autocannon' ? 6 : 4, r: c[0], g: c[1], b: c[2], air }); + break; + case 'cannon': + case 'boulder': + this.add({ x: mx, y: my, life: 0.12, max: 0.12, size: ev.fx === 'boulder' ? 14 : 9, r: 255, g: 220, b: 150, air }); + this.burst(mx, my, 5, [120, 110, 100], 40, 4, 0.5, 1, air); + this.beams.push({ x0: mx, y0: my, x1: ev.tx, y1: ev.ty, color: 'rgba(255,230,170,0.8)', glow: 'rgba(255,170,80,0.3)', width: ev.fx === 'boulder' ? 2.5 : 1.6, life: 0.07, max: 0.07, air }); + break; + case 'flame': { + for (let i = 0; i < (this.lowFx ? 6 : 14); i++) { + const t = Math.random(); + const l = 0.25 + Math.random() * 0.25; + this.add({ x: mx + dx * t * 0.9, y: my + dy * t * 0.9, vx: (Math.random() - 0.5) * 30 + (dx / d) * 40, vy: (Math.random() - 0.5) * 30 + (dy / d) * 40, life: l, max: l, size: 4 + t * 7, grow: 8, r: 255, g: 120 + Math.random() * 80, b: 30, air }); + } + break; + } + case 'guns': + case 'broadside': + case 'prism': { + const col = ev.fx === 'guns' ? ['rgba(255,120,110,1)', 'rgba(255,40,30,0.4)'] : ev.fx === 'broadside' ? ['rgba(200,225,255,1)', 'rgba(80,140,255,0.45)'] : ['rgba(200,255,255,1)', 'rgba(60,220,255,0.45)']; + this.beams.push({ x0: mx, y0: my, x1: ev.tx, y1: ev.ty, color: col[0], glow: col[1], width: 1.6, life: 0.1, max: 0.1, air: air || ev.air }); + break; + } + case 'sunlance': + this.beams.push({ x0: mx, y0: my, x1: ev.tx, y1: ev.ty, color: 'rgba(255,250,210,1)', glow: 'rgba(255,200,80,0.5)', width: 2.2, life: 0.12, max: 0.12, air: ev.air }); + break; + case 'tongue': + this.beams.push({ x0: mx, y0: my, x1: ev.tx, y1: ev.ty, color: 'rgba(255,150,185,1)', glow: 'rgba(200,60,110,0.4)', width: 3, life: 0.35, max: 0.35, air: true }); + break; + case 'lockon': + this.beams.push({ x0: mx, y0: my, x1: ev.tx, y1: ev.ty, color: 'rgba(255,140,110,0.9)', glow: 'rgba(255,60,40,0.3)', width: 1, life: 0.3, max: 0.3, air: ev.air }); + this.add({ x: ev.tx, y: ev.ty, life: 0.2, max: 0.2, size: 6, r: c[0], g: c[1], b: c[2], air: ev.air }); + break; + case 'eruption': + for (let i = 1; i <= 8; i++) { + const t = i / 8; + this.add({ x: ev.x + dx * t, y: ev.y + dy * t, vy: -12, life: 0.35 + t * 0.15, max: 0.5, size: 3, r: c[0], g: c[1], b: c[2], kind: 4 }); + } + break; + case 'eyebeam': + this.beams.push({ x0: mx, y0: my, x1: ev.tx, y1: ev.ty, color: 'rgba(255,230,180,1)', glow: 'rgba(255,100,30,0.5)', width: 3, life: 0.18, max: 0.18, air }); + break; + case 'grandshot': + this.beams.push({ x0: ev.x, y0: ev.y, x1: ev.tx, y1: ev.ty, color: 'rgba(230,245,255,1)', glow: 'rgba(90,160,255,0.6)', width: 6, life: 0.5, max: 0.5, air: true }); + break; + case 'torch': + case 'starspark': + this.burst(ev.tx, ev.ty, 4, c, 50, 2, 0.2, 3); + break; + default: + break; + } + break; + } + case 'hit': { + const c = FX_COLORS[ev.fx] ?? [255, 255, 255]; + const t = g.get(ev.tid); + const air = !!t?.air; + if (t) this.flash.set(t.id, 0.08); + if (ev.shield && t) { + this.shieldHits.push({ x: t.x, y: t.y, r: t.isBuilding ? Math.max(t.tw, t.th) * 18 : t.r + 4, life: 0.25, ang: Math.random() * 6 }); + } + switch (ev.fx) { + case 'claw': + case 'maul': + case 'blade': + case 'scythe': + this.burst(ev.x, ev.y, ev.fx === 'maul' ? 8 : 4, c, 60, 2.5, 0.22, 3, air); + break; + case 'keg': + this.explosion(ev.x, ev.y, 0.9, air, true); + break; + case 'honey': + case 'eruption': + this.burst(ev.x, ev.y, 6, c, 50, 2.5, 0.35, ev.fx === 'honey' ? 5 : 4, air); + break; + case 'frost': + this.add({ x: ev.x, y: ev.y, life: 0.2, max: 0.2, size: 8, r: c[0], g: c[1], b: c[2], air }); + this.burst(ev.x, ev.y, 5, c, 50, 2, 0.3, 3, air); + break; + case 'sap': + case 'missile': + this.explosion(ev.x, ev.y, 0.6, air, false); + break; + case 'boulder': + this.explosion(ev.x, ev.y, 1.0, air, true); + break; + case 'quill': + case 'sting': + case 'glaive': + this.burst(ev.x, ev.y, 6, c, 50, 2.5, 0.3, 0, air); + break; + case 'moonorb': + case 'lantern': + this.add({ x: ev.x, y: ev.y, life: 0.2, max: 0.2, size: 10, r: c[0], g: c[1], b: c[2], air }); + this.burst(ev.x, ev.y, 5, c, 60, 2, 0.25, 0, air); + break; + case 'starbolt': + this.add({ x: ev.x, y: ev.y, life: 0.3, max: 0.3, size: 20, r: 255, g: 220, b: 140, air }); + this.add({ x: ev.x, y: ev.y, life: 0.3, max: 0.3, size: 6, grow: 60, r: 255, g: 220, b: 140, kind: 2, air }); + break; + case 'thorn': + this.burst(ev.x, ev.y, 8, [150, 130, 100], 50, 3, 0.4, 4); + break; + case 'grandshot': + this.explosion(ev.x, ev.y, 1.6, air, false); + break; + case 'rifle': + case 'autocannon': + this.burst(ev.x, ev.y, 2, c, 40, 1.5, 0.15, 3, air); + break; + default: + this.add({ x: ev.x, y: ev.y, life: 0.12, max: 0.12, size: 5, r: c[0], g: c[1], b: c[2], air }); + } + break; + } + case 'splash': { + const c = FX_COLORS[ev.fx] ?? [255, 255, 255]; + if (ev.fx === 'burst') { + this.splat(ev.x, ev.y, 2.2, [240, 190, 60], 1); + this.add({ x: ev.x, y: ev.y, life: 0.45, max: 0.45, size: 10, grow: ev.r * 2.5, r: 255, g: 220, b: 110, a: 0.8, kind: 2 }); + } else if (ev.fx === 'honey') { + this.splat(ev.x, ev.y, 1.6, [240, 170, 40], 1); + } else if (ev.fx === 'snowball') { + this.add({ x: ev.x, y: ev.y, life: 0.5, max: 0.5, size: 10, grow: ev.r * 3, r: 200, g: 240, b: 255, a: 0.9, kind: 2 }); + this.burst(ev.x, ev.y, 24, [210, 240, 255], 110, 3.5, 0.7); + } else if (ev.fx === 'eyebeam') { + this.burst(ev.x, ev.y, 10, c, 50, 4, 0.45, 0); + } else if (ev.fx === 'flame') { + this.burst(ev.x, ev.y, 6, c, 40, 5, 0.35, 0); + } + break; + } + case 'death': { + const def = DEFS[ev.def]; + const race = def?.race; + const scale = Math.max(0.6, ev.r / 12); + if (ev.building) { + const n = Math.max(2, Math.round(ev.r / 14)); + for (let i = 0; i < n; i++) { + const ox = (Math.random() - 0.5) * ev.r * 1.6; + const oy = (Math.random() - 0.5) * ev.r * 1.6; + this.explosion(ev.x + ox, ev.y + oy, 1.2 + Math.random(), false, true); + } + if (race === 'horde') this.splat(ev.x, ev.y, 3, [200, 140, 50], 1); + } else if (def?.kind === 'mineral' || def?.kind === 'geyser') { + this.burst(ev.x, ev.y, 10, [120, 200, 255], 50, 3, 0.6, 4); + } else if (race === 'horde') { + this.splat(ev.x, ev.y, scale, [230, 170, 50], 1); + this.burst(ev.x, ev.y, 5, [200, 140, 50], 50 * scale, 3, 0.5, 5, ev.air); + } else if (race === 'starborn') { + this.add({ x: ev.x, y: ev.y, life: 0.4, max: 0.4, size: 14 * scale, r: 150, g: 220, b: 255, air: ev.air }); + this.add({ x: ev.x, y: ev.y, life: 0.4, max: 0.4, size: 4, grow: 60 * scale, r: 140, g: 210, b: 255, kind: 2, air: ev.air }); + this.burst(ev.x, ev.y, 12, [150, 220, 255], 70 * scale, 2.5, 0.7, 0, ev.air); + if (def?.attrs.includes('mech')) this.explosion(ev.x, ev.y, scale * 0.8, ev.air, !ev.air); + } else if (def?.attrs.includes('mech')) { + this.explosion(ev.x, ev.y, scale, ev.air, !ev.air); + } else { + this.splat(ev.x, ev.y, scale * 0.8, [150, 30, 30], 2); + } + break; + } + case 'blink': + this.burst(ev.x0, ev.y0, 12, [140, 200, 255], 60, 3, 0.4); + this.burst(ev.x1, ev.y1, 12, [140, 200, 255], 60, 3, 0.4); + this.beams.push({ x0: ev.x0, y0: ev.y0, x1: ev.x1, y1: ev.y1, color: 'rgba(170,220,255,0.6)', glow: 'rgba(100,160,255,0.3)', width: 3, life: 0.2, max: 0.2, air: false }); + break; + case 'heal': + this.beams.push({ x0: ev.x, y0: ev.y, x1: ev.tx, y1: ev.ty, color: 'rgba(160,255,170,0.8)', glow: 'rgba(80,255,120,0.35)', width: 1.4, life: 0.2, max: 0.2, air: false }); + this.burst(ev.tx, ev.ty, 2, [120, 255, 140], 20, 2, 0.4); + break; + case 'spark': + this.burst(ev.x, ev.y, 3, [255, 220, 120], 70, 1.5, 0.25, 3); + break; + case 'built': { + const e = g.get(ev.id); + if (e) this.add({ x: e.x, y: e.y, life: 0.6, max: 0.6, size: 10, grow: e.r * 3, r: 200, g: 255, b: 220, a: 0.6, kind: 2 }); + break; + } + case 'placed': { + const def = DEFS[ev.def]; + if (def?.race === 'starborn') { + this.add({ x: ev.x, y: ev.y, life: 0.6, max: 0.6, size: 30, r: 140, g: 210, b: 255 }); + this.burst(ev.x, ev.y, 16, [140, 210, 255], 80, 3, 0.6); + } else if (def?.race === 'horde') { + this.splat(ev.x, ev.y, 1.5, [200, 140, 50], 1); + } else { + this.burst(ev.x, ev.y, 8, [180, 170, 150], 40, 3, 0.6, 1); + } + break; + } + case 'trained': { + const def = DEFS[ev.def]; + if (def?.race === 'starborn') this.add({ x: ev.x, y: ev.y, life: 0.45, max: 0.45, size: 18, r: 150, g: 220, b: 255 }); + else if (def?.race === 'horde') this.burst(ev.x, ev.y, 8, [240, 190, 80], 50, 2.5, 0.4, 5); + break; + } + case 'ability': { + if (ev.ab === 'honeyrush') this.burst(ev.x, ev.y, 6, [255, 80, 60], 30, 2, 0.4); + else if (ev.ab === 'royaljelly') this.burst(ev.x, ev.y, 14, [255, 210, 90], 60, 4, 0.7, 4); + else if (ev.ab === 'haste') this.burst(ev.tx, ev.ty, 14, [255, 220, 120], 60, 3, 0.7); + else if (ev.ab === 'bubble') this.add({ x: ev.tx, y: ev.ty, life: 0.5, max: 0.5, size: 6, grow: 50, r: 120, g: 200, b: 255, kind: 2 }); + break; + } + } + } + + update(dt: number): void { + const parts = this.parts; + let w = 0; + for (let i = 0; i < parts.length; i++) { + const p = parts[i]; + p.life -= dt; + if (p.life <= 0) continue; + p.x += p.vx * dt; + p.y += p.vy * dt; + const d = Math.pow(p.drag, dt * 60); + p.vx *= d; + p.vy *= d; + p.size += p.grow * dt; + parts[w++] = p; + } + parts.length = w; + let bw = 0; + for (const b of this.beams) { + b.life -= dt; + if (b.life > 0) this.beams[bw++] = b; + } + this.beams.length = bw; + let dw = 0; + for (const d of this.decals) { + d.life -= dt; + if (d.life > 0) this.decals[dw++] = d; + } + this.decals.length = dw; + if (this.decals.length > 200) this.decals.splice(0, this.decals.length - 200); + for (const [id, t] of this.flash) { + if (t - dt <= 0) this.flash.delete(id); + else this.flash.set(id, t - dt); + } + let sw = 0; + for (const s of this.shieldHits) { + s.life -= dt; + if (s.life > 0) this.shieldHits[sw++] = s; + } + this.shieldHits.length = sw; + } + + drawDecals(ctx: CanvasRenderingContext2D): void { + for (const d of this.decals) { + const a = Math.min(1, d.life / (d.max * 0.3)) * (d.kind === 0 ? 0.55 : 0.5); + if (d.kind === 0) { + const g = ctx.createRadialGradient(d.x, d.y, 0, d.x, d.y, d.r); + g.addColorStop(0, `rgba(15,10,6,${a})`); + g.addColorStop(0.6, `rgba(25,18,10,${a * 0.6})`); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + } else if (d.kind === 1) { + ctx.fillStyle = `rgba(150,100,30,${a})`; + } else { + ctx.fillStyle = `rgba(90,15,15,${a})`; + } + ctx.beginPath(); + if (d.kind === 0) ctx.arc(d.x, d.y, d.r, 0, Math.PI * 2); + else { + for (let i = 0; i < 5; i++) { + const a2 = d.rot + i * 1.3; + const rr = d.r * (0.35 + ((i * 37) % 10) / 20); + ctx.moveTo(d.x + Math.cos(a2) * d.r * 0.5 + rr, d.y + Math.sin(a2) * d.r * 0.5); + ctx.arc(d.x + Math.cos(a2) * d.r * 0.5, d.y + Math.sin(a2) * d.r * 0.5, rr, 0, Math.PI * 2); + } + } + ctx.fill(); + } + } + + /** Draw particles & beams for one layer (ground or air). */ + draw(ctx: CanvasRenderingContext2D, air: boolean): void { + // beams + ctx.lineCap = 'round'; + for (const b of this.beams) { + if (b.air !== air) continue; + const a = b.life / b.max; + ctx.globalAlpha = a; + ctx.strokeStyle = b.glow; + ctx.lineWidth = b.width * 3.5; + ctx.beginPath(); + ctx.moveTo(b.x0, b.y0); + ctx.lineTo(b.x1, b.y1); + ctx.stroke(); + ctx.strokeStyle = b.color; + ctx.lineWidth = b.width; + ctx.stroke(); + } + ctx.globalAlpha = 1; + // smoke & chunks (normal blending) + for (const p of this.parts) { + if (p.air !== air || (p.kind !== 1 && p.kind !== 4 && p.kind !== 5)) continue; + const t = p.life / p.max; + if (p.kind === 1) { + ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${p.a * t})`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.fill(); + } else if (p.kind === 5) { + ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${Math.min(1, t * 2)})`; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size * 0.5, 0, Math.PI * 2); + ctx.fill(); + } else { + ctx.fillStyle = `rgba(${p.r},${p.g},${p.b},${Math.min(1, t * 2)})`; + ctx.fillRect(p.x - p.size / 2, p.y - p.size / 2, p.size, p.size); + } + } + // glowing particles (additive) + ctx.globalCompositeOperation = 'lighter'; + for (const p of this.parts) { + if (p.air !== air || p.kind === 1 || p.kind === 4 || p.kind === 5) continue; + const t = p.life / p.max; + if (p.kind === 2) { + ctx.strokeStyle = `rgba(${p.r},${p.g},${p.b},${p.a * t * 0.8})`; + ctx.lineWidth = 2 + 3 * t; + ctx.beginPath(); + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2); + ctx.stroke(); + } else if (p.kind === 3) { + ctx.strokeStyle = `rgba(${p.r},${p.g},${p.b},${t})`; + ctx.lineWidth = p.size * 0.6; + ctx.beginPath(); + ctx.moveTo(p.x, p.y); + ctx.lineTo(p.x - p.vx * 0.04, p.y - p.vy * 0.04); + ctx.stroke(); + } else { + const s = p.size * (0.5 + t * 0.5); + ctx.globalAlpha = p.a * t; + ctx.drawImage(glowSprite(p.r, p.g, p.b), p.x - s, p.y - s, s * 2, s * 2); + ctx.globalAlpha = 1; + } + } + // shield hits + if (!air) { + for (const s of this.shieldHits) { + const t = s.life / 0.25; + ctx.strokeStyle = `rgba(120,200,255,${t * 0.7})`; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.arc(s.x, s.y, s.r, s.ang, s.ang + 2.2); + ctx.stroke(); + ctx.fillStyle = `rgba(80,160,255,${t * 0.12})`; + ctx.beginPath(); + ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.globalCompositeOperation = 'source-over'; + } +} diff --git a/gh-pages/bear-war/src/render/minimap.ts b/gh-pages/bear-war/src/render/minimap.ts new file mode 100644 index 00000000..9c563e31 --- /dev/null +++ b/gh-pages/bear-war/src/render/minimap.ts @@ -0,0 +1,142 @@ +import { TEAM_COLORS, TILE } from '../game/constants.ts'; +import type { Game } from '../game/game.ts'; +import type { Camera } from './camera.ts'; +import type { TerrainRenderer } from './terrain.ts'; + +export interface Ping { + x: number; + y: number; + t: number; + color: string; +} + +export class Minimap { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + base: HTMLCanvasElement | null = null; + fog = document.createElement('canvas'); + pings: Ping[] = []; + size = 160; + dpr = 1; + private worldW = 1; + private worldH = 1; + private fogVer = -1; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d')!; + } + + setGame(g: Game, terrain: TerrainRenderer): void { + this.base = terrain.minimapImage(2); + this.worldW = g.map.w * TILE; + this.worldH = g.map.h * TILE; + this.fog.width = g.map.w; + this.fog.height = g.map.h; + this.fogVer = -1; + } + + resize(cssSize: number): void { + this.size = cssSize; + this.dpr = Math.min(2, window.devicePixelRatio || 1); + this.canvas.width = Math.round(cssSize * this.dpr); + this.canvas.height = Math.round(cssSize * this.dpr); + this.canvas.style.width = cssSize + 'px'; + this.canvas.style.height = cssSize + 'px'; + } + + /** Map screen-space offset inside the minimap to world coordinates. */ + toWorld(ox: number, oy: number): { x: number; y: number } { + const s = this.scale(); + const { offX, offY } = this.offsets(); + return { x: (ox - offX) / s, y: (oy - offY) / s }; + } + + private scale(): number { + return this.size / Math.max(this.worldW, this.worldH); + } + private offsets(): { offX: number; offY: number } { + const s = this.scale(); + return { offX: (this.size - this.worldW * s) / 2, offY: (this.size - this.worldH * s) / 2 }; + } + + draw(g: Game, team: number, player: number, cam: Camera, dt: number): void { + const ctx = this.ctx; + const s = this.scale(); + const { offX, offY } = this.offsets(); + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + ctx.fillStyle = '#000'; + ctx.fillRect(0, 0, this.size, this.size); + ctx.translate(offX, offY); + ctx.scale(s, s); + ctx.imageSmoothingEnabled = true; + if (this.base) ctx.drawImage(this.base, 0, 0, this.worldW, this.worldH); + // comb + const cr = g.comb; + ctx.fillStyle = 'rgba(205,145,40,0.75)'; + for (let ty = 0; ty < cr.h; ty++) { + for (let tx = 0; tx < cr.w; tx++) { + if (cr.grid[ty * cr.w + tx] && (team < 0 || g.vision.explored[team][ty * cr.w + tx])) ctx.fillRect(tx * TILE, ty * TILE, TILE, TILE); + } + } + const minDot = 2.2 / s; + for (const e of g.entities) { + if (!e.alive || e.inside || e.def.kind === 'cub') continue; + const own = e.owner >= 0 && team >= 0 && g.players[e.owner].team === team; + if (!own && team >= 0) { + if (e.isResource) { + if (!g.vision.exploredAt(team, e.x, e.y)) continue; + } else if (e.isBuilding) { + if (!g.vision.visibleAt(team, e.x, e.y) && !g.vision.visibleAt(team, e.x0 + 4, e.y0 + 4)) continue; + } else { + if (!g.vision.visibleAt(team, e.x, e.y)) continue; + if (e.def.cloaked && !g.vision.detectedAt(team, e.x, e.y)) continue; + } + } + if (e.isResource) { + ctx.fillStyle = e.def.kind === 'mineral' ? '#7fdcff' : '#4fd06a'; + ctx.fillRect(e.x0, e.y0, e.x1 - e.x0, e.y1 - e.y0); + continue; + } + const col = e.owner === player ? '#3dff5a' : (TEAM_COLORS[g.players[e.owner]?.color ?? 0] ?? TEAM_COLORS[0]).main; + ctx.fillStyle = col; + if (e.isBuilding) { + ctx.fillRect(e.x0, e.y0, e.x1 - e.x0, e.y1 - e.y0); + } else { + const r = Math.max(minDot, e.r * 1.2); + ctx.fillRect(e.x - r, e.y - r, r * 2, r * 2); + } + } + // fog + if (team >= 0) { + const v = g.vision; + if (this.fogVer !== v.version) { + this.fogVer = v.version; + const fctx = this.fog.getContext('2d')!; + const img = fctx.createImageData(v.w, v.h); + const vis = v.vis[team]; + const exp = v.explored[team]; + for (let i = 0; i < vis.length; i++) img.data[i * 4 + 3] = vis[i] ? 0 : exp[i] ? 110 : 220; + fctx.putImageData(img, 0, 0); + } + ctx.drawImage(this.fog, 0, 0, this.worldW, this.worldH); + } + // camera rect + ctx.strokeStyle = 'rgba(255,255,255,0.9)'; + ctx.lineWidth = 1.5 / s; + ctx.strokeRect(cam.x, cam.y, cam.viewW, cam.viewH); + // pings + for (const p of this.pings) { + p.t -= dt; + const k = Math.max(0, p.t / 2.5); + ctx.strokeStyle = p.color; + ctx.globalAlpha = Math.min(1, k * 2); + ctx.lineWidth = 2 / s; + ctx.beginPath(); + ctx.arc(p.x, p.y, (1 - (p.t % 0.8) / 0.8) * 30 / s + 4 / s, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.globalAlpha = 1; + this.pings = this.pings.filter((p) => p.t > 0); + } +} diff --git a/gh-pages/bear-war/src/render/noise.ts b/gh-pages/bear-war/src/render/noise.ts new file mode 100644 index 00000000..da443c54 --- /dev/null +++ b/gh-pages/bear-war/src/render/noise.ts @@ -0,0 +1,72 @@ +// Visual-only noise helpers (rendering doesn't need to be deterministic across engines). + +export function hash2(x: number, y: number, seed: number): number { + let h = (x * 374761393 + y * 668265263 + seed * 2246822519) | 0; + h = Math.imul(h ^ (h >>> 13), 1274126177); + h ^= h >>> 16; + return (h >>> 0) / 4294967296; +} + +function smooth(t: number): number { + return t * t * (3 - 2 * t); +} + +export function vnoise(x: number, y: number, seed: number): number { + const xi = Math.floor(x); + const yi = Math.floor(y); + const xf = smooth(x - xi); + const yf = smooth(y - yi); + const a = hash2(xi, yi, seed); + const b = hash2(xi + 1, yi, seed); + const c = hash2(xi, yi + 1, seed); + const d = hash2(xi + 1, yi + 1, seed); + return a + (b - a) * xf + (c - a) * yf + (a - b - c + d) * xf * yf; +} + +export function fbm(x: number, y: number, seed: number, oct = 3): number { + let s = 0; + let a = 0.5; + let f = 1; + let norm = 0; + for (let i = 0; i < oct; i++) { + s += vnoise(x * f, y * f, seed + i * 17) * a; + norm += a; + a *= 0.5; + f *= 2.03; + } + return s / norm; +} + +/** Small deterministic PRNG for procedural art. */ +export function mulberry(seed: number): () => number { + let s = seed >>> 0; + return () => { + s = (s + 0x6d2b79f5) | 0; + let t = Math.imul(s ^ (s >>> 15), s | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +export type RGB = [number, number, number]; + +export function hex(c: string): RGB { + const n = parseInt(c.slice(1), 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +export function rgb(c: RGB, a = 1): string { + return a >= 1 + ? `rgb(${c[0] | 0},${c[1] | 0},${c[2] | 0})` + : `rgba(${c[0] | 0},${c[1] | 0},${c[2] | 0},${a})`; +} + +export function mix(a: RGB, b: RGB, t: number): RGB { + return [a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t]; +} + +export function shade(c: string, amt: number): string { + const [r, g, b] = hex(c); + const f = (v: number) => Math.max(0, Math.min(255, amt >= 0 ? v + (255 - v) * amt : v * (1 + amt))); + return rgb([f(r), f(g), f(b)]); +} diff --git a/gh-pages/bear-war/src/render/renderer.ts b/gh-pages/bear-war/src/render/renderer.ts new file mode 100644 index 00000000..e6e808a7 --- /dev/null +++ b/gh-pages/bear-war/src/render/renderer.ts @@ -0,0 +1,1057 @@ +// Draws the game world: terrain, structures, units, projectiles, effects, fog of war +// and world-space UI (selection rings, health bars, rally points, placement ghost). + +import { cargoUsed } from '../game/systems/cargo.ts'; +import { TEAM_COLORS, TILE } from '../game/constants.ts'; +import { DEFS, switchingMode } from '../game/data/index.ts'; +import type { EntityDef } from '../game/data/types.ts'; +import type { Entity } from '../game/entity.ts'; +import type { Game } from '../game/game.ts'; +import { maxHp, maxShields } from '../game/stats.ts'; +import type { AreaEffect, Projectile } from '../game/types.ts'; +import { Camera } from './camera.ts'; +import { Effects, glowSprite } from './effects.ts'; +import { SpriteCache, teamPal } from './sprites.ts'; +import { TerrainRenderer } from './terrain.ts'; +import type { ArtDef } from './art/index.ts'; + +export interface Marker { + x: number; + y: number; + t: number; + color: string; +} + +export interface Placement { + def: EntityDef; + tx: number; + ty: number; + ok: boolean; + bad: boolean[]; +} + +export interface ViewState { + player: number; + team: number; + selection: Entity[]; + hover: Entity | null; + placement: Placement | null; + showPower: boolean; + dragBox: { x0: number; y0: number; x1: number; y1: number } | null; + markers: Marker[]; + showAllBars: boolean; + /** world-space range circle for targeting */ + rangeRing: { x: number; y: number; r: number } | null; +} + +interface Memory { + def: EntityDef; + owner: number; + x: number; + y: number; + tx: number; + ty: number; + built: boolean; + progress: number; +} + +interface RState { + walk: number; + lx: number; + ly: number; +} + +export class Renderer { + canvas: HTMLCanvasElement; + ctx: CanvasRenderingContext2D; + cam = new Camera(); + dpr = 1; + sprites: SpriteCache; + fx = new Effects(); + terrain: TerrainRenderer | null = null; + time = 0; + private fogCanvas = document.createElement('canvas'); + private fogVer = -1; + private fogTeam = -2; + private memory = new Map(); + private rstate = new Map(); + private game: Game | null = null; + maxDpr = 2; + + constructor(canvas: HTMLCanvasElement) { + this.canvas = canvas; + this.ctx = canvas.getContext('2d', { alpha: false })!; + this.sprites = new SpriteCache(2, 1.5); + } + + setGame(g: Game): void { + this.game = g; + this.terrain = new TerrainRenderer(g.map); + this.cam.worldW = g.map.w * TILE; + this.cam.worldH = g.map.h * TILE; + this.memory.clear(); + this.rstate.clear(); + this.fogCanvas.width = g.map.w; + this.fogCanvas.height = g.map.h; + this.fogVer = -1; + this.fx = new Effects(); + } + + resize(cssW: number, cssH: number): void { + this.dpr = Math.min(this.maxDpr, window.devicePixelRatio || 1); + this.canvas.width = Math.round(cssW * this.dpr); + this.canvas.height = Math.round(cssH * this.dpr); + this.canvas.style.width = cssW + 'px'; + this.canvas.style.height = cssH + 'px'; + this.cam.w = cssW; + this.cam.h = cssH; + this.cam.clamp(); + } + + /** Can the viewer see entity e right now? Returns 0 hidden, 1 visible, 2 cloaked-shimmer. */ + visibility(g: Game, e: Entity, team: number): number { + if (e.inside) return 0; + if (team < 0) return 1; + if (e.owner >= 0 && g.players[e.owner].team === team) return 1; + if (e.isBuilding || e.isResource) { + const v = g.vision; + return v.visibleAt(team, e.x, e.y) || v.visibleAt(team, e.x0 + 4, e.y0 + 4) || v.visibleAt(team, e.x1 - 4, e.y1 - 4) || + v.visibleAt(team, e.x1 - 4, e.y0 + 4) || v.visibleAt(team, e.x0 + 4, e.y1 - 4) ? 1 : 0; + } + if (!g.vision.visibleAt(team, e.x, e.y)) return 0; + if (e.def.cloaked && !g.vision.detectedAt(team, e.x, e.y)) return 2; + return 1; + } + + private updateMemory(g: Game, team: number): void { + if (team < 0) return; + for (const e of g.entities) { + if (!e.alive || !(e.isBuilding || e.isResource)) continue; + if (e.owner >= 0 && g.players[e.owner].team === team) continue; + if (this.visibility(g, e, team)) { + this.memory.set(e.id, { def: e.def, owner: e.owner, x: e.x, y: e.y, tx: e.tx, ty: e.ty, built: e.built, progress: e.progress }); + } + } + // forget remembered structures whose location is visible but which are gone + for (const [id, m] of this.memory) { + const e = g.get(id); + if (e) continue; + if (g.vision.visibleAt(team, m.x, m.y)) this.memory.delete(id); + } + } + + draw(g: Game, alpha: number, v: ViewState, dt: number): void { + this.time += dt; + const ctx = this.ctx; + const cam = this.cam; + const k = this.dpr * cam.zoom; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.fillStyle = '#07080a'; + ctx.fillRect(0, 0, this.canvas.width, this.canvas.height); + this.wk = k; + this.wox = -cam.x * k; + this.woy = -cam.y * k; + ctx.setTransform(k, 0, 0, k, this.wox, this.woy); + ctx.imageSmoothingEnabled = true; + if (!this.terrain) return; + this.terrain.draw(ctx, cam, g.comb, 6); + this.fx.update(dt); + this.fx.drawDecals(ctx); + + const team = v.team; + if (g.tick % 4 === 0 || this.memory.size === 0) this.updateMemory(g, team); + const vx0 = cam.x - 120; + const vy0 = cam.y - 120; + const vx1 = cam.x + cam.viewW + 120; + const vy1 = cam.y + cam.viewH + 120; + const inView = (x: number, y: number) => x > vx0 && x < vx1 && y > vy0 && y < vy1; + + // power fields + if (v.showPower) this.drawPowerFields(g, v.player); + + // area effects on the ground + for (const fx of g.effects) this.drawArea(fx, team, g); + + // remembered (fogged) structures + for (const [id, m] of this.memory) { + if (!inView(m.x, m.y)) continue; + const e = g.get(id); + if (e && this.visibility(g, e, team)) continue; + this.drawStructureSprite(m.def, m.owner < 0 ? -1 : g.players[m.owner].color, m.x, m.y, m.built ? 1 : m.progress, 0.55, null); + } + + const sel = new Set(v.selection.map((e) => e.id)); + const visible: Entity[] = []; + const air: Entity[] = []; + for (const e of g.entities) { + if (!e.alive) continue; + const ix = e.px + (e.x - e.px) * alpha; + const iy = e.py + (e.y - e.py) * alpha; + if (!inView(ix, iy)) continue; + const vis = this.visibility(g, e, team); + if (!vis) continue; + if (e.air) air.push(e); + else visible.push(e); + } + + // selection rings (ground) + for (const e of visible) if (sel.has(e.id) || v.hover === e) this.drawSelRing(g, e, alpha, v, sel.has(e.id)); + + // structures & resources + for (const e of visible) { + if (!(e.isBuilding || e.isResource)) continue; + this.drawStructure(g, e, team); + } + // ground units + for (const e of visible) { + if (e.isBuilding || e.isResource) continue; + this.drawUnit(g, e, alpha, team); + } + this.drawProjectiles(g, alpha, false, team); + this.fx.draw(ctx, false); + + // air units with shadows + for (const e of air) { + const ix = e.px + (e.x - e.px) * alpha; + const iy = e.py + (e.y - e.py) * alpha; + ctx.globalAlpha = 0.3; + ctx.drawImage(glowSprite(0, 0, 0), ix - e.r + 10, iy - e.r * 0.8 + 16, e.r * 2, e.r * 1.6); + ctx.globalAlpha = 1; + } + for (const e of air) if (sel.has(e.id) || v.hover === e) this.drawSelRing(g, e, alpha, v, sel.has(e.id)); + for (const e of air) this.drawUnit(g, e, alpha, team); + this.drawProjectiles(g, alpha, true, team); + this.fx.draw(ctx, true); + + // fog of war + if (team >= 0) this.drawFog(g, team); + + // overlays + for (const e of [...visible, ...air]) { + const show = v.showAllBars || sel.has(e.id) || v.hover === e || this.damaged(g, e); + if (show && (e.isUnit || e.isBuilding) && e.def.kind !== 'cub') this.drawBars(g, e, alpha, sel.has(e.id), v.player); + } + this.drawRallies(g, v); + if (v.placement) this.drawPlacement(g, v.placement, v.player); + if (v.rangeRing) { + ctx.strokeStyle = 'rgba(120,220,255,0.5)'; + ctx.setLineDash([6, 6]); + ctx.lineWidth = 1.5 / cam.zoom; + ctx.beginPath(); + ctx.arc(v.rangeRing.x, v.rangeRing.y, v.rangeRing.r, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + this.drawMarkers(v.markers, dt); + + // screen-space overlays + ctx.setTransform(this.dpr, 0, 0, this.dpr, 0, 0); + if (v.dragBox) { + const b = v.dragBox; + ctx.strokeStyle = 'rgba(80,255,120,0.9)'; + ctx.fillStyle = 'rgba(80,255,120,0.08)'; + ctx.lineWidth = 1; + const x = Math.min(b.x0, b.x1); + const y = Math.min(b.y0, b.y1); + ctx.fillRect(x, y, Math.abs(b.x1 - b.x0), Math.abs(b.y1 - b.y0)); + ctx.strokeRect(x + 0.5, y + 0.5, Math.abs(b.x1 - b.x0), Math.abs(b.y1 - b.y0)); + } + } + + private damaged(g: Game, e: Entity): boolean { + if (e.isResource) return false; + const p = g.players[e.owner]; + return e.hp < maxHp(e, p) - 0.5 || e.shields < maxShields(e) - 0.5 || (e.isBuilding && !e.built); + } + + private colorOf(g: Game, owner: number): number { + return owner < 0 ? -1 : g.players[owner].color; + } + + // ------------------------------------------------------------------ units + + private drawUnit(g: Game, e: Entity, alpha: number, team: number): void { + const ctx = this.ctx; + const ix = e.px + (e.x - e.px) * alpha; + const iy = e.py + (e.y - e.py) * alpha; + const color = this.colorOf(g, e.owner); + const art = this.sprites.art(e.def); + let rs = this.rstate.get(e.id); + if (!rs) { + rs = { walk: 0, lx: ix, ly: iy }; + this.rstate.set(e.id, rs); + } + const moved = Math.abs(ix - rs.lx) + Math.abs(iy - rs.ly); + if (moved < 40) rs.walk += moved; + rs.lx = ix; + rs.ly = iy; + const frames = art.frames ?? 1; + let frame = 0; + if (frames > 1) { + if (e.def.kind === 'cub' || e.def.kind === 'cocoon' || e.air) frame = Math.floor(this.time * 6 + e.id) % frames; + else frame = Math.floor(rs.walk / Math.max(4, e.r * 0.9)) % frames; + } + const vis = this.visibility(g, e, team); + const own = team >= 0 && e.owner >= 0 && g.players[e.owner].team === team; + let a = 1; + if (e.def.cloaked) a = vis === 2 ? 0.14 : own ? 0.5 : 0.85; + const bob = e.air ? Math.sin(this.time * 2 + e.id) * 1.5 : 0; + ctx.globalAlpha = a; + if (e.morphTo && e.def.kind === 'unit' && !switchingMode(e.morphTo, e.def)) { + // cocoon + this.drawCocoon(ix, iy, e.r * 1.2, e.morphT / Math.max(1, e.morphTotal)); + ctx.globalAlpha = 1; + return; + } + // ground shadow for ground units + if (!e.air && e.def.kind === 'unit') { + ctx.globalAlpha = 0.28 * a; + ctx.drawImage(glowSprite(0, 0, 0), ix - e.r * 1.1 + 2, iy - e.r * 0.9 + 3, e.r * 2.2, e.r * 1.9); + ctx.globalAlpha = a; + } + const s = this.sprites.get(e.def, color, 'body', frame); + if (s) this.blit(s.canvas, s.w, s.h, ix, iy + bob, e.def.kind === 'cub' || e.def.kind === 'cocoon' ? 0 : e.angle); + const t = art.turret ? this.sprites.get(e.def, color, 'turret', frame) : null; + if (t) this.blit(t.canvas, t.w, t.h, ix, iy + bob, e.turret); + if (e.carry > 0 && e.def.worker) this.drawCargo(e, ix, iy); + if (art.overlay) { + ctx.save(); + ctx.translate(ix, iy + bob); + ctx.rotate(e.angle); + art.overlay(ctx, teamPal(color < 0 ? 0 : color), { t: this.time, built: true, powered: true, working: g.tick - e.lastAttackTick < 10, seed: e.id }, e.def); + ctx.restore(); + } + const fl = this.fx.flash.get(e.id); + if (fl) { + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = Math.min(0.5, fl * 6); + ctx.drawImage(glowSprite(255, 255, 255), ix - e.r, iy - e.r, e.r * 2, e.r * 2); + ctx.globalCompositeOperation = 'source-over'; + } + if (e.rushT > 0) { + ctx.globalAlpha = 0.25 + 0.15 * Math.sin(this.time * 20); + ctx.drawImage(glowSprite(255, 60, 40), ix - e.r * 1.4, iy - e.r * 1.4, e.r * 2.8, e.r * 2.8); + } + if (e.def.weapons?.[0]?.zone && (own || vis)) { + ctx.globalAlpha = 0.35; + ctx.strokeStyle = Renderer.colorCss(color < 0 ? 0 : color); + ctx.lineWidth = 1.5 / this.cam.zoom; + ctx.setLineDash([6, 5]); + ctx.beginPath(); + ctx.arc(e.zoneX, e.zoneY, e.def.weapons[0].zone * TILE, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + } + if (e.bubble > 0) { + ctx.globalAlpha = 0.5; + ctx.strokeStyle = '#7fd0ff'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(ix, iy, e.r + 5, 0, Math.PI * 2); + ctx.stroke(); + } + if (switchingMode(e.morphTo, e.def)) { + ctx.globalAlpha = 0.8; + ctx.strokeStyle = '#ffd36b'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(ix, iy, e.r + 3, this.time * 4, this.time * 4 + 2 * Math.PI * (e.morphT / Math.max(1, e.morphTotal))); + ctx.stroke(); + } + ctx.globalAlpha = 1; + } + + /** world transform (scale and offset) of the current frame */ + private wk = 1; + private wox = 0; + private woy = 0; + + /** Draw a sprite centred at (x,y) rotated by ang, without save/restore (hot path). */ + private blit(c: HTMLCanvasElement, w: number, h: number, x: number, y: number, ang: number): void { + const ctx = this.ctx; + if (ang === 0) { + ctx.drawImage(c, x - w / 2, y - h / 2, w, h); + return; + } + const k = this.wk; + const cs = Math.cos(ang) * k; + const sn = Math.sin(ang) * k; + ctx.setTransform(cs, sn, -sn, cs, x * k + this.wox, y * k + this.woy); + ctx.drawImage(c, -w / 2, -h / 2, w, h); + ctx.setTransform(k, 0, 0, k, this.wox, this.woy); + } + + private drawCargo(e: Entity, x: number, y: number): void { + const ctx = this.ctx; + const ox = x + Math.cos(e.angle + 2.6) * e.r * 0.6; + const oy = y + Math.sin(e.angle + 2.6) * e.r * 0.6; + if (e.carryKind === 1) { + ctx.fillStyle = '#7fe3ff'; + ctx.strokeStyle = '#1d6f8f'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.moveTo(ox, oy - 4); + ctx.lineTo(ox + 3, oy); + ctx.lineTo(ox, oy + 4); + ctx.lineTo(ox - 3, oy); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + } else if (e.carryKind === 2) { + ctx.fillStyle = '#5fdc6a'; + ctx.strokeStyle = '#1f6a2a'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.arc(ox, oy, 3, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + + private drawCocoon(x: number, y: number, r: number, p: number): void { + const ctx = this.ctx; + const pulse = 1 + Math.sin(this.time * 5) * 0.05; + const gr = ctx.createRadialGradient(x - r * 0.3, y - r * 0.3, 1, x, y, r * pulse); + gr.addColorStop(0, '#f2c36b'); + gr.addColorStop(0.6, '#c98a2c'); + gr.addColorStop(1, '#5a3510'); + ctx.fillStyle = gr; + ctx.beginPath(); + ctx.ellipse(x, y, r * pulse, r * 0.85 * pulse, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(255,220,110,0.8)'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.arc(x, y, r + 3, -Math.PI / 2, -Math.PI / 2 + p * Math.PI * 2); + ctx.stroke(); + } + + // ------------------------------------------------------------------ structures + + private drawStructure(g: Game, e: Entity, team: number): void { + const color = this.colorOf(g, e.owner); + const art = this.sprites.art(e.def); + const working = !!e.queue.length || (e.def.spawnsCubs && e.cubs.length < 3) || g.tick - e.lastAttackTick < 10; + this.drawStructureSprite(e.def, color, e.x, e.y, e.built ? 1 : e.progress, 1, art, e, working); + const ctx = this.ctx; + if (e.isBuilding && e.built && e.def.needsPower && !e.powered) { + ctx.fillStyle = 'rgba(0,0,10,0.45)'; + ctx.fillRect(e.x0, e.y0, e.x1 - e.x0, e.y1 - e.y0); + ctx.fillStyle = '#ffcf4a'; + ctx.font = 'bold 14px sans-serif'; + ctx.textAlign = 'center'; + ctx.fillText('⚡', e.x, e.y + 5); + } + // turret layer (towers) + if (e.built && art.turret) { + const t = this.sprites.get(e.def, color, 'turret', 0); + if (t) this.blit(t.canvas, t.w, t.h, e.x, e.y, e.turret); + } + // burning + if (e.isBuilding && e.built && e.def.race === 'ironpaw' && e.hp < maxHp(e, g.players[e.owner]) / 3 && Math.random() < 0.3) { + this.fx.add({ x: e.x + (Math.random() - 0.5) * (e.x1 - e.x0) * 0.6, y: e.y + (Math.random() - 0.5) * (e.y1 - e.y0) * 0.6, vy: -20, vx: 5, life: 0.6, max: 0.6, size: 5, grow: 6, r: 255, g: 130, b: 40 }); + if (Math.random() < 0.5) this.fx.add({ x: e.x, y: e.y - 10, vy: -25, vx: 10, life: 1.4, max: 1.4, size: 6, grow: 10, r: 40, g: 38, b: 36, a: 0.5, kind: 1 }); + } + // geyser steam + if (e.def.kind === 'geyser' && e.amount > 0 && Math.random() < 0.08) { + this.fx.add({ x: e.x + (Math.random() - 0.5) * 20, y: e.y + (Math.random() - 0.5) * 20, vy: -14, vx: 6, life: 2, max: 2, size: 6, grow: 8, r: 140, g: 200, b: 150, a: 0.35, kind: 1 }); + } + void team; + } + + private drawStructureSprite(def: EntityDef, color: number, x: number, y: number, progress: number, alpha: number, art: ArtDef | null, e?: Entity, working = false): void { + const ctx = this.ctx; + const s = this.sprites.get(def, color, 'body', def.kind === 'mineral' && e ? mineralStage(e) : 0); + if (!s) return; + if (progress >= 1) { + ctx.globalAlpha = alpha; + ctx.drawImage(s.canvas, x - s.w / 2, y - s.h / 2, s.w, s.h); + if (art?.overlay && e) { + ctx.save(); + ctx.translate(x, y); + art.overlay(ctx, teamPal(color < 0 ? 0 : color), { t: this.time, built: true, powered: e.powered, working, seed: e.id }, def); + ctx.restore(); + } + ctx.globalAlpha = 1; + return; + } + // under construction + const race = def.race; + const W = (def.size?.[0] ?? 2) * TILE; + const H = (def.size?.[1] ?? 2) * TILE; + if (race === 'ironpaw') { + ctx.globalAlpha = 0.3 * alpha; + ctx.drawImage(s.canvas, x - s.w / 2, y - s.h / 2, s.w, s.h); + ctx.globalAlpha = alpha; + ctx.save(); + ctx.beginPath(); + const ch = s.h * progress; + ctx.rect(x - s.w / 2, y + s.h / 2 - ch, s.w, ch); + ctx.clip(); + ctx.drawImage(s.canvas, x - s.w / 2, y - s.h / 2, s.w, s.h); + ctx.restore(); + // scaffolding + ctx.strokeStyle = 'rgba(255,210,90,0.55)'; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i <= W; i += 8) { + ctx.moveTo(x - W / 2 + i, y - H / 2); + ctx.lineTo(x - W / 2 + i, y + H / 2); + } + ctx.stroke(); + ctx.strokeRect(x - W / 2, y - H / 2, W, H); + } else if (race === 'horde') { + const sc = 0.45 + 0.55 * progress; + ctx.globalAlpha = alpha * (0.4 + 0.6 * progress); + ctx.drawImage(s.canvas, x - (s.w * sc) / 2, y - (s.h * sc) / 2, s.w * sc, s.h * sc); + ctx.globalAlpha = alpha; + this.drawCocoon(x, y, Math.min(W, H) * 0.32 * (1 - progress * 0.5), progress); + } else { + ctx.globalAlpha = alpha * (0.25 + 0.6 * progress); + ctx.drawImage(s.canvas, x - s.w / 2, y - s.h / 2, s.w, s.h); + ctx.globalAlpha = alpha * 0.6; + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(glowSprite(90, 170, 255), x - W * 0.7, y - H * 0.7, W * 1.4, H * 1.4); + const scan = y - H / 2 + ((this.time * 40) % H); + ctx.fillStyle = 'rgba(140,210,255,0.5)'; + ctx.fillRect(x - W / 2, scan, W, 2); + ctx.globalCompositeOperation = 'source-over'; + } + ctx.globalAlpha = 1; + } + + private drawPowerFields(g: Game, player: number): void { + const ctx = this.ctx; + for (const e of g.entities) { + if (!e.alive || e.owner !== player || !e.def.powerRadius || !e.built) continue; + const r = e.def.powerRadius * TILE; + ctx.fillStyle = 'rgba(70,150,255,0.12)'; + ctx.strokeStyle = 'rgba(110,190,255,0.5)'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.arc(e.x, e.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + } + } + + // ------------------------------------------------------------------ misc world elements + + private drawSelRing(g: Game, e: Entity, alpha: number, v: ViewState, selected: boolean): void { + const ctx = this.ctx; + const ix = e.px + (e.x - e.px) * alpha; + const iy = e.py + (e.y - e.py) * alpha; + let col = '#45ff6a'; + if (e.owner < 0) col = '#f0e070'; + else if (g.isEnemy(v.player < 0 ? 0 : v.player, e.owner) && v.player >= 0) col = '#ff4a3c'; + else if (e.owner !== v.player) col = '#f0e070'; + ctx.strokeStyle = col; + ctx.globalAlpha = selected ? 0.95 : 0.45; + ctx.lineWidth = Math.max(1, 1.6 / this.cam.zoom); + ctx.beginPath(); + if (e.isBuilding || e.isResource) { + const w = e.x1 - e.x0; + const h = e.y1 - e.y0; + ctx.ellipse(ix, iy + 2, w * 0.58, h * 0.58, 0, 0, Math.PI * 2); + } else { + const yo = e.air ? 14 : 2; + ctx.ellipse(ix, iy + yo, e.r + 3, (e.r + 3) * 0.8, 0, 0, Math.PI * 2); + } + ctx.stroke(); + ctx.globalAlpha = 1; + } + + private drawBars(g: Game, e: Entity, alpha: number, selected: boolean, viewer: number): void { + const ctx = this.ctx; + const p = g.players[e.owner]; + const mh = maxHp(e, p); + const ms = maxShields(e); + const me = e.def.energy ?? 0; + const ix = e.px + (e.x - e.px) * alpha; + const iy = e.py + (e.y - e.py) * alpha; + const isS = e.isBuilding; + const w = isS ? (e.x1 - e.x0) * 0.7 : Math.max(18, e.r * 2.2); + const top = isS ? e.y0 - 6 : iy - e.r - (e.air ? 10 : 7); + const z = this.cam.zoom; + const bh = Math.max(2.5, 3.5 / z); + let y = top; + const x0 = ix - w / 2; + const bar = (frac: number, col: string, yy: number) => { + ctx.fillStyle = 'rgba(0,0,0,0.65)'; + ctx.fillRect(x0 - 0.5, yy - 0.5, w + 1, bh + 1); + ctx.fillStyle = col; + ctx.fillRect(x0, yy, w * Math.max(0, Math.min(1, frac)), bh); + }; + if (me && selected) { + bar(e.energy / me, '#c56cff', y); + y -= bh + 1; + } + const f = e.hp / mh; + bar(f, f > 0.6 ? '#46e05a' : f > 0.3 ? '#f2c94c' : '#ef4b3b', y); + y -= bh + 1; + if (ms) bar(e.shields / ms, '#4fb7ff', y); + if (isS && !e.built) { + y -= bh + 1; + bar(e.progress, '#ffffff', y); + } + if (e.bubble > 0) { + ctx.fillStyle = '#9fe0ff'; + ctx.fillRect(x0, top + bh + 1, w * Math.min(1, e.bubble / 100), 1.5); + } + // cargo pips under the health bar (own side only: enemy cargo is hidden, as in the classics) + if (e.cargo.length && e.def.cargo && (viewer < 0 || g.isAlly(e.owner, viewer))) { + const used = cargoUsed(g, e); + const n = e.def.cargo; + const pw = Math.max(2, (w - (n - 1)) / n); + for (let i = 0; i < n; i++) { + ctx.fillStyle = i < used ? '#ffe08a' : 'rgba(0,0,0,0.55)'; + ctx.fillRect(x0 + i * (pw + 1), top + bh + 2, pw, Math.max(1.5, bh * 0.6)); + } + } + } + + private drawRallies(g: Game, v: ViewState): void { + const ctx = this.ctx; + for (const e of v.selection) { + if (!e.alive || e.owner !== v.player) continue; + if (e.isBuilding && e.rally) { + const t = g.get(e.rally.target); + const rx = t ? t.x : e.rally.x; + const ry = t ? t.y : e.rally.y; + ctx.strokeStyle = 'rgba(120,255,140,0.6)'; + ctx.setLineDash([4, 4]); + ctx.lineWidth = 1.2 / this.cam.zoom; + ctx.beginPath(); + ctx.moveTo(e.x, e.y); + ctx.lineTo(rx, ry); + ctx.stroke(); + ctx.setLineDash([]); + this.drawFlag(rx, ry, '#5f5'); + } else if (e.isUnit && e.orders.length > 0) { + let px = e.x; + let py = e.y; + ctx.lineWidth = 1 / this.cam.zoom; + for (const o of e.orders.slice(0, 8)) { + let tx: number | undefined; + let ty: number | undefined; + let col = 'rgba(120,255,140,0.45)'; + if ('x' in o && o.t !== 'ability') { + tx = o.x; + ty = o.y; + } else if ('target' in o && o.target) { + const t = g.get(o.target); + if (t) { + tx = t.x; + ty = t.y; + } + } else if (o.t === 'ability') { + tx = o.x; + ty = o.y; + } + if (o.t === 'amove' || o.t === 'attack' || o.t === 'patrol') col = 'rgba(255,110,90,0.5)'; + if (o.t === 'build') { + const d = DEFS[o.def]; + tx = (o.tx + d.size![0] / 2) * TILE; + ty = (o.ty + d.size![1] / 2) * TILE; + col = 'rgba(255,220,90,0.5)'; + const s = this.sprites.get(d, g.players[e.owner].color, 'body', 0); + if (s) { + ctx.globalAlpha = 0.35; + ctx.drawImage(s.canvas, tx - s.w / 2, ty - s.h / 2, s.w, s.h); + ctx.globalAlpha = 1; + } + } + if (tx === undefined || ty === undefined) continue; + if (e.orders.length > 1 || o.t === 'build') { + ctx.strokeStyle = col; + ctx.beginPath(); + ctx.moveTo(px, py); + ctx.lineTo(tx, ty); + ctx.stroke(); + ctx.fillStyle = col; + ctx.beginPath(); + ctx.arc(tx, ty, 2.5, 0, Math.PI * 2); + ctx.fill(); + } + px = tx; + py = ty; + } + } + } + } + + private drawFlag(x: number, y: number, col: string): void { + const ctx = this.ctx; + ctx.strokeStyle = '#ddd'; + ctx.lineWidth = 1.2; + ctx.beginPath(); + ctx.moveTo(x, y); + ctx.lineTo(x, y - 14); + ctx.stroke(); + ctx.fillStyle = col; + ctx.beginPath(); + ctx.moveTo(x, y - 14); + ctx.lineTo(x + 9, y - 11); + ctx.lineTo(x, y - 8); + ctx.fill(); + } + + private drawPlacement(g: Game, pl: Placement, player: number): void { + const ctx = this.ctx; + const d = pl.def; + const [w, h] = d.size!; + const color = player >= 0 ? g.players[player].color : 0; + const s = this.sprites.get(d, color, 'body', 0); + const x = (pl.tx + w / 2) * TILE; + const y = (pl.ty + h / 2) * TILE; + if (s) { + ctx.globalAlpha = 0.55; + ctx.drawImage(s.canvas, x - s.w / 2, y - s.h / 2, s.w, s.h); + ctx.globalAlpha = 1; + } + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + const bad = pl.bad[j * w + i]; + ctx.fillStyle = bad ? 'rgba(255,50,40,0.38)' : 'rgba(60,255,90,0.22)'; + ctx.fillRect((pl.tx + i) * TILE + 1, (pl.ty + j) * TILE + 1, TILE - 2, TILE - 2); + } + } + ctx.strokeStyle = pl.ok ? 'rgba(80,255,110,0.9)' : 'rgba(255,70,60,0.9)'; + ctx.lineWidth = 1.5 / this.cam.zoom; + ctx.strokeRect(pl.tx * TILE, pl.ty * TILE, w * TILE, h * TILE); + if (d.townHall) { + // show the resource exclusion zone + ctx.strokeStyle = 'rgba(255,220,90,0.25)'; + ctx.strokeRect((pl.tx - 3) * TILE, (pl.ty - 3) * TILE, (w + 6) * TILE, (h + 6) * TILE); + } + } + + private drawMarkers(markers: Marker[], dt: number): void { + const ctx = this.ctx; + for (const m of markers) { + m.t -= dt; + if (m.t <= 0) continue; + const k = m.t / 0.5; + ctx.strokeStyle = m.color; + ctx.globalAlpha = k; + ctx.lineWidth = 2 / this.cam.zoom; + ctx.beginPath(); + ctx.ellipse(m.x, m.y, 4 + (1 - k) * 12, (4 + (1 - k) * 12) * 0.75, 0, 0, Math.PI * 2); + ctx.stroke(); + } + ctx.globalAlpha = 1; + for (let i = markers.length - 1; i >= 0; i--) if (markers[i].t <= 0) markers.splice(i, 1); + } + + private drawProjectiles(g: Game, alpha: number, air: boolean, team: number): void { + const ctx = this.ctx; + for (const pr of g.projectiles) { + if (!pr.alive && pr.age === 0) continue; + if (pr.air !== air) continue; + const x = pr.px + (pr.x - pr.px) * alpha; + const y = pr.py + (pr.y - pr.py) * alpha; + if (team >= 0 && !g.vision.visibleAt(team, x, y)) continue; + this.drawProjectile(pr, x, y); + } + } + + private drawProjectile(pr: Projectile, x: number, y: number): void { + const ctx = this.ctx; + const ang = Math.atan2(pr.ty - pr.y, pr.tx - pr.x); + switch (pr.w.proj) { + case 'keg': + ctx.fillStyle = '#6b4320'; + ctx.strokeStyle = '#2a1a0c'; + ctx.lineWidth = 0.8; + ctx.beginPath(); + ctx.ellipse(x, y, 3.6, 3, this.time * 8, 0, Math.PI * 2); + ctx.fill(); + ctx.stroke(); + ctx.drawImage(glowSprite(255, 200, 90), x - 3, y - 6, 6, 6); + break; + case 'honey': + ctx.drawImage(glowSprite(255, 190, 60), x - 6, y - 6, 12, 12); + ctx.fillStyle = '#f0b030'; + ctx.beginPath(); + ctx.arc(x, y, 2.6, 0, Math.PI * 2); + ctx.fill(); + break; + case 'frost': + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(glowSprite(150, 220, 255), x - 7, y - 7, 14, 14); + ctx.globalCompositeOperation = 'source-over'; + ctx.save(); + ctx.translate(x, y); + ctx.rotate(this.time * 16); + ctx.fillStyle = '#e8f8ff'; + ctx.fillRect(-4, -1, 8, 2); + ctx.restore(); + break; + case 'sap': + ctx.fillStyle = '#7a5418'; + ctx.beginPath(); + ctx.arc(x, y, 2.4, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 0.7; + ctx.drawImage(glowSprite(255, 190, 90), x - 5, y - 5, 10, 10); + ctx.globalAlpha = 1; + break; + case 'missile': + ctx.save(); + ctx.translate(x, y); + ctx.rotate(ang); + ctx.fillStyle = '#e8e8e8'; + ctx.fillRect(-4, -1.2, 7, 2.4); + ctx.restore(); + ctx.drawImage(glowSprite(255, 170, 70), x - Math.cos(ang) * 5 - 4, y - Math.sin(ang) * 5 - 4, 8, 8); + if (Math.random() < 0.6) this.fx.add({ x: x - Math.cos(ang) * 5, y: y - Math.sin(ang) * 5, life: 0.5, max: 0.5, size: 2.5, grow: 5, r: 200, g: 200, b: 200, a: 0.4, kind: 1, air: pr.air }); + break; + case 'quill': + ctx.save(); + ctx.translate(x, y); + ctx.rotate(ang); + ctx.fillStyle = '#d8ec8a'; + ctx.beginPath(); + ctx.moveTo(5, 0); + ctx.lineTo(-4, -1.3); + ctx.lineTo(-4, 1.3); + ctx.fill(); + ctx.restore(); + break; + case 'glaive': + ctx.save(); + ctx.translate(x, y); + ctx.rotate(this.time * 20); + ctx.fillStyle = '#c58bff'; + ctx.beginPath(); + ctx.moveTo(5, 0); + ctx.lineTo(0, 1.5); + ctx.lineTo(-5, 0); + ctx.lineTo(0, -1.5); + ctx.fill(); + ctx.restore(); + ctx.globalAlpha = 0.6; + ctx.drawImage(glowSprite(190, 110, 255), x - 6, y - 6, 12, 12); + ctx.globalAlpha = 1; + break; + case 'sting': + ctx.drawImage(glowSprite(150, 255, 90), x - 6, y - 6, 12, 12); + ctx.fillStyle = '#b8ff7a'; + ctx.beginPath(); + ctx.arc(x, y, 2.2, 0, Math.PI * 2); + ctx.fill(); + break; + case 'moonorb': + case 'lantern': + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(glowSprite(90, 170, 255), x - 8, y - 8, 16, 16); + ctx.drawImage(glowSprite(220, 240, 255), x - 3, y - 3, 6, 6); + ctx.globalCompositeOperation = 'source-over'; + break; + case 'starbolt': + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(glowSprite(255, 200, 90), x - 13, y - 13, 26, 26); + ctx.drawImage(glowSprite(255, 250, 220), x - 5, y - 5, 10, 10); + ctx.globalCompositeOperation = 'source-over'; + break; + default: + ctx.fillStyle = '#fff'; + ctx.fillRect(x - 1.5, y - 1.5, 3, 3); + } + } + + private drawArea(fx: AreaEffect, team: number, g: Game): void { + const ctx = this.ctx; + const t = fx.t / fx.total; + const time = this.time; + switch (fx.kind) { + case 'sniff': + if (fx.team !== team && team >= 0) return; + ctx.strokeStyle = `rgba(110,200,255,${0.5 * Math.min(1, t * 4)})`; + ctx.lineWidth = 2 / this.cam.zoom; + ctx.beginPath(); + ctx.arc(fx.x, fx.y, fx.r, 0, Math.PI * 2); + ctx.stroke(); + ctx.fillStyle = 'rgba(110,200,255,0.05)'; + ctx.fill(); + ctx.save(); + ctx.translate(fx.x, fx.y); + ctx.rotate(time * 2); + const gr = ctx.createLinearGradient(0, 0, fx.r, 0); + gr.addColorStop(0, 'rgba(110,200,255,0.0)'); + gr.addColorStop(1, 'rgba(110,200,255,0.25)'); + ctx.fillStyle = gr; + ctx.beginPath(); + ctx.moveTo(0, 0); + ctx.arc(0, 0, fx.r, 0, 0.5); + ctx.fill(); + ctx.restore(); + return; + case 'beecloud': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + const a = Math.min(1, t * 5, (1 - t) * 10); + for (let i = 0; i < 9; i++) { + const ang = i * 0.7 + time * 0.3; + const rr = fx.r * (0.3 + ((i * 37) % 7) / 10); + const x = fx.x + Math.cos(ang) * rr * 0.6; + const y = fx.y + Math.sin(ang) * rr * 0.6; + ctx.globalAlpha = 0.35 * a; + ctx.drawImage(glowSprite(110, 120, 90), x - rr, y - rr, rr * 2, rr * 2); + } + ctx.globalAlpha = 1; + return; + } + case 'stingswarm': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + ctx.globalAlpha = 0.45 * Math.min(1, t * 6); + ctx.drawImage(glowSprite(150, 60, 170), fx.x - fx.r, fx.y - fx.r, fx.r * 2, fx.r * 2); + ctx.globalAlpha = 1; + if (Math.random() < 0.5) this.fx.add({ x: fx.x + (Math.random() - 0.5) * fx.r * 1.5, y: fx.y + (Math.random() - 0.5) * fx.r * 1.5, vy: -10, life: 0.6, max: 0.6, size: 3, r: 190, g: 90, b: 210 }); + return; + } + case 'starfall': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + ctx.globalCompositeOperation = 'lighter'; + ctx.globalAlpha = 0.5; + ctx.drawImage(glowSprite(120, 110, 255), fx.x - fx.r * 1.2, fx.y - fx.r * 1.2, fx.r * 2.4, fx.r * 2.4); + ctx.globalAlpha = 1; + ctx.strokeStyle = 'rgba(200,220,255,0.85)'; + ctx.lineWidth = 1.2; + for (let i = 0; i < 5; i++) { + ctx.beginPath(); + let x = fx.x + (Math.random() - 0.5) * fx.r * 1.6; + let y = fx.y + (Math.random() - 0.5) * fx.r * 1.6; + ctx.moveTo(x, y); + for (let s = 0; s < 4; s++) { + x += (Math.random() - 0.5) * 14; + y += (Math.random() - 0.5) * 14; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + ctx.globalCompositeOperation = 'source-over'; + return; + } + case 'staticburst': + ctx.strokeStyle = `rgba(140,200,255,${t})`; + ctx.lineWidth = 3; + ctx.beginPath(); + ctx.arc(fx.x, fx.y, fx.r * (1.2 - t * 0.5), 0, Math.PI * 2); + ctx.stroke(); + return; + case 'pollen': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + const a = Math.min(1, t * 5, (1 - t) * 10); + ctx.globalAlpha = 0.4 * a; + ctx.drawImage(glowSprite(255, 225, 90), fx.x - fx.r * 1.2, fx.y - fx.r * 1.2, fx.r * 2.4, fx.r * 2.4); + ctx.fillStyle = 'rgba(255,240,150,0.8)'; + for (let i = 0; i < 18; i++) { + const ang = i * 2.4 + time * (0.4 + (i % 3) * 0.2); + const rr = fx.r * (0.2 + ((i * 53) % 10) / 12); + ctx.fillRect(fx.x + Math.cos(ang) * rr, fx.y + Math.sin(ang) * rr, 1.6, 1.6); + } + ctx.globalAlpha = 1; + return; + } + case 'hornets': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + for (let i = 0; i < 10; i++) { + const ang = i * 0.63 + time * (3 + (i % 4)); + const rr = 6 + ((i * 37) % 9) + Math.sin(time * 5 + i) * 3; + const x = fx.x + Math.cos(ang) * rr; + const y = fx.y + Math.sin(ang) * rr * 0.8 - 10; + ctx.fillStyle = i % 2 ? '#2a1d0a' : '#f2c230'; + ctx.fillRect(x - 1.2, y - 1, 2.4, 2); + } + ctx.strokeStyle = 'rgba(255,200,60,0.25)'; + ctx.beginPath(); + ctx.arc(fx.x, fx.y, fx.r, 0, Math.PI * 2); + ctx.stroke(); + return; + } + case 'honeyglob': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + ctx.strokeStyle = `rgba(255,190,60,${0.4 + 0.4 * (1 - t)})`; + ctx.lineWidth = 1.5 / this.cam.zoom; + ctx.beginPath(); + ctx.arc(fx.x, fx.y, fx.r, 0, Math.PI * 2); + ctx.stroke(); + const h = t * 160; + ctx.drawImage(glowSprite(255, 190, 60), fx.x - 7, fx.y - h - 7, 14, 14); + return; + } + case 'spirit': { + const src = g.get(fx.src); + if (!src || (team >= 0 && fx.team !== team && !g.vision.visibleAt(team, fx.x, fx.y))) return; + const s = this.sprites.get(src.def, this.colorOf(g, src.owner), 'body', Math.floor(time * 8) % 4); + ctx.globalAlpha = 0.45; + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(glowSprite(140, 210, 255), fx.x - 14, fx.y - 14, 28, 28); + ctx.globalCompositeOperation = 'source-over'; + if (s) ctx.drawImage(s.canvas, fx.x - s.w / 2, fx.y - s.h / 2, s.w, s.h); + ctx.globalAlpha = 1; + return; + } + case 'snowball': { + if (team >= 0 && !g.vision.visibleAt(team, fx.x, fx.y)) return; + const r = 7 + (1 - t) * 3; + ctx.globalCompositeOperation = 'lighter'; + ctx.drawImage(glowSprite(150, 220, 255), fx.x - r * 2.2, fx.y - r * 2.2, r * 4.4, r * 4.4); + ctx.globalCompositeOperation = 'source-over'; + ctx.fillStyle = '#eef9ff'; + ctx.beginPath(); + ctx.arc(fx.x, fx.y, r, 0, Math.PI * 2); + ctx.fill(); + ctx.strokeStyle = 'rgba(120,200,255,0.5)'; + ctx.setLineDash([4, 4]); + ctx.beginPath(); + ctx.arc(fx.x, fx.y, fx.r, 0, Math.PI * 2); + ctx.stroke(); + ctx.setLineDash([]); + return; + } + case 'grandshot': { + const src = g.get(fx.src); + const tgt = g.get(fx.target); + if (!src || !tgt) return; + ctx.globalCompositeOperation = 'lighter'; + const s = 6 + (1 - t) * 14; + ctx.drawImage(glowSprite(120, 180, 255), src.x - s, src.y - s, s * 2, s * 2); + ctx.globalCompositeOperation = 'source-over'; + return; + } + } + } + + private drawFog(g: Game, team: number): void { + const v = g.vision; + if (this.fogVer !== v.version || this.fogTeam !== team) { + this.fogVer = v.version; + this.fogTeam = team; + const fctx = this.fogCanvas.getContext('2d')!; + const img = fctx.createImageData(v.w, v.h); + const vis = v.vis[team]; + const exp = v.explored[team]; + for (let i = 0; i < vis.length; i++) { + const o = i * 4; + img.data[o] = 0; + img.data[o + 1] = 0; + img.data[o + 2] = 0; + img.data[o + 3] = vis[i] ? 0 : exp[i] ? 125 : 235; + } + fctx.putImageData(img, 0, 0); + } + const ctx = this.ctx; + ctx.imageSmoothingEnabled = true; + ctx.drawImage(this.fogCanvas, 0, 0, v.w * TILE, v.h * TILE); + } + + /** Team colour as CSS string. */ + static colorCss(color: number): string { + return (TEAM_COLORS[color] ?? TEAM_COLORS[0]).main; + } +} + +function mineralStage(e: Entity): number { + return e.amount > 1000 ? 0 : e.amount > 500 ? 1 : 2; +} diff --git a/gh-pages/bear-war/src/render/sprites.ts b/gh-pages/bear-war/src/render/sprites.ts new file mode 100644 index 00000000..80ac3f41 --- /dev/null +++ b/gh-pages/bear-war/src/render/sprites.ts @@ -0,0 +1,98 @@ +// Sprite cache. Every sprite is drawn procedurally (see art/*.ts) into an offscreen +// canvas once per (type, team colour, frame) and then blitted with rotation. + +import { TEAM_COLORS } from '../game/constants.ts'; +import type { EntityDef } from '../game/data/types.ts'; +import { ART, fallbackArt, type ArtDef, type TeamPal } from './art/index.ts'; + +export interface Sprite { + canvas: HTMLCanvasElement; + /** size in world px */ + w: number; + h: number; +} + +const NEUTRAL_PAL: TeamPal = { main: '#9aa4ad', light: '#d5dde4', dark: '#4a525a', index: -1 }; + +export function teamPal(color: number): TeamPal { + const c = TEAM_COLORS[color] ?? TEAM_COLORS[0]; + return { main: c.main, light: c.light, dark: c.dark, index: color }; +} + +export class SpriteCache { + unitScale: number; + buildingScale: number; + private cache = new Map(); + + constructor(unitScale = 2, buildingScale = 1.5) { + this.unitScale = unitScale; + this.buildingScale = buildingScale; + } + + art(def: EntityDef): ArtDef { + return ART[def.art ?? def.id] ?? ART[def.id] ?? fallbackArt(def); + } + + /** layer: 'body' | 'turret'; frame: animation frame index */ + get(def: EntityDef, color: number, layer: 'body' | 'turret' = 'body', frame = 0): Sprite | null { + const art = this.art(def); + const frames = art.frames ?? 1; + const f = frames > 1 ? ((frame % frames) + frames) % frames : 0; + const key = `${def.id}|${color}|${layer}|${f}`; + let s = this.cache.get(key); + if (s) return s; + if (layer === 'turret' && !art.turret) return null; + const isStruct = def.kind === 'building' || def.kind === 'mineral' || def.kind === 'geyser'; + const scale = isStruct ? this.buildingScale : this.unitScale; + let w: number; + let h: number; + if (isStruct && def.size) { + w = def.size[0] * 32 + (art.pad ?? 8) * 2; + h = def.size[1] * 32 + (art.pad ?? 8) * 2; + } else { + w = h = art.size ?? (def.radius ?? 10) * 3; + } + if (layer === 'turret') w = h = art.turretSize ?? w; + const c = document.createElement('canvas'); + c.width = Math.ceil(w * scale); + c.height = Math.ceil(h * scale); + const ctx = c.getContext('2d')!; + ctx.setTransform(scale, 0, 0, scale, (w * scale) / 2, (h * scale) / 2); + const pal = color < 0 ? NEUTRAL_PAL : teamPal(color); + try { + if (layer === 'turret') art.turret!(ctx, pal, f, def); + else art.draw(ctx, pal, f, def); + } catch (err) { + console.error('sprite draw failed', def.id, err); + } + s = { canvas: c, w, h }; + this.cache.set(key, s); + return s; + } + + /** Icon for UI buttons/portraits (unrotated, facing right for units). */ + icon(def: EntityDef, color: number, px: number): HTMLCanvasElement { + const key = `icon|${def.id}|${color}|${px}`; + const hit = this.cache.get(key); + if (hit) return hit.canvas; + const s = this.get(def, color, 'body', 0)!; + const c = document.createElement('canvas'); + c.width = px; + c.height = px; + const ctx = c.getContext('2d')!; + const isStruct = def.kind !== 'unit' && def.kind !== 'cub' && def.kind !== 'cocoon'; + const span = isStruct ? Math.max(s.w, s.h) : (def.radius ?? 10) * 2.6; + const k = px / span; + ctx.translate(px / 2, px / 2); + if (!isStruct) ctx.rotate(-Math.PI / 2); + ctx.drawImage(s.canvas, (-s.w * k) / 2, (-s.h * k) / 2, s.w * k, s.h * k); + const t = this.get(def, color, 'turret', 0); + if (t) ctx.drawImage(t.canvas, (-t.w * k) / 2, (-t.h * k) / 2, t.w * k, t.h * k); + this.cache.set(key, { canvas: c, w: px, h: px }); + return c; + } + + clear(): void { + this.cache.clear(); + } +} diff --git a/gh-pages/bear-war/src/render/terrain.ts b/gh-pages/bear-war/src/render/terrain.ts new file mode 100644 index 00000000..d720b978 --- /dev/null +++ b/gh-pages/bear-war/src/render/terrain.ts @@ -0,0 +1,510 @@ +// Procedural terrain: a smooth height field derived from the tile elevations is +// hill-shaded per pixel (light from the top-left), giving plateaus, cliffs and ramps a +// 3D look without any image assets. Rendered lazily in cached chunks. + +import { TILE } from '../game/constants.ts'; +import { F_CLIFF, F_EDGE, F_RAMP, F_ROCK, F_ROUGH, F_WATER, type GameMap, type Tileset } from '../game/map/gamemap.ts'; +import type { Comb } from '../game/systems/comb.ts'; +import { COMB_CHUNK } from '../game/systems/comb.ts'; +import type { Camera } from './camera.ts'; +import { fbm, hash2, hex, mix, mulberry, rgb, vnoise, type RGB } from './noise.ts'; + +interface Palette { + low: string; + mid: string; + high: string; + rock: string; + water: string; + deep: string; + edge: string; + tintA: string; + tintB: string; + pebble: string; + comb: string; + combDark: string; + combVein: string; +} + +export const TILESETS: Record = { + badlands: { + low: '#4c4033', mid: '#6a5843', high: '#8b7656', rock: '#5f5349', water: '#1f5363', deep: '#10303c', edge: '#0d0b09', + tintA: '#7b5536', tintB: '#4f5a43', pebble: '#a28d6b', comb: '#a8731d', combDark: '#5a3a0e', combVein: '#e9b545', + }, + frost: { + low: '#5e6b78', mid: '#7f8f9e', high: '#a9b8c6', rock: '#59626c', water: '#1e4c70', deep: '#0f2b44', edge: '#0a0d10', + tintA: '#8a9fb4', tintB: '#6d7a80', pebble: '#cfd9e2', comb: '#a8731d', combDark: '#5a3a0e', combVein: '#e9b545', + }, + ash: { + low: '#36302d', mid: '#4a423d', high: '#625751', rock: '#2f2a28', water: '#5a1d0c', deep: '#2e0b04', edge: '#080605', + tintA: '#5a3a2a', tintB: '#3a3a3a', pebble: '#7a6d64', comb: '#a8731d', combDark: '#5a3a0e', combVein: '#e9b545', + }, + verdant: { + low: '#3b5230', mid: '#53703f', high: '#6e8c50', rock: '#585c50', water: '#1d5160', deep: '#0f2f3a', edge: '#0a0e08', + tintA: '#6a7a3a', tintB: '#3f5a4a', pebble: '#9aa37a', comb: '#a8731d', combDark: '#5a3a0e', combVein: '#e9b545', + }, +}; + +const CHUNK_TILES = COMB_CHUNK; // 8 tiles per chunk side +const CHUNK_PX = CHUNK_TILES * TILE; // 256 world px +const HALF = CHUNK_PX / 2; // shaded at half resolution +const LEVEL_H = 38; // px of height per elevation level (for shading) + +interface Chunk { + base: HTMLCanvasElement; + final: HTMLCanvasElement; + combVer: number; + used: number; +} + +export class TerrainRenderer { + map: GameMap; + pal: Palette; + heights: Float32Array; + chunks = new Map(); + chunkCols: number; + chunkRows: number; + maxChunks = 64; + frame = 0; + private pool: HTMLCanvasElement[] = []; + private shadeBuf: ImageData | null = null; + private shadeCanvas: HTMLCanvasElement; + + constructor(map: GameMap) { + this.map = map; + this.pal = TILESETS[map.tileset] ?? TILESETS.badlands; + this.chunkCols = Math.ceil(map.w / CHUNK_TILES); + this.chunkRows = Math.ceil(map.h / CHUNK_TILES); + this.heights = computeHeights(map); + this.shadeCanvas = document.createElement('canvas'); + this.shadeCanvas.width = HALF; + this.shadeCanvas.height = HALF; + } + + private newCanvas(): HTMLCanvasElement { + const c = this.pool.pop() ?? document.createElement('canvas'); + c.width = CHUNK_PX; + c.height = CHUNK_PX; + return c; + } + + /** Bilinear tile height at world position. */ + heightAt(wx: number, wy: number): number { + const m = this.map; + const u = wx / TILE - 0.5; + const v = wy / TILE - 0.5; + let x0 = Math.floor(u); + let y0 = Math.floor(v); + const fx = u - x0; + const fy = v - y0; + const cl = (a: number, n: number) => (a < 0 ? 0 : a >= n ? n - 1 : a); + const x1 = cl(x0 + 1, m.w); + const y1 = cl(y0 + 1, m.h); + x0 = cl(x0, m.w); + y0 = cl(y0, m.h); + const H = this.heights; + const a = H[y0 * m.w + x0]; + const b = H[y0 * m.w + x1]; + const c = H[y1 * m.w + x0]; + const d = H[y1 * m.w + x1]; + return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy; + } + + private renderBase(cx: number, cy: number): HTMLCanvasElement { + const m = this.map; + const pal = this.pal; + const seed = m.seed * 131 + 7; + const ox = cx * CHUNK_PX; + const oy = cy * CHUNK_PX; + // height samples at half resolution (2 world px per sample), with 1-sample margin + const S = HALF + 2; + const hbuf = new Float32Array(S * S); + const rockMask = new Float32Array(S * S); + for (let j = 0; j < S; j++) { + for (let i = 0; i < S; i++) { + const wx = ox + (i - 1) * 2 + 1; + const wy = oy + (j - 1) * 2 + 1; + let h = this.heightAt(wx, wy) * LEVEL_H; + const tx = Math.floor(wx / TILE); + const ty = Math.floor(wy / TILE); + const f = m.inBounds(tx, ty) ? m.flags[ty * m.w + tx] : F_EDGE; + // boulders on rock tiles + let rm = 0; + if (f & F_ROCK) { + const n = fbm(wx / 26, wy / 26, seed + 3, 2); + rm = Math.max(0, n - 0.32) * 2.2; + h += rm * 26; + } + // rugged cliff faces + if (f & F_CLIFF) h += (vnoise(wx / 9, wy / 9, seed + 9) - 0.5) * 10; + h += (vnoise(wx / 23, wy / 23, seed + 1) - 0.5) * 3; + hbuf[j * S + i] = h; + rockMask[j * S + i] = rm; + } + } + if (!this.shadeBuf) this.shadeBuf = new ImageData(HALF, HALF); + const img = this.shadeBuf; + const data = img.data; + const cLow = hex(pal.low); + const cMid = hex(pal.mid); + const cHigh = hex(pal.high); + const cRock = hex(pal.rock); + const cWater = hex(pal.water); + const cDeep = hex(pal.deep); + const cEdge = hex(pal.edge); + const cTA = hex(pal.tintA); + const cTB = hex(pal.tintB); + // light direction (from top-left, above) + const lx = -0.55; + const ly = -0.62; + const lz = 0.56; + for (let j = 0; j < HALF; j++) { + for (let i = 0; i < HALF; i++) { + const k = (j + 1) * S + (i + 1); + const wx = ox + i * 2 + 1; + const wy = oy + j * 2 + 1; + const tx = Math.floor(wx / TILE); + const ty = Math.floor(wy / TILE); + const f = m.inBounds(tx, ty) ? m.flags[ty * m.w + tx] : F_EDGE; + const gx = (hbuf[k + 1] - hbuf[k - 1]) / 4; + const gy = (hbuf[k + S] - hbuf[k - S]) / 4; + const nl = 1 / Math.sqrt(gx * gx + gy * gy + 1); + const dot = (-gx * lx - gy * ly + lz) * nl; + let sh = 0.42 + dot * 0.78; + const steep = Math.min(1, Math.sqrt(gx * gx + gy * gy) * 0.9); + const lev = Math.max(0, Math.min(2, hbuf[k] / LEVEL_H)); + let col: RGB = lev < 1 ? mix(cLow, cMid, lev) : mix(cMid, cHigh, lev - 1); + // large-scale tint patches + const t1 = fbm(wx / 160, wy / 160, seed + 21, 2); + const t2 = fbm(wx / 110, wy / 110, seed + 33, 2); + col = mix(col, cTA, Math.max(0, t1 - 0.5) * 0.9); + col = mix(col, cTB, Math.max(0, t2 - 0.55) * 0.9); + col = mix(col, cRock, steep * 0.75); + const rm = rockMask[k]; + if (rm > 0) col = mix(col, cRock, Math.min(1, rm * 1.4)); + if (f & F_ROUGH) col = mix(col, cRock, 0.25); + // fine grain + const grain = vnoise(wx / 3.1, wy / 3.1, seed + 5) * 0.12 + vnoise(wx / 11, wy / 11, seed + 6) * 0.1 - 0.11; + sh += grain; + if (f & F_WATER) { + // water: depth from distance to shore (approx by height dip) + const depth = Math.max(0, Math.min(1, (this.heightAt(wx, wy) * -1 + (m.elev[ty * m.w + tx] - 0.0)) * 2)); + col = mix(cWater, cDeep, 0.35 + depth * 0.4); + sh = 0.9 + (vnoise(wx / 14, wy / 6, seed + 40) - 0.5) * 0.25; + } + if (f & F_EDGE) { + col = cEdge; + sh = 1; + } + const o = (j * HALF + i) * 4; + data[o] = Math.max(0, Math.min(255, col[0] * sh)); + data[o + 1] = Math.max(0, Math.min(255, col[1] * sh)); + data[o + 2] = Math.max(0, Math.min(255, col[2] * sh)); + data[o + 3] = 255; + } + } + const sctx = this.shadeCanvas.getContext('2d')!; + sctx.putImageData(img, 0, 0); + const c = this.newCanvas(); + const ctx = c.getContext('2d')!; + ctx.imageSmoothingEnabled = true; + ctx.drawImage(this.shadeCanvas, 0, 0, CHUNK_PX, CHUNK_PX); + this.drawDetails(ctx, cx, cy); + return c; + } + + /** Pebbles, cracks and debris at full resolution. */ + private drawDetails(ctx: CanvasRenderingContext2D, cx: number, cy: number): void { + const m = this.map; + const pal = this.pal; + const pebble = hex(pal.pebble); + const seed = m.seed * 977 + 13; + for (let ty = cy * CHUNK_TILES; ty < (cy + 1) * CHUNK_TILES && ty < m.h; ty++) { + for (let tx = cx * CHUNK_TILES; tx < (cx + 1) * CHUNK_TILES && tx < m.w; tx++) { + const f = m.flags[ty * m.w + tx]; + if (f & (F_EDGE | F_WATER)) continue; + const rnd = mulberry((tx * 73856093) ^ (ty * 19349663) ^ seed); + const lx = tx * TILE - cx * CHUNK_PX; + const ly = ty * TILE - cy * CHUNK_PX; + if (f & F_ROCK) { + // boulder highlights + if (rnd() < 0.55) { + const bx = lx + 6 + rnd() * 20; + const by = ly + 6 + rnd() * 20; + const r = 4 + rnd() * 7; + ctx.fillStyle = 'rgba(0,0,0,0.28)'; + ctx.beginPath(); + ctx.ellipse(bx + 3, by + 4, r, r * 0.8, 0, 0, Math.PI * 2); + ctx.fill(); + const g = ctx.createRadialGradient(bx - r * 0.4, by - r * 0.4, 1, bx, by, r); + g.addColorStop(0, rgb(mix(hex(pal.rock), [255, 255, 255], 0.35))); + g.addColorStop(1, rgb(mix(hex(pal.rock), [0, 0, 0], 0.35))); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.ellipse(bx, by, r, r * 0.85, rnd() * 3, 0, Math.PI * 2); + ctx.fill(); + } + continue; + } + if (f & F_CLIFF) { + if (rnd() < 0.5) { + ctx.strokeStyle = 'rgba(0,0,0,0.25)'; + ctx.lineWidth = 1; + ctx.beginPath(); + const sx = lx + rnd() * 32; + const sy = ly + rnd() * 32; + ctx.moveTo(sx, sy); + ctx.lineTo(sx + (rnd() - 0.5) * 16, sy + (rnd() - 0.5) * 16); + ctx.stroke(); + } + continue; + } + // pebbles + const n = rnd() < 0.35 ? 1 + Math.floor(rnd() * 3) : 0; + for (let i = 0; i < n; i++) { + const px = lx + rnd() * 30 + 1; + const py = ly + rnd() * 30 + 1; + const r = 0.8 + rnd() * 1.8; + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.beginPath(); + ctx.arc(px + 0.8, py + 0.9, r, 0, Math.PI * 2); + ctx.fill(); + const v = 0.75 + rnd() * 0.4; + ctx.fillStyle = rgb([pebble[0] * v, pebble[1] * v, pebble[2] * v]); + ctx.beginPath(); + ctx.arc(px, py, r, 0, Math.PI * 2); + ctx.fill(); + } + // cracks + if (!(f & F_RAMP) && rnd() < 0.06) { + ctx.strokeStyle = 'rgba(20,12,6,0.35)'; + ctx.lineWidth = 1; + ctx.beginPath(); + let x = lx + rnd() * 32; + let y = ly + rnd() * 32; + ctx.moveTo(x, y); + for (let s = 0; s < 4; s++) { + x += (rnd() - 0.5) * 14; + y += (rnd() - 0.5) * 14; + ctx.lineTo(x, y); + } + ctx.stroke(); + } + // small craters + if (!(f & F_RAMP) && rnd() < 0.012) { + const x = lx + 16; + const y = ly + 16; + const r = 6 + rnd() * 6; + const g = ctx.createRadialGradient(x - 2, y - 2, 1, x, y, r); + g.addColorStop(0, 'rgba(0,0,0,0.30)'); + g.addColorStop(0.7, 'rgba(0,0,0,0.12)'); + g.addColorStop(0.85, 'rgba(255,240,210,0.10)'); + g.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = g; + ctx.beginPath(); + ctx.arc(x, y, r, 0, Math.PI * 2); + ctx.fill(); + } + } + } + void hash2; + } + + private renderComb(chunk: Chunk, cx: number, cy: number, comb: Comb): void { + const ctx = chunk.final.getContext('2d')!; + ctx.clearRect(0, 0, CHUNK_PX, CHUNK_PX); + ctx.drawImage(chunk.base, 0, 0); + const m = this.map; + const pal = this.pal; + const ox = cx * CHUNK_PX; + const oy = cy * CHUNK_PX; + // organic blobs: union of jittered circles, drawn in 3 layers for depth + const layers: [string, number, number][] = [ + [pal.combDark, 1.0, 2], + [pal.comb, 0.82, 0], + [pal.combVein, 0.22, 0], + ]; + for (const [color, scale, offset] of layers) { + ctx.fillStyle = color; + ctx.beginPath(); + for (let ty = cy * CHUNK_TILES - 1; ty <= (cy + 1) * CHUNK_TILES; ty++) { + for (let tx = cx * CHUNK_TILES - 1; tx <= (cx + 1) * CHUNK_TILES; tx++) { + if (!comb.has(tx, ty)) continue; + const h = hash2(tx, ty, 99); + const jx = (h - 0.5) * 10; + const jy = (hash2(ty, tx, 98) - 0.5) * 10; + const x = tx * TILE + 16 + jx - ox + offset; + const y = ty * TILE + 16 + jy - oy + offset; + let r = (21 + h * 6) * scale; + if (scale < 0.5) { + // veins: only some tiles + if (h > 0.45) continue; + r = 3 + h * 6; + } + ctx.moveTo(x + r, y); + ctx.arc(x, y, r, 0, Math.PI * 2); + } + } + ctx.fill(); + } + // glossy speckles + ctx.fillStyle = 'rgba(255,235,150,0.22)'; + for (let ty = cy * CHUNK_TILES; ty < (cy + 1) * CHUNK_TILES; ty++) { + for (let tx = cx * CHUNK_TILES; tx < (cx + 1) * CHUNK_TILES; tx++) { + if (!comb.has(tx, ty)) continue; + const rnd = mulberry(tx * 31 + ty * 1013); + for (let i = 0; i < 3; i++) { + ctx.beginPath(); + ctx.arc(tx * TILE - ox + rnd() * 32, ty * TILE - oy + rnd() * 32, 0.8 + rnd() * 1.5, 0, Math.PI * 2); + ctx.fill(); + } + } + } + void m; + chunk.combVer = comb.chunkVer[cy * comb.cw + cx]; + } + + private getChunk(cx: number, cy: number, comb: Comb): Chunk { + const key = cy * this.chunkCols + cx; + let ch = this.chunks.get(key); + if (!ch) { + const base = this.renderBase(cx, cy); + ch = { base, final: base, combVer: 0, used: this.frame }; + this.chunks.set(key, ch); + this.evict(); + } + ch.used = this.frame; + const ver = comb.chunkVer[cy * comb.cw + cx]; + if (ver !== ch.combVer) { + if (ch.final === ch.base) ch.final = this.newCanvas(); + this.renderComb(ch, cx, cy, comb); + } + return ch; + } + + private evict(): void { + if (this.chunks.size <= this.maxChunks) return; + let oldestKey = -1; + let oldest = Infinity; + for (const [k, c] of this.chunks) { + if (c.used < oldest) { + oldest = c.used; + oldestKey = k; + } + } + const c = this.chunks.get(oldestKey); + if (c) { + this.pool.push(c.base); + if (c.final !== c.base) this.pool.push(c.final); + this.chunks.delete(oldestKey); + } + } + + /** Draw visible chunks. ctx must be in world coordinates. At most `budget` new chunks per frame. */ + /** ms of chunk generation allowed per frame (at least one chunk is always made) */ + budgetMs = 8; + + draw(ctx: CanvasRenderingContext2D, cam: Camera, comb: Comb, budget = 6): void { + this.frame++; + const t0 = performance.now(); + const x0 = Math.max(0, Math.floor(cam.x / CHUNK_PX)); + const y0 = Math.max(0, Math.floor(cam.y / CHUNK_PX)); + const x1 = Math.min(this.chunkCols - 1, Math.floor((cam.x + cam.viewW) / CHUNK_PX)); + const y1 = Math.min(this.chunkRows - 1, Math.floor((cam.y + cam.viewH) / CHUNK_PX)); + let made = 0; + for (let cy = y0; cy <= y1; cy++) { + for (let cx = x0; cx <= x1; cx++) { + const key = cy * this.chunkCols + cx; + if (!this.chunks.has(key)) { + if (made >= budget || (made > 0 && performance.now() - t0 > this.budgetMs)) { + ctx.fillStyle = this.pal.low; + ctx.fillRect(cx * CHUNK_PX, cy * CHUNK_PX, CHUNK_PX, CHUNK_PX); + continue; + } + made++; + } + const ch = this.getChunk(cx, cy, comb); + ctx.drawImage(ch.final, cx * CHUNK_PX, cy * CHUNK_PX, CHUNK_PX + 0.5, CHUNK_PX + 0.5); + } + } + this.maxChunks = Math.max(64, (x1 - x0 + 2) * (y1 - y0 + 2) + 8); + } + + /** Small overview image for the minimap (one pixel per tile, scaled up later). */ + minimapImage(scale = 2): HTMLCanvasElement { + const m = this.map; + const c = document.createElement('canvas'); + c.width = m.w * scale; + c.height = m.h * scale; + const ctx = c.getContext('2d')!; + const img = ctx.createImageData(c.width, c.height); + const pal = this.pal; + const lv = [hex(pal.low), hex(pal.mid), hex(pal.high)]; + for (let y = 0; y < c.height; y++) { + for (let x = 0; x < c.width; x++) { + const tx = Math.floor(x / scale); + const ty = Math.floor(y / scale); + const f = m.flags[ty * m.w + tx]; + let col: RGB = lv[Math.min(2, m.elev[ty * m.w + tx])]; + if (f & F_CLIFF) col = mix(col, [0, 0, 0], 0.45); + if (f & F_ROCK) col = hex(pal.rock); + if (f & F_WATER) col = hex(pal.water); + if (f & F_EDGE) col = hex(pal.edge); + if (f & F_RAMP) col = mix(col, [255, 255, 255], 0.08); + const o = (y * c.width + x) * 4; + img.data[o] = col[0]; + img.data[o + 1] = col[1]; + img.data[o + 2] = col[2]; + img.data[o + 3] = 255; + } + } + ctx.putImageData(img, 0, 0); + return c; + } +} + +function computeHeights(m: GameMap): Float32Array { + const { w, h } = m; + const H = new Float32Array(w * h); + for (let i = 0; i < w * h; i++) H[i] = m.elev[i]; + // cliffs sit halfway down + for (let ty = 0; ty < h; ty++) { + for (let tx = 0; tx < w; tx++) { + const i = ty * w + tx; + if (!(m.flags[i] & F_CLIFF)) continue; + let lo = m.elev[i]; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + const x = tx + dx; + const y = ty + dy; + if (x < 0 || y < 0 || x >= w || y >= h) continue; + lo = Math.min(lo, m.elev[y * w + x]); + } + } + H[i] = (m.elev[i] + lo) / 2; + } + } + // ramps: relax towards the average of their neighbours (harmonic interpolation) + const ramps: number[] = []; + for (let i = 0; i < w * h; i++) if (m.flags[i] & F_RAMP) ramps.push(i); + for (let it = 0; it < 40; it++) { + for (const i of ramps) { + const tx = i % w; + const ty = (i - tx) / w; + let s = 0; + let n = 0; + for (let dy = -1; dy <= 1; dy++) { + for (let dx = -1; dx <= 1; dx++) { + if (!dx && !dy) continue; + const x = tx + dx; + const y = ty + dy; + if (x < 0 || y < 0 || x >= w || y >= h) continue; + s += H[y * w + x]; + n++; + } + } + if (n) H[i] = s / n; + } + } + for (let i = 0; i < w * h; i++) if (m.flags[i] & F_WATER) H[i] -= 0.45; + return H; +} diff --git a/gh-pages/bear-war/src/style.css b/gh-pages/bear-war/src/style.css new file mode 100644 index 00000000..a11e3831 --- /dev/null +++ b/gh-pages/bear-war/src/style.css @@ -0,0 +1,1263 @@ +:root { + --bg: #07080a; + --panel: rgba(12, 16, 22, 0.86); + --panel-solid: #0f141b; + --line: rgba(220, 170, 110, 0.28); + --line-strong: rgba(255, 200, 120, 0.55); + --text: #dfe8f1; + --muted: #8da0b3; + --accent: #ffbe4a; + --good: #46e05a; + --warn: #f2c94c; + --bad: #ff5a4a; + --btn: 52px; + --mini: 190px; + --gap: 6px; + --font: system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + --mono: ui-monospace, 'SF Mono', Menlo, Consolas, monospace; + color-scheme: dark; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + height: 100%; + background: var(--bg); + color: var(--text); + font-family: var(--font); + overflow: hidden; + overscroll-behavior: none; + -webkit-user-select: none; + user-select: none; + -webkit-touch-callout: none; + -webkit-tap-highlight-color: transparent; +} + +button { + font-family: inherit; + color: inherit; +} + +#app { + position: fixed; + inset: 0; +} + +canvas.world { + position: absolute; + inset: 0; + display: block; + touch-action: none; + cursor: default; +} + +canvas.world.targeting { + cursor: crosshair; +} + +/* ---------------------------------------------------------------- HUD */ + +.hud { + position: absolute; + inset: 0; + pointer-events: none; + font-size: 13px; +} + +.hud > * { + pointer-events: auto; +} + +.panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.45); +} + +.hud-top { + position: absolute; + top: calc(env(safe-area-inset-top) + 6px); + right: calc(env(safe-area-inset-right) + 8px); + display: flex; + gap: 14px; + align-items: center; + padding: 5px 12px; + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.hud-top .res { + display: flex; + align-items: center; + gap: 6px; + min-width: 52px; +} + +.ico { + display: inline-block; + width: 14px; + height: 14px; +} + +.ico.min { + background: linear-gradient(135deg, #b8f1ff, #2b9fd6); + clip-path: polygon(50% 0, 100% 40%, 75% 100%, 25% 100%, 0 40%); +} + +.ico.gas { + background: radial-gradient(circle at 35% 35%, #c7ffc7, #2fb04a 70%); + border-radius: 50%; +} + +.ico.sup { + background: linear-gradient(#ffe39a, #d89a2a); + clip-path: polygon(50% 0, 100% 45%, 85% 45%, 85% 100%, 15% 100%, 15% 45%, 0 45%); +} + +.res.blocked b { + color: var(--bad); +} + +.clock { + color: var(--muted); + min-width: 40px; + text-align: right; +} + +.hud-menu { + position: absolute; + top: calc(env(safe-area-inset-top) + 6px); + left: calc(var(--mini) + env(safe-area-inset-left) + 16px); + width: 36px; + height: 32px; + display: grid; + place-items: center; + cursor: pointer; +} + +.hud-minimap { + position: absolute; + left: calc(env(safe-area-inset-left) + 6px); + top: calc(env(safe-area-inset-top) + 6px); + padding: 4px; + line-height: 0; +} + +.hud-minimap canvas { + border-radius: 4px; + touch-action: none; + cursor: pointer; +} + +.hud-bottom { + position: absolute; + left: calc(env(safe-area-inset-left) + 6px); + right: calc(env(safe-area-inset-right) + 6px); + bottom: calc(env(safe-area-inset-bottom) + 6px); + display: flex; + align-items: flex-end; + gap: var(--gap); + pointer-events: none; +} + +.hud-bottom > * { + pointer-events: auto; +} + +.hud-left { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column-reverse; + gap: var(--gap); + align-items: flex-start; + pointer-events: none; +} + +.hud-left > * { + pointer-events: auto; +} + +.hud-groups { + display: flex; + gap: 4px; + flex-wrap: wrap; +} + +.qbtn { + min-width: 38px; + height: 34px; + padding: 0 7px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + border-radius: 7px; + background: var(--panel); + border: 1px solid var(--line); + font-weight: 700; + font-size: 12px; + cursor: pointer; + position: relative; +} + +.qbtn.on { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; + color: var(--accent); +} + +.qbtn .n { + position: absolute; + right: 2px; + bottom: 0; + font-size: 9px; + color: var(--muted); +} + +.qbtn.empty { + opacity: 0.45; +} + +.hud-sel { + width: 100%; + max-width: 560px; + min-height: 92px; + padding: 8px; + display: flex; + gap: 10px; + align-items: stretch; +} + +.hud-sel.empty { + visibility: hidden; +} + +.sel-portrait { + width: 76px; + height: 76px; + flex: none; + border-radius: 6px; + background: radial-gradient(circle at 50% 40%, #233142, #0b0f15); + border: 1px solid var(--line); + display: grid; + place-items: center; + overflow: hidden; +} + +.sel-portrait canvas, +.sel-portrait img { + width: 72px; + height: 72px; +} + +.sel-info { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.sel-name { + font-weight: 700; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sel-name small { + color: var(--muted); + font-weight: 500; + margin-left: 6px; +} + +.bars { + display: flex; + flex-direction: column; + gap: 3px; + max-width: 260px; +} + +.bar { + position: relative; + height: 12px; + background: rgba(0, 0, 0, 0.55); + border-radius: 3px; + overflow: hidden; + font-size: 10px; + line-height: 12px; +} + +.bar > i { + position: absolute; + inset: 0 auto 0 0; + background: var(--good); +} + +.bar > span { + position: relative; + padding-left: 4px; + font-weight: 700; + text-shadow: 0 1px 1px #000; + font-variant-numeric: tabular-nums; +} + +.bar.sh > i { + background: #4fb7ff; +} + +.bar.en > i { + background: #b06cff; +} + +.bar.pr > i { + background: #d8e6f2; +} + +.sel-stats { + color: var(--muted); + font-size: 11px; + display: flex; + gap: 10px; + flex-wrap: wrap; +} + +.queue, +.cargo { + display: flex; + gap: 4px; + margin-top: 2px; +} + +.qslot { + width: 34px; + height: 34px; + border-radius: 5px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.35); + position: relative; + overflow: hidden; + cursor: pointer; + display: grid; + place-items: center; + color: var(--muted); +} + +.qslot img, +.qslot canvas, +.qslot svg { + width: 28px; + height: 28px; +} + +.qslot .prog { + position: absolute; + left: 0; + bottom: 0; + height: 3px; + background: var(--accent); +} + +.sel-grid { + display: flex; + flex-wrap: wrap; + gap: 3px; + align-content: flex-start; + max-height: 110px; + overflow: hidden; +} + +.sel-grid .cell { + width: 34px; + height: 34px; + border-radius: 5px; + border: 1px solid var(--line); + background: rgba(0, 0, 0, 0.35); + display: grid; + place-items: center; + position: relative; + cursor: pointer; +} + +.sel-grid .cell.active { + border-color: var(--accent); +} + +.sel-grid .cell img { + width: 28px; + height: 28px; +} + +.sel-grid .cell .hp { + position: absolute; + left: 2px; + right: 2px; + bottom: 2px; + height: 3px; + border-radius: 2px; + background: var(--good); +} + +.hud-card.none { + visibility: hidden; +} + +@media (max-height: 520px), (max-width: 760px) { + .hud-card.none { + display: none; + } +} + +.hud-card { + display: grid; + grid-template-columns: repeat(4, var(--btn)); + grid-template-rows: repeat(3, var(--btn)); + gap: 4px; + padding: 6px; + flex: none; +} + +/* clicks always land on the button itself, even while its contents are re-rendered */ +.cbtn > *, +.sel-grid .cell > *, +.qslot > * { + pointer-events: none; +} + +.cbtn { + width: var(--btn); + height: var(--btn); + border-radius: 7px; + border: 1px solid var(--line); + background: linear-gradient(180deg, rgba(40, 56, 74, 0.9), rgba(16, 22, 30, 0.95)); + position: relative; + display: grid; + place-items: center; + cursor: pointer; + padding: 0; + overflow: hidden; + color: #cfe6ff; + touch-action: manipulation; +} + +.cbtn:hover { + border-color: var(--line-strong); +} + +.cbtn:active { + transform: translateY(1px); +} + +.cbtn.empty { + background: rgba(0, 0, 0, 0.18); + border-style: dashed; + border-color: rgba(120, 170, 220, 0.1); + cursor: default; +} + +.cbtn.disabled { + filter: grayscale(1) brightness(0.55); +} + +.cbtn.poor .cost { + color: var(--bad); +} + +.cbtn.armed { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent) inset, 0 0 14px rgba(255, 190, 74, 0.5); +} + +.cbtn img { + width: 80%; + height: 80%; + object-fit: contain; +} + +.cbtn svg { + width: 56%; + height: 56%; +} + +.cbtn .hk { + position: absolute; + left: 4px; + top: 2px; + font-size: 10px; + font-weight: 800; + color: #ffe08a; + text-shadow: 0 1px 2px #000; +} + +.cbtn .badge { + position: absolute; + right: 3px; + bottom: 2px; + font-size: 10px; + font-weight: 800; + color: #fff; + text-shadow: 0 1px 2px #000; +} + +.hud-msgs { + position: absolute; + left: 50%; + top: 22%; + transform: translateX(-50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + pointer-events: none; + text-align: center; +} + +.msg { + font-weight: 700; + font-size: 15px; + text-shadow: 0 2px 4px #000, 0 0 2px #000; + animation: msgfade 3s forwards; +} + +.msg.error { + color: #ffd166; +} + +.msg.alert { + color: #ff6a5a; +} + +.msg.info { + color: #bfe6ff; +} + +@keyframes msgfade { + 0%, + 75% { + opacity: 1; + } + 100% { + opacity: 0; + } +} + +.hud-status { + position: absolute; + left: 50%; + top: 40%; + transform: translate(-50%, -50%); + padding: 14px 22px; + font-weight: 700; + font-size: 16px; + display: none; + text-align: center; +} + +.hud-status.show { + display: block; +} + +.hud-tip { + position: absolute; + max-width: 300px; + padding: 8px 10px; + font-size: 12px; + line-height: 1.4; + display: none; + pointer-events: none; + z-index: 5; +} + +.hud-tip.show { + display: block; +} + +.hud-tip b { + font-size: 13px; +} + +.hud-tip .cost { + display: flex; + gap: 10px; + margin: 3px 0; + font-weight: 700; + font-variant-numeric: tabular-nums; +} + +.hud-tip .cost span { + display: inline-flex; + align-items: center; + gap: 4px; +} + +.hud-tip .why { + color: var(--bad); + font-weight: 700; +} + +.hud-tip p { + margin: 3px 0 0; + color: #b9c9d8; +} + +.hud-place { + position: absolute; + left: 50%; + bottom: calc(env(safe-area-inset-bottom) + 160px); + transform: translateX(-50%); + display: none; + gap: 10px; +} + +.hud-place.show { + display: flex; +} + +.hud-place button { + height: 44px; + min-width: 96px; + font-weight: 800; + font-size: 15px; + border-radius: 10px; + border: 1px solid var(--line-strong); + background: var(--panel); + cursor: pointer; +} + +.hud-place .ok { + color: var(--good); +} + +.hud-place .no { + color: var(--bad); +} + +.hud-hint { + position: absolute; + left: 50%; + bottom: calc(env(safe-area-inset-bottom) + 160px); + transform: translateX(-50%); + padding: 6px 14px; + font-weight: 700; + color: var(--accent); + display: none; + pointer-events: none; +} + +.hud-hint.show { + display: block; +} + +/* ---------------------------------------------------------------- touch / small screens */ + +@media (max-height: 520px), (max-width: 760px) { + :root { + --btn: 44px; + --mini: 112px; + } + .hud { + font-size: 12px; + } + .hud-top { + gap: 9px; + padding: 4px 8px; + font-size: 12px; + } + .hud-top .res { + min-width: 40px; + } + .hud-sel { + min-height: 64px; + padding: 5px; + gap: 6px; + } + .sel-portrait { + width: 52px; + height: 52px; + } + .sel-portrait canvas, + .sel-portrait img { + width: 48px; + height: 48px; + } + .sel-stats { + display: none; + } + .qslot, + .sel-grid .cell { + width: 28px; + height: 28px; + } + .qslot img, + .sel-grid .cell img { + width: 22px; + height: 22px; + } + .sel-grid { + max-height: 60px; + } + .qbtn { + height: 32px; + min-width: 34px; + padding: 0 5px; + } + .hud-place { + bottom: calc(env(safe-area-inset-bottom) + 150px); + } +} + +@media (max-height: 420px) { + .hud-sel { + min-height: 0; + max-width: 380px; + padding: 4px 6px; + } + .sel-portrait { + width: 42px; + height: 42px; + } + .sel-portrait canvas, + .sel-portrait img { + width: 38px; + height: 38px; + } + .sel-name { + font-size: 12px; + } + .bars { + gap: 2px; + } + .bar { + height: 10px; + line-height: 10px; + font-size: 9px; + } + .queue { + margin-top: 0; + } + .qslot { + width: 24px; + height: 24px; + } + .qslot img, + .qslot svg { + width: 20px; + height: 20px; + } + .sel-grid .cell { + width: 26px; + height: 26px; + } + .sel-grid .cell img { + width: 20px; + height: 20px; + } + .sel-grid { + max-height: 56px; + } + .qbtn { + height: 30px; + min-width: 32px; + } +} + +/* ---------------------------------------------------------------- menus */ + +.screen { + position: fixed; + inset: 0; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + background: + radial-gradient(ellipse at 20% 10%, rgba(200, 130, 50, 0.18), transparent 50%), + radial-gradient(ellipse at 80% 90%, rgba(90, 150, 70, 0.12), transparent 55%), + linear-gradient(180deg, #06080c, #0b1018); + user-select: none; + z-index: 10; +} + +.screen.overlay { + background: rgba(3, 5, 9, 0.78); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); +} + +.fs-btn { + position: absolute; + top: calc(env(safe-area-inset-top) + 12px); + right: calc(env(safe-area-inset-right) + 12px); + font-size: 18px; +} + +.attract .hud-menu { + display: none; +} + +.menu { + max-width: 760px; + margin: 0 auto; + padding: calc(env(safe-area-inset-top) + 24px) 18px calc(env(safe-area-inset-bottom) + 28px); + display: flex; + flex-direction: column; + gap: 14px; +} + +.title { + text-align: center; + margin: 10px 0 4px; +} + +.title h1 { + margin: 0; + font-size: clamp(34px, 8vw, 64px); + letter-spacing: 0.18em; + font-weight: 800; + text-transform: uppercase; + background: linear-gradient(180deg, #fff3cf, #ffbe4a 60%, #b06a1c); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + text-shadow: 0 0 40px rgba(80, 160, 255, 0.25); +} + +.title p { + margin: 6px 0 0; + color: var(--muted); + letter-spacing: 0.08em; +} + +.mbtn { + display: block; + width: 100%; + padding: 14px 18px; + border-radius: 10px; + border: 1px solid var(--line); + background: linear-gradient(180deg, rgba(36, 52, 72, 0.9), rgba(14, 20, 28, 0.95)); + font-size: 16px; + font-weight: 700; + letter-spacing: 0.04em; + text-align: left; + cursor: pointer; +} + +.mbtn:hover { + border-color: var(--line-strong); +} + +.mbtn small { + display: block; + font-weight: 500; + color: var(--muted); + font-size: 12px; + margin-top: 3px; + letter-spacing: 0; +} + +.mbtn.primary { + border-color: rgba(255, 190, 74, 0.6); + background: linear-gradient(180deg, rgba(160, 96, 30, 0.9), rgba(74, 40, 12, 0.95)); +} + +.mbtn.danger { + border-color: rgba(255, 90, 74, 0.5); +} + +.row { + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} + +.row > * { + flex: 1; +} + +.card { + padding: 14px; + border-radius: 12px; + border: 1px solid var(--line); + background: rgba(12, 16, 22, 0.8); +} + +.card h2, +.card h3 { + margin: 0 0 10px; + font-size: 15px; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #a9cbea; +} + +label.field { + display: flex; + flex-direction: column; + gap: 5px; + font-size: 12px; + color: var(--muted); +} + +select, +input[type='text'], +input[type='url'] { + font: inherit; + font-size: 15px; + color: var(--text); + background: #0b1118; + border: 1px solid var(--line); + border-radius: 8px; + padding: 9px 10px; + width: 100%; +} + +input[type='range'] { + width: 100%; +} + +.slot { + display: grid; + grid-template-columns: minmax(70px, 1fr) minmax(90px, 1fr) minmax(90px, 1fr) minmax(86px, 0.8fr) 34px; + gap: 6px; + align-items: center; + margin-bottom: 8px; +} + +.slot .who { + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.swatch { + width: 18px; + height: 18px; + border-radius: 4px; + display: inline-block; + vertical-align: middle; +} + +.races { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.race { + padding: 12px; + border-radius: 10px; + border: 1px solid var(--line); + background: rgba(10, 14, 20, 0.8); + cursor: pointer; + text-align: center; +} + +.race.sel { + border-color: var(--accent); + box-shadow: 0 0 0 1px var(--accent) inset; +} + +.race b { + display: block; + margin-top: 6px; +} + +.race small { + display: block; + color: var(--muted); + font-size: 11px; + margin-top: 4px; + line-height: 1.35; +} + +.race canvas { + width: 64px; + height: 64px; +} + +.muted { + color: var(--muted); +} + +.small { + font-size: 12px; +} + +.chat { + height: 140px; + overflow-y: auto; + font-size: 13px; + background: #0a0f15; + border: 1px solid var(--line); + border-radius: 8px; + padding: 6px 8px; + user-select: text; +} + +.chat div { + margin: 2px 0; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.stats-table th, +.stats-table td { + padding: 6px 8px; + border-bottom: 1px solid var(--line); + text-align: right; +} + +.stats-table th:first-child, +.stats-table td:first-child { + text-align: left; +} + +.banner { + text-align: center; + font-size: clamp(36px, 9vw, 72px); + font-weight: 900; + letter-spacing: 0.12em; + margin: 20px 0 10px; +} + +.banner.win { + color: #7dffa0; + text-shadow: 0 0 30px rgba(80, 255, 140, 0.35); +} + +.banner.lose { + color: #ff7a6a; + text-shadow: 0 0 30px rgba(255, 80, 60, 0.35); +} + +.rotate-hint { + display: none; +} + +@media (orientation: portrait) and (max-width: 600px) { + .rotate-hint.ingame { + display: block; + position: absolute; + top: calc(env(safe-area-inset-top) + 124px); + left: 8px; + right: auto; + font-size: 11px; + padding: 4px 8px; + color: var(--muted); + pointer-events: none; + } +} + +.help { + line-height: 1.55; + font-size: 14px; + color: #c9d7e4; +} + +.help h3 { + margin-top: 14px; +} + +.help kbd { + font-family: var(--mono); + font-size: 12px; + border: 1px solid var(--line); + border-bottom-width: 2px; + border-radius: 4px; + padding: 0 5px; + background: #0b1118; +} + +a { + color: var(--accent); +} + +.toggle { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 6px 0; +} + +.toggle input { + width: 20px; + height: 20px; +} + +/* ---------------------------------------------------------------- spectator / replay */ + +.hud-spec { + position: absolute; + top: calc(env(safe-area-inset-top) + 6px); + right: calc(env(safe-area-inset-right) + 8px); + padding: 6px 10px; + display: flex; + flex-direction: column; + gap: 3px; + font-size: 12px; + font-variant-numeric: tabular-nums; +} + +.spec-ctrl { + display: flex; + gap: 4px; + margin-bottom: 4px; +} + +.spec-ctrl .qbtn { + height: 26px; + min-width: 34px; +} + +.spec-speed { + width: auto; + height: 26px; + padding: 0 6px; + font-size: 12px; + font-weight: 700; +} + +.spec-row { + display: grid; + grid-template-columns: 14px minmax(70px, 1fr) 64px 56px 64px; + gap: 6px; + align-items: center; + padding: 2px 4px; + border: 1px solid transparent; + border-radius: 5px; + background: none; + font: inherit; + text-align: left; + cursor: pointer; +} + +.spec-row:hover { + border-color: var(--line); +} + +.spec-row.on { + border-color: var(--accent); +} + +.spec-row .swatch { + width: 12px; + height: 12px; +} + +.spec-row b { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.spec-row .m { + color: #8fe3ff; +} + +.spec-row .gs { + color: #7be68a; +} + +.spec-row.dead { + opacity: 0.4; + text-decoration: line-through; +} + +.hud-chat { + position: absolute; + left: 50%; + bottom: calc(env(safe-area-inset-bottom) + 170px); + transform: translateX(-50%); + width: min(520px, 90vw); + padding: 10px 12px; + font: inherit; + font-size: 15px; + color: var(--text); + z-index: 6; +} + +.qbtn.alert .n { + color: var(--warn); + font-weight: 800; +} + +@media (max-width: 480px) { + .hud-menu { + left: calc(env(safe-area-inset-left) + 6px); + top: calc(env(safe-area-inset-top) + var(--mini) + 18px); + } + .hud-top { + gap: 7px; + padding: 4px 7px; + font-size: 11px; + } + .hud-top .res { + min-width: 0; + gap: 4px; + } + .clock { + min-width: 0; + } +} + +@media (max-width: 520px) { + .slot { + grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr) minmax(0, 1fr) 34px; + padding-bottom: 8px; + border-bottom: 1px solid var(--line); + } + .slot .who { + grid-column: 1 / -1; + } + .slot select { + font-size: 14px; + padding: 8px 4px 8px 6px; + } + .races { + grid-template-columns: 1fr; + } + .race { + display: grid; + grid-template-columns: 56px 1fr; + text-align: left; + gap: 2px 10px; + align-items: center; + } + .race canvas { + grid-row: span 2; + width: 52px; + height: 52px; + } + .race b { + margin-top: 0; + } +} + +.map-row { + display: flex; + gap: 12px; + align-items: center; + margin-top: 10px; +} + +.map-preview { + border-radius: 8px; + border: 1px solid var(--line); + flex: none; + image-rendering: auto; +} diff --git a/gh-pages/bear-war/src/ui/commandcard.ts b/gh-pages/bear-war/src/ui/commandcard.ts new file mode 100644 index 00000000..e88711cd --- /dev/null +++ b/gh-pages/bear-war/src/ui/commandcard.ts @@ -0,0 +1,297 @@ +import { ABILITY_DEFS, DEFS, RESEARCH_DEFS, buildablesFor, switchingMode } from '../game/data/index.ts'; +import type { Cost, EntityDef } from '../game/data/types.ts'; +import type { Entity } from '../game/entity.ts'; +import type { Game } from '../game/game.ts'; +import type { Command } from '../game/types.ts'; +import { canCarry } from '../game/systems/cargo.ts'; + +export type TargetMode = + | { t: 'move' } + | { t: 'amove' } + | { t: 'patrol' } + | { t: 'gather' } + | { t: 'repair' } + | { t: 'rally' } + | { t: 'load' } + | { t: 'unload' } + | { t: 'ability'; ab: string }; + +export type Action = + | { kind: 'target'; mode: TargetMode } + | { kind: 'cmd'; cmd: Command } + | { kind: 'page'; page: Page } + | { kind: 'place'; def: string }; + +export type Page = 'main' | 'build1' | 'build2'; + +export interface CmdButton { + key: string; + /** Filled in by layout() from the button's grid slot. */ + hotkey?: string; + label: string; + svg?: string; + def?: EntityDef; + enabled: boolean; + why?: string; + cost?: Cost; + supply?: number; + time?: number; + energy?: number; + desc: string; + action: Action; + badge?: string; + /** grid slot 0..11 (row-major, 4 columns); undefined = next free */ + slot?: number; +} + +const ids = (es: Entity[]) => es.map((e) => e.id); + +export function buildCard(g: Game, player: number, all: Entity[], active: Entity[], page: Page): CmdButton[] { + const p = g.players[player]; + const own = all.filter((e) => e.owner === player); + const act = active.filter((e) => e.owner === player); + if (!act.length || !p) return []; + const lead = act[0]; + const d = lead.def; + const out: CmdButton[] = []; + const allIds = ids(own); + const actIds = ids(act); + + // cocoons, growing units, construction: cancel only + if (d.kind === 'cocoon' || (lead.isUnit && lead.morphTo && !switchingMode(lead.morphTo, d))) { + out.push(cancelBtn({ c: 'cancel', id: lead.id, slot: -1 }, 11)); + return out; + } + if (lead.isBuilding && !lead.built) { + out.push(cancelBtn({ c: 'cancel', id: lead.id, slot: -1 }, 11)); + return out; + } + if (d.kind === 'cub') { + const hive = g.get(lead.hive); + if (hive) return buildCard(g, player, [hive], [hive], page); + return []; + } + + // ---------------- build menus + if (page === 'build1' || page === 'build2') { + const list = buildablesFor(p.race, page === 'build1' ? 1 : 2); + list.forEach((b, i) => { + const missing = g.missingTech(p, b.requires); + out.push({ + key: 'build:' + b.id, label: b.name, def: b, enabled: !missing, + why: missing ? `Requires ${missing}` : undefined, cost: b.cost, time: b.time, desc: b.desc, + action: { kind: 'place', def: b.id }, slot: i, + }); + }); + out.push({ key: 'back', label: 'Back', svg: 'back', enabled: true, desc: 'Return to commands', action: { kind: 'page', page: 'main' }, slot: 11 }); + return out; + } + + // ---------------- units + if (lead.isUnit) { + const movable = (d.speed ?? 0) > 0; + if (movable) { + out.push({ key: 'move', label: 'Move', svg: 'move', enabled: true, desc: 'Move to a location or follow a unit.', action: { kind: 'target', mode: { t: 'move' } }, slot: 0 }); + out.push({ key: 'stop', label: 'Stop', svg: 'stop', enabled: true, desc: 'Stop all orders.', action: { kind: 'cmd', cmd: { c: 'stop', ids: allIds } }, slot: 1 }); + out.push({ key: 'hold', label: 'Hold', svg: 'hold', enabled: true, desc: 'Hold position: attack only what is in range.', action: { kind: 'cmd', cmd: { c: 'hold', ids: allIds } }, slot: 2 }); + out.push({ key: 'patrol', label: 'Patrol', svg: 'patrol', enabled: true, desc: 'Patrol between here and a location.', action: { kind: 'target', mode: { t: 'patrol' } }, slot: 3 }); + } else if (d.weapons) { + out.push({ key: 'stop', label: 'Stop', svg: 'stop', enabled: true, desc: 'Stop.', action: { kind: 'cmd', cmd: { c: 'stop', ids: allIds } }, slot: 1 }); + } + if (own.some((e) => e.def.weapons)) { + out.push({ key: 'attack', label: 'Attack', svg: 'attack', enabled: true, desc: 'Attack a target, or attack-move to a location (engage enemies on the way).', action: { kind: 'target', mode: { t: 'amove' } }, slot: 4 }); + } + if (d.cargo) { + const able = act.some((e) => canCarry(g, e)); + const why = able ? undefined : `Requires ${RESEARCH_DEFS[d.cargoReq ?? '']?.name ?? 'research'}`; + out.push({ key: 'load', label: 'Load', svg: 'load', enabled: able, why, desc: `Pick up a unit (carries ${d.cargo} slots: small units 1, medium 2, large 4).`, action: { kind: 'target', mode: { t: 'load' } }, slot: 8 }); + out.push({ + key: 'unload', label: 'Unload All', svg: 'unload', enabled: able && act.some((e) => e.cargo.length > 0), why: why ?? 'Nothing on board', + desc: 'Fly to a location and unload everything.', action: { kind: 'target', mode: { t: 'unload' } }, slot: 9, + }); + } + let slot = 5; + if (d.worker) { + out.push({ key: 'gather', label: 'Gather', svg: 'gather', enabled: true, desc: 'Harvest minerals or gas.', action: { kind: 'target', mode: { t: 'gather' } }, slot: 5 }); + out.push({ key: 'return', label: 'Return', svg: 'return', enabled: act.some((e) => e.carry > 0), desc: 'Return carried resources.', action: { kind: 'cmd', cmd: { c: 'return', ids: actIds } }, slot: 6 }); + if (d.id === 'hardhat') out.push({ key: 'repair', label: 'Repair', svg: 'repair', enabled: true, desc: 'Repair mechanical units and structures (costs resources). Also resumes unfinished construction.', action: { kind: 'target', mode: { t: 'repair' } }, slot: 7 }); + out.push({ key: 'b1', label: 'Build', svg: 'build', enabled: true, desc: 'Basic structures.', action: { kind: 'page', page: 'build1' }, slot: 8 }); + out.push({ key: 'b2', label: 'Advanced', svg: 'build2', enabled: true, desc: 'Advanced structures.', action: { kind: 'page', page: 'build2' }, slot: 9 }); + return out; + } + for (const abId of d.abilities ?? []) { + const ab = ABILITY_DEFS[abId]; + if (!ab || ['gather', 'return', 'repair', 'build', 'build2'].includes(abId)) continue; + if (ab.passive) { + if (ab.requires && !p.has(ab.requires)) continue; + out.push({ key: 'ab:' + ab.id, label: ab.name, svg: ab.icon, enabled: false, why: 'Passive', desc: ab.desc, action: { kind: 'cmd', cmd: { c: 'stop', ids: [] } }, slot: slot++ }); + continue; + } + const missing = g.missingTech(p, ab.needs); + const researched = (!ab.requires || p.has(ab.requires)) && !missing; + let why: string | undefined; + if (missing) why = `Requires ${missing}`; + else if (!researched) why = `Requires ${RESEARCH_DEFS[ab.requires!]?.name ?? ab.requires} research`; + const energyOk = !ab.energy || act.some((e) => e.energy >= ab.energy!); + if (researched && !energyOk) why = 'Not enough energy'; + const enabled = researched && energyOk; + const action: Action = ab.target === 'none' + ? { kind: 'cmd', cmd: { c: 'ability', ids: actIds, a: ab.id } } + : { kind: 'target', mode: { t: 'ability', ab: ab.id } }; + out.push({ key: 'ab:' + ab.id, label: ab.name, svg: ab.icon, enabled, why, energy: ab.energy, desc: ab.desc, action, slot: slot++ }); + } + for (const m of d.unitMorphs ?? []) { + const md = DEFS[m]; + const missing = g.missingTech(p, md.requires); + out.push({ + key: 'morph:' + m, label: `Grow ${md.name}`, def: md, enabled: !missing, why: missing ? `Requires ${missing}` : undefined, + cost: md.cost, time: md.time, desc: md.desc, action: { kind: 'cmd', cmd: { c: 'morph', ids: actIds, d: m } }, slot: slot++, + }); + } + return out; + } + + // ---------------- structures + let slot = 0; + const cubs = act.reduce((s, e) => s + e.cubs.length, 0); + for (const u of d.produces ?? []) { + const ud = DEFS[u]; + const missing = g.missingTech(p, ud.requires); + const unpowered = d.needsPower && !act.some((e) => e.powered); + out.push({ + key: 'train:' + u, label: (ud.count ?? 1) > 1 ? `${ud.name} ×${ud.count}` : ud.name, def: ud, + enabled: !missing && !unpowered && (!d.spawnsCubs || cubs > 0), + why: missing ? `Requires ${missing}` : unpowered ? 'Unpowered: needs a Star Totem\'s glow' : d.spawnsCubs && cubs === 0 ? 'No cubs available' : undefined, + cost: ud.cost, supply: (ud.supply ?? 0) * (ud.count ?? 1), time: ud.time, desc: ud.desc, + action: { kind: 'cmd', cmd: { c: 'train', ids: actIds, d: u } }, slot: slot++, + }); + } + for (const r of d.researches ?? []) { + const rd = RESEARCH_DEFS[r]; + const lvl = p.level(r) + (p.researching[r] ?? 0); + if (lvl >= rd.costs.length) continue; + const reqs = rd.reqs?.[lvl]; + const missing = g.missingTech(p, reqs); + const busy = (p.researching[r] ?? 0) > 0; + const label = rd.costs.length > 1 ? `${rd.name} ${lvl + 1}` : rd.name; + out.push({ + key: 'research:' + r, label, svg: rd.icon, enabled: !missing && !busy, why: busy ? 'Researching…' : missing ? `Requires ${missing}` : undefined, + cost: rd.costs[lvl], time: rd.times[lvl], desc: rd.desc, action: { kind: 'cmd', cmd: { c: 'research', ids: actIds, r } }, slot: slot++, + }); + } + for (const m of d.morphs ?? []) { + const md = DEFS[m]; + const missing = g.missingTech(p, md.requires); + const busy = act.every((e) => e.queue.some((q) => q.kind === 'morph')); + out.push({ + key: 'bmorph:' + m, label: `Evolve ${md.name}`, def: md, enabled: !missing && !busy, + why: busy ? 'Already evolving' : missing ? `Requires ${missing}` : undefined, cost: md.cost, time: md.time, desc: md.desc, + action: { kind: 'cmd', cmd: { c: 'morph', ids: actIds, d: m } }, slot: slot++, + }); + } + for (const abId of d.abilities ?? []) { + const ab = ABILITY_DEFS[abId]; + if (!ab) continue; + const energyOk = !ab.energy || act.some((e) => e.energy >= ab.energy!); + const action: Action = ab.target === 'none' + ? { kind: 'cmd', cmd: { c: 'ability', ids: actIds, a: ab.id } } + : { kind: 'target', mode: { t: 'ability', ab: ab.id } }; + out.push({ key: 'ab:' + ab.id, label: ab.name, svg: ab.icon, enabled: energyOk, why: energyOk ? undefined : 'Not enough energy', energy: ab.energy, desc: ab.desc, action, slot: slot++ }); + } + if ((d.weapons && !d.produces) || d.bunker) { + out.push({ key: 'stop', label: 'Stop', svg: 'stop', enabled: true, desc: 'Stop attacking.', action: { kind: 'cmd', cmd: { c: 'stop', ids: allIds } }, slot: 0 }); + out.push({ key: 'attack', label: 'Attack', svg: 'attack', enabled: true, desc: 'Attack a target.', action: { kind: 'target', mode: { t: 'amove' } }, slot: 1 }); + } + if (d.bunker) { + out.push({ + key: 'unload', label: 'Unload All', svg: 'unload', enabled: act.some((e) => e.cargo.length > 0), why: 'Nobody inside', + desc: `Send everyone out. Up to ${d.cargo} infantry can take cover inside (right-click the bunker with them) and fire out with +${d.bunker} range.`, + action: { kind: 'cmd', cmd: { c: 'unload', ids: actIds } }, slot: 2, + }); + } + if (d.produces || d.spawnsCubs) { + out.push({ key: 'rally', label: 'Rally', svg: 'rally', enabled: true, desc: 'Set where new units go. Rally to minerals to auto-mine.', action: { kind: 'target', mode: { t: 'rally' } }, slot: 11 }); + } + return out; +} + +function cancelBtn(cmd: Command, slot: number): CmdButton { + return { key: 'cancel', label: 'Cancel', svg: 'cancel', enabled: true, desc: 'Cancel and refund 75%.', action: { kind: 'cmd', cmd }, slot }; +} + +/** + * Physical key positions for the 4×3 command card, row-major. + * `code` names come from the QWERTY reference, but they identify the key's + * position, so the same corners work on AZERTY, Dvorak, and other layouts. + */ +export const GRID_CODES = ['KeyQ', 'KeyW', 'KeyE', 'KeyR', 'KeyA', 'KeyS', 'KeyD', 'KeyF', 'KeyZ', 'KeyX', 'KeyC', 'KeyV']; + +const QWERTY_LABELS = ['Q', 'W', 'E', 'R', 'A', 'S', 'D', 'F', 'Z', 'X', 'C', 'V']; +let gridLabels = QWERTY_LABELS.slice(); + +function glyph(raw: string | undefined, fallback: string): string { + if (!raw) return fallback; + const chars = [...raw]; + if (chars.length !== 1) return fallback; + const ch = chars[0].toUpperCase(); + return ch.trim() ? ch : fallback; +} + +/** Character printed on that physical key. QWERTY letters until the OS layout is known. */ +export function gridLabel(slot: number): string { + return gridLabels[slot] ?? ''; +} + +interface KeyLayoutMap { + get(code: string): string | undefined; +} + +/** Not in the DOM lib this project typechecks against. Chromium implements it. */ +interface KeyboardWithLayout { + getLayoutMap?: () => Promise; +} + +/** Read the OS layout so the letters on the buttons match the keycaps. */ +export function watchGridLabels(onChange: () => void): () => void { + let dead = false; + const read = (): void => { + const keyboard = (navigator as Navigator & { keyboard?: KeyboardWithLayout }).keyboard; + const get = keyboard?.getLayoutMap?.bind(keyboard); + if (!get) return; + void get() + .then((map) => { + if (dead) return; + const next = GRID_CODES.map((code, i) => glyph(map.get(code), QWERTY_LABELS[i])); + if (next.every((ch, i) => ch === gridLabels[i])) return; + gridLabels = next; + onChange(); + }) + .catch(() => {}); + }; + read(); + window.addEventListener('focus', read); + return () => { + dead = true; + window.removeEventListener('focus', read); + }; +} + +/** Lay buttons out on a 4x3 grid, honouring preferred slots. */ +export function layout(buttons: CmdButton[]): (CmdButton | null)[] { + const grid: (CmdButton | null)[] = new Array(12).fill(null); + const rest: CmdButton[] = []; + for (const b of buttons) { + if (b.slot !== undefined && b.slot < 12 && !grid[b.slot]) grid[b.slot] = b; + else rest.push(b); + } + for (const b of rest) { + const i = grid.indexOf(null); + if (i < 0) break; + grid[i] = b; + } + for (let i = 0; i < grid.length; i++) { + const b = grid[i]; + if (b) b.hotkey = gridLabel(i); + } + return grid; +} diff --git a/gh-pages/bear-war/src/ui/controller.ts b/gh-pages/bear-war/src/ui/controller.ts new file mode 100644 index 00000000..ffe09533 --- /dev/null +++ b/gh-pages/bear-war/src/ui/controller.ts @@ -0,0 +1,469 @@ +import { CELLS_PER_TILE, TILE } from '../game/constants.ts'; +import { ABILITY_DEFS, DEFS, RACES, family } from '../game/data/index.ts'; +import type { EntityDef } from '../game/data/types.ts'; +import type { Entity } from '../game/entity.ts'; +import type { Game } from '../game/game.ts'; +import type { Command } from '../game/types.ts'; +import { canPlace } from '../game/systems/placement.ts'; +import type { Session } from '../net/session.ts'; +import type { Renderer, ViewState } from '../render/renderer.ts'; +import type { Action, Page, TargetMode } from './commandcard.ts'; +import { Selection } from './selection.ts'; + +export interface Feedback { + ack(kind: 'move' | 'attack' | 'select' | 'build' | 'error' | 'click', e?: Entity): void; + message(text: string, kind?: 'error' | 'info'): void; +} + +export class Controller { + g: Game; + session: Session; + r: Renderer; + sel = new Selection(); + view: ViewState; + fb: Feedback; + page: Page = 'main'; + targeting: TargetMode | null = null; + placing: EntityDef | null = null; + placeTx = 0; + placeTy = 0; + /** mobile: placement ghost waits for explicit confirmation */ + placeConfirm = false; + lastAlert: { x: number; y: number } | null = null; + private idleCursor = 0; + private baseCursor = 0; + version = 0; + + constructor(g: Game, session: Session, r: Renderer, fb: Feedback) { + this.g = g; + this.session = session; + this.r = r; + this.fb = fb; + const p = g.players[session.localPlayer]; + this.view = { + player: session.localPlayer, + team: p ? p.team : -1, + selection: [], + hover: null, + placement: null, + showPower: false, + dragBox: null, + markers: [], + showAllBars: false, + rangeRing: null, + }; + } + + get player(): number { + return this.session.localPlayer; + } + + issue(cmd: Command): void { + if (this.player < 0) return; + this.session.issue(cmd); + } + + bump(): void { + this.version++; + } + + // ------------------------------------------------------------------ selection + + selected(): Entity[] { + return this.sel.entities(this.g); + } + ownSelected(): Entity[] { + return this.selected().filter((e) => e.owner === this.player); + } + + select(es: Entity[], add = false): void { + if (add) this.sel.add(this.g, es); + else this.sel.set(this.g, es); + this.page = 'main'; + this.cancelModes(); + if (es.length) this.fb.ack('select', es[0]); + this.bump(); + } + + clickSelect(wx: number, wy: number, shift: boolean, sameType: boolean, slack: number): void { + const e = this.pickVisible(wx, wy, slack); + if (!e) { + if (!shift) { + this.sel.clear(); + this.page = 'main'; + this.bump(); + } + return; + } + if (sameType && e.owner === this.player) { + this.select(this.onScreenOfType(e.def.id), shift); + return; + } + if (shift && e.owner === this.player && this.selected().every((s) => s.owner === this.player)) { + if (this.sel.has(e.id)) { + this.sel.remove(this.g, [e]); + this.bump(); + } else this.select([e], true); + return; + } + this.select([e]); + } + + /** Box in world coords. */ + boxSelect(x0: number, y0: number, x1: number, y1: number, shift: boolean): void { + const minX = Math.min(x0, x1); + const maxX = Math.max(x0, x1); + const minY = Math.min(y0, y1); + const maxY = Math.max(y0, y1); + const units: Entity[] = []; + const blds: Entity[] = []; + for (const e of this.g.entities) { + if (!e.alive || e.owner !== this.player || e.inside || e.def.kind === 'cub') continue; + const pad = e.isBuilding ? 0 : e.r * 0.6; + if (e.x + pad < minX || e.x - pad > maxX || e.y + pad < minY || e.y - pad > maxY) continue; + if (e.isBuilding) blds.push(e); + else if (e.def.kind !== 'cocoon') units.push(e); + } + const pick = units.length ? units : blds; + if (!pick.length) { + if (!shift) this.clickSelect((x0 + x1) / 2, (y0 + y1) / 2, false, false, 12); + return; + } + this.select(pick, shift); + } + + onScreenOfType(defId: string): Entity[] { + const cam = this.r.cam; + const fam = family(DEFS[defId]); + return this.g.entities.filter( + (e) => e.alive && e.owner === this.player && family(e.def) === fam && !e.inside && + e.x >= cam.x && e.x <= cam.x + cam.viewW && e.y >= cam.y && e.y <= cam.y + cam.viewH, + ); + } + + pickVisible(wx: number, wy: number, slack: number): Entity | undefined { + const g = this.g; + const team = this.view.team; + let best: Entity | undefined; + let bestD = Infinity; + for (const e of g.unitsNear(wx, wy, slack + 26, [])) { + if (e.def.hidden || !this.r.visibility(g, e, team) || this.r.visibility(g, e, team) === 2) continue; + const d = Math.hypot(e.x - wx, e.y - wy + (e.air ? 8 : 0)) - e.r; + // prefer own units when tapping in a crowd + const bias = e.owner === this.player ? -2 : 0; + if (d <= slack && d + bias < bestD) { + bestD = d + bias; + best = e; + } + } + if (best) return best; + for (const e of g.structuresNear(wx, wy, slack * 0.5, [])) { + if (this.r.visibility(g, e, team)) return e; + } + return undefined; + } + + // ------------------------------------------------------------------ commands + + /** Right-click / tap with a selection. */ + smart(wx: number, wy: number, target: Entity | undefined, queue: boolean): void { + const own = this.ownSelected(); + if (!own.length) return; + const ids = own.map((e) => e.id); + this.issue({ c: 'smart', ids, x: wx, y: wy, t: target?.id, q: queue || undefined }); + const enemy = target && this.g.isEnemy(this.player, target.owner); + this.view.markers.push({ x: target ? target.x : wx, y: target ? target.y : wy, t: 0.5, color: enemy ? '#ff5040' : '#50ff70' }); + if (own.some((e) => e.isUnit)) this.fb.ack(enemy ? 'attack' : 'move', own[0]); + } + + startTargeting(mode: TargetMode): void { + this.targeting = mode; + this.placing = null; + this.view.placement = null; + if (mode.t === 'ability') { + const ab = ABILITY_DEFS[mode.ab]; + const caster = this.sel.activeEntities(this.g)[0]; + this.view.rangeRing = caster && ab.range ? { x: caster.x, y: caster.y, r: ab.range * TILE } : null; + } + this.bump(); + } + + /** Execute the pending targeted command at a world point. */ + targetAt(wx: number, wy: number, target: Entity | undefined, queue: boolean): void { + const mode = this.targeting; + if (!mode) return; + const own = this.ownSelected(); + const ids = own.map((e) => e.id); + const act = this.sel.activeEntities(this.g).filter((e) => e.owner === this.player).map((e) => e.id); + let color = '#50ff70'; + switch (mode.t) { + case 'move': + if (target && target !== own[0]) this.issue({ c: 'smart', ids, x: wx, y: wy, t: target.isUnit && !this.g.isEnemy(this.player, target.owner) ? target.id : undefined, q: queue || undefined }); + else this.issue({ c: 'move', ids, x: wx, y: wy, q: queue || undefined }); + this.fb.ack('move', own[0]); + break; + case 'amove': + color = '#ff5040'; + if (target && this.g.isEnemy(this.player, target.owner)) this.issue({ c: 'attack', ids, t: target.id, q: queue || undefined }); + else this.issue({ c: 'amove', ids, x: wx, y: wy, q: queue || undefined }); + this.fb.ack('attack', own[0]); + break; + case 'patrol': + this.issue({ c: 'patrol', ids, x: wx, y: wy, q: queue || undefined }); + this.fb.ack('move', own[0]); + break; + case 'gather': + if (target) this.issue({ c: 'gather', ids, t: target.id, q: queue || undefined }); + break; + case 'repair': + if (target) this.issue({ c: 'repair', ids, t: target.id, q: queue || undefined }); + break; + case 'rally': + this.issue({ c: 'rally', ids: act, x: wx, y: wy, t: target?.id }); + break; + case 'load': + if (target) this.issue({ c: 'load', ids: act, t: target.id, q: queue || undefined }); + this.fb.ack('move', own[0]); + break; + case 'unload': + this.issue({ c: 'unload', ids: act, x: wx, y: wy, q: queue || undefined }); + this.fb.ack('move', own[0]); + break; + case 'ability': { + const ab = ABILITY_DEFS[mode.ab]; + color = '#9ad0ff'; + this.issue({ c: 'ability', ids: act, a: mode.ab, x: wx, y: wy, t: target?.id, q: queue || undefined }); + void ab; + break; + } + } + this.view.markers.push({ x: target ? target.x : wx, y: target ? target.y : wy, t: 0.5, color }); + if (!queue) this.cancelModes(); + this.bump(); + } + + doAction(a: Action): void { + switch (a.kind) { + case 'cmd': + this.issue(a.cmd); + if (a.cmd.c === 'train' || a.cmd.c === 'research' || a.cmd.c === 'morph') this.fb.ack('click'); + if (a.cmd.c === 'stop' || a.cmd.c === 'hold') this.fb.ack('move', this.ownSelected()[0]); + break; + case 'target': + this.startTargeting(a.mode); + break; + case 'page': + this.page = a.page; + this.cancelModes(); + break; + case 'place': + this.startPlacement(DEFS[a.def]); + break; + } + this.fb.ack('click'); + this.bump(); + } + + cancelModes(): void { + this.targeting = null; + this.placing = null; + this.placeConfirm = false; + this.view.placement = null; + this.view.rangeRing = null; + this.view.showPower = false; + } + + /** Escape: leave the innermost mode. */ + escape(): void { + if (this.targeting || this.placing) this.cancelModes(); + else if (this.page !== 'main') this.page = 'main'; + else { + // cancel last queued item of a selected structure + const b = this.sel.activeEntities(this.g).find((e) => e.owner === this.player && (e.queue.length || !e.built || e.morphTo)); + if (b) { + if (!b.built || b.morphTo) this.issue({ c: 'cancel', id: b.id, slot: -1 }); + else this.issue({ c: 'cancel', id: b.id, slot: b.queue.length - 1 }); + } + } + this.bump(); + } + + // ------------------------------------------------------------------ placement + + startPlacement(def: EntityDef): void { + this.placing = def; + this.targeting = null; + this.view.showPower = def.race === 'starborn'; + const cam = this.r.cam; + this.updatePlacement(cam.centerX, cam.centerY); + this.bump(); + } + + updatePlacement(wx: number, wy: number): void { + const def = this.placing; + if (!def) return; + const [w, h] = def.size!; + let tx = Math.round(wx / TILE - w / 2); + let ty = Math.round(wy / TILE - h / 2); + if (def.refinery) { + // snap to the nearest geyser + let best: Entity | undefined; + let bd = 6 * TILE; + for (const e of this.g.structuresNear(wx, wy, 6 * TILE, [])) { + if (e.def.kind !== 'geyser') continue; + const d = Math.hypot(e.x - wx, e.y - wy); + if (d < bd) { + bd = d; + best = e; + } + } + if (best) { + tx = best.tx; + ty = best.ty; + } + } + this.placeTx = tx; + this.placeTy = ty; + this.view.placement = { def, tx, ty, ...this.placementMask(def, tx, ty) }; + } + + placementMask(def: EntityDef, tx: number, ty: number): { ok: boolean; bad: boolean[] } { + const g = this.g; + const [w, h] = def.size!; + const builder = this.ownSelected().find((e) => e.def.worker); + const err = canPlace(g, this.player, def.id, tx, ty, builder?.id ?? 0); + const bad: boolean[] = []; + const team = this.view.team; + let anyBad = false; + for (let j = 0; j < h; j++) { + for (let i = 0; i < w; i++) { + const x = tx + i; + const y = ty + j; + let b = false; + if (!def.refinery) { + if (!g.map.buildable(x, y)) b = true; + else { + const ci = y * CELLS_PER_TILE * g.grid.cw + x * CELLS_PER_TILE; + if (g.grid.occ[ci]) b = true; + if (team >= 0 && !g.vision.explored[team][y * g.map.w + x]) b = true; + const comb = g.comb.has(x, y); + if (def.needsComb && !comb) b = true; + if (def.race !== 'horde' && comb) b = true; + } + } + bad.push(b); + anyBad ||= b; + } + } + if (err && !anyBad) bad.fill(true); + return { ok: !err, bad }; + } + + /** Place the current ghost. Returns true if a build order was issued. */ + confirmPlacement(queue: boolean): boolean { + const def = this.placing; + if (!def) return false; + const workers = this.ownSelected().filter((e) => e.def.worker); + const { ok } = this.placementMask(def, this.placeTx, this.placeTy); + if (!ok) { + const err = canPlace(this.g, this.player, def.id, this.placeTx, this.placeTy, workers[0]?.id ?? 0); + this.fb.message(err ?? "Can't build there", 'error'); + this.fb.ack('error'); + return false; + } + const p = this.g.players[this.player]; + if (p.minerals < def.cost.m) { + this.fb.message('Not enough minerals', 'error'); + this.fb.ack('error'); + return false; + } + if (p.gas < def.cost.g) { + this.fb.message('Not enough gas', 'error'); + this.fb.ack('error'); + return false; + } + this.issue({ c: 'build', ids: workers.map((e) => e.id), d: def.id, tx: this.placeTx, ty: this.placeTy, q: queue || undefined }); + this.fb.ack('build', workers[0]); + if (!queue) { + this.cancelModes(); + this.page = 'main'; + } + this.bump(); + return true; + } + + // ------------------------------------------------------------------ groups & shortcuts + + groupSelect(i: number): boolean { + const es = this.sel.loadGroup(i, this.g); + if (!es.length) return false; + this.select(es); + return true; + } + groupSet(i: number): void { + this.sel.saveGroup(i, this.g); + this.fb.message(`Control group ${i} set`, 'info'); + this.bump(); + } + groupAdd(i: number): void { + this.sel.addToGroup(i, this.g); + this.bump(); + } + + centerOnSelection(): void { + const es = this.selected(); + if (!es.length) return; + let x = 0; + let y = 0; + for (const e of es) { + x += e.x; + y += e.y; + } + this.r.cam.centerOn(x / es.length, y / es.length); + } + + selectArmy(): void { + const army = this.g.entities.filter( + (e) => e.alive && e.owner === this.player && e.isUnit && e.def.kind === 'unit' && !e.def.worker && e.def.id !== 'bumblebear' && !e.inside, + ); + if (army.length) this.select(army); + else this.fb.message('No army units', 'info'); + } + + selectIdleWorker(all = false): void { + const idle = this.g.entities.filter((e) => e.alive && e.owner === this.player && e.def.worker && e.orders.length === 0 && !e.inside); + if (!idle.length) { + this.fb.message('No idle workers', 'info'); + return; + } + if (all) { + this.select(idle); + return; + } + const e = idle[this.idleCursor++ % idle.length]; + this.select([e]); + this.r.cam.centerOn(e.x, e.y); + } + + cycleBase(): void { + const halls = this.g.entities.filter((e) => e.alive && e.owner === this.player && e.def.townHall); + if (!halls.length) return; + const e = halls[this.baseCursor++ % halls.length]; + this.select([e]); + this.r.cam.centerOn(e.x, e.y); + } + + jumpToAlert(): void { + if (this.lastAlert) this.r.cam.centerOn(this.lastAlert.x, this.lastAlert.y); + } + + /** Is the current selection something we can command? */ + hasOwnSelection(): boolean { + return this.ownSelected().length > 0; + } + + raceDef() { + const p = this.g.players[this.player]; + return p ? RACES[p.race] : null; + } +} diff --git a/gh-pages/bear-war/src/ui/hud.ts b/gh-pages/bear-war/src/ui/hud.ts new file mode 100644 index 00000000..ca9e51a8 --- /dev/null +++ b/gh-pages/bear-war/src/ui/hud.ts @@ -0,0 +1,710 @@ +import { canCarry, cargoUsed } from '../game/systems/cargo.ts'; +import { DEFS, RESEARCH_DEFS } from '../game/data/index.ts'; +import { TEAM_COLORS } from '../game/constants.ts'; +import type { EntityDef } from '../game/data/types.ts'; +import type { Entity } from '../game/entity.ts'; +import type { Game } from '../game/game.ts'; +import { armorOf, maxHp, maxShields, weaponDamage, weaponRangePx } from '../game/stats.ts'; +import { TILE } from '../game/constants.ts'; +import type { SpriteCache } from '../render/sprites.ts'; +import { GRID_CODES, buildCard, layout, watchGridLabels, type CmdButton } from './commandcard.ts'; +import type { Controller } from './controller.ts'; +import { iconSvg } from './icons.ts'; + +function el(tag: K, cls = '', parent?: HTMLElement): HTMLElementTagNameMap[K] { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (parent) parent.appendChild(e); + return e; +} + +function fmtTime(sec: number): string { + const m = Math.floor(sec / 60); + const s = Math.floor(sec % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +export interface HudCallbacks { + onMenu(): void; + /** spectator/replay speed control; undefined when not available */ + setSpeed?(speed: number): void; + togglePause?(): boolean; + /** online chat; undefined in offline games */ + chat?(text: string): void; + boxMode(): boolean; + setBoxMode(on: boolean): void; + touch(): boolean; +} + +export class Hud { + root: HTMLElement; + g: Game; + ctl: Controller; + sprites: SpriteCache; + cb: HudCallbacks; + minimapCanvas: HTMLCanvasElement; + buttons: (CmdButton | null)[] = []; + private elMin: HTMLElement; + private elGas: HTMLElement; + private elSup: HTMLElement; + private elSupWrap: HTMLElement; + private elTime: HTMLElement; + private elSel: HTMLElement; + private elCard: HTMLElement; + private elMsgs: HTMLElement; + private elStatus: HTMLElement; + private elTip: HTMLElement; + private elPlace: HTMLElement; + private elHint: HTMLElement; + private elGroups: HTMLElement; + private cardBtns: HTMLButtonElement[] = []; + /** current innerHTML of each command button */ + private cardInner: string[] = []; + private iconUrls = new Map(); + private lastSelVer = -1; + private lastCtlVer = -1; + private cardTimer = 0; + private selTimer = 0; + private lastCardKey = ''; + private selKey = ''; + private tipFor: CmdButton | null = null; + private groupBtns: HTMLButtonElement[] = []; + private idleBtn: HTMLButtonElement | null = null; + private chatBox: HTMLInputElement | null = null; + private stopGridLabels: (() => void) | null = null; + + constructor(parent: HTMLElement, g: Game, ctl: Controller, sprites: SpriteCache, cb: HudCallbacks) { + this.g = g; + this.ctl = ctl; + this.sprites = sprites; + this.cb = cb; + const root = (this.root = el('div', 'hud', parent)); + // keep keyboard focus off HUD buttons so hotkeys (and Space) always reach the game + root.addEventListener('mousedown', (e) => { + if ((e.target as HTMLElement).closest('button')) e.preventDefault(); + }); + + const mm = el('div', 'hud-minimap panel', root); + this.minimapCanvas = el('canvas', '', mm); + + const menu = el('button', 'hud-menu panel', root); + menu.innerHTML = iconSvg('menu', 20); + menu.title = 'Menu (F10)'; + menu.addEventListener('click', () => cb.onMenu()); + + const top = el('div', 'hud-top panel', root); + const res = (cls: string) => { + const w = el('div', 'res', top); + el('span', 'ico ' + cls, w); + return { wrap: w, val: el('b', '', w) }; + }; + this.elMin = res('min').val; + this.elGas = res('gas').val; + const sup = res('sup'); + this.elSup = sup.val; + this.elSupWrap = sup.wrap; + this.elTime = el('div', 'clock', top); + + if (ctl.player < 0) { + top.style.display = 'none'; + this.buildSpectatorBar(root); + } + this.elMsgs = el('div', 'hud-msgs', root); + this.elStatus = el('div', 'hud-status panel', root); + this.elTip = el('div', 'hud-tip panel', root); + this.elHint = el('div', 'hud-hint panel', root); + + this.elPlace = el('div', 'hud-place', root); + const ok = el('button', 'ok', this.elPlace); + ok.textContent = '✓ Build'; + ok.addEventListener('click', () => { + if (this.ctl.confirmPlacement(false)) this.refreshCard(true); + }); + const no = el('button', 'no', this.elPlace); + no.textContent = '✕ Cancel'; + no.addEventListener('click', () => { + this.ctl.cancelModes(); + this.ctl.bump(); + }); + + const bottom = el('div', 'hud-bottom', root); + const left = el('div', 'hud-left', bottom); + this.elGroups = el('div', 'hud-groups', left); + this.buildGroups(); + this.elSel = el('div', 'hud-sel panel empty', left); + this.elCard = el('div', 'hud-card panel', bottom); + for (let i = 0; i < 12; i++) { + const b = el('button', 'cbtn empty', this.elCard); + b.dataset.i = String(i); + this.attachButton(b, i); + this.cardBtns.push(b); + } + this.stopGridLabels = watchGridLabels(() => this.refreshCard(true)); + } + + // ------------------------------------------------------------------ spectator + + private specRows: HTMLElement[] = []; + private speedSel?: HTMLSelectElement; + private autoBtn?: HTMLElement; + private calm = 0; + + private buildSpectatorBar(root: HTMLElement): void { + const bar = el('div', 'hud-spec panel', root); + const ctrl = el('div', 'spec-ctrl', bar); + if (this.cb.togglePause) { + const pb = el('button', 'qbtn', ctrl); + pb.textContent = '⏸'; + pb.addEventListener('click', () => (pb.textContent = this.cb.togglePause!() ? '▶' : '⏸')); + } + if (this.cb.setSpeed) { + const sel = (this.speedSel = el('select', 'spec-speed', ctrl) as HTMLSelectElement); + for (const sp of [0.5, 1, 2, 4, 8, 16, 32]) sel.add(new Option(`${sp}×`, String(sp), sp === 1, sp === 1)); + sel.addEventListener('change', () => { + this.autoBtn!.classList.remove('on'); + this.cb.setSpeed!(Number(sel.value)); + }); + const auto = (this.autoBtn = el('button', 'qbtn spec-auto', ctrl)); + auto.textContent = 'Auto'; + auto.title = 'Fast-forward quiet moments, slow down for fights'; + auto.addEventListener('click', () => auto.classList.toggle('on')); + } + for (const p of this.g.players) { + const row = el('button', 'spec-row', bar); + row.title = 'See this team\'s vision (click again to see everything)'; + row.innerHTML = ``; + (row.querySelector('b') as HTMLElement).textContent = p.name; + row.addEventListener('click', () => { + const team = this.ctl.view.team === p.team ? -1 : p.team; + this.ctl.view.team = team; + this.specRows.forEach((r, i) => r.classList.toggle('on', this.g.players[i].team === team)); + }); + this.specRows.push(row); + } + } + + private updateSpectator(dt: number): void { + const sel = this.speedSel; + if (sel && this.autoBtn!.classList.contains('on')) { + const fighting = this.g.entities.filter((e) => e.alive && e.cds.some((c) => c > 0)).length; + const want = fighting > 12 ? 0.5 : fighting ? 1 : this.g.entities.some((e) => e.alive && e.wantMove && e.def.weapons && !e.def.worker) ? 4 : 32; + const cur = Number(sel.value); + // slow down at once, speed up only after 3 calm seconds so volleys don't flicker the speed + if (want <= cur) this.calm = 3; + if (want < cur || (want > cur && (this.calm -= dt) <= 0)) { + sel.value = String(want); + this.cb.setSpeed!(want); + } + } + this.g.players.forEach((p, i) => { + const row = this.specRows[i]; + if (!row) return; + row.classList.toggle('dead', p.defeated); + setText(row.querySelector('.m') as HTMLElement, `◆ ${Math.floor(p.minerals)}`); + setText(row.querySelector('.gs') as HTMLElement, `● ${Math.floor(p.gas)}`); + setText(row.querySelector('.sp') as HTMLElement, `⌂ ${Math.ceil(p.supplyUsed)}/${p.supplyMax}`); + }); + } + + // ------------------------------------------------------------------ quick buttons + + private buildGroups(): void { + const box = el('button', 'qbtn', this.elGroups); + box.innerHTML = iconSvg('select', 18); + box.title = 'Box select mode (touch)'; + box.addEventListener('click', () => { + this.cb.setBoxMode(!this.cb.boxMode()); + box.classList.toggle('on', this.cb.boxMode()); + }); + if (!this.cb.touch()) box.style.display = 'none'; + const army = el('button', 'qbtn', this.elGroups); + army.innerHTML = iconSvg('army', 18); + army.title = 'Select army (F2)'; + army.addEventListener('click', () => this.ctl.selectArmy()); + const idle = el('button', 'qbtn', this.elGroups); + this.idleBtn = idle; + idle.innerHTML = iconSvg('worker', 18) + ''; + idle.title = 'Idle worker (F1). Long-press: all idle workers'; + this.longPress(idle, () => this.ctl.selectIdleWorker(false), () => this.ctl.selectIdleWorker(true)); + if (this.cb.chat) { + const cbtn = el('button', 'qbtn', this.elGroups); + cbtn.textContent = '💬'; + cbtn.title = 'Chat (Enter)'; + cbtn.addEventListener('click', () => this.openChat()); + } + for (let i = 1; i <= 5; i++) { + const b = el('button', 'qbtn empty', this.elGroups); + b.innerHTML = `${i}`; + b.title = `Control group ${i}: tap to select (double-tap to jump), long-press to assign. Keys: ${i} / Ctrl+${i}`; + let lastTap = 0; + this.longPress( + b, + () => { + const now = performance.now(); + if (this.ctl.groupSelect(i) && now - lastTap < 350) this.ctl.centerOnSelection(); + lastTap = now; + }, + () => { + this.ctl.groupSet(i); + navigator.vibrate?.(20); + }, + ); + this.groupBtns.push(b); + } + } + + private longPress(b: HTMLElement, tap: () => void, hold: () => void): void { + let timer = 0; + let held = false; + b.addEventListener('pointerdown', () => { + held = false; + timer = window.setTimeout(() => { + held = true; + hold(); + }, 450); + }); + const end = () => window.clearTimeout(timer); + b.addEventListener('pointerup', end); + b.addEventListener('pointerleave', end); + b.addEventListener('pointercancel', end); + b.addEventListener('click', (ev) => { + ev.preventDefault(); + if (!held) tap(); + }); + b.addEventListener('contextmenu', (ev) => ev.preventDefault()); + } + + // ------------------------------------------------------------------ chat + + openChat(): void { + if (!this.cb.chat || this.chatBox) return; + const input = el('input', 'hud-chat panel', this.root); + input.type = 'text'; + input.maxLength = 200; + input.placeholder = 'Message all players, Enter to send, Esc to cancel'; + this.chatBox = input; + const close = () => { + input.remove(); + this.chatBox = null; + }; + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { + if (input.value.trim()) this.cb.chat!(input.value); + close(); + } else if (e.key === 'Escape') close(); + }); + input.addEventListener('blur', () => window.setTimeout(close, 150)); + window.setTimeout(() => input.focus(), 0); + } + + get chatOpen(): boolean { + return !!this.chatBox; + } + + // ------------------------------------------------------------------ command card + + private attachButton(b: HTMLButtonElement, i: number): void { + let timer = 0; + let held = false; + b.addEventListener('pointerdown', (ev) => { + held = false; + if (ev.pointerType !== 'mouse') { + timer = window.setTimeout(() => { + held = true; + const cb = this.buttons[i]; + if (cb) this.showTip(cb, b); + }, 420); + } + }); + const clear = () => { + window.clearTimeout(timer); + if (held) this.hideTip(); + }; + b.addEventListener('pointerup', clear); + b.addEventListener('pointercancel', clear); + b.addEventListener('pointerenter', (ev) => { + if (ev.pointerType === 'mouse') { + const cb = this.buttons[i]; + if (cb) this.showTip(cb, b); + } + }); + b.addEventListener('pointerleave', () => { + window.clearTimeout(timer); + this.hideTip(); + }); + b.addEventListener('click', (ev) => { + ev.preventDefault(); + if (held) return; + this.press(i); + }); + b.addEventListener('contextmenu', (ev) => ev.preventDefault()); + } + + press(i: number): void { + const cb = this.buttons[i]; + if (!cb || this.ctl.player < 0) return; + if (!cb.enabled) { + if (cb.why) this.message(cb.why, 'error'); + this.ctl.fb.ack('error'); + return; + } + this.ctl.doAction(cb.action); + this.refreshCard(true); + } + + /** Press the command-card button for a physical key (`KeyboardEvent.code`). */ + pressHotkey(code: string): boolean { + const i = GRID_CODES.indexOf(code); + if (i < 0 || !this.buttons[i]) return false; + this.press(i); + return true; + } + + private iconUrl(def: EntityDef, color: number, px: number): string { + const key = `${def.id}|${color}|${px}`; + let u = this.iconUrls.get(key); + if (!u) { + u = this.sprites.icon(def, color, px).toDataURL(); + this.iconUrls.set(key, u); + } + return u; + } + + private myColor(): number { + return this.g.players[this.ctl.player]?.color ?? 0; + } + + refreshCard(force = false): void { + const ctl = this.ctl; + const g = this.g; + const all = ctl.selected(); + const active = ctl.sel.activeEntities(g); + const owner = ctl.player < 0 ? (active[0]?.owner ?? -1) : ctl.player; + const btns = buildCard(g, owner, all, active, ctl.page); + this.buttons = layout(btns); + // on small screens an empty command card is just wasted space + this.elCard.classList.toggle('none', btns.length === 0); + const p = g.players[owner]; + const key = this.buttons.map((b) => (b ? `${b.key}|${b.enabled}|${b.label}|${b.hotkey ?? ''}|${b.badge ?? ''}` : '-')).join(',') + ctl.page + JSON.stringify(ctl.targeting); + const keyFull = key + (p ? `|${Math.floor(p.minerals / 25)}|${Math.floor(p.gas / 25)}` : ''); + if (!force && keyFull === this.lastCardKey) return; + this.lastCardKey = keyFull; + const armedKey = ctl.targeting ? (ctl.targeting.t === 'ability' ? 'ab:' + ctl.targeting.ab : ctl.targeting.t === 'amove' ? 'attack' : ctl.targeting.t) : ctl.placing ? 'build:' + ctl.placing.id : ''; + // Only touch the DOM where something changed: replacing a button's contents between + // pointerdown and pointerup makes the browser drop the click. + this.buttons.forEach((b, i) => { + const elb = this.cardBtns[i]; + if (!b) { + if (elb.className !== 'cbtn empty') elb.className = 'cbtn empty'; + if (this.cardInner[i]) { + this.cardInner[i] = ''; + elb.innerHTML = ''; + } + return; + } + const poor = !!(b.cost && p && (p.minerals < b.cost.m || p.gas < b.cost.g)); + const cls = 'cbtn' + (b.enabled ? '' : ' disabled') + (poor ? ' poor' : '') + (b.key === armedKey ? ' armed' : ''); + if (elb.className !== cls) elb.className = cls; + let inner = ''; + if (b.def) inner = ``; + else inner = iconSvg(b.svg ?? 'move', 26); + if (b.hotkey && !this.cb.touch()) inner += `${b.hotkey}`; + if (b.badge) inner += `${b.badge}`; + if (this.cardInner[i] !== inner) { + this.cardInner[i] = inner; + elb.innerHTML = inner; + elb.setAttribute('aria-label', b.label); + } + }); + } + + // ------------------------------------------------------------------ tooltip + + private showTip(b: CmdButton, anchor: HTMLElement): void { + this.tipFor = b; + const p = this.g.players[this.ctl.player]; + let html = `${b.label}`; + if (b.hotkey && !this.cb.touch()) html += ` (${b.hotkey})`; + if (b.cost || b.energy || b.supply) { + html += '
'; + if (b.cost) { + html += `${b.cost.m}`; + if (b.cost.g) html += `${b.cost.g}`; + } + if (b.supply) html += `${b.supply}`; + if (b.energy) html += `⚡${b.energy}`; + if (b.time) html += `⏱${b.time}s`; + html += '
'; + } + if (!b.enabled && b.why) html += `
${b.why}
`; + html += `

${b.desc}

`; + this.elTip.innerHTML = html; + this.elTip.classList.add('show'); + const r = anchor.getBoundingClientRect(); + const tr = this.elTip.getBoundingClientRect(); + let x = r.left + r.width / 2 - tr.width / 2; + x = Math.max(6, Math.min(window.innerWidth - tr.width - 6, x)); + let y = r.top - tr.height - 8; + if (y < 6) y = r.bottom + 8; + this.elTip.style.left = x + 'px'; + this.elTip.style.top = y + 'px'; + } + + hideTip(): void { + this.tipFor = null; + this.elTip.classList.remove('show'); + } + + // ------------------------------------------------------------------ messages & status + + message(text: string, kind: 'error' | 'info' | 'alert' = 'info'): void { + const m = el('div', 'msg ' + kind, this.elMsgs); + m.textContent = text; + while (this.elMsgs.children.length > 3) this.elMsgs.firstChild!.remove(); + window.setTimeout(() => m.remove(), 3000); + } + + setStatus(text: string): void { + this.elStatus.textContent = text; + this.elStatus.classList.toggle('show', !!text); + } + + // ------------------------------------------------------------------ per-frame update + + update(dt: number): void { + const g = this.g; + const ctl = this.ctl; + const p = g.players[ctl.player]; + if (p) { + setText(this.elMin, String(Math.floor(p.minerals))); + setText(this.elGas, String(Math.floor(p.gas))); + setText(this.elSup, `${fmtSupply(p.supplyUsed)}/${p.supplyMax}`); + this.elSupWrap.classList.toggle('blocked', p.supplyUsed >= p.supplyMax && p.supplyMax < 200); + } + setText(this.elTime, fmtTime(g.time)); + if (ctl.player < 0) this.updateSpectator(dt); + // placement controls (touch) + const placing = !!ctl.placing && this.cb.touch(); + this.elPlace.classList.toggle('show', placing); + let hint = ''; + if (ctl.targeting) { + const t = ctl.targeting; + hint = t.t === 'ability' ? 'Choose a target' : t.t === 'amove' ? 'Attack: tap a target or location' : t.t === 'rally' ? 'Set rally point' : t.t === 'gather' ? 'Tap minerals or a refinery' : t.t === 'repair' ? 'Tap something to repair' : t.t === 'load' ? 'Tap a unit to pick up' : t.t === 'unload' ? 'Tap where to unload' : 'Tap a destination'; + } else if (ctl.placing) hint = this.cb.touch() ? 'Drag to position, then ✓ Build' : 'Click to place (Shift: keep placing)'; + else if (this.cb.boxMode()) hint = 'Drag to select units'; + setText(this.elHint, hint); + this.elHint.classList.toggle('show', !!hint && !placing); + + this.cardTimer -= dt; + if (ctl.version !== this.lastCtlVer || this.cardTimer <= 0) { + this.lastCtlVer = ctl.version; + this.cardTimer = 0.25; + this.refreshCard(); + } + this.selTimer -= dt; + if (ctl.sel.version !== this.lastSelVer || this.selTimer <= 0) { + this.lastSelVer = ctl.sel.version; + this.selTimer = 0.2; + this.refreshSelection(); + } + // idle workers + if (this.idleBtn) { + let idle = 0; + for (const e of g.entities) if (e.alive && e.owner === ctl.player && e.def.worker && e.orders.length === 0 && !e.inside) idle++; + setText(this.idleBtn.querySelector('.n') as HTMLElement, idle ? String(idle) : ''); + this.idleBtn.classList.toggle('alert', idle > 0); + } + // control group counts + this.groupBtns.forEach((b, i) => { + const n = ctl.sel.groups[i + 1].filter((id) => g.get(id)).length; + b.classList.toggle('empty', n === 0); + const s = b.querySelector('.n')!; + setText(s as HTMLElement, n ? String(n) : ''); + }); + if (this.tipFor && !this.cb.touch()) { + // keep tooltip text fresh (affordability), cheap enough + } + } + + private refreshSelection(): void { + const g = this.g; + const ctl = this.ctl; + const es = ctl.selected(); + const box = this.elSel; + if (!es.length) { + box.classList.add('empty'); + box.innerHTML = ''; + this.selKey = ''; + return; + } + box.classList.remove('empty'); + if (es.length === 1) { + this.renderSingle(es[0]); + return; + } + // multi: grid of icons + const key = 'multi:' + es.map((e) => e.id).join(',') + '|' + ctl.sel.active; + if (key !== this.selKey) { + this.selKey = key; + box.innerHTML = ''; + const grid = el('div', 'sel-grid', box); + for (const e of es.slice(0, 48)) { + const c = el('div', 'cell' + (e.def.id === ctl.sel.active ? ' active' : ''), grid); + c.dataset.id = String(e.id); + const img = el('img', '', c); + img.src = this.iconUrl(e.def, g.players[e.owner]?.color ?? -1, 48); + el('div', 'hp', c); + c.addEventListener('click', (ev) => { + const ent = g.get(e.id); + if (!ent) return; + if (ev.shiftKey) { + ctl.sel.remove(g, [ent]); + ctl.bump(); + } else if (ev.ctrlKey || ev.metaKey) { + ctl.select(ctl.selected().filter((x) => x.def.id === ent.def.id)); + } else if (ctl.sel.active !== ent.def.id) { + ctl.sel.active = ent.def.id; + ctl.sel.version++; + ctl.bump(); + } else ctl.select([ent]); + }); + } + if (es.length > 48) { + const more = el('div', 'muted small', grid); + more.textContent = `+${es.length - 48}`; + } + } + const cells = box.querySelectorAll('.cell'); + cells.forEach((c) => { + const e = g.get(Number(c.dataset.id)); + const hp = c.querySelector('.hp')!; + if (!e) { + c.style.opacity = '0.3'; + return; + } + const f = e.hp / maxHp(e, g.players[e.owner]); + hp.style.width = `calc(${Math.max(0, f) * 100}% - 4px)`; + hp.style.background = f > 0.6 ? 'var(--good)' : f > 0.3 ? 'var(--warn)' : 'var(--bad)'; + }); + } + + private renderSingle(e: Entity): void { + const g = this.g; + const ctl = this.ctl; + const box = this.elSel; + const key = 'single:' + e.id + '|' + e.def.id + '|' + e.queue.map((q) => q.id).join(',') + '|' + e.built + '|' + (e.morphTo ?? '') + '|' + e.cargo.join(','); + const p = g.players[e.owner]; + if (key !== this.selKey) { + this.selKey = key; + box.innerHTML = ''; + const por = el('div', 'sel-portrait', box); + const img = el('img', '', por); + img.src = this.iconUrl(e.def, p?.color ?? -1, 96); + const info = el('div', 'sel-info', box); + const name = el('div', 'sel-name', info); + name.innerHTML = `${e.def.name}${p ? `${escapeHtml(p.name)}` : ''}`; + const bars = el('div', 'bars', info); + if (!e.isResource) { + if (e.def.shields) el('div', 'bar sh', bars).innerHTML = ''; + el('div', 'bar hp', bars).innerHTML = ''; + if (e.def.energy) el('div', 'bar en', bars).innerHTML = ''; + if ((e.isBuilding && !e.built) || e.morphTo) el('div', 'bar pr', bars).innerHTML = ''; + } + el('div', 'sel-stats', info); + if (e.def.cargo && e.owner === ctl.player && e.cargo.length) { + // passengers: click one to let it out + const row = el('div', 'cargo', info); + for (const id of e.cargo) { + const u = g.get(id); + if (!u) continue; + const s = el('div', 'qslot', row); + s.title = `${u.def.name}: click to unload`; + el('img', '', s).src = this.iconUrl(u.def, p?.color ?? 0, 48); + s.addEventListener('click', () => ctl.issue({ c: 'unload', ids: [e.id], u: id })); + } + } + if (e.isBuilding && e.owner === ctl.player && (e.def.produces || e.def.researches || e.def.morphs) && e.built) { + const q = el('div', 'queue', info); + for (let i = 0; i < 5; i++) { + const s = el('div', 'qslot', q); + s.dataset.slot = String(i); + s.title = 'Click to cancel'; + s.addEventListener('click', () => { + const ent = g.get(e.id); + if (ent && ent.queue[i]) ctl.issue({ c: 'cancel', id: ent.id, slot: i }); + }); + } + } + } + // dynamic parts + const mh = maxHp(e, p); + const set = (sel: string, frac: number, text: string) => { + const b = box.querySelector(sel); + if (!b) return; + (b.firstChild as HTMLElement).style.width = `${Math.max(0, Math.min(1, frac)) * 100}%`; + setText(b.lastChild as HTMLElement, text); + }; + if (!e.isResource) { + set('.bar.hp', e.hp / mh, `${Math.ceil(e.hp)} / ${mh}`); + if (e.def.shields) set('.bar.sh', e.shields / maxShields(e), `${Math.ceil(e.shields)} / ${maxShields(e)}`); + if (e.def.energy) set('.bar.en', e.energy / e.def.energy, `${Math.floor(e.energy)} / ${e.def.energy}`); + if (e.isBuilding && !e.built) set('.bar.pr', e.progress, `Building ${Math.floor(e.progress * 100)}%`); + else if (e.morphTo) set('.bar.pr', e.morphT / Math.max(1, e.morphTotal), `${DEFS[e.morphTo].name} ${Math.floor((e.morphT / Math.max(1, e.morphTotal)) * 100)}%`); + } + const stats = box.querySelector('.sel-stats'); + if (stats) { + const parts: string[] = []; + if (e.isResource) parts.push(`Remaining: ${e.def.kind === 'geyser' ? e.amount : e.amount}`); + else { + parts.push(`Armor ${armorOf(e, p)}`); + for (const w of e.def.weapons ?? []) { + const dmg = weaponDamage(p, w, e) - (w.bonus ? 0 : 0); + parts.push(`${w.name}: ${w.dmg + (p && w.upg ? p.level(w.upg) * (w.upgDmg ?? 1) : 0)}${w.hits && w.hits > 1 ? '×' + w.hits : ''} dmg, range ${(weaponRangePx(e, w, p) / TILE).toFixed(0)}`); + void dmg; + } + if (e.kills) parts.push(`Kills ${e.kills}`); + if (e.def.spawnsCubs) parts.push(`Cubs ${e.cubs.length}`); + if (e.def.refinery) { + const gey = g.get(e.link); + parts.push(`Gas left: ${gey ? Math.floor(gey.amount) : 0}`); + } + if (e.carry) parts.push(`Carrying ${e.carry} ${e.carryKind === 1 ? 'minerals' : 'gas'}`); + if (e.def.cargo && e.owner === ctl.player && canCarry(g, e)) parts.push(`Cargo ${cargoUsed(g, e)}/${e.def.cargo}`); + if (e.hasteT > 0) parts.push('⏩ Moonlight Haste'); + if (e.isBuilding && e.def.needsPower && !e.powered) parts.push('⚡ UNPOWERED'); + } + setText(stats, parts.join(' · ')); + } + const slots = box.querySelectorAll('.queue .qslot'); + slots.forEach((s, i) => { + const it = e.queue[i]; + const want = it ? it.kind + it.id + it.level : ''; + if (s.dataset.want !== want) { + s.dataset.want = want; + if (!it) s.innerHTML = ''; + else if (it.kind === 'research') s.innerHTML = iconSvg(RESEARCH_DEFS[it.id]?.icon ?? 'up_weapon', 22) + '
'; + else s.innerHTML = `
`; + } + const prog = s.querySelector('.prog'); + if (prog && it) prog.style.width = `${(it.progress / it.total) * 100}%`; + }); + } + + dispose(): void { + this.stopGridLabels?.(); + this.root.remove(); + } +} + +function setText(e: HTMLElement, t: string): void { + if (e.textContent !== t) e.textContent = t; +} + +function fmtSupply(v: number): string { + return String(Math.ceil(v)); +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!); +} diff --git a/gh-pages/bear-war/src/ui/icons.ts b/gh-pages/bear-war/src/ui/icons.ts new file mode 100644 index 00000000..ad8c2ed3 --- /dev/null +++ b/gh-pages/bear-war/src/ui/icons.ts @@ -0,0 +1,77 @@ +// Original line-art command icons (24x24 SVG). No external icon sets are used. + +const P: Record = { + move: '', + stop: '', + hold: '', + patrol: '', + attack: '', + gather: '', + return: '', + build: '', + build2: '', + repair: '', + rally: '', + load: '', + unload: '', + cancel: '', + back: '', + sniff: '', + honeyrush: '', + brace: '', + unbrace: '', + hopoff: '', + hopon: '', + digin: '', + digout: '', + lockon: '', + anchor: '', + liftanchor: '', + scaldinghoney: '', + releasecubs: '', + yank: '', + pollen: '', + hornetbomb: '', + spiritwalk: '', + snowball: '', + sunlanceon: '', + sunlanceoff: '', + bandage: '', + bubble: '', + staticburst: '', + grandcannon: '', + royaljelly: '', + smash: '', + beecloud: '', + stingswarm: '', + haste: '', + moonstep: '', + starfall: '', + pounce: '', + up_weapon: '', + up_armor: '', + up_air: '', + up_claw: '', + up_sting: '', + up_hide: '', + up_shield: '', + thickfur: '', + speed: '', + range: '', + morph: '', + army: '', + worker: '', + select: '', + menu: '', + check: '', + cub: '', +}; + +export function iconSvg(id: string, size = 24): string { + const body = P[id] ?? P.move; + return ``; +} + +export function hasIcon(id: string): boolean { + return id in P; +} diff --git a/gh-pages/bear-war/src/ui/input.ts b/gh-pages/bear-war/src/ui/input.ts new file mode 100644 index 00000000..55d2777c --- /dev/null +++ b/gh-pages/bear-war/src/ui/input.ts @@ -0,0 +1,491 @@ +// Pointer / keyboard / touch handling for the game view. +// +// Desktop: left-click select, drag box, right-click smart command, Shift queue, +// Ctrl/double-click select type, wheel zoom, middle-drag/arrow/edge scroll. +// Touch: tap own unit = select, tap ground/enemy/resource = command selection, +// one-finger drag = pan, long-press + drag (or box mode) = box select, +// two fingers = pinch zoom & pan, double-tap = select all of that type. + +import { canBoard } from '../game/systems/cargo.ts'; +import type { Entity } from '../game/entity.ts'; +import type { Controller } from './controller.ts'; +import type { Hud } from './hud.ts'; +import type { Minimap } from '../render/minimap.ts'; + +/** Pointer capture can throw (e.g. for pointers the browser considers inactive); never let it break input. */ +function capture(el: Element, id: number): void { + try { + el.setPointerCapture(id); + } catch { + // ignore + } +} + +interface TouchPt { + id: number; + x: number; + y: number; + sx: number; + sy: number; +} + +export class Input { + canvas: HTMLCanvasElement; + ctl: Controller; + hud: Hud; + minimap: Minimap; + touchMode = false; + boxMode = false; + edgeScroll = true; + scrollSpeed = 1; + onMenu: (() => void) | null = null; + onPause: (() => void) | null = null; + private keys = new Set(); + private mouse = { x: -1, y: -1, inside: false }; + private leftDown: { x: number; y: number; t: number } | null = null; + private dragging = false; + private midDrag: { x: number; y: number } | null = null; + private lastClick = { t: 0, x: 0, y: 0 }; + private touches = new Map(); + private touchState: 'none' | 'tap' | 'pan' | 'box' | 'pinch' | 'place' = 'none'; + private longTimer = 0; + private longFired = false; + private pinch = { d: 0, mx: 0, my: 0, zoom: 1 }; + private lastTap = { t: 0, x: 0, y: 0 }; + private mmDown = false; + private disposers: (() => void)[] = []; + + constructor(canvas: HTMLCanvasElement, ctl: Controller, hud: Hud, minimap: Minimap) { + this.canvas = canvas; + this.ctl = ctl; + this.hud = hud; + this.minimap = minimap; + this.touchMode = matchMedia('(pointer: coarse)').matches || 'ontouchstart' in window; + const on = (t: HTMLElement | Window, ev: K, fn: (e: HTMLElementEventMap[K]) => void, opts?: AddEventListenerOptions) => { + t.addEventListener(ev, fn as EventListener, opts); + this.disposers.push(() => t.removeEventListener(ev, fn as EventListener, opts)); + }; + on(canvas, 'pointerdown', (e) => this.down(e)); + on(window, 'pointermove', (e) => this.move(e)); + on(window, 'pointerup', (e) => this.up(e)); + on(window, 'pointercancel', (e) => this.up(e, true)); + on(canvas, 'wheel', (e) => this.wheel(e), { passive: false }); + on(canvas, 'contextmenu', (e) => e.preventDefault()); + // edge scrolling: track whether the mouse is inside the window at all + on(window, 'mouseout', (e) => { + if (!(e as MouseEvent).relatedTarget) this.mouse.inside = false; + }); + on(window, 'keydown', (e) => this.keydown(e)); + on(window, 'keyup', (e) => this.keys.delete(e.code)); + on(window, 'blur', () => this.keys.clear()); + const mm = minimap.canvas; + on(mm, 'pointerdown', (e) => this.mmDownH(e)); + on(mm, 'contextmenu', (e) => e.preventDefault()); + } + + dispose(): void { + for (const d of this.disposers) d(); + this.disposers = []; + } + + private world(sx: number, sy: number): { x: number; y: number } { + const cam = this.ctl.r.cam; + return { x: cam.toWorldX(sx), y: cam.toWorldY(sy) }; + } + + private pick(sx: number, sy: number, touch: boolean): Entity | undefined { + const w = this.world(sx, sy); + const slack = (touch ? 18 : 6) / this.ctl.r.cam.zoom; + return this.ctl.pickVisible(w.x, w.y, slack); + } + + // ------------------------------------------------------------------ pointer + + private down(e: PointerEvent): void { + if (e.pointerType === 'touch') { + this.touchMode = true; + this.touchDown(e); + return; + } + capture(this.canvas, e.pointerId); + const ctl = this.ctl; + if (e.button === 1) { + this.midDrag = { x: e.clientX, y: e.clientY }; + e.preventDefault(); + return; + } + if (e.button === 2) { + if (ctl.placing || ctl.targeting) { + ctl.cancelModes(); + ctl.bump(); + return; + } + const w = this.world(e.clientX, e.clientY); + ctl.smart(w.x, w.y, this.pick(e.clientX, e.clientY, false), e.shiftKey); + return; + } + if (e.button !== 0) return; + if (ctl.placing) { + const w = this.world(e.clientX, e.clientY); + ctl.updatePlacement(w.x, w.y); + ctl.confirmPlacement(e.shiftKey); + return; + } + if (ctl.targeting) { + const w = this.world(e.clientX, e.clientY); + ctl.targetAt(w.x, w.y, this.pick(e.clientX, e.clientY, false), e.shiftKey); + return; + } + this.leftDown = { x: e.clientX, y: e.clientY, t: performance.now() }; + this.dragging = false; + } + + private move(e: PointerEvent): void { + if (e.pointerType === 'touch') { + this.touchMove(e); + return; + } + if (this.mmDown) { + this.mmMove(e); + return; + } + this.mouse.x = e.clientX; + this.mouse.y = e.clientY; + this.mouse.inside = true; + const ctl = this.ctl; + if (this.midDrag) { + ctl.r.cam.pan(this.midDrag.x - e.clientX, this.midDrag.y - e.clientY); + this.midDrag = { x: e.clientX, y: e.clientY }; + return; + } + const w = this.world(e.clientX, e.clientY); + if (ctl.placing) ctl.updatePlacement(w.x, w.y); + if (this.leftDown) { + const dx = e.clientX - this.leftDown.x; + const dy = e.clientY - this.leftDown.y; + if (!this.dragging && dx * dx + dy * dy > 36) this.dragging = true; + if (this.dragging) ctl.view.dragBox = { x0: this.leftDown.x, y0: this.leftDown.y, x1: e.clientX, y1: e.clientY }; + } else if (e.target === this.canvas) { + ctl.view.hover = this.pick(e.clientX, e.clientY, false) ?? null; + } + } + + private up(e: PointerEvent, cancel = false): void { + if (e.pointerType === 'touch') { + this.touchUp(e, cancel); + return; + } + if (this.mmDown) { + this.mmDown = false; + return; + } + if (e.button === 1) { + this.midDrag = null; + return; + } + const ctl = this.ctl; + if (this.leftDown && e.button === 0 && !cancel) { + if (this.dragging) { + const a = this.world(this.leftDown.x, this.leftDown.y); + const b = this.world(e.clientX, e.clientY); + ctl.boxSelect(a.x, a.y, b.x, b.y, e.shiftKey); + } else { + const now = performance.now(); + const dbl = now - this.lastClick.t < 320 && Math.abs(e.clientX - this.lastClick.x) < 8 && Math.abs(e.clientY - this.lastClick.y) < 8; + this.lastClick = { t: now, x: e.clientX, y: e.clientY }; + const w = this.world(e.clientX, e.clientY); + ctl.clickSelect(w.x, w.y, e.shiftKey, dbl || e.ctrlKey || e.metaKey, 6 / ctl.r.cam.zoom); + } + } + this.leftDown = null; + this.dragging = false; + ctl.view.dragBox = null; + } + + private wheel(e: WheelEvent): void { + e.preventDefault(); + const cam = this.ctl.r.cam; + const f = Math.exp(-e.deltaY * (e.deltaMode === 1 ? 0.05 : 0.0015)); + cam.setZoom(cam.zoom * f, e.clientX, e.clientY); + } + + // ------------------------------------------------------------------ touch + + private touchDown(e: PointerEvent): void { + capture(this.canvas, e.pointerId); + this.touches.set(e.pointerId, { id: e.pointerId, x: e.clientX, y: e.clientY, sx: e.clientX, sy: e.clientY }); + window.clearTimeout(this.longTimer); + if (this.touches.size === 2) { + // switch to pinch + this.ctl.view.dragBox = null; + this.touchState = 'pinch'; + const [a, b] = [...this.touches.values()]; + this.pinch = { d: Math.hypot(a.x - b.x, a.y - b.y), mx: (a.x + b.x) / 2, my: (a.y + b.y) / 2, zoom: this.ctl.r.cam.zoom }; + return; + } + if (this.touches.size > 2) return; + this.longFired = false; + if (this.ctl.placing) { + this.touchState = 'place'; + const w = this.world(e.clientX, e.clientY); + this.ctl.updatePlacement(w.x, w.y); + return; + } + this.touchState = this.boxMode ? 'box' : 'tap'; + this.longTimer = window.setTimeout(() => { + if (this.touchState === 'tap') { + this.longFired = true; + this.touchState = 'box'; + navigator.vibrate?.(15); + } + }, 380); + } + + private touchMove(e: PointerEvent): void { + const t = this.touches.get(e.pointerId); + if (!t) return; + const px = t.x; + const py = t.y; + t.x = e.clientX; + t.y = e.clientY; + const ctl = this.ctl; + const cam = ctl.r.cam; + if (this.touchState === 'pinch' && this.touches.size >= 2) { + const [a, b] = [...this.touches.values()]; + const d = Math.hypot(a.x - b.x, a.y - b.y); + const mx = (a.x + b.x) / 2; + const my = (a.y + b.y) / 2; + cam.pan(this.pinch.mx - mx, this.pinch.my - my); + if (this.pinch.d > 10) cam.setZoom(this.pinch.zoom * (d / this.pinch.d), mx, my); + this.pinch.mx = mx; + this.pinch.my = my; + return; + } + const moved = Math.hypot(t.x - t.sx, t.y - t.sy); + if (this.touchState === 'tap' && moved > 12) { + window.clearTimeout(this.longTimer); + this.touchState = 'pan'; + } + if (this.touchState === 'pan') { + cam.pan(px - t.x, py - t.y); + } else if (this.touchState === 'box') { + if (moved > 8) ctl.view.dragBox = { x0: t.sx, y0: t.sy, x1: t.x, y1: t.y }; + } else if (this.touchState === 'place') { + const w = this.world(t.x, t.y); + ctl.updatePlacement(w.x, w.y); + } + } + + private touchUp(e: PointerEvent, cancel: boolean): void { + const t = this.touches.get(e.pointerId); + this.touches.delete(e.pointerId); + window.clearTimeout(this.longTimer); + if (!t) return; + const ctl = this.ctl; + if (this.touchState === 'pinch') { + if (this.touches.size === 0) this.touchState = 'none'; + else { + // remaining finger continues as a pan + this.touchState = 'pan'; + for (const r of this.touches.values()) { + r.sx = r.x; + r.sy = r.y; + } + } + return; + } + if (cancel) { + this.touchState = 'none'; + ctl.view.dragBox = null; + return; + } + if (this.touchState === 'box') { + if (ctl.view.dragBox) { + const a = this.world(t.sx, t.sy); + const b = this.world(t.x, t.y); + ctl.boxSelect(a.x, a.y, b.x, b.y, false); + } else if (this.longFired) { + // long-press without dragging: select all of that type on screen + const ent = this.pick(t.x, t.y, true); + if (ent && ent.owner === ctl.player) ctl.select(ctl.onScreenOfType(ent.def.id)); + } + ctl.view.dragBox = null; + this.touchState = 'none'; + return; + } + if (this.touchState === 'tap') this.tap(t.x, t.y); + this.touchState = 'none'; + } + + private tap(sx: number, sy: number): void { + const ctl = this.ctl; + const w = this.world(sx, sy); + const ent = this.pick(sx, sy, true); + if (ctl.targeting) { + ctl.targetAt(w.x, w.y, ent, false); + return; + } + const now = performance.now(); + const dbl = now - this.lastTap.t < 350 && Math.hypot(sx - this.lastTap.x, sy - this.lastTap.y) < 30; + this.lastTap = { t: now, x: sx, y: sy }; + const own = ctl.ownSelected(); + // only units take tap-commands; buildings set their rally with the Rally button, so + // tapping around with a building selected never moves the rally point by accident + const hasCommandable = own.some((e) => e.isUnit && e.def.kind === 'unit'); + if (ent && ent.owner === ctl.player) { + if (dbl && ctl.sel.has(ent.id)) ctl.select(ctl.onScreenOfType(ent.def.id)); + else if (own.length && own.some((e) => e.def.worker) && ((ent.def.refinery && ent.built) || (ent.isBuilding && !ent.built) || (ent.def.depot && own.some((x) => x.carry > 0)))) { + // workers: tap own refinery / unfinished building / depot = work on it + ctl.smart(w.x, w.y, ent, false); + } else if (!ctl.sel.has(ent.id) && own.some((e) => canBoard(ctl.g, e, ent))) { + // tap a transport or bunker with passengers selected: climb in + ctl.smart(w.x, w.y, ent, false); + } else ctl.select([ent]); + return; + } + if (hasCommandable) { + ctl.smart(w.x, w.y, ent, false); + return; + } + if (ent) ctl.select([ent]); + else { + ctl.sel.clear(); + ctl.bump(); + } + } + + // ------------------------------------------------------------------ minimap + + private mmDownH(e: PointerEvent): void { + e.preventDefault(); + const ctl = this.ctl; + const r = this.minimap.canvas.getBoundingClientRect(); + const w = this.minimap.toWorld(e.clientX - r.left, e.clientY - r.top); + if (e.button === 2) { + ctl.smart(w.x, w.y, undefined, e.shiftKey); + return; + } + if (ctl.targeting) { + ctl.targetAt(w.x, w.y, undefined, e.shiftKey); + return; + } + this.mmDown = true; + capture(this.minimap.canvas, e.pointerId); + ctl.r.cam.centerOn(w.x, w.y); + const move = (ev: PointerEvent) => { + if (!this.mmDown) return; + const p = this.minimap.toWorld(ev.clientX - r.left, ev.clientY - r.top); + ctl.r.cam.centerOn(p.x, p.y); + }; + const upH = () => { + this.mmDown = false; + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', upH); + }; + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', upH); + } + + private mmMove(_e: PointerEvent): void {} + + // ------------------------------------------------------------------ keyboard + + private keydown(e: KeyboardEvent): void { + const tag = (e.target as HTMLElement)?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + const ctl = this.ctl; + this.keys.add(e.code); + const k = e.key; + if (k === 'Escape') { + ctl.escape(); + e.preventDefault(); + return; + } + if (k === 'Enter') { + this.hud.openChat(); + e.preventDefault(); + return; + } + if (/^Digit[0-9]$/.test(e.code)) { + const i = Number(e.code.slice(5)); + if (e.ctrlKey || e.metaKey) ctl.groupSet(i); + else if (e.shiftKey) ctl.groupAdd(i); + else { + const now = performance.now(); + const again = (this as unknown as { _lg?: { i: number; t: number } })._lg; + if (ctl.groupSelect(i) && again && again.i === i && now - again.t < 350) ctl.centerOnSelection(); + (this as unknown as { _lg?: { i: number; t: number } })._lg = { i, t: now }; + } + e.preventDefault(); + return; + } + switch (e.code) { + case 'F10': + this.onMenu?.(); + e.preventDefault(); + return; + case 'Pause': + this.onPause?.(); + e.preventDefault(); + return; + case 'F1': + ctl.selectIdleWorker(e.ctrlKey); + e.preventDefault(); + return; + case 'F2': + ctl.selectArmy(); + e.preventDefault(); + return; + case 'Space': + ctl.jumpToAlert(); + e.preventDefault(); + return; + case 'Backspace': + ctl.cycleBase(); + e.preventDefault(); + return; + case 'Tab': + ctl.sel.cycle(ctl.g); + ctl.bump(); + e.preventDefault(); + return; + case 'Equal': + case 'NumpadAdd': + ctl.r.cam.setZoom(ctl.r.cam.zoom * 1.15); + return; + case 'Minus': + case 'NumpadSubtract': + ctl.r.cam.setZoom(ctl.r.cam.zoom / 1.15); + return; + } + if (e.ctrlKey || e.metaKey || e.altKey) return; + // Match the key's position, not the character it prints, so the grid + // stays in the same place on every keyboard layout. + if (this.hud.pressHotkey(e.code)) e.preventDefault(); + } + + /** Per-frame camera movement (arrow keys, edge scrolling). */ + update(dt: number): void { + const cam = this.ctl.r.cam; + const speed = 900 * this.scrollSpeed * dt; + let dx = 0; + let dy = 0; + if (this.keys.has('ArrowLeft')) dx -= speed; + if (this.keys.has('ArrowRight')) dx += speed; + if (this.keys.has('ArrowUp')) dy -= speed; + if (this.keys.has('ArrowDown')) dy += speed; + if (this.edgeScroll && !this.touchMode && this.mouse.inside && this.mouse.x >= 0 && document.hasFocus()) { + const m = 6; + if (this.mouse.x <= m) dx -= speed; + if (this.mouse.x >= window.innerWidth - m) dx += speed; + if (this.mouse.y <= m) dy -= speed; + if (this.mouse.y >= window.innerHeight - m) dy += speed; + } + if (dx || dy) cam.pan(dx, dy); + this.canvas.classList.toggle('targeting', !!this.ctl.targeting || !!this.ctl.placing); + } + + get shift(): boolean { + return this.keys.has('ShiftLeft') || this.keys.has('ShiftRight'); + } +} diff --git a/gh-pages/bear-war/src/ui/selection.ts b/gh-pages/bear-war/src/ui/selection.ts new file mode 100644 index 00000000..381db834 --- /dev/null +++ b/gh-pages/bear-war/src/ui/selection.ts @@ -0,0 +1,106 @@ +import { DEFS, family } from '../game/data/index.ts'; +import type { Entity } from '../game/entity.ts'; +import type { Game } from '../game/game.ts'; + +/** Display priority of entity types within a mixed selection (lower first). */ +function typeRank(e: Entity): number { + if (e.isBuilding) return 50 + (e.def.order ?? 0); + if (e.def.worker) return 40; + if (e.def.kind === 'cub' || e.def.kind === 'cocoon') return 45; + return (e.def.order ?? 0) - (e.def.energy ? 0.5 : 0); +} + +export class Selection { + ids: number[] = []; + groups: number[][] = Array.from({ length: 10 }, () => []); + /** def id of the subgroup whose commands are shown */ + active: string | null = null; + version = 0; + + entities(g: Game): Entity[] { + const out: Entity[] = []; + let changed = false; + for (const id of this.ids) { + const e = g.get(id); + if (e && !e.inside) out.push(e); + else if (!e) changed = true; + } + if (changed) { + this.ids = this.ids.filter((id) => g.get(id)); + this.version++; + this.fixActive(g); + } + return out; + } + + set(g: Game, es: Entity[]): void { + const sorted = [...es].sort((a, b) => typeRank(a) - typeRank(b) || a.id - b.id); + this.ids = sorted.map((e) => e.id); + this.active = sorted[0]?.def.id ?? null; + this.version++; + void g; + } + + add(g: Game, es: Entity[]): void { + const cur = this.entities(g); + for (const e of es) if (!cur.includes(e)) cur.push(e); + const keep = this.active; + this.set(g, cur); + if (keep && cur.some((e) => e.def.id === keep)) this.active = keep; + } + + remove(g: Game, es: Entity[]): void { + const set = new Set(es.map((e) => e.id)); + this.ids = this.ids.filter((id) => !set.has(id)); + this.version++; + this.fixActive(g); + } + + clear(): void { + this.ids = []; + this.active = null; + this.version++; + } + + has(id: number): boolean { + return this.ids.includes(id); + } + + fixActive(g: Game): void { + const es = this.ids.map((id) => g.get(id)).filter((e): e is Entity => !!e); + if (!es.some((e) => e.def.id === this.active)) this.active = es[0]?.def.id ?? null; + } + + /** cycle the active subgroup (Tab) */ + cycle(g: Game): void { + const types: string[] = []; + for (const e of this.entities(g)) if (!types.includes(e.def.id)) types.push(e.def.id); + if (types.length < 2) return; + const i = types.indexOf(this.active ?? ''); + this.active = types[(i + 1) % types.length]; + this.version++; + } + + activeEntities(g: Game): Entity[] { + return this.entities(g).filter((e) => e.def.id === this.active || sameFamily(e.def.id, this.active)); + } + + saveGroup(i: number, g: Game): void { + this.groups[i] = this.entities(g).map((e) => e.id); + } + addToGroup(i: number, g: Game): void { + const cur = new Set(this.groups[i]); + for (const e of this.entities(g)) cur.add(e.id); + this.groups[i] = [...cur]; + } + loadGroup(i: number, g: Game): Entity[] { + const es = this.groups[i].map((id) => g.get(id)).filter((e): e is Entity => !!e); + this.groups[i] = es.map((e) => e.id); + return es; + } +} + +/** Unit modes share commands; hive tiers too. */ +function sameFamily(a: string, b: string | null): boolean { + return !!b && family(DEFS[a]) === family(DEFS[b]); +} diff --git a/gh-pages/bear-war/tests/abilities.test.ts b/gh-pages/bear-war/tests/abilities.test.ts new file mode 100644 index 00000000..80db6540 --- /dev/null +++ b/gh-pages/bear-war/tests/abilities.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from 'vitest'; +import { Game } from '../src/game/game.ts'; +import { MapBuilder } from '../src/game/map/builder.ts'; +import type { RaceId } from '../src/game/data/types.ts'; +import type { Command } from '../src/game/types.ts'; +import { TILE } from '../src/game/constants.ts'; +import type { Entity } from '../src/game/entity.ts'; + +function arena(r0: RaceId, r1: RaceId): Game { + const b = new MapBuilder('arena', 'Arena', 64, 40, 'none', 'badlands', 1); + b.fillElev(0); + b.base(6.5, 6.5, -2.3, { start: true }); + b.base(57.5, 33.5, 0.8, { start: true }); + const g = new Game({ map: b.build(), seed: 5, players: [{ name: 'A', race: r0, color: 0, team: 0 }, { name: 'B', race: r1, color: 1, team: 1 }] }); + for (const e of g.entities) if (e.def.worker || e.def.id === 'bumblebear') g.removeSilently(e); + for (const p of g.players) p.supplyMax = 200; + return g; +} + +const run = (g: Game, ticks: number, cmds: { p: number; c: Command }[] = []) => { + g.step(cmds); + for (let i = 1; i < ticks; i++) g.step([]); +}; + +function spawn(g: Game, id: string, owner: number, tx: number, ty: number): Entity { + const u = g.spawnUnit(id, owner, tx * TILE, ty * TILE); + u.energy = u.def.energy ?? 0; + return u; +} + +describe('abilities', () => { + it('Starfall damages everything in the area, including friendlies', () => { + const g = arena('starborn', 'ironpaw'); + g.players[0].upgrades.starfall = 1; + const m = spawn(g, 'pandasage', 0, 20, 20); + const foe = spawn(g, 'ranger', 1, 26, 20); + const friend = spawn(g, 'spiritbear', 0, 26.5, 21); + friend.holdPos = true; + foe.holdPos = true; + run(g, 80, [{ p: 0, c: { c: 'ability', ids: [m.id], a: 'starfall', x: foe.x, y: foe.y } }]); + expect(m.energy).toBeLessThan(200 - 70); + expect(foe.alive ? foe.hp : 0).toBeLessThan(45); + expect(friend.hp + friend.shields).toBeLessThan(150); + }); + + it('Moonstep teleports a Moon Bear and starts a cooldown', () => { + const g = arena('starborn', 'horde'); + g.players[0].upgrades.moonstep = 1; + const s = spawn(g, 'moonbear', 0, 20, 20); + run(g, 2, [{ p: 0, c: { c: 'ability', ids: [s.id], a: 'moonstep', x: 26 * TILE, y: 20 * TILE } }]); + expect(s.x / TILE).toBeGreaterThan(25); + expect(s.cooldowns.moonstep).toBeGreaterThan(0); + }); + + it('abilities that need research are refused without it', () => { + const g = arena('starborn', 'horde'); + const s = spawn(g, 'moonbear', 0, 20, 20); + run(g, 2, [{ p: 0, c: { c: 'ability', ids: [s.id], a: 'moonstep', x: 26 * TILE, y: 20 * TILE } }]); + expect(s.x / TILE).toBeLessThan(21); + }); + + it('a Kodiak braces and gets up again', () => { + const g = arena('ironpaw', 'horde'); + const t = spawn(g, 'kodiak', 0, 20, 20); + run(g, 70, [{ p: 0, c: { c: 'ability', ids: [t.id], a: 'brace' } }]); + expect(t.def.id).toBe('kodiak_b'); + run(g, 70, [{ p: 0, c: { c: 'ability', ids: [t.id], a: 'unbrace' } }]); + expect(t.def.id).toBe('kodiak'); + }); + + it('Honey Rush costs health and speeds units up', () => { + const g = arena('ironpaw', 'horde'); + g.players[0].upgrades.honeyrush = 1; + const tr = spawn(g, 'ranger', 0, 20, 20); + run(g, 2, [{ p: 0, c: { c: 'ability', ids: [tr.id], a: 'honeyrush' } }]); + expect(tr.hp).toBe(35); + expect(tr.rushT).toBeGreaterThan(0); + }); + + it('Big Sniff reveals and detects a cloaked unit', () => { + const g = arena('ironpaw', 'starborn'); + const hub = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + const shadowpaw = spawn(g, 'shadowpaw', 1, 40, 20); + run(g, 8, [{ p: 0, c: { c: 'ability', ids: [hub.id], a: 'sniff', x: shadowpaw.x, y: shadowpaw.y } }]); + expect(g.vision.detectedAt(0, shadowpaw.x, shadowpaw.y)).toBe(true); + }); + + it('cloaked units cannot be attacked without detection', () => { + const g = arena('ironpaw', 'starborn'); + const tr = spawn(g, 'ranger', 0, 20, 20); + const shadowpaw = spawn(g, 'shadowpaw', 1, 23, 20); + shadowpaw.holdPos = true; + const before = shadowpaw.hp + shadowpaw.shields; + run(g, 60); + expect(shadowpaw.hp + shadowpaw.shields).toBe(before); + expect(tr.alive).toBe(true); + }); + + it('Shield Bubble absorbs damage; Static Burst drains shields', () => { + const g = arena('ironpaw', 'starborn'); + g.players[0].upgrades.staticburst = 1; + const ship = spawn(g, 'lookout', 0, 20, 20); + const ally = spawn(g, 'ranger', 0, 21, 22); + run(g, 2, [{ p: 0, c: { c: 'ability', ids: [ship.id], a: 'bubble', t: ally.id, x: ally.x, y: ally.y } }]); + expect(ally.bubble).toBe(100); + const w = spawn(g, 'spiritbear', 1, 26, 20); + w.holdPos = true; + run(g, 40, [{ p: 0, c: { c: 'ability', ids: [ship.id], a: 'staticburst', x: w.x, y: w.y } }]); + expect(w.shields).toBeLessThan(5); + }); + + it('Royal Jelly adds cubs; Moonlight Haste speeds production', () => { + const g = arena('horde', 'starborn'); + const hive = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + hive.energy = 50; + run(g, 2, [{ p: 0, c: { c: 'ability', ids: [hive.id], a: 'royaljelly' } }]); + const before = hive.cubs.length; + run(g, 29 * 20 + 5); + expect(hive.cubs.length).toBeGreaterThan(before); + + const g2 = arena('starborn', 'horde'); + const s = g2.entities.find((e) => e.owner === 0 && e.def.townHall)!; + s.energy = 100; + g2.players[0].minerals = 1000; + run(g2, 2, [{ p: 0, c: { c: 'train', ids: [s.id], d: 'snowcub' } }]); + run(g2, 2, [{ p: 0, c: { c: 'ability', ids: [s.id], a: 'haste', t: s.id, x: s.x, y: s.y } }]); + expect(s.hasteT).toBeGreaterThan(0); + }); + + it('Buzzbombs explode with splash; Smash works manually', () => { + const g = arena('horde', 'ironpaw'); + const b = spawn(g, 'buzzbomb', 0, 20, 20); + const t1 = spawn(g, 'ranger', 1, 21.2, 20); + const t2 = spawn(g, 'ranger', 1, 21.2, 21.2); + t1.holdPos = t2.holdPos = true; + run(g, 60); + expect(b.alive).toBe(false); + expect(t1.alive && t1.hp > 20).toBe(false); + expect(t2.alive && t2.hp > 20).toBe(false); + }); + + it('Bee Cloud blocks ranged damage to ground units inside', () => { + const g = arena('horde', 'ironpaw'); + const bl = spawn(g, 'swarmcaller', 0, 20, 20); + const ling = spawn(g, 'scrapper', 0, 24, 20); + ling.holdPos = true; + run(g, 2, [{ p: 0, c: { c: 'ability', ids: [bl.id], a: 'beecloud', x: ling.x, y: ling.y } }]); + const tr = spawn(g, 'ranger', 1, 28, 20); + tr.holdPos = true; + run(g, 60); + expect(ling.hp).toBeGreaterThan(30); + }); + + it('Cubs wrap up in cocoons and grow into bears', () => { + const g = arena('horde', 'ironpaw'); + const hive = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + g.players[0].minerals = 500; + g.players[0].tech.cubden = 1; + g.placeBuilding('cubden', 0, 14, 4, true); + run(g, 2, [{ p: 0, c: { c: 'train', ids: [hive.id], d: 'scrapper' } }]); + expect(g.entities.some((e) => e.def.kind === 'cocoon' && e.owner === 0)).toBe(true); + run(g, 17 * 20 + 5); + expect(g.entities.filter((e) => e.def.id === 'scrapper' && e.owner === 0).length).toBe(2); + }); +}); + +describe('race mechanics', () => { + const setup = (race: RaceId) => { + const g = arena(race, race === 'horde' ? 'ironpaw' : 'horde'); + const hall = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + g.players[0].minerals = 2000; + g.players[0].gas = 2000; + return { g, hall }; + }; + + it('Ironpaw hardhats stay to construct; construction pauses without them', () => { + const { g, hall } = setup('ironpaw'); + const eng = spawn(g, 'hardhat', 0, 12, 12); + run(g, 8); // let vision reveal the site + run(g, 2, [{ p: 0, c: { c: 'build', ids: [eng.id], d: 'larder', tx: 16, ty: 12 } }]); + run(g, 60); + const depot = g.entities.find((e) => e.def.id === 'larder')!; + expect(depot).toBeTruthy(); + expect(eng.orders[0]?.t).toBe('construct'); + const p1 = depot.progress; + run(g, 2, [{ p: 0, c: { c: 'stop', ids: [eng.id] } }]); + run(g, 40); + expect(depot.progress).toBeCloseTo(p1, 1); + run(g, 2, [{ p: 0, c: { c: 'smart', ids: [eng.id], x: depot.x, y: depot.y, t: depot.id } }]); + run(g, 21 * 20 + 40); + expect(depot.built).toBe(true); + void hall; + }); + + it('Horde foragers are used up and structures need honeycomb', () => { + const { g } = setup('horde'); + const d = spawn(g, 'forager', 0, 12, 14); + run(g, 8); + const before = g.entities.filter((e) => e.owner === 0 && e.def.worker).length; + run(g, 2, [{ p: 0, c: { c: 'build', ids: [d.id], d: 'cubden', tx: 10, ty: 12 } }]); + run(g, 80); + expect(g.entities.some((e) => e.def.id === 'cubden')).toBe(true); + expect(g.entities.filter((e) => e.owner === 0 && e.def.worker).length).toBe(before - 1); + // far from comb: refused + const d2 = spawn(g, 'forager', 0, 40, 30); + run(g, 8); + run(g, 2, [{ p: 0, c: { c: 'build', ids: [d2.id], d: 'honeycellar', tx: 40, ty: 28 } }]); + run(g, 60); + expect(g.entities.some((e) => e.def.id === 'honeycellar')).toBe(false); + }); + + it('Starborn snowcubs call structures down and leave; structures need a glow', () => { + const { g } = setup('starborn'); + const s = spawn(g, 'snowcub', 0, 12, 14); + run(g, 8); + run(g, 2, [{ p: 0, c: { c: 'build', ids: [s.id], d: 'moongate', tx: 14, ty: 14 } }]); + run(g, 60); + expect(g.entities.some((e) => e.def.id === 'moongate')).toBe(false); // no power yet + run(g, 2, [{ p: 0, c: { c: 'build', ids: [s.id], d: 'startotem', tx: 14, ty: 14 } }]); + run(g, 80); + const c = g.entities.find((e) => e.def.id === 'startotem')!; + expect(c).toBeTruthy(); + expect(s.orders[0]?.t).not.toBe('build'); + run(g, 18 * 20 + 10); + expect(c.built).toBe(true); + run(g, 2, [{ p: 0, c: { c: 'build', ids: [s.id], d: 'moongate', tx: 17, ty: 14 } }]); + run(g, 80); + expect(g.entities.some((e) => e.def.id === 'moongate')).toBe(true); + }); + + it('research completes and upgrades apply; Hive evolves into Grand Hive', () => { + const { g, hall } = setup('horde'); + g.placeBuilding('cubden', 0, 14, 4, true); + g.placeBuilding('honeycellar', 0, 18, 4, true); + const den = g.entities.find((e) => e.def.id === 'honeycellar')!; + run(g, 2, [{ p: 0, c: { c: 'research', ids: [den.id], r: 'claws' } }]); + run(g, 90 * 20 + 5); + expect(g.players[0].level('claws')).toBe(1); + run(g, 2, [{ p: 0, c: { c: 'morph', ids: [hall.id], d: 'royalhive' } }]); + run(g, 57 * 20 + 5); + expect(hall.def.id).toBe('royalhive'); + expect(g.players[0].tech.hive).toBeGreaterThan(0); + }); + + it('Ironpaw buildings in the red burn down unless repaired', () => { + const { g } = setup('ironpaw'); + const b = g.placeBuilding('bunkhouse', 0, 20, 20, true); + b.hp = 300; + run(g, 40); + expect(b.hp).toBeLessThan(300); + }); + + it('a player with no town hall is revealed after 30 seconds', () => { + const { g, hall } = setup('ironpaw'); + g.placeBuilding('larder', 0, 20, 20, true); + g.kill(hall); + run(g, 31 * 20 + 20); + expect(g.players[0].revealed).toBe(true); + }); +}); diff --git a/gh-pages/bear-war/tests/cargo.test.ts b/gh-pages/bear-war/tests/cargo.test.ts new file mode 100644 index 00000000..ebff6f2c --- /dev/null +++ b/gh-pages/bear-war/tests/cargo.test.ts @@ -0,0 +1,233 @@ +import { describe, expect, it } from 'vitest'; +import { Game } from '../src/game/game.ts'; +import { MapBuilder, rect } from '../src/game/map/builder.ts'; +import type { RaceId } from '../src/game/data/types.ts'; +import type { Command, GameEvent } from '../src/game/types.ts'; +import { TILE } from '../src/game/constants.ts'; +import type { Entity } from '../src/game/entity.ts'; +import { cargoUsed } from '../src/game/systems/cargo.ts'; + +function arena(r0: RaceId, r1: RaceId, plateau = false): Game { + const b = new MapBuilder('arena', 'Arena', 64, 40, 'none', 'badlands', 1); + b.fillElev(0); + // a raised block with cliffs all round and no ramp: only flyers get up there + if (plateau) b.setElev(rect(30, 4, 42, 14), 2); + b.base(6.5, 6.5, -2.3, { start: true }); + b.base(57.5, 33.5, 0.8, { start: true }); + const g = new Game({ map: b.build(), seed: 5, players: [{ name: 'A', race: r0, color: 0, team: 0 }, { name: 'B', race: r1, color: 1, team: 1 }] }); + for (const e of g.entities) if (e.def.worker || e.def.id === 'bumblebear') g.removeSilently(e); + for (const p of g.players) p.supplyMax = 200; + for (const ex of g.vision.explored) ex.fill(1); + return g; +} + +const run = (g: Game, ticks: number, cmds: { p: number; c: Command }[] = []): GameEvent[] => { + const evs: GameEvent[] = []; + g.step(cmds); + evs.push(...g.events); + for (let i = 1; i < ticks; i++) { + g.step([]); + evs.push(...g.events); + } + return evs; +}; + +const spawn = (g: Game, id: string, owner: number, tx: number, ty: number): Entity => g.spawnUnit(id, owner, tx * TILE, ty * TILE); + +describe('transports', () => { + it('units board a transport, ride along and unload at a point', () => { + const g = arena('ironpaw', 'horde'); + const bearloon = spawn(g, 'bearloon', 0, 20, 20); + const troops = [spawn(g, 'ranger', 0, 16, 22), spawn(g, 'ranger', 0, 16, 23), spawn(g, 'grizzly', 0, 17, 24)]; + run(g, 80, [{ p: 0, c: { c: 'smart', ids: troops.map((t) => t.id), x: bearloon.x, y: bearloon.y, t: bearloon.id } }]); + expect(troops.every((t) => t.inside && t.carrier === bearloon.id)).toBe(true); + expect(bearloon.cargo.length).toBe(3); + // carried units can't be ordered around or shot at + run(g, 2, [{ p: 0, c: { c: 'move', ids: [troops[0].id], x: 0, y: 0 } }]); + expect(troops[0].orders.length).toBe(0); + run(g, 300, [{ p: 0, c: { c: 'unload', ids: [bearloon.id], x: 44 * TILE, y: 20 * TILE } }]); + expect(bearloon.cargo.length).toBe(0); + for (const t of troops) { + expect(t.inside).toBe(false); + expect(Math.hypot(t.x - 44 * TILE, t.y - 20 * TILE)).toBeLessThan(3 * TILE); + } + }); + + it('a transport flies over cliffs to drop units where they could not walk', () => { + const g = arena('starborn', 'horde', true); + const floe = spawn(g, 'floebear', 0, 24, 20); + const w = spawn(g, 'spiritbear', 0, 22, 20); + run(g, 60, [{ p: 0, c: { c: 'load', ids: [floe.id], t: w.id } }]); + expect(w.inside).toBe(true); + run(g, 300, [{ p: 0, c: { c: 'unload', ids: [floe.id], x: 36 * TILE, y: 9 * TILE } }]); + expect(w.inside).toBe(false); + expect(g.map.elevAt(Math.floor(w.x / TILE), Math.floor(w.y / TILE))).toBe(2); + }); + + it('enforces cargo capacity', () => { + const g = arena('ironpaw', 'horde'); + const bearloon = spawn(g, 'bearloon', 0, 20, 20); + const kodiaks = [spawn(g, 'kodiak', 0, 16, 20), spawn(g, 'kodiak', 0, 24, 20)]; + const extra = spawn(g, 'ranger', 0, 20, 24); + run(g, 120, [{ p: 0, c: { c: 'smart', ids: [...kodiaks.map((t) => t.id), extra.id], x: bearloon.x, y: bearloon.y, t: bearloon.id } }]); + expect(kodiaks.every((t) => t.inside)).toBe(true); + expect(extra.inside).toBe(false); + expect(cargoUsed(g, bearloon)).toBe(8); + const evs = run(g, 2, [{ p: 0, c: { c: 'load', ids: [extra.id], t: bearloon.id } }]); + expect(evs.some((e) => e.k === 'error' && e.msg === 'Not enough room')).toBe(true); + }); + + it('a destroyed transport takes its cargo down; a destroyed bunker lets units out', () => { + const g = arena('ironpaw', 'horde'); + const bearloon = spawn(g, 'bearloon', 0, 20, 20); + const a = spawn(g, 'ranger', 0, 20, 22); + run(g, 60, [{ p: 0, c: { c: 'smart', ids: [a.id], x: bearloon.x, y: bearloon.y, t: bearloon.id } }]); + expect(a.inside).toBe(true); + g.kill(bearloon); + expect(a.alive).toBe(false); + + const bunker = g.placeBuilding('bunker', 0, 30, 20, true); + const b = spawn(g, 'ranger', 0, 31, 25); + run(g, 60, [{ p: 0, c: { c: 'smart', ids: [b.id], x: bunker.x, y: bunker.y, t: bunker.id } }]); + expect(b.inside).toBe(true); + g.kill(bunker); + expect(b.alive).toBe(true); + expect(b.inside).toBe(false); + }); + + it('units fire out of a bunker with bonus range and cannot be hit inside', () => { + const g = arena('ironpaw', 'horde'); + const bunker = g.placeBuilding('bunker', 0, 20, 20, true); + const troops = [spawn(g, 'ranger', 0, 21, 24), spawn(g, 'ranger', 0, 22, 24)]; + run(g, 60, [{ p: 0, c: { c: 'smart', ids: troops.map((t) => t.id), x: bunker.x, y: bunker.y, t: bunker.id } }]); + expect(troops.every((t) => t.inside)).toBe(true); + // just beyond a Ranger's own range from the bunker's centre, within range from its wall + 1 + const foe = g.spawnUnit('forager', 1, bunker.x + 200, bunker.y); + foe.holdPos = true; + foe.orders = [{ t: 'hold' }]; + run(g, 120); + expect(foe.alive ? foe.hp : 0).toBeLessThan(40); + expect(troops.every((t) => t.hp === t.def.hp)).toBe(true); + }); + + it('only infantry may enter a bunker', () => { + const g = arena('ironpaw', 'horde'); + const bunker = g.placeBuilding('bunker', 0, 20, 20, true); + const bike = spawn(g, 'blazerider', 0, 21, 25); + const evs = run(g, 60, [{ p: 0, c: { c: 'load', ids: [bike.id], t: bunker.id } }]); + expect(bike.inside).toBe(false); + expect(evs.some((e) => e.k === 'error')).toBe(true); + }); + + it('Bumblebears carry units only after Cargo Pouch', () => { + const g = arena('horde', 'ironpaw'); + const f = spawn(g, 'bumblebear', 0, 20, 20); + const s = spawn(g, 'stinger', 0, 21, 21); + run(g, 60, [{ p: 0, c: { c: 'load', ids: [s.id], t: f.id } }]); + expect(s.inside).toBe(false); + g.players[0].upgrades.pouch = 1; + run(g, 120, [{ p: 0, c: { c: 'load', ids: [s.id], t: f.id } }]); + expect(s.inside).toBe(true); + }); + + it('a unit inside can be unloaded on its own', () => { + const g = arena('ironpaw', 'horde'); + const bunker = g.placeBuilding('bunker', 0, 20, 20, true); + const troops = [spawn(g, 'ranger', 0, 21, 24), spawn(g, 'medic', 0, 22, 24)]; + run(g, 60, [{ p: 0, c: { c: 'smart', ids: troops.map((t) => t.id), x: bunker.x, y: bunker.y, t: bunker.id } }]); + expect(bunker.cargo.length).toBe(2); + run(g, 2, [{ p: 0, c: { c: 'unload', ids: [bunker.id], u: troops[1].id } }]); + expect(troops[1].inside).toBe(false); + expect(troops[0].inside).toBe(true); + run(g, 2, [{ p: 0, c: { c: 'unload', ids: [bunker.id] } }]); + expect(bunker.cargo.length).toBe(0); + }); +}); + +describe('review regressions', () => { + it('Gather only works on resources (it used to destroy any target)', () => { + const g = arena('ironpaw', 'starborn'); + const w = spawn(g, 'hardhat', 0, 20, 20); + const hall = g.entities.find((e) => e.owner === 1 && e.def.townHall)!; + const evil = spawn(g, 'spiritbear', 1, 22, 20); + run(g, 2, [{ p: 0, c: { c: 'gather', ids: [w.id], t: hall.id } }]); + expect(w.orders.length).toBe(0); + // even an order that slips through is dropped instead of "mining" the target + w.orders = [{ t: 'gather', target: evil.id }]; + run(g, 700); + expect(evil.alive).toBe(true); + expect(hall.alive).toBe(true); + }); + + it('caps queued orders', () => { + const g = arena('ironpaw', 'horde'); + const t = spawn(g, 'ranger', 0, 20, 20); + const cmds = Array.from({ length: 100 }, (_, i) => ({ p: 0, c: { c: 'move', ids: [t.id], x: (10 + (i % 20)) * TILE, y: 10 * TILE, q: true } as Command })); + run(g, 1, [{ p: 0, c: { c: 'move', ids: [t.id], x: 30 * TILE, y: 30 * TILE } }, ...cmds]); + expect(t.orders.length).toBeLessThanOrEqual(32); + }); + + it('cancelling a cocoon gives the cub back to its hive', () => { + const g = arena('horde', 'ironpaw'); + const hive = g.entities.find((e) => e.owner === 0 && e.def.spawnsCubs)!; + const before = hive.cubs.length; + run(g, 2, [{ p: 0, c: { c: 'train', ids: [hive.id], d: 'forager' } }]); + expect(hive.cubs.length).toBe(before - 1); + const cocoon = g.entities.find((e) => e.owner === 0 && e.def.kind === 'cocoon')!; + run(g, 2, [{ p: 0, c: { c: 'cancel', id: cocoon.id, slot: -1 } }]); + expect(cocoon.def.kind).toBe('cub'); + expect(hive.cubs).toContain(cocoon.id); + // and it can be used again + run(g, 2, [{ p: 0, c: { c: 'train', ids: [hive.id], d: 'forager' } }]); + expect(g.entities.filter((e) => e.owner === 0 && e.def.kind === 'cocoon').length).toBe(1); + }); + + it('research lost with its building can be started again', () => { + const g = arena('ironpaw', 'horde'); + const bay = g.placeBuilding('workshop', 0, 20, 20, true); + g.players[0].minerals = 1000; + g.players[0].gas = 1000; + run(g, 2, [{ p: 0, c: { c: 'research', ids: [bay.id], r: 'inf_w' } }]); + expect(g.players[0].researching.inf_w).toBe(1); + g.kill(bay); + const bay2 = g.placeBuilding('workshop', 0, 26, 20, true); + const evs = run(g, 2, [{ p: 0, c: { c: 'research', ids: [bay2.id], r: 'inf_w' } }]); + expect(evs.some((e) => e.k === 'error')).toBe(false); + expect(bay2.queue.length).toBe(1); + }); + + it('a worker pulled off a mineral frees it', () => { + const g = arena('ironpaw', 'horde'); + const hall = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + const m = g.entities.filter((e) => e.def.kind === 'mineral').sort((a, b) => Math.hypot(a.x - hall.x, a.y - hall.y) - Math.hypot(b.x - hall.x, b.y - hall.y))[0]; + const w = g.spawnUnit('hardhat', 0, m.x, m.y + 24); + run(g, 40, [{ p: 0, c: { c: 'gather', ids: [w.id], t: m.id } }]); + expect(m.occupiedBy).toBe(w.id); + run(g, 2, [{ p: 0, c: { c: 'move', ids: [w.id], x: 30 * TILE, y: 30 * TILE } }]); + expect(m.occupiedBy).toBe(0); + }); + + it('a build site nobody can reach fails with an error instead of hanging', () => { + const g = arena('ironpaw', 'horde', true); + const w = spawn(g, 'hardhat', 0, 24, 20); + g.players[0].minerals = 1000; + const evs = run(g, 400, [{ p: 0, c: { c: 'build', ids: [w.id], d: 'larder', tx: 35, ty: 8 } }]); + expect(evs.some((e) => e.k === 'error' && /reach/.test(e.msg))).toBe(true); + expect(w.orders.length).toBe(0); + expect(g.entities.some((e) => e.def.id === 'larder')).toBe(false); + }); + + it('braced Kodiaks focus fire on command', () => { + const g = arena('ironpaw', 'horde'); + const t = spawn(g, 'kodiak', 0, 20, 20); + run(g, 70, [{ p: 0, c: { c: 'ability', ids: [t.id], a: 'brace' } }]); + expect(t.def.id).toBe('kodiak_b'); + const a = spawn(g, 'cavetitan', 1, 28, 20); + const b = spawn(g, 'cavetitan', 1, 28, 24); + a.holdPos = b.holdPos = true; + a.orders = [{ t: 'hold' }]; + b.orders = [{ t: 'hold' }]; + run(g, 2, [{ p: 0, c: { c: 'attack', ids: [t.id], t: b.id } }]); + expect(t.target).toBe(b.id); + }); +}); diff --git a/gh-pages/bear-war/tests/room.test.ts b/gh-pages/bear-war/tests/room.test.ts new file mode 100644 index 00000000..35d773eb --- /dev/null +++ b/gh-pages/bear-war/tests/room.test.ts @@ -0,0 +1,166 @@ +import { describe, expect, it } from 'vitest'; +import { Room, type RoomClient } from '../src/net/room.ts'; +import type { S2C } from '../src/net/protocol.ts'; + +function client(id: string): RoomClient & { inbox: S2C[] } { + const inbox: S2C[] = []; + return { id, name: id, inbox, send: (m) => inbox.push(m) }; +} + +describe('room', () => { + it('runs a lobby, starts a game and emits turns with commands', () => { + const a = client('alice'); + const b = client('bob'); + const room = new Room('ABCDE', a, 'test', 'twin-craters', 2, true); + expect(room.join(b)).toBeNull(); + expect(room.start(b, 0)).toMatch(/host/); + // same team is rejected + room.setSlot(a, { t: 'slot', slot: 1, team: 0 }); + room.setSlot(b, { t: 'slot', slot: 1, team: 0 }); + expect(room.start(a, 0)).toMatch(/two teams/); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + expect(room.start(a, 0)).toBeNull(); + const startA = a.inbox.find((m) => m.t === 'start') as Extract; + const startB = b.inbox.find((m) => m.t === 'start') as Extract; + expect(startA.seed).toBe(startB.seed); + expect(startA.you).toBe(0); + expect(startB.you).toBe(1); + expect(startA.players).toEqual(startB.players); + + room.command(b, [{ c: 'stop', ids: [1] }]); + room.tickClock(600 + 250); // ~3 turns after start + const turns = a.inbox.filter((m) => m.t === 'turn') as Extract[]; + expect(turns.length).toBeGreaterThanOrEqual(2); + expect(turns[0].n).toBe(0); + expect(turns[0].c).toEqual([[1, { c: 'stop', ids: [1] }]]); + expect(b.inbox.filter((m) => m.t === 'turn').length).toBe(turns.length); + }); + + it('pauses for a disconnected player and drops them after the grace period', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('QWERT', a, 'test', 'twin-craters', 2, false); + room.join(b); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + room.start(a, 0); + room.ack(a, 1000); + room.leave(b); // connection lost, no 'leave' command + room.tickClock(600 + 100 * 40); + room.tickClock(600 + 100 * 41); + expect(a.inbox.some((m) => m.t === 'pause')).toBe(true); + const before = (a.inbox.filter((m) => m.t === 'turn') as Extract[]).flatMap((t) => t.c ?? []); + expect(before).not.toContainEqual([1, { c: 'leave' }]); + room.tickClock(600 + 100 * 41 + 46000); // grace period expires + room.tickClock(600 + 100 * 41 + 46200); + const after = (a.inbox.filter((m) => m.t === 'turn') as Extract[]).flatMap((t) => t.c ?? []); + expect(after).toContainEqual([1, { c: 'leave' }]); + }); + + it('lets a disconnected player rejoin and resume from their last turn', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('RJOIN', a, 'test', 'twin-craters', 2, false); + room.join(b); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + room.start(a, 0); + const token = (b.inbox.find((m) => m.t === 'start') as Extract).token; + expect(token.length).toBeGreaterThan(10); + room.tickClock(600 + 100 * 10); + room.leave(b); + room.command(a, [{ c: 'stop', ids: [5] }]); + room.tickClock(600 + 100 * 14); + const b2 = client('b2'); + expect(room.rejoin(b2, 'wrong', 0)).toMatch(/Invalid/); + expect(room.rejoin(b2, token, 8)).toBeNull(); + const rj = b2.inbox.find((m) => m.t === 'rejoined') as Extract; + expect(rj.turns[0][0]).toBe(8); + expect(rj.turns.flatMap(([, c]) => c ?? [])).toContainEqual([0, { c: 'stop', ids: [5] }]); + // the rejoined client receives new turns and its commands are accepted again + room.command(b2, [{ c: 'stop', ids: [9] }]); + room.tickClock(600 + 100 * 16); + const last = b2.inbox.filter((m) => m.t === 'turn') as Extract[]; + expect(last.flatMap((t) => t.c ?? [])).toContainEqual([1, { c: 'stop', ids: [9] }]); + }); + + it('caps commands per turn and strips unknown fields', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('CAPPP', a, 'test', 'twin-craters', 2, false); + room.join(b); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + room.start(a, 0); + const many = (n: number) => Array.from({ length: n }, () => ({ c: 'stop', ids: [1], junk: 'x'.repeat(1000) })); + room.command(a, many(65)); + room.command(a, many(3000)); + room.command(a, [{ c: 'nonsense' }, { c: 'move', ids: [1, 'x', null], x: 5, y: Infinity }]); + room.tickClock(600 + 50); + const turn = a.inbox.find((m) => m.t === 'turn' && m.c) as Extract; + expect(turn.c!.length).toBe(64); + expect(turn.c![0][1]).toEqual({ c: 'stop', ids: [1] }); + }); + + it('keeps a bounded turn history and rate-limits rejoins', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('HISTO', a, 'test', 'twin-craters', 2, false); + room.join(b); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + room.start(a, 0); + const token = (b.inbox.find((m) => m.t === 'start') as Extract).token; + for (let t = 1; t <= 2000; t++) { + room.ack(a, t); + room.ack(b, t); + room.tickClock(600 + t * 100); + } + room.leave(b); + const b2 = client('b2'); + expect(room.rejoin(b2, token, 0, 1e9)).toMatch(/behind/); + expect(room.rejoin(b2, token, 1900, 1e9)).toBeNull(); + const rj = b2.inbox.find((m) => m.t === 'rejoined') as Extract; + expect(rj.turns[0][0]).toBe(1900); + // a second rejoin right away is refused; later it takes over the connection + const b3 = client('b3'); + expect(room.rejoin(b3, token, 1900, 1e9 + 500)).toMatch(/too fast/); + expect(room.rejoin(b3, token, 1900, 1e9 + 5000)).toBeNull(); + expect(b2.inbox.some((m) => m.t === 'room' && m.room === null)).toBe(true); + }); + + it('does not wait for a player who surrendered', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('SURRN', a, 'test', 'twin-craters', 2, false); + room.join(b); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + room.start(a, 0); + room.command(b, [{ c: 'surrender' }]); + room.leave(b); + for (let t = 1; t <= 60; t++) { + room.ack(a, t); + room.tickClock(600 + t * 100); + } + expect(a.inbox.some((m) => m.t === 'pause')).toBe(false); + }); + + it('rejects bad slot indexes and chat from non-members', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('SLOTS', a, 'test', 'twin-craters', 2, false); + room.setSlot(a, { t: 'slot', slot: '__proto__' as unknown as number, race: 'horde' }); + room.setSlot(a, { t: 'slot', slot: 1.5, race: 'horde' }); + expect(([] as unknown as { race?: string }).race).toBeUndefined(); + room.chat(b, 'hello from outside'); + expect(a.inbox.some((m) => m.t === 'chat' && m.text === 'hello from outside')).toBe(false); + }); + + it('detects desyncs from mismatching hashes', () => { + const a = client('a'); + const b = client('b'); + const room = new Room('ZXCVB', a, 'test', 'twin-craters', 2, false); + room.join(b); + room.setSlot(b, { t: 'slot', slot: 1, team: 1 }); + room.start(a, 0); + room.ack(a, 50, 111, 100); + room.ack(b, 50, 222, 100); + expect(a.inbox.some((m) => m.t === 'desync')).toBe(true); + }); +}); diff --git a/gh-pages/bear-war/tests/sim.test.ts b/gh-pages/bear-war/tests/sim.test.ts new file mode 100644 index 00000000..4476d9f0 --- /dev/null +++ b/gh-pages/bear-war/tests/sim.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from 'vitest'; +import { readFileSync, readdirSync, statSync } from 'node:fs'; +import path from 'node:path'; +import { validateData, DEFS, RACE_IDS, RACES } from '../src/game/data/index.ts'; +import { datan2, dcos, dsin } from '../src/game/math.ts'; +import { Game } from '../src/game/game.ts'; +import { MAPS, buildMapWithWarnings, loadMap } from '../src/game/map/maps.ts'; +import type { RaceId } from '../src/game/data/types.ts'; +import type { Command } from '../src/game/types.ts'; +import { TILE } from '../src/game/constants.ts'; +import { ReplaySession, makeReplay } from '../src/net/replay.ts'; + +function newGame(r1: RaceId, r2: RaceId, seed = 42, ai: 'normal' | null = null): Game { + return new Game({ + map: loadMap(MAPS[0].id), + seed, + players: [ + { name: 'A', race: r1, color: 0, team: 0, ai }, + { name: 'B', race: r2, color: 1, team: 1, ai }, + ], + }); +} + +describe('data', () => { + it('validates all cross references', () => { + expect(() => validateData()).not.toThrow(); + }); + it('every clan has worker, town hall, supply and refinery', () => { + for (const r of RACE_IDS) { + const race = RACES[r]; + expect(DEFS[race.worker].worker).toBe(true); + expect(DEFS[race.townHall].townHall).toBe(true); + expect(DEFS[race.refinery].refinery).toBe(true); + } + }); +}); + +describe('deterministic math', () => { + it('dsin/dcos match Math within tolerance', () => { + for (let a = -10; a <= 10; a += 0.37) { + expect(Math.abs(dsin(a) - Math.sin(a))).toBeLessThan(1e-8); + expect(Math.abs(dcos(a) - Math.cos(a))).toBeLessThan(1e-8); + } + }); + it('datan2 matches Math.atan2 within tolerance', () => { + for (let y = -5; y <= 5; y += 0.7) { + for (let x = -5; x <= 5; x += 0.9) { + if (x === 0 && y === 0) continue; + expect(Math.abs(datan2(y, x) - Math.atan2(y, x))).toBeLessThan(2e-5); + } + } + }); + it('simulation code never calls non-deterministic Math functions', () => { + const banned = /Math\.(sin|cos|tan|asin|acos|atan|atan2|sinh|cosh|tanh|exp|expm1|log|log1p|log2|log10|pow|cbrt|hypot|random)\b/; + const root = path.resolve(import.meta.dirname, '..', 'src', 'game'); + const files: string[] = []; + const walk = (d: string) => { + for (const f of readdirSync(d)) { + const p = path.join(d, f); + if (statSync(p).isDirectory()) walk(p); + else if (p.endsWith('.ts')) files.push(p); + } + }; + walk(root); + const strip = (src: string) => src.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/.*$/gm, ''); + const offenders = files.filter((f) => !f.endsWith('math.ts') && banned.test(strip(readFileSync(f, 'utf8')))); + expect(offenders).toEqual([]); + }); +}); + +describe('maps', () => { + for (const m of MAPS) { + it(`${m.id} builds without warnings`, () => { + const { map, warnings } = buildMapWithWarnings(m.id); + expect(warnings).toEqual([]); + expect(map.starts.length).toBe(m.players); + expect(map.resources.filter((r) => r.kind === 'mineral').length).toBeGreaterThanOrEqual(8 * map.bases.length - 4); + }); + } +}); + +describe('pathfinding', () => { + it('finds a path between the two start locations', () => { + const g = newGame('ironpaw', 'horde'); + const [a, b] = g.map.starts; + const ax = (a.tx + 2.5) * TILE; + const ay = (a.ty + 8) * TILE; + const bx = (b.tx + 2.5) * TILE; + const by = (b.ty - 3) * TILE; + const p = g.grid.findPath(ax, ay, bx, by, 1, 20000); + expect(p).not.toBeNull(); + const ex = p![p!.length - 2]; + const ey = p![p!.length - 1]; + expect(Math.hypot(ex - bx, ey - by)).toBeLessThan(TILE * 2); + }); +}); + +describe('simulation', () => { + it('workers mine minerals', () => { + const g = newGame('starborn', 'ironpaw'); + for (let i = 0; i < 20 * 60; i++) g.step([]); + for (const p of g.players) expect(p.stats.mineralsGathered).toBeGreaterThan(400); + }); + + it('is deterministic with identical commands', () => { + const script = (g: Game): Command[] => { + if (g.tick !== 40) return []; + const hall = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + const ws = g.entities.filter((e) => e.owner === 0 && e.def.worker).map((e) => e.id); + return [ + { c: 'train', ids: [hall.id], d: 'hardhat' }, + { c: 'move', ids: ws.slice(0, 4), x: hall.x + 300, y: hall.y + 200 }, + ]; + }; + const run = () => { + const g = newGame('ironpaw', 'horde', 7, 'normal'); + for (let i = 0; i < 20 * 120; i++) g.step(script(g).map((c) => ({ p: 0, c }))); + return g.hash(); + }; + expect(run()).toBe(run()); + }); + + it('build, train and fight: AIs expand, produce armies and trade blows', () => { + const g = newGame('ironpaw', 'starborn', 1234, 'normal'); + for (let i = 0; i < 20 * 60 * 18 && !g.over; i++) g.step([]); + for (const p of g.players) { + expect(p.stats.unitsProduced).toBeGreaterThan(30); + expect(p.stats.buildingsBuilt).toBeGreaterThan(8); + } + const kills = g.players.reduce((s, p) => s + p.stats.unitsKilled + p.stats.buildingsKilled, 0); + expect(kills).toBeGreaterThan(20); + }, 120000); + + it('rejects commands for units the player does not own', () => { + const g = newGame('ironpaw', 'horde'); + const enemyWorker = g.entities.find((e) => e.owner === 1 && e.def.worker)!; + const before = enemyWorker.orders.length; + g.step([{ p: 0, c: { c: 'stop', ids: [enemyWorker.id] } }]); + g.step([{ p: 0, c: { c: 'move', ids: [enemyWorker.id], x: 10, y: 10 } }]); + expect(enemyWorker.orders.length).toBe(before); + }); + + it('ignores malformed commands without throwing', () => { + const g = newGame('horde', 'starborn'); + const junk = [ + { c: 'move', ids: 'nope', x: 'a', y: null }, + { c: 'build', ids: [1, 2, 3], d: 'doesnotexist', tx: -5, ty: 1e9 }, + { c: 'train', ids: [999999], d: 'ranger' }, + { c: 'ability', ids: [], a: 'starfall' }, + { c: 'cancel', id: 'x', slot: 'y' }, + { c: 'whatever' }, + ] as unknown as Command[]; + expect(() => g.step(junk.map((c) => ({ p: 0, c })))).not.toThrow(); + }); + + it('replays reproduce a game exactly', () => { + const g = newGame('starborn', 'horde', 31, 'normal'); + g.players[0].ai = null; + const log: [number, number, Command][] = []; + for (let i = 0; i < 20 * 90; i++) { + const cmds: Command[] = []; + if (g.tick === 30 || g.tick === 900) { + const hall = g.entities.find((e) => e.owner === 0 && e.def.townHall)!; + cmds.push({ c: 'train', ids: [hall.id], d: 'snowcub' }); + const ws = g.entities.filter((e) => e.owner === 0 && e.def.worker).map((e) => e.id); + cmds.push({ c: 'move', ids: ws.slice(0, 2), x: hall.x + 250, y: hall.y - 100 }); + } + for (const c of cmds) log.push([g.tick, 0, c]); + g.step(cmds.map((c) => ({ p: 0, c }))); + } + const rep = JSON.parse(JSON.stringify(makeReplay(g, g.setups, log, 'test'))); + const rs = new ReplaySession(rep); + rs.speed = 50; + for (let i = 0; i < 10000 && rs.game.tick < rep.ticks; i++) rs.update(250); + expect(rs.game.tick).toBe(g.tick); + expect(rs.game.hash()).toBe(g.hash()); + }); + + it('surrender ends the game', () => { + const g = newGame('horde', 'starborn'); + g.step([{ p: 1, c: { c: 'surrender' } }]); + for (let i = 0; i < 20; i++) g.step([]); + expect(g.over).toBe(true); + expect(g.winners).toEqual([0]); + }); +}); diff --git a/gh-pages/bear-war/tests/units.test.ts b/gh-pages/bear-war/tests/units.test.ts new file mode 100644 index 00000000..3b34895f --- /dev/null +++ b/gh-pages/bear-war/tests/units.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { Game } from '../src/game/game.ts'; +import { MapBuilder } from '../src/game/map/builder.ts'; +import type { RaceId } from '../src/game/data/types.ts'; +import type { Command } from '../src/game/types.ts'; +import { TICK_RATE, TILE } from '../src/game/constants.ts'; +import type { Entity } from '../src/game/entity.ts'; + +function arena(r0: RaceId, r1: RaceId): Game { + const b = new MapBuilder('arena', 'Arena', 64, 40, 'none', 'badlands', 1); + b.fillElev(0); + b.base(6.5, 6.5, -2.3, { start: true }); + b.base(57.5, 33.5, 0.8, { start: true }); + const g = new Game({ map: b.build(), seed: 5, players: [{ name: 'A', race: r0, color: 0, team: 0 }, { name: 'B', race: r1, color: 1, team: 1 }] }); + for (const e of g.entities) if (e.def.worker || e.def.id === 'bumblebear') g.removeSilently(e); + for (const p of g.players) p.supplyMax = 200; + return g; +} + +const run = (g: Game, ticks: number, cmds: { p: number; c: Command }[] = []) => { + g.step(cmds); + for (let i = 1; i < ticks; i++) g.step([]); +}; + +function spawn(g: Game, id: string, owner: number, tx: number, ty: number): Entity { + const u = g.spawnUnit(id, owner, tx * TILE, ty * TILE); + u.energy = u.def.energy ?? 0; + u.holdPos = true; + return u; +} + +const ability = (u: Entity, a: string, x?: number, y?: number, t?: Entity) => [{ p: u.owner, c: { c: 'ability', ids: [u.id], a, x, y, t: t?.id } as Command }]; + +describe('modes', () => { + it('a dug-in Sapper hides and its keg splashes friend and foe', () => { + const g = arena('ironpaw', 'horde'); + const s = spawn(g, 'sapper', 0, 20, 20); + run(g, 2, ability(s, 'digin')); + run(g, 2.2 * TICK_RATE); + expect(s.def.id).toBe('sapper_b'); + expect(s.def.cloaked).toBe(true); + const friend = spawn(g, 'ranger', 0, 24, 21); + const foe = spawn(g, 'stinger', 1, 24, 20); + run(g, 40); + expect(foe.alive).toBe(false); + expect(friend.hp).toBeLessThan(45); + expect(s.cds[0]).toBeGreaterThan(10 * TICK_RATE); + }); + + it('Blaze Riders need an Armory to hop off', () => { + const g = arena('ironpaw', 'horde'); + const r = spawn(g, 'blazerider', 0, 20, 20); + run(g, 3 * TICK_RATE, ability(r, 'hopoff')); + expect(r.def.id).toBe('blazerider'); + g.placeBuilding('armory', 0, 30, 4, true); + run(g, 3 * TICK_RATE, ability(r, 'hopoff')); + expect(r.def.id).toBe('blazebrute'); + expect(r.hp).toBe(135); + }); + + it('an anchored Hoverbear only hits ground units inside its zone', () => { + const g = arena('ironpaw', 'horde'); + const h = spawn(g, 'hoverbear', 0, 20, 20); + run(g, 3 * TICK_RATE, ability(h, 'anchor', 24 * TILE, 20 * TILE)); + expect(h.def.id).toBe('hoverbear_a'); + const outside = spawn(g, 'stinger', 1, 15, 20); + const inside = spawn(g, 'stinger', 1, 25, 20); + run(g, 3 * TICK_RATE); + expect(outside.hp).toBe(100); + expect(inside.alive).toBe(false); + }); + + it('the Seer burns energy with its Sunlance lit and douses it when empty', () => { + const g = arena('starborn', 'horde'); + const s = spawn(g, 'seer', 0, 20, 20); + s.energy = 30; + run(g, 2, ability(s, 'sunlanceon')); + expect(s.def.id).toBe('seer_b'); + run(g, 4 * TICK_RATE); + expect(s.def.id).toBe('seer'); + }); +}); + +describe('new abilities', () => { + it('Lock On keeps hurting its target until it leaves the leash', () => { + const g = arena('ironpaw', 'starborn'); + const t = spawn(g, 'tracker', 0, 20, 20); + const foe = spawn(g, 'polarcolossus', 1, 26, 20); + foe.def = { ...foe.def, weapons: undefined }; + run(g, 2, ability(t, 'lockon', foe.x, foe.y, foe)); + const before = foe.hp + foe.shields; + run(g, 4 * TICK_RATE); + expect(before - (foe.hp + foe.shields)).toBeGreaterThan(80); + expect(t.lockT).toBeGreaterThan(0); + foe.x += 30 * TILE; + run(g, 2); + expect(t.lockT).toBe(0); + }); + + it('Wild Cubs rush out of the Den Mother and fade after 18 seconds', () => { + const g = arena('horde', 'ironpaw'); + const d = spawn(g, 'denmother', 0, 20, 20); + run(g, 2, ability(d, 'releasecubs', 40 * TILE, 20 * TILE)); + expect(g.entities.filter((e) => e.def.id === 'wildcub').length).toBe(2); + run(g, 18 * TICK_RATE); + expect(g.entities.filter((e) => e.def.id === 'wildcub').length).toBe(0); + }); + + it('Yank pulls a unit next to the Longtongue', () => { + const g = arena('horde', 'ironpaw'); + const l = spawn(g, 'longtongue', 0, 20, 20); + const k = spawn(g, 'kodiak', 1, 28, 20); + run(g, 2, ability(l, 'yank', k.x, k.y, k)); + expect(Math.hypot(k.x - l.x, k.y - l.y)).toBeLessThan(l.r + k.r + 2 * TILE); + }); + + it('Pollen Cloud cuts enemy ranged attacks down to melee range', () => { + const g = arena('horde', 'ironpaw'); + const l = spawn(g, 'longtongue', 0, 20, 30); + const r = spawn(g, 'ranger', 1, 20, 20); + const victim = spawn(g, 'scrapper', 0, 24, 20); + run(g, 2, ability(l, 'pollen', r.x, r.y)); + run(g, 3 * TICK_RATE); + expect(victim.hp).toBe(35); + }); + + it('Scalding Honey lands 2.5 seconds after it is thrown', () => { + const g = arena('horde', 'ironpaw'); + const b = spawn(g, 'bombardier', 0, 20, 20); + b.def = { ...b.def, weapons: undefined }; + const foe = spawn(g, 'grizzly', 1, 27, 20); + run(g, 2, ability(b, 'scaldinghoney', foe.x, foe.y)); + run(g, 2 * TICK_RATE); + expect(foe.hp).toBe(125); + run(g, TICK_RATE); + expect(foe.hp).toBeLessThan(125 - 50); + }); + + it('a hibernating Burrower hits every enemy along its line', () => { + const g = arena('horde', 'ironpaw'); + const b = spawn(g, 'burrower', 0, 20, 20); + run(g, 2 * TICK_RATE, ability(b, 'hibernate')); + expect(b.def.id).toBe('burrower_b'); + const near = spawn(g, 'ranger', 1, 23, 20); + const far = spawn(g, 'ranger', 1, 27, 20); + run(g, 10); + expect(near.hp).toBeLessThan(45); + expect(far.hp).toBeLessThan(45); + }); + + it('Spirit Walk moves the Glacier Bear to its spirit after 7 seconds', () => { + const g = arena('starborn', 'horde'); + const gb = spawn(g, 'glacierbear', 0, 20, 20); + run(g, 2, ability(gb, 'spiritwalk', 40 * TILE, 20 * TILE)); + run(g, 6 * TICK_RATE); + expect(gb.x).toBeLessThan(22 * TILE); + run(g, 1.2 * TICK_RATE); + expect(gb.x).toBeGreaterThan(38 * TILE); + }); + + it('the Star Snowball bursts on friend and foe alike', () => { + const g = arena('starborn', 'horde'); + const s = spawn(g, 'snowballer', 0, 20, 20); + const friend = spawn(g, 'spiritbear', 0, 26, 21); + const foe = spawn(g, 'stinger', 1, 26, 20); + run(g, 2, ability(s, 'snowball', 26 * TILE, 20 * TILE)); + run(g, 2.2 * TICK_RATE); + expect(foe.alive).toBe(false); + expect(friend.shields + friend.hp).toBeLessThan(150 - 100); + }); +}); diff --git a/gh-pages/bear-war/tsconfig.json b/gh-pages/bear-war/tsconfig.json new file mode 100644 index 00000000..3049ca8a --- /dev/null +++ b/gh-pages/bear-war/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2023", "DOM", "DOM.Iterable"], + "types": ["node", "vite/client"], + "strict": true, + "noEmit": true, + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "erasableSyntaxOnly": true, + "isolatedModules": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src", "server", "scripts", "tests", "vite.config.ts"] +} diff --git a/gh-pages/bear-war/vite.config.ts b/gh-pages/bear-war/vite.config.ts new file mode 100644 index 00000000..947cc448 --- /dev/null +++ b/gh-pages/bear-war/vite.config.ts @@ -0,0 +1,90 @@ +import { defineConfig, type Plugin } from 'vite'; +import { createHash } from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { simHash } from './server/simhash.ts'; + +const ROOT = fileURLToPath(new URL('.', import.meta.url)); + +/** + * Emits sw.js with a precache list of every file in the build, so the game works fully + * offline once loaded (and can be installed as a PWA). No dependencies needed. + */ +function serviceWorker(): Plugin { + return { + name: 'bear-war-sw', + apply: 'build', + generateBundle(_opts, bundle) { + const files = Object.keys(bundle).filter((f) => !f.endsWith('.map') && f !== 'sw.js' && !f.startsWith('gallery')); + const extra = ['./', 'manifest.webmanifest', 'icon.svg', 'icon-192.png', 'icon-512.png', 'icon-maskable-512.png', 'apple-touch-icon.png']; + const urls = [...new Set([...extra, ...files])]; + const version = createHash('sha256').update(urls.join('|') + Date.now()).digest('hex').slice(0, 12); + const src = `// Generated at build time, do not edit. +const CACHE = 'bear-war-${version}'; +const PRECACHE = ${JSON.stringify(urls)}; +self.addEventListener('install', (e) => { + e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE)).then(() => self.skipWaiting())); +}); +self.addEventListener('activate', (e) => { + e.waitUntil( + caches.keys().then((keys) => Promise.all(keys.filter((k) => k.startsWith('bear-war-') && k !== CACHE).map((k) => caches.delete(k)))) + .then(() => self.clients.claim()), + ); +}); +self.addEventListener('fetch', (e) => { + const req = e.request; + if (req.method !== 'GET') return; + const url = new URL(req.url); + if (url.origin !== location.origin || url.pathname.endsWith('/ws')) return; + if (req.mode === 'navigate') { + // network first so updates arrive when online; fall back to the cached app shell + e.respondWith( + fetch(req) + .then((res) => { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put('./', copy)); + return res; + }) + .catch(() => caches.match('./', { ignoreSearch: true }).then((r) => r || caches.match(req, { ignoreSearch: true }))), + ); + return; + } + e.respondWith( + caches.match(req, { ignoreSearch: true }).then( + (hit) => hit || fetch(req).then((res) => { + if (res.ok && url.pathname.includes('/assets/')) { + const copy = res.clone(); + caches.open(CACHE).then((c) => c.put(req, copy)); + } + return res; + }), + ), + ); +}); +`; + this.emitFile({ type: 'asset', fileName: 'sw.js', source: src }); + }, + }; +} + +export default defineConfig(({ command }) => ({ + base: './', + plugins: [serviceWorker()], + define: { + // the multiplayer server only lets clients with the same simulation code play together + __SIM_HASH__: JSON.stringify(command === 'build' ? simHash(ROOT) : 'dev'), + }, + server: { + host: true, + port: 5173, + proxy: { + // during development the multiplayer server runs separately (npm run dev:server) + '/ws': { target: 'ws://localhost:8787', ws: true }, + }, + }, + build: { + target: 'es2022', + outDir: 'dist', + assetsInlineLimit: 0, + chunkSizeWarningLimit: 1500, + }, +})); diff --git a/gh-pages/docusaurus.config.ts b/gh-pages/docusaurus.config.ts index 38a9b5de..fdd59fc9 100644 --- a/gh-pages/docusaurus.config.ts +++ b/gh-pages/docusaurus.config.ts @@ -82,6 +82,7 @@ const config: Config = { ], scripts: [], + stylesheets: ["https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;800&display=swap"], themeConfig: { // Replace with your project's social card @@ -113,6 +114,7 @@ const config: Config = { label: "Developer Manual", }, { to: "/blog", label: "News", position: "left" }, + { to: "/support-us", label: "Support us", position: "left" }, { href: repositoryUrl, label: "GitHub", @@ -121,34 +123,15 @@ const config: Config = { ], }, footer: { - style: "dark", - links: [ - { - label: "User Manual", - to: "/docs/user/getting-started", - }, - { - label: "Developer Manual", - to: "/docs/developer/how-to-build", - }, - { - label: "Support us 🎗️", - to: "/support-us", - }, - { - label: "Download", - href: downloadUrl, - }, - { - label: "Source Code", - href: repositoryUrl, - }, - ], copyright: `Copyright © ${new Date().getFullYear()} Local Desktop.`, }, + colorMode: { + defaultMode: "dark", + disableSwitch: true, + respectPrefersColorScheme: false, + }, prism: { - theme: prismThemes.github, - darkTheme: prismThemes.dracula, + theme: prismThemes.nightOwl, }, mermaid: { theme: { light: "neutral", dark: "dark" }, @@ -190,9 +173,6 @@ const config: Config = { downloadUrl, repositoryUrl, emailCollectForm: "https://forms.gle/UDrYH9xwhznT2u8Y9", - // Landing page only — always the dark-themed Looker report (docs keep light/dark toggle). - audienceChartEmbedUrlDark: - "https://lookerstudio.google.com/embed/reporting/adb676dc-c6b1-4161-a995-ec93d008ab79/page/JoHzF", }, }; diff --git a/gh-pages/package.json b/gh-pages/package.json index 9f34164b..172a6345 100644 --- a/gh-pages/package.json +++ b/gh-pages/package.json @@ -6,7 +6,7 @@ "scripts": { "docusaurus": "docusaurus", "start": "docusaurus start", - "build": "docusaurus build", + "build": "DOCUSAURUS_GENERATED_FILES_DIR_NAME=.docusaurus-build docusaurus build", "swizzle": "docusaurus swizzle", "deploy": "docusaurus deploy", "clear": "docusaurus clear", diff --git a/gh-pages/src/client-modules/force-home-dark.ts b/gh-pages/src/client-modules/force-home-dark.ts deleted file mode 100644 index 33adb116..00000000 --- a/gh-pages/src/client-modules/force-home-dark.ts +++ /dev/null @@ -1,8 +0,0 @@ -function isHomePath(pathname: string): boolean { - const path = pathname.replace(/\/$/, "") || "/"; - return path === "/"; -} - -if (typeof window !== "undefined" && isHomePath(window.location.pathname)) { - document.documentElement.classList.add("home-force-dark"); -} diff --git a/gh-pages/src/components/audience-chart.tsx b/gh-pages/src/components/audience-chart.tsx deleted file mode 100644 index be74606a..00000000 --- a/gh-pages/src/components/audience-chart.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import React, { type ReactNode } from "react"; -import config from "@site/docusaurus.config"; -import { useChartInteractive } from "@site/src/hooks/use-chart-interactive"; - -/** Dark Looker report — landing page only; ignores site color mode. */ -const EMBED_URL = config.customFields.audienceChartEmbedUrlDark as string; - -export default function AudienceChart(): ReactNode { - const interactive = useChartInteractive(); - - return ( -