@netforge/template-tower 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/client/main.ts ADDED
@@ -0,0 +1,746 @@
1
+ /**
2
+ * COGSPIRE client: join a board by link, point to move your tinker, click
3
+ * a tile to build the tower picked in the bar, click a tower to upgrade or
4
+ * sell it, and call the next wave early when everyone is ready. The room
5
+ * decides every shot, kill, bounty and leak; your tinker is predicted, so
6
+ * the cursor never waits for the server.
7
+ *
8
+ * URL parameters: see @netforge/tabletop boot.ts (?room, ?server,
9
+ * ?style, ?autopilot, ?preview, ?stats, ?shapes, ?webgl, Net Forge flags).
10
+ */
11
+ import {
12
+ AdditiveBlending,
13
+ BoxGeometry,
14
+ CanvasTexture,
15
+ Color,
16
+ CylinderGeometry,
17
+ DoubleSide,
18
+ Group,
19
+ InstancedMesh,
20
+ Matrix4,
21
+ Mesh,
22
+ MeshBasicMaterial,
23
+ MeshStandardMaterial,
24
+ NearestFilter,
25
+ PlaneGeometry,
26
+ Quaternion,
27
+ RingGeometry,
28
+ SRGBColorSpace,
29
+ Vector3,
30
+ } from "three/webgpu";
31
+ import type { InterpolatedEntity } from "@netforge/engine/net";
32
+ import { bestTowerTile, CreepFlag, ENTITY_BASE, RF, RulesKind, TILE_M, tileAt, tileCenter, tileKey, TowerAct, UNITS } from "@netforge/engine/rules";
33
+ import type { RulesInput } from "@netforge/engine/rules";
34
+ import { $, bootBoard, cssColor, type BoardView, type Placed } from "@netforge/tabletop";
35
+ import { COSMETICS, GAME_VERSION, PLACE, TEMPLATE, towerAdapter, towerGame, towerManifest } from "../src/manifest.js";
36
+ import { COGSPIRE_COIN } from "../src/store.js";
37
+ import { GATE_MODEL, ROCK_MODEL, SMALL_DECOR, SPIRE_MODEL, TREE_MODELS } from "./decor.js";
38
+ import { ENEMY_NAMES, KEEPER_COLORS, RULE_NAMES, SKU_NAMES, TITLE, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
39
+
40
+ interface TowerView extends BoardView {
41
+ readonly wave: number;
42
+ readonly waves: number;
43
+ readonly stage: "build" | "wave";
44
+ readonly countdown: number;
45
+ readonly lives: number;
46
+ readonly livesMax: number;
47
+ readonly left: number;
48
+ readonly next: readonly { type: number; count: number }[];
49
+ readonly boss: { type: number; hp: number; maxHp: number } | null;
50
+ readonly players: readonly { id: number; gold: number; kills: number; ready: boolean; built: number }[];
51
+ readonly gold?: number;
52
+ readonly ready?: boolean;
53
+ }
54
+
55
+ const genre = towerGame();
56
+ const g = genre.game;
57
+ const b = g.board;
58
+ const manifest = towerManifest();
59
+ const W = b.w * TILE_M;
60
+ const H = b.h * TILE_M;
61
+ const FINISH = [0xc9a15a, 0x5fbfa0, 0xffd76a];
62
+ const CREEP_COLOR = [0xd9643a, 0x8fa3b8, 0xff9a3c, 0x9a7b5a, 0xb0a0ff, 0x77d06a];
63
+
64
+ /** The browser's autopilot (?autopilot=1): build the cheapest-first rotation on the best free tile, upgrade, ready up. */
65
+ let lastView: TowerView | null = null;
66
+ let towerTiles = new Set<number>();
67
+ let myTowers: { tx: number; ty: number; type: number; level: number }[] = [];
68
+ let selfId = 0;
69
+ const autopilot = (tick: number): RulesInput | null => {
70
+ const v = lastView;
71
+ if (!v || tick % 15 !== 0) return null;
72
+ const gold = v.gold ?? 0;
73
+ const order = g.towers.map((_, i) => i).sort((x, y) => (g.towers[x]?.def.cost ?? 0) - (g.towers[y]?.def.cost ?? 0));
74
+ const want = order[(myTowers.length + selfId) % order.length] ?? 0;
75
+ const type = (g.towers[want]?.def.cost ?? Infinity) <= gold ? want : (order.find((i) => (g.towers[i]?.def.cost ?? Infinity) <= gold) ?? want);
76
+ const tt = g.towers[type];
77
+ const at = (tx: number, ty: number, act: number, a = 0): RulesInput => {
78
+ const c = tileCenter(b, tx, ty);
79
+ return { mx: 0, mz: 0, cx: Math.round(c.x * 16), cz: Math.round(c.z * 16), act, a, b: tx, c: ty };
80
+ };
81
+ if (tt && gold >= tt.def.cost && myTowers.length < 7) {
82
+ const tile = bestTowerTile(g, towerTiles, type, selfId, 0);
83
+ if (tile) return at(tile.tx, tile.ty, TowerAct.Build, type);
84
+ }
85
+ const up = myTowers.filter((t) => (g.towers[t.type]?.upgradeCost[t.level] ?? Infinity) <= gold).sort((x, y) => x.level - y.level)[0];
86
+ if (up) return at(up.tx, up.ty, TowerAct.Upgrade);
87
+ if (v.stage === "build" && !v.ready) return { mx: 0, mz: 0, cx: 0, cz: 0, act: TowerAct.Ready, a: 1, b: 0, c: 0 };
88
+ return null;
89
+ };
90
+
91
+ async function main(): Promise<void> {
92
+ const app = await bootBoard({
93
+ adapter: towerAdapter(),
94
+ manifest,
95
+ template: TEMPLATE,
96
+ place: PLACE,
97
+ version: GAME_VERSION,
98
+ labels: {
99
+ title: TITLE,
100
+ rules: RULE_NAMES,
101
+ skus: SKU_NAMES,
102
+ upgrades: UPGRADE_NAMES,
103
+ loading: "Winding the spire",
104
+ help: "Click a tile to build · click a tower to upgrade · 1-4 pick a tower · Space call the wave · ` stats",
105
+ },
106
+ theme: {
107
+ accent: "#f2b84b",
108
+ gold: "#ffd76a",
109
+ ink: "#f6ecd9",
110
+ dim: "#b4a68c",
111
+ panel: "rgba(34, 24, 14, 0.86)",
112
+ bg: "#1d1710",
113
+ font: '"Trebuchet MS", "Gill Sans", system-ui, sans-serif',
114
+ display: 'Georgia, "Palatino Linotype", serif',
115
+ radius: 4,
116
+ },
117
+ coin: COGSPIRE_COIN,
118
+ cosmetics: COSMETICS,
119
+ swatch: (c) => `linear-gradient(135deg, ${cssColor(FINISH[c] ?? 0xc9a15a)}, #3a2a14)`,
120
+ ground: { w: W + 7, h: H + 2 },
121
+ preset: "day",
122
+ theme3d: "meadow",
123
+ groundColor: 0x4a5a32,
124
+ pitch: 1.0,
125
+ autopilot,
126
+ soundKey: "cogspire:sound",
127
+ });
128
+ selfId = app.self;
129
+ const { stage, hud, clock, sfx } = app;
130
+ sfx.define("shoot", { wave: "square", f0: 900, f1: 400, ms: 40, gain: 0.18 }, { minGapMs: 45, voices: 3 });
131
+ sfx.define("thud", { wave: "noise", f0: 400, f1: 80, ms: 160, gain: 0.5 }, { minGapMs: 90, voices: 2 });
132
+ sfx.define("bell", { wave: "triangle", f0: 880, f1: 660, ms: 380, gain: 0.6 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
133
+
134
+ // ---- The board: one textured plate (tiles, road, rocks), rails along the road, the spire and the gate.
135
+ const plate = boardTexture();
136
+ const board = new Mesh(new PlaneGeometry(W, H, b.w * 2, b.h * 2), new MeshStandardMaterial({ map: plate, roughness: 0.95, metalness: 0 }));
137
+ board.rotation.x = -Math.PI / 2;
138
+ board.position.y = stage.groundAt(0, 0) + 0.03;
139
+ board.receiveShadow = true;
140
+ stage.add(board);
141
+ const railMat = new MeshStandardMaterial({ color: 0x8a7a66, roughness: 0.4, metalness: 0.8 });
142
+ for (let i = 1; i < b.path.length; i++) {
143
+ const a = b.path[i - 1] as readonly [number, number];
144
+ const c = b.path[i] as readonly [number, number];
145
+ const pa = tileCenter(b, a[0], a[1]);
146
+ const pc = tileCenter(b, c[0], c[1]);
147
+ const len = Math.hypot(pc.x - pa.x, pc.z - pa.z) + 0.3;
148
+ for (const off of [-0.45, 0.45]) {
149
+ const rail = new Mesh(new BoxGeometry(0.09, 0.08, len), railMat);
150
+ const horiz = a[1] === c[1];
151
+ rail.position.set((pa.x + pc.x) / 2 + (horiz ? 0 : off), board.position.y + 0.05, (pa.z + pc.z) / 2 + (horiz ? off : 0));
152
+ if (horiz) rail.rotation.y = Math.PI / 2;
153
+ stage.add(rail);
154
+ }
155
+ }
156
+ const last = b.path[b.path.length - 1] as readonly [number, number];
157
+ const prev = b.path[b.path.length - 2] ?? last;
158
+ const first = b.path[0] as readonly [number, number];
159
+ const endC = tileCenter(b, last[0], last[1]);
160
+ const dirX = Math.sign(last[0] - prev[0]);
161
+ const dirZ = Math.sign(last[1] - prev[1]);
162
+ const spireAt = { x: endC.x + dirX * TILE_M * 1.2, z: endC.z + dirZ * TILE_M * 1.2 };
163
+ const spire = stage.model(SPIRE_MODEL, 6, 0xc9a15a);
164
+ spire.root.position.set(spireAt.x, stage.groundAt(spireAt.x, spireAt.z), spireAt.z);
165
+ stage.add(spire.root);
166
+ const bellGlow = new Mesh(new CylinderGeometry(0.6, 0.6, 0.05, 24), new MeshBasicMaterial({ color: 0xffd76a, transparent: true, opacity: 0.55, blending: AdditiveBlending, depthWrite: false }));
167
+ bellGlow.position.set(spireAt.x, 0.08, spireAt.z);
168
+ bellGlow.scale.setScalar(2.4);
169
+ stage.add(bellGlow);
170
+ const startC = tileCenter(b, first[0], first[1]);
171
+ // Frame the board with the spire and the gate in view.
172
+ const minX = Math.min(-W / 2, spireAt.x - 3, startC.x - 3);
173
+ const maxX = Math.max(W / 2, spireAt.x + 3, startC.x + 3);
174
+ const minZ = Math.min(-H / 2, spireAt.z - 3);
175
+ const maxZ = Math.max(H / 2, spireAt.z + 3);
176
+ stage.frameRect((minX + maxX) / 2, (minZ + maxZ) / 2, maxX - minX, maxZ - minZ);
177
+ for (const side of [-1, 1]) {
178
+ const gate = stage.model(GATE_MODEL, 2.2, 0x8f8676);
179
+ const horiz = first[0] === 0 || first[0] === b.w - 1;
180
+ gate.root.position.set(startC.x + (horiz ? -TILE_M * 0.9 : side * 1.4), 0, startC.z + (horiz ? side * 1.4 : -TILE_M * 0.9));
181
+ stage.add(gate.root);
182
+ }
183
+ for (const k of b.blocked) {
184
+ const c = tileCenter(b, k % 64, Math.floor(k / 64));
185
+ const rock = stage.model(ROCK_MODEL, 1.1, 0x7c7468, TILE_M * 0.95);
186
+ rock.root.position.set(c.x, stage.groundAt(c.x, c.z), c.z);
187
+ rock.root.rotation.y = (k * 1.7) % (Math.PI * 2);
188
+ stage.add(rock.root);
189
+ }
190
+ // The moor past the board: trees and small things, scattered by a fixed hash, drawn as crowds (one draw a model).
191
+ const scatter: { crowd: ReturnType<typeof stage.crowd>; at: { x: number; z: number; yaw: number; s: number }[] }[] = [];
192
+ let seed = 11;
193
+ const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
194
+ for (const [list, n, h, ring] of [
195
+ [TREE_MODELS, 60, 3.2, 9],
196
+ [SMALL_DECOR, 50, 0.6, 5],
197
+ ] as const) {
198
+ for (const id of list) {
199
+ const at: { x: number; z: number; yaw: number; s: number }[] = [];
200
+ for (let i = 0; i < Math.floor(n / list.length); i++) {
201
+ const side = rnd() * 4;
202
+ const t = rnd() * 2 - 1;
203
+ const out = 1.5 + rnd() * ring;
204
+ const x = side < 1 ? -W / 2 - out : side < 2 ? W / 2 + out : t * (W / 2 + ring);
205
+ const z = side < 1 || side < 2 ? t * (H / 2 + ring) : side < 3 ? -H / 2 - out : H / 2 + out;
206
+ if (Math.abs(x - spireAt.x) < 3 && Math.abs(z - spireAt.z) < 3) continue;
207
+ at.push({ x, z, yaw: rnd() * 6.28, s: 0.75 + rnd() * 0.5 });
208
+ }
209
+ scatter.push({ crowd: stage.crowd(id, h, at.length, 0x557a3a), at });
210
+ }
211
+ }
212
+
213
+ // ---- Pieces that move: towers (one model each), creeps (a crowd per kind), health bars, tracers, tinkers.
214
+ const towers = new Map<number, { root: Group; model: Placed; type: number; level: number; shots: number; pips: Mesh[]; aim: number }>();
215
+ const creepCrowds = g.creeps.map((c, i) => stage.crowd(c.model, c.boss ? 2.8 : 1.2, 240, CREEP_COLOR[i % CREEP_COLOR.length]));
216
+ const BAR_CAP = 260;
217
+ const barBack = new InstancedMesh(new BoxGeometry(1, 0.06, 0.12), new MeshBasicMaterial({ color: 0x1a120a }), BAR_CAP);
218
+ const barFill = new InstancedMesh(new BoxGeometry(1, 0.07, 0.13), new MeshBasicMaterial({ color: 0x9dff7a }), BAR_CAP);
219
+ barBack.frustumCulled = barFill.frustumCulled = false;
220
+ stage.add(barBack, barFill);
221
+ const tracers: { mesh: Mesh; life: number }[] = [];
222
+ const tracerGeo = new CylinderGeometry(0.05, 0.05, 1, 5);
223
+ tracerGeo.rotateX(Math.PI / 2);
224
+ tracerGeo.translate(0, 0, 0.5);
225
+ for (let i = 0; i < 48; i++) {
226
+ const m = new Mesh(tracerGeo, new MeshBasicMaterial({ color: 0xffe0a0, transparent: true, blending: AdditiveBlending, depthWrite: false }));
227
+ m.visible = false;
228
+ stage.add(m);
229
+ tracers.push({ mesh: m, life: 0 });
230
+ }
231
+ const tinkers = new Map<number, { root: Group; ring: Mesh }>();
232
+ const tinkerFor = (owner: number): { root: Group; ring: Mesh } => {
233
+ let t = tinkers.get(owner);
234
+ if (t) return t;
235
+ const root = new Group();
236
+ const m = stage.model(manifest.game.players.prefab, 2.1, KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length]);
237
+ root.add(m.root);
238
+ const ring = new Mesh(new RingGeometry(0.45, 0.6, 24), new MeshBasicMaterial({ color: KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length] ?? 0xffffff, side: DoubleSide, transparent: true, opacity: 0.85 }));
239
+ ring.rotation.x = -Math.PI / 2;
240
+ ring.position.y = 0.06;
241
+ root.add(ring);
242
+ stage.add(root);
243
+ t = { root, ring };
244
+ tinkers.set(owner, t);
245
+ return t;
246
+ };
247
+
248
+ // ---- The cursor: a ghost of the picked tower with its range, green where it can go.
249
+ let picked = 0;
250
+ const ghost = new Group();
251
+ const ghostBody = new Mesh(new CylinderGeometry(0.5, 0.65, 1.4, 10), new MeshStandardMaterial({ color: 0x9dff7a, transparent: true, opacity: 0.45, emissive: new Color(0x2a5a1a) }));
252
+ ghostBody.position.y = 0.7;
253
+ const rangeRing = new Mesh(new RingGeometry(0.96, 1, 64), new MeshBasicMaterial({ color: 0x9dff7a, side: DoubleSide, transparent: true, opacity: 0.7 }));
254
+ rangeRing.rotation.x = -Math.PI / 2;
255
+ rangeRing.position.y = 0.09;
256
+ const tileMark = new Mesh(new PlaneGeometry(TILE_M * 0.94, TILE_M * 0.94), new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.22, depthWrite: false }));
257
+ tileMark.rotation.x = -Math.PI / 2;
258
+ tileMark.position.y = 0.07;
259
+ ghost.add(ghostBody, rangeRing, tileMark);
260
+ ghost.visible = false;
261
+ stage.add(ghost);
262
+ let hover: { tx: number; ty: number } | null = null;
263
+ const towerAt = (tx: number, ty: number) => [...towerEnts.values()].find((e) => e.tx === tx && e.ty === ty);
264
+ let towerEnts = new Map<number, { tx: number; ty: number; type: number; level: number; owner: number }>();
265
+
266
+ // ---- The bar: tower cards, the wave button; the tower panel.
267
+ const bar = document.createElement("div");
268
+ bar.id = "bar";
269
+ bar.className = "hud";
270
+ document.body.append(bar);
271
+ const style = document.createElement("style");
272
+ style.textContent = `
273
+ #bar { left: 50%; bottom: max(10px, env(safe-area-inset-bottom)); transform: translateX(-50%); display: flex; gap: 6px; align-items: stretch; }
274
+ #bar .card { pointer-events: auto; width: min(118px, 19vw); padding: 7px 8px; text-align: left; display: grid; gap: 1px; border: 1px solid rgba(242,184,75,0.25); background: var(--panel); border-radius: var(--r); cursor: pointer; }
275
+ #bar .card.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), 0 6px 18px rgba(0,0,0,0.4); transform: translateY(-3px); }
276
+ #bar .card.poor { opacity: 0.55; }
277
+ #bar .card kbd { font: 700 10px ui-monospace, monospace; color: var(--accent); }
278
+ #bar .card b { font-size: 13px; }
279
+ #bar .card span { font-size: 11px; color: var(--dim); }
280
+ #bar .card i { font-style: normal; color: var(--gold); font-weight: 800; font-size: 12px; }
281
+ #wavebtn { pointer-events: auto; min-width: 104px; font-weight: 800; letter-spacing: 0.06em; }
282
+ #wavebtn.ready { background: #3f6b2a; border-color: #9dff7a; }
283
+ #panel { display: none; pointer-events: auto; position: fixed; z-index: 7; transform: translate(-50%, calc(-100% - 18px)); background: var(--panel); border: 1px solid var(--accent); border-radius: var(--r); padding: 8px 10px; min-width: 150px; font-size: 13px; }
284
+ #panel.show { display: block; }
285
+ #panel .row { display: flex; gap: 6px; margin-top: 6px; }
286
+ #panel small { color: var(--dim); }
287
+ #bossbar { top: max(12px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); width: min(360px, 44vw); text-align: center; font: 800 11px var(--display, inherit); letter-spacing: 0.2em; color: #ffb0a0; display: none; }
288
+ #bossbar.show { display: block; }
289
+ #bossbar > div { height: 9px; margin-top: 4px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden; }
290
+ #bossbar > div > i { display: block; height: 100%; background: linear-gradient(90deg, #ff5a3c, #ffb347); transform-origin: left; }
291
+ #wavebanner { left: 50%; top: 32%; transform: translate(-50%, -50%); font: 900 42px Georgia, serif; letter-spacing: 0.22em; color: var(--accent); text-shadow: 0 2px 0 #000, 0 0 24px rgba(242,184,75,0.6); opacity: 0; transition: opacity 300ms; }
292
+ #wavebanner.show { opacity: 1; }
293
+ #next { left: 12px; bottom: max(84px, calc(env(safe-area-inset-bottom) + 74px)); font-size: 11px; color: var(--dim); background: var(--panel); padding: 5px 8px; border-radius: var(--r); }
294
+ @media (max-width: 560px) { #bar .card span { display: none; } #bar .card { width: 21vw; padding: 5px 6px; } #next { display: none; } }
295
+ @media (max-height: 500px) { #bar .card span { display: none; } #bar .card { width: 92px; padding: 4px 6px; font-size: 11px; } #bar .card b { font-size: 11px; } #wavebtn { min-width: 80px; padding: 4px 8px; } #next { display: none; } }
296
+ `;
297
+ document.head.append(style);
298
+ const cards: HTMLButtonElement[] = [];
299
+ g.towers.forEach((t, i) => {
300
+ const c = document.createElement("button");
301
+ c.className = "card";
302
+ c.innerHTML = `<kbd>${i + 1}</kbd><b></b><span></span><i></i>`;
303
+ (c.querySelector("b") as HTMLElement).textContent = WEAPON_NAMES[t.id]?.name ?? t.id;
304
+ (c.querySelector("span") as HTMLElement).textContent = WEAPON_NAMES[t.id]?.line ?? t.def.kind;
305
+ (c.querySelector("i") as HTMLElement).textContent = `${t.def.cost} gold`;
306
+ c.addEventListener("click", (e) => {
307
+ e.stopPropagation();
308
+ pick(i);
309
+ });
310
+ bar.append(c);
311
+ cards.push(c);
312
+ });
313
+ const waveBtn = document.createElement("button");
314
+ waveBtn.id = "wavebtn";
315
+ waveBtn.addEventListener("click", (e) => {
316
+ e.stopPropagation();
317
+ callWave();
318
+ });
319
+ bar.append(waveBtn);
320
+ const panel = document.createElement("div");
321
+ panel.id = "panel";
322
+ document.body.append(panel);
323
+ const bossbar = document.createElement("div");
324
+ bossbar.id = "bossbar";
325
+ bossbar.className = "hud";
326
+ bossbar.innerHTML = `<span></span><div><i></i></div>`;
327
+ document.body.append(bossbar);
328
+ const waveBanner = document.createElement("div");
329
+ waveBanner.id = "wavebanner";
330
+ waveBanner.className = "hud";
331
+ document.body.append(waveBanner);
332
+ const nextEl = document.createElement("div");
333
+ nextEl.id = "next";
334
+ nextEl.className = "hud";
335
+ document.body.append(nextEl);
336
+
337
+ const pick = (i: number) => {
338
+ picked = i;
339
+ cards.forEach((c, j) => c.classList.toggle("on", j === i));
340
+ sfx.play("pick");
341
+ };
342
+ pick(0);
343
+ const callWave = () => {
344
+ const v = lastView;
345
+ if (!v || v.stage !== "build") return;
346
+ clock.act(TowerAct.Ready, v.ready ? 0 : 1);
347
+ sfx.play("pick");
348
+ };
349
+ addEventListener("keydown", (e) => {
350
+ const n = Number(e.key);
351
+ if (n >= 1 && n <= g.towers.length) pick(n - 1);
352
+ if (e.code === "Space") {
353
+ e.preventDefault();
354
+ callWave();
355
+ }
356
+ if (e.code === "Escape") panelFor = null;
357
+ });
358
+
359
+ // ---- Pointer: move the tinker, build, open a tower's panel. Touch: first tap aims, second builds.
360
+ let panelFor: { tx: number; ty: number } | null = null;
361
+ let armed: number | null = null;
362
+ const toTile = (e: PointerEvent) => {
363
+ const p = stage.pick(e.clientX, e.clientY, app.canvas.getBoundingClientRect());
364
+ if (!p) return null;
365
+ clock.cursor(p.x, p.z);
366
+ return tileAt(b, p.x, p.z);
367
+ };
368
+ app.canvas.addEventListener("pointermove", (e) => {
369
+ hover = toTile(e);
370
+ });
371
+ app.canvas.addEventListener("pointerdown", (e) => {
372
+ sfx.unlock();
373
+ const t = toTile(e);
374
+ hover = t;
375
+ if (!t) {
376
+ panelFor = null;
377
+ return;
378
+ }
379
+ const tw = towerAt(t.tx, t.ty);
380
+ if (tw) {
381
+ panelFor = { tx: t.tx, ty: t.ty };
382
+ armed = null;
383
+ return;
384
+ }
385
+ panelFor = null;
386
+ const k = tileKey(t.tx, t.ty);
387
+ if (e.pointerType === "touch" && armed !== k) {
388
+ armed = k;
389
+ return;
390
+ }
391
+ armed = null;
392
+ if (!canBuild(t.tx, t.ty)) {
393
+ sfx.play("thud");
394
+ return;
395
+ }
396
+ const cost = g.towers[picked]?.def.cost ?? 0;
397
+ if ((lastView?.gold ?? 0) < cost) {
398
+ hud.toast("Not enough gold");
399
+ return;
400
+ }
401
+ clock.act(TowerAct.Build, picked, t.tx, t.ty);
402
+ sfx.play("land");
403
+ });
404
+ const canBuild = (tx: number, ty: number) => {
405
+ const k = tileKey(tx, ty);
406
+ return !b.road.has(k) && !b.blocked.has(k) && !towerTiles.has(k);
407
+ };
408
+
409
+ // ---- Every frame.
410
+ const creeps = new Map<number, { flags: number; hp: number; x: number; z: number; type: number; hit: number; by: number }>();
411
+ const tmp = new Matrix4();
412
+ const q = new Quaternion();
413
+ let knownWave = 0;
414
+ let knownLives = -1;
415
+ let bannerTimer = 0;
416
+ const m = (e: InterpolatedEntity, f: number) => (e.values[f] ?? 0) / UNITS;
417
+ app.loop((dt, now) => {
418
+ const conn = app.conn();
419
+ const v = app.view<TowerView>();
420
+ lastView = v;
421
+ const view = conn.net.render(now);
422
+ const t = now / 1000;
423
+
424
+ // Towers.
425
+ const seenT = new Set<number>();
426
+ const nextEnts = new Map<number, { tx: number; ty: number; type: number; level: number; owner: number }>();
427
+ const nextTiles = new Set<number>();
428
+ const mine: { tx: number; ty: number; type: number; level: number }[] = [];
429
+ const creepNow = new Map<number, InterpolatedEntity>();
430
+ for (const e of view.others) {
431
+ if (e.kind === RulesKind.Structure) {
432
+ seenT.add(e.id);
433
+ const x = m(e, RF.x);
434
+ const z = m(e, RF.z);
435
+ const tile = tileAt(b, x, z);
436
+ const type = Math.round(e.values[RF.type] ?? 0);
437
+ const level = Math.round(e.values[RF.value] ?? 0);
438
+ const shots = Math.round(e.values[RF.aux] ?? 0);
439
+ if (tile) {
440
+ nextEnts.set(e.id, { ...tile, type, level, owner: e.owner });
441
+ nextTiles.add(tileKey(tile.tx, tile.ty));
442
+ if (e.owner === app.self) mine.push({ ...tile, type, level });
443
+ }
444
+ let tw = towers.get(e.id);
445
+ if (!tw) {
446
+ const root = new Group();
447
+ const model = stage.model(g.towers[type]?.def.model ?? "style.lowpoly.turret", 1.7, 0xc9a15a);
448
+ root.add(model.root);
449
+ const base = new Mesh(new CylinderGeometry(0.85, 0.95, 0.16, 16), new MeshStandardMaterial({ color: KEEPER_COLORS[(e.owner - 1) % KEEPER_COLORS.length] ?? 0x888888, roughness: 0.6, metalness: 0.3 }));
450
+ base.position.y = 0.08;
451
+ root.add(base);
452
+ root.position.set(x, stage.groundAt(x, z) + 0.03, z);
453
+ stage.add(root);
454
+ tw = { root, model, type, level: -1, shots, pips: [], aim: e.values[RF.yaw] ?? 0 };
455
+ towers.set(e.id, tw);
456
+ stage.burst("dust", x, 0.3, z, 0xd8c8a0, 14);
457
+ if (e.owner === app.self) sfx.play("land");
458
+ }
459
+ if (tw.level !== level) {
460
+ if (tw.level >= 0 && level > tw.level) {
461
+ stage.burst("magic", x, 1.6, z, 0xffd76a, 18);
462
+ sfx.play("levelUp");
463
+ }
464
+ tw.level = level;
465
+ for (const p of tw.pips) tw.root.remove(p);
466
+ tw.pips = [];
467
+ for (let i = 0; i < level; i++) {
468
+ const pip = new Mesh(new BoxGeometry(0.18, 0.18, 0.18), new MeshStandardMaterial({ color: 0xffd76a, emissive: new Color(0x7a5a10) }));
469
+ pip.position.set(-0.45 + i * 0.3, 0.22, 0.75);
470
+ tw.root.add(pip);
471
+ tw.pips.push(pip);
472
+ }
473
+ tw.model.root.scale.setScalar(1 + level * 0.12);
474
+ }
475
+ // Face the target, turning smoothly.
476
+ const want = e.values[RF.yaw] ?? 0;
477
+ let d = want - tw.aim;
478
+ d = Math.atan2(Math.sin(d), Math.cos(d));
479
+ tw.aim += d * Math.min(1, dt * 14);
480
+ tw.model.root.rotation.y = tw.aim;
481
+ if (shots !== tw.shots) {
482
+ tw.shots = shots;
483
+ const target = view.others.find((o) => o.id === ENTITY_BASE + Math.round(e.values[RF.hp] ?? 0));
484
+ if (target) fireFx(type, x, z, m(target, RF.x), m(target, RF.z), e.owner === app.self);
485
+ }
486
+ } else if (e.kind === RulesKind.Unit) creepNow.set(e.id, e);
487
+ }
488
+ for (const [id, tw] of towers) {
489
+ if (seenT.has(id)) continue;
490
+ stage.burst("dust", tw.root.position.x, 0.4, tw.root.position.z, 0x9a8a6a, 18);
491
+ stage.scene.remove(tw.root);
492
+ towers.delete(id);
493
+ }
494
+ towerEnts = nextEnts;
495
+ towerTiles = nextTiles;
496
+ myTowers = mine;
497
+
498
+ // Creeps: crowds, bars, deaths and leaks.
499
+ for (const c of creepCrowds) c.begin();
500
+ let bars = 0;
501
+ for (const [id, e] of creepNow) {
502
+ const x = m(e, RF.x);
503
+ const z = m(e, RF.z);
504
+ const type = Math.round(e.values[RF.type] ?? 0);
505
+ const flags = Math.round(e.values[RF.flags] ?? 0);
506
+ const hp = e.values[RF.hp] ?? 0;
507
+ const known = creeps.get(id);
508
+ let hit = known ? Math.max(0, known.hit - dt * 5) : 0;
509
+ if (known && hp < known.hp) hit = 1;
510
+ if ((flags & CreepFlag.Dead) !== 0 && (!known || (known.flags & CreepFlag.Dead) === 0)) {
511
+ const boss = (flags & CreepFlag.Boss) !== 0;
512
+ stage.burst(boss ? "bigKill" : "kill", x, 0.6, z, CREEP_COLOR[type % CREEP_COLOR.length], boss ? 60 : 16);
513
+ if (boss) {
514
+ stage.kick(0.7);
515
+ sfx.play("bigKill");
516
+ } else sfx.play("kill", { gain: e.owner === app.self ? 1 : 0.35 });
517
+ }
518
+ if ((flags & CreepFlag.Leaked) !== 0 && (!known || (known.flags & CreepFlag.Leaked) === 0)) {
519
+ stage.ring(spireAt.x, spireAt.z, 4, 0xff5a3c);
520
+ stage.burst("sparks", spireAt.x, 3, spireAt.z, 0xff8a5c, 24);
521
+ stage.kick(0.5);
522
+ sfx.play("bell");
523
+ }
524
+ creeps.set(id, { flags, hp, x, z, type, hit, by: e.owner });
525
+ if ((flags & (CreepFlag.Dead | CreepFlag.Leaked)) !== 0) continue;
526
+ const slowed = (flags & CreepFlag.Slowed) !== 0;
527
+ const speed = (g.creeps[type]?.speed ?? 1) * (slowed ? 0.5 : 1);
528
+ creepCrowds[type]?.push(x, stage.groundAt(x, z) + 0.05, z, e.values[RF.yaw] ?? 0, ((id * 0.618) % 1 + 1) % 1, speed, hit, hit * 0.8 + (slowed ? 0.25 : 0), 1);
529
+ // A health bar over every hurt creep.
530
+ const frac = (e.values[RF.value] ?? 1000) / 1000;
531
+ if (frac < 0.999 && bars < BAR_CAP) {
532
+ const h = (g.creeps[type]?.boss ? 3.1 : 1.35) + stage.groundAt(x, z);
533
+ const wbar = g.creeps[type]?.boss ? 2.4 : 0.9;
534
+ tmp.compose(new Vector3(x, h, z), q.identity(), new Vector3(wbar, 1, 1));
535
+ barBack.setMatrixAt(bars, tmp);
536
+ tmp.compose(new Vector3(x - (wbar * (1 - frac)) / 2, h + 0.005, z), q.identity(), new Vector3(Math.max(0.001, wbar * frac), 1, 1));
537
+ barFill.setMatrixAt(bars, tmp);
538
+ bars++;
539
+ }
540
+ }
541
+ for (const id of [...creeps.keys()]) if (!creepNow.has(id)) creeps.delete(id);
542
+ barBack.count = barFill.count = bars;
543
+ barBack.instanceMatrix.needsUpdate = barFill.instanceMatrix.needsUpdate = true;
544
+ for (const c of creepCrowds) c.end(t);
545
+ for (const s of scatter) {
546
+ s.crowd.begin();
547
+ for (const p of s.at) s.crowd.push(p.x, stage.groundAt(p.x, p.z), p.z, p.yaw, (p.x * 0.13) % 1, 0, 0, 0, p.s);
548
+ s.crowd.end(t);
549
+ }
550
+
551
+ // Tracers fade.
552
+ for (const tr of tracers) {
553
+ if (tr.life <= 0) continue;
554
+ tr.life -= dt;
555
+ tr.mesh.visible = tr.life > 0;
556
+ (tr.mesh.material as MeshBasicMaterial).opacity = Math.max(0, tr.life / 0.14);
557
+ }
558
+
559
+ // Tinkers: ours predicted, the others interpolated.
560
+ const seenP = new Set<number>();
561
+ const placeTinker = (e: InterpolatedEntity | undefined, owner: number) => {
562
+ if (!e) return;
563
+ seenP.add(owner);
564
+ const tk = tinkerFor(owner);
565
+ const x = m(e, RF.x);
566
+ const z = m(e, RF.z);
567
+ tk.root.position.set(x, stage.groundAt(x, z) + 0.05 + Math.sin(t * 3 + owner) * 0.04, z);
568
+ tk.root.rotation.y = e.values[RF.yaw] ?? 0;
569
+ tk.ring.scale.setScalar((Math.round(e.values[RF.flags] ?? 0) & 1) !== 0 ? 1.4 + Math.sin(t * 6) * 0.1 : 1);
570
+ };
571
+ placeTinker(view.self, app.self);
572
+ for (const e of view.others) if (e.kind === RulesKind.Avatar) placeTinker(e, e.owner);
573
+ for (const [owner, tk] of tinkers) {
574
+ if (seenP.has(owner)) continue;
575
+ stage.scene.remove(tk.root);
576
+ tinkers.delete(owner);
577
+ }
578
+
579
+ // The ghost: where a click builds, with the picked tower's reach.
580
+ const show = hover && !app.preview && !towerAt(hover.tx, hover.ty) && v?.phase !== "ended";
581
+ ghost.visible = !!show;
582
+ if (show && hover) {
583
+ const c = tileCenter(b, hover.tx, hover.ty);
584
+ ghost.position.set(c.x, stage.groundAt(c.x, c.z) + 0.03, c.z);
585
+ const ok = canBuild(hover.tx, hover.ty) && (v?.gold ?? 0) >= (g.towers[picked]?.def.cost ?? 0);
586
+ const col = ok ? 0x9dff7a : 0xff6a4a;
587
+ (ghostBody.material as MeshStandardMaterial).color.setHex(col);
588
+ (rangeRing.material as MeshBasicMaterial).color.setHex(col);
589
+ const r = (g.towers[picked]?.levels[0]?.range ?? 3) * TILE_M;
590
+ rangeRing.scale.setScalar(r);
591
+ ghostBody.visible = canBuild(hover.tx, hover.ty);
592
+ }
593
+ bellGlow.scale.setScalar(2.4 + Math.sin(t * 2) * 0.15);
594
+
595
+ // HUD.
596
+ if (v) {
597
+ const me = v.players.find((p) => p.id === app.self);
598
+ hud.status([
599
+ ["WAVE", `${Math.max(1, v.wave)} / ${v.waves}`],
600
+ ["BELLS", `${v.lives}`],
601
+ ["GOLD", `${v.gold ?? me?.gold ?? 0}`],
602
+ v.stage === "build" ? ["NEXT WAVE", `${v.countdown}s`] : ["CREEPS", `${v.left}`],
603
+ ]);
604
+ cards.forEach((c, i) => c.classList.toggle("poor", (v.gold ?? 0) < (g.towers[i]?.def.cost ?? 0)));
605
+ const readyN = v.players.filter((p) => p.ready).length;
606
+ waveBtn.disabled = v.stage !== "build";
607
+ waveBtn.classList.toggle("ready", v.ready === true);
608
+ waveBtn.textContent = v.stage === "build" ? (v.ready ? `Ready ${readyN}/${v.players.length}` : `Call wave ${v.countdown}s`) : `Wave ${v.wave}`;
609
+ nextEl.textContent = v.next.length ? `Next: ${v.next.map((n) => `${n.count} ${ENEMY_NAMES[g.creeps[n.type]?.id ?? ""] ?? g.creeps[n.type]?.id ?? ""}`).join(" · ")}` : "";
610
+ if (v.boss) {
611
+ bossbar.classList.add("show");
612
+ (bossbar.firstChild as HTMLElement).textContent = (ENEMY_NAMES[g.creeps[v.boss.type]?.id ?? ""] ?? "BOSS").toUpperCase();
613
+ ((bossbar.lastChild as HTMLElement).firstChild as HTMLElement).style.transform = `scaleX(${Math.max(0, v.boss.hp / v.boss.maxHp)})`;
614
+ } else bossbar.classList.remove("show");
615
+ if (v.wave !== knownWave && v.stage === "wave") {
616
+ knownWave = v.wave;
617
+ waveBanner.textContent = g.creeps.some((c, i) => c.boss && v.next.some((n) => n.type === i)) ? `WAVE ${v.wave} · BOSS` : `WAVE ${v.wave}`;
618
+ waveBanner.classList.add("show");
619
+ clearTimeout(bannerTimer);
620
+ bannerTimer = window.setTimeout(() => waveBanner.classList.remove("show"), 1600);
621
+ sfx.play("levelUp");
622
+ }
623
+ if (knownLives >= 0 && v.lives < knownLives) hud.toast(`${knownLives - v.lives} bell${knownLives - v.lives > 1 ? "s" : ""} lost`);
624
+ knownLives = v.lives;
625
+ hud.end(v.phase === "ended", v.outcome === "won" ? "SPIRE HELD" : "SPIRE FELL", v.outcome === "won" ? `All ${v.waves} waves held` : `Fell at wave ${v.wave}`, v.endsIn ?? null);
626
+ // The tower panel.
627
+ const tw = panelFor ? towerAt(panelFor.tx, panelFor.ty) : undefined;
628
+ if (tw && panelFor) {
629
+ const c = tileCenter(b, panelFor.tx, panelFor.ty);
630
+ const p = stage.project(c.x, 2.2, c.z, app.canvas.clientWidth, app.canvas.clientHeight);
631
+ panel.style.left = `${p.x}px`;
632
+ panel.style.top = `${p.y}px`;
633
+ const def = g.towers[tw.type];
634
+ const cost = def?.upgradeCost[tw.level];
635
+ const spent = (def?.def.cost ?? 0) + (def?.upgradeCost.slice(0, tw.level).reduce((a, x) => a + x, 0) ?? 0);
636
+ const key = `${panelFor.tx},${panelFor.ty},${tw.level},${v.gold},${tw.owner}`;
637
+ if (panel.dataset.k !== key) {
638
+ panel.dataset.k = key;
639
+ panel.innerHTML = `<b></b> <small></small><div class="row"></div>`;
640
+ (panel.querySelector("b") as HTMLElement).textContent = WEAPON_NAMES[def?.id ?? ""]?.name ?? def?.id ?? "";
641
+ (panel.querySelector("small") as HTMLElement).textContent = `L${tw.level + 1}${tw.owner === app.self ? "" : ` · P${tw.owner}`}`;
642
+ const row = panel.querySelector(".row") as HTMLElement;
643
+ const upBtn = document.createElement("button");
644
+ upBtn.className = "go";
645
+ upBtn.textContent = cost === undefined ? "Max" : `Upgrade ${cost}`;
646
+ upBtn.disabled = cost === undefined || (v.gold ?? 0) < cost;
647
+ const at = panelFor;
648
+ upBtn.addEventListener("click", () => clock.act(TowerAct.Upgrade, 0, at.tx, at.ty));
649
+ row.append(upBtn);
650
+ if (tw.owner === app.self) {
651
+ const sell = document.createElement("button");
652
+ sell.textContent = `Sell +${Math.floor(spent * (g.refund ?? 0.6))}`;
653
+ sell.addEventListener("click", () => {
654
+ clock.act(TowerAct.Sell, 0, at.tx, at.ty);
655
+ panelFor = null;
656
+ });
657
+ row.append(sell);
658
+ }
659
+ }
660
+ panel.classList.add("show");
661
+ } else panel.classList.remove("show");
662
+ }
663
+ });
664
+ app.extraStats(() => ({ towers: towers.size, creeps: creeps.size, wave: lastView?.wave ?? 0, lives: lastView?.lives ?? 0, gold: lastView?.gold ?? 0 }));
665
+
666
+ function fireFx(type: number, x: number, z: number, tx: number, tz: number, mine: boolean): void {
667
+ const kind = g.towers[type]?.def.kind ?? "bolt";
668
+ const color = kind === "frost" ? 0x9fe8ff : kind === "chain" ? 0xb9a0ff : kind === "splash" ? 0xffa040 : 0xffe0a0;
669
+ const tr = tracers.find((t) => t.life <= 0);
670
+ if (tr) {
671
+ const from = new Vector3(x, 1.5, z);
672
+ const to = new Vector3(tx, 0.6, tz);
673
+ const len = from.distanceTo(to);
674
+ tr.mesh.position.copy(from);
675
+ tr.mesh.lookAt(to);
676
+ tr.mesh.scale.set(kind === "splash" ? 3 : 1, kind === "splash" ? 3 : 1, len);
677
+ (tr.mesh.material as MeshBasicMaterial).color.setHex(color);
678
+ tr.life = 0.14;
679
+ tr.mesh.visible = true;
680
+ }
681
+ if (kind === "splash") {
682
+ stage.ring(tx, tz, (g.towers[type]?.def.radius ?? 1) * TILE_M, 0xffa040);
683
+ stage.burst("burst", tx, 0.5, tz, 0xffa040, 10);
684
+ } else if (kind === "frost") stage.burst("magic", tx, 0.6, tz, 0x9fe8ff, 6);
685
+ else if (kind === "chain") stage.burst("sparks", tx, 0.7, tz, 0xb9a0ff, 8);
686
+ else stage.burst("hitSpark", tx, 0.6, tz, 0xffe0a0, 4);
687
+ if (mine || Math.random() < 0.3) sfx.play("shoot", { gain: mine ? 0.7 : 0.25 });
688
+ }
689
+
690
+ /** The board plate: tiles, the road and its sleepers, drawn once on a canvas. */
691
+ function boardTexture(): CanvasTexture {
692
+ const px = 32;
693
+ const c = document.createElement("canvas");
694
+ c.width = b.w * px;
695
+ c.height = b.h * px;
696
+ const ctx = c.getContext("2d");
697
+ if (!ctx) throw new Error("no 2d canvas");
698
+ let s = 3;
699
+ const r = () => ((s = (s * 16807) % 2147483647) / 2147483647);
700
+ for (let y = 0; y < b.h; y++)
701
+ for (let x = 0; x < b.w; x++) {
702
+ const k = tileKey(x, y);
703
+ const road = b.road.has(k);
704
+ ctx.fillStyle = road ? "#a88a5c" : (x + y) % 2 === 0 ? "#5f7a3e" : "#587238";
705
+ ctx.fillRect(x * px, y * px, px, px);
706
+ if (road) {
707
+ for (let i = 0; i < 6; i++) {
708
+ ctx.fillStyle = `rgba(70, 50, 30, ${0.2 + r() * 0.2})`;
709
+ ctx.fillRect(x * px + r() * px, y * px + r() * px, 3, 3);
710
+ }
711
+ } else {
712
+ ctx.strokeStyle = "rgba(20, 30, 10, 0.25)";
713
+ ctx.strokeRect(x * px + 0.5, y * px + 0.5, px - 1, px - 1);
714
+ for (let i = 0; i < 5; i++) {
715
+ ctx.fillStyle = `rgba(150, 190, 90, ${0.15 + r() * 0.2})`;
716
+ ctx.fillRect(x * px + r() * px, y * px + r() * px, 2, 3);
717
+ }
718
+ }
719
+ }
720
+ // Sleepers across the road.
721
+ ctx.fillStyle = "rgba(60, 40, 22, 0.75)";
722
+ for (let i = 1; i < b.path.length; i++) {
723
+ const a = b.path[i - 1] as readonly [number, number];
724
+ const d = b.path[i] as readonly [number, number];
725
+ const n = Math.abs(d[0] - a[0]) + Math.abs(d[1] - a[1]);
726
+ const sx = Math.sign(d[0] - a[0]);
727
+ const sy = Math.sign(d[1] - a[1]);
728
+ for (let j = 0; j <= n * 2; j++) {
729
+ const cx = (a[0] + 0.5 + (sx * j) / 2) * px;
730
+ const cy = (a[1] + 0.5 + (sy * j) / 2) * px;
731
+ if (sx !== 0) ctx.fillRect(cx - 2, cy - px * 0.42, 4, px * 0.84);
732
+ else ctx.fillRect(cx - px * 0.42, cy - 2, px * 0.84, 4);
733
+ }
734
+ }
735
+ const t = new CanvasTexture(c);
736
+ t.colorSpace = SRGBColorSpace;
737
+ t.magFilter = NearestFilter;
738
+ return t;
739
+ }
740
+ }
741
+
742
+ main().catch((e: unknown) => {
743
+ const t = document.getElementById("banner-text");
744
+ if (t) t.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
745
+ console.error(e);
746
+ });