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 00000000..56682ba7 Binary files /dev/null and b/gh-pages/bear-war/docs/images/base-horde.jpg differ diff --git a/gh-pages/bear-war/docs/images/base-ironpaw.jpg b/gh-pages/bear-war/docs/images/base-ironpaw.jpg new file mode 100644 index 00000000..b6be8ebe Binary files /dev/null and b/gh-pages/bear-war/docs/images/base-ironpaw.jpg differ 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 00000000..df9ef996 Binary files /dev/null and b/gh-pages/bear-war/docs/images/battle.jpg differ diff --git a/gh-pages/bear-war/gallery.html b/gh-pages/bear-war/gallery.html new file mode 100644 index 00000000..23665bc2 --- /dev/null +++ b/gh-pages/bear-war/gallery.html @@ -0,0 +1,18 @@ + + + + + + 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 00000000..a044c7fb Binary files /dev/null and b/gh-pages/bear-war/public/apple-touch-icon.png differ 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 00000000..3bd4653c Binary files /dev/null and b/gh-pages/bear-war/public/icon-192.png differ 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 00000000..d2a1585c Binary files /dev/null and b/gh-pages/bear-war/public/icon-512.png differ diff --git a/gh-pages/bear-war/public/icon-maskable-512.png b/gh-pages/bear-war/public/icon-maskable-512.png new file mode 100644 index 00000000..9370fb76 Binary files /dev/null and b/gh-pages/bear-war/public/icon-maskable-512.png differ diff --git a/gh-pages/bear-war/public/icon.svg b/gh-pages/bear-war/public/icon.svg new file mode 100644 index 00000000..f6085dba --- /dev/null +++ b/gh-pages/bear-war/public/icon.svg @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + 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 ( -