@netforge/template-tower 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/NOTICE +28 -0
- package/README.md +53 -2
- package/client/decor.ts +11 -0
- package/client/index.html +11 -0
- package/client/main.ts +746 -0
- package/client/names.ts +50 -0
- package/llms.txt +129 -0
- package/package.json +54 -3
- package/src/game.ts +148 -0
- package/src/manifest.ts +44 -0
- package/src/store.ts +25 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/NOTICE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
@netforge/template-tower (COGSPIRE)
|
|
2
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
3
|
+
|
|
4
|
+
An original game. No names, characters, maps, models, sounds or code from
|
|
5
|
+
any other game or franchise.
|
|
6
|
+
|
|
7
|
+
Art and sound
|
|
8
|
+
- Models come from the per-style asset library
|
|
9
|
+
(@netforge/asset-library): CC0 1.0 models by Kenney and
|
|
10
|
+
Quaternius (Space Kit turrets, Nature Kit, monsters, robots), each pack listed with its
|
|
11
|
+
source URL and licence verification in that package's NOTICE and
|
|
12
|
+
manifest/packs.json. The files are fetched and built, never committed
|
|
13
|
+
here; the build copies only the models this game uses.
|
|
14
|
+
- Fallback shapes (@netforge/tabletop stage.ts) are generated from three.js
|
|
15
|
+
primitives; all textures are drawn on a canvas at load; sounds are the
|
|
16
|
+
library's CC0 samples with WebAudio synth fallbacks.
|
|
17
|
+
|
|
18
|
+
Dependencies (unmodified, via npm)
|
|
19
|
+
- three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
|
|
20
|
+
- @netforge/engine and @netforge/engine-sim (this repository);
|
|
21
|
+
see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
|
|
22
|
+
zod MIT).
|
|
23
|
+
|
|
24
|
+
Patterns (ideas only)
|
|
25
|
+
- The tower defense genre's general mechanics (a fixed road, waves, towers
|
|
26
|
+
that target and upgrade, a bounty economy, lives). Mechanics, not
|
|
27
|
+
expression; no map, tower, creep, character or text was copied from any
|
|
28
|
+
game.
|
package/README.md
CHANGED
|
@@ -1,3 +1,54 @@
|
|
|
1
|
-
#
|
|
1
|
+
# COGSPIRE (tower defense template)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A brass clockwork spire stands at the end of an old rail road across the
|
|
4
|
+
moor. Rust swarms crawl the road toward it in waves. Up to four tinkers
|
|
5
|
+
share the board: each builds and upgrades brass towers with their own
|
|
6
|
+
gold, every kill pays its builder and a share to the rest, and the team
|
|
7
|
+
shares the spire's bells. Hold twenty waves to keep the spire.
|
|
8
|
+
|
|
9
|
+
A board genre (`packages/engine/src/rules`): the rules run on the room
|
|
10
|
+
server at 30 Hz behind the same SimAdapter seam as the action templates,
|
|
11
|
+
so the netcode, the referee, run verification, the store and the stake
|
|
12
|
+
are the ones every template uses. The client kit is `@netforge/tabletop`.
|
|
13
|
+
|
|
14
|
+
| Path | What |
|
|
15
|
+
|---|---|
|
|
16
|
+
| `src/game.ts` | The game, as the model writes it (declarations only). |
|
|
17
|
+
| `src/manifest.ts` | Loads it; `towerAdapter()` for the room server and client. Room template `tower`. |
|
|
18
|
+
| `src/store.ts` | Coin prices the client is wired to, the template's own coin (COG). |
|
|
19
|
+
| `client/` | The browser client: the board, towers, creeps, tracers, the build bar and tower panel. |
|
|
20
|
+
| `test/` | Playtest bots through `engine.test.run`, and the store. |
|
|
21
|
+
| `llms.txt` | How to change the game (for agents). |
|
|
22
|
+
|
|
23
|
+
## How it plays on the server
|
|
24
|
+
|
|
25
|
+
- Server-authoritative at 30 Hz. Your tinker (the cursor) is predicted, so
|
|
26
|
+
pointing never waits for the server; builds, upgrades, sells and the
|
|
27
|
+
ready vote are actions the room validates (gold, tile, ownership).
|
|
28
|
+
- Towers aim at the creep furthest along the road in range. Splash hits a
|
|
29
|
+
radius, frost slows, chain jumps. Kills pay the builder a bounty and the
|
|
30
|
+
rest of the team a share; a cleared wave pays everyone.
|
|
31
|
+
- Shared bells: a leak costs the creep's damage; zero bells loses the run.
|
|
32
|
+
The end screen shows what the run paid, then a fresh run starts.
|
|
33
|
+
- Earn (the room is the referee): three waves, ten waves, the first boss,
|
|
34
|
+
sixty kills. A player who joins mid-run is not paid for waves cleared
|
|
35
|
+
before their run started.
|
|
36
|
+
|
|
37
|
+
## Run it
|
|
38
|
+
|
|
39
|
+
This template runs on your own computer with play money through
|
|
40
|
+
`kit play` (`@netforge/kit-play`). From the repository root:
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pnpm install --frozen-lockfile && pnpm build
|
|
44
|
+
mkdir -p play-tower/src && cp packages/template-tower/src/game.ts play-tower/src/
|
|
45
|
+
echo '{"template":"tower-defense"}' > play-tower/netnet.json
|
|
46
|
+
node packages/kit-play/dist/cli.js play-tower # opens http://127.0.0.1:4663
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Edit `play-tower/src/game.ts` and run it again to try a change. Real-token
|
|
50
|
+
earning, stakes and claims run only on the hosted Net Forge server.
|
|
51
|
+
|
|
52
|
+
Click a tile to build the tower picked in the bar (1-4), click a tower to
|
|
53
|
+
upgrade or sell it, Space calls the next wave. Touch: tap a tile to aim,
|
|
54
|
+
tap again to build. ` shows stats.
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the scene draws on its own (not named in game.ts): the
|
|
3
|
+
* spire, the gate, the moor's trees and rocks. The build copies these into
|
|
4
|
+
* ./library next to the page, in every art style (?style= may draw any).
|
|
5
|
+
*/
|
|
6
|
+
export const SPIRE_MODEL = "style.lowpoly.obelisk";
|
|
7
|
+
export const GATE_MODEL = "style.lowpoly.column_broken";
|
|
8
|
+
export const ROCK_MODEL = "style.lowpoly.rock_large";
|
|
9
|
+
export const TREE_MODELS = ["style.lowpoly.tree_pine", "style.lowpoly.tree_autumn", "style.lowpoly.tree_fat", "style.lowpoly.bush"] as const;
|
|
10
|
+
export const SMALL_DECOR = ["style.lowpoly.rock_small", "style.lowpoly.mushroom_red", "style.lowpoly.stump", "style.lowpoly.flower_red"] as const;
|
|
11
|
+
export const SCENE_MODELS: readonly string[] = [SPIRE_MODEL, GATE_MODEL, ROCK_MODEL, ...TREE_MODELS, ...SMALL_DECOR];
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, user-scalable=no" />
|
|
6
|
+
<title>Cogspire</title>
|
|
7
|
+
</head>
|
|
8
|
+
<body>
|
|
9
|
+
<script type="module" src="./main.ts"></script>
|
|
10
|
+
</body>
|
|
11
|
+
</html>
|
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
|
+
});
|
package/client/names.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display names for the template's content ids. Labels only; the rules are
|
|
3
|
+
* in src/game.ts. A Net Forge game generated on this template ships its own
|
|
4
|
+
* table (the model writes src/names.json; the build turns it into this
|
|
5
|
+
* module), so the client imports only these names from here. Towers are
|
|
6
|
+
* the "weapons" table, creeps the "enemies" table.
|
|
7
|
+
*/
|
|
8
|
+
export const TITLE = "Cogspire";
|
|
9
|
+
|
|
10
|
+
export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
11
|
+
bolt_post: { name: "Bolt Post", line: "Fast single shots" },
|
|
12
|
+
tar_mortar: { name: "Tar Mortar", line: "Slow, hits a crowd" },
|
|
13
|
+
frost_vane: { name: "Frost Vane", line: "Slows what it hits" },
|
|
14
|
+
arc_coil: { name: "Arc Coil", line: "Jumps between creeps" },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
18
|
+
overclock: { name: "Overclock", line: "Your towers fire faster" },
|
|
19
|
+
tempered_brass: { name: "Tempered Brass", line: "Your towers hit harder" },
|
|
20
|
+
spyglass: { name: "Spyglass", line: "Your towers reach further" },
|
|
21
|
+
spare_bell: { name: "Spare Bell", line: "+5 bells for the team" },
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const ENEMY_NAMES: Readonly<Record<string, string>> = {
|
|
25
|
+
rustling: "Rustling",
|
|
26
|
+
gearbeetle: "Gearbeetle",
|
|
27
|
+
cinder_moth: "Cinder Moth",
|
|
28
|
+
boilerback: "Boilerback",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export const RULE_NAMES: Readonly<Record<string, string>> = {
|
|
32
|
+
first_gear: "First Gear",
|
|
33
|
+
long_shift: "Long Shift",
|
|
34
|
+
boiler_breaker: "Boiler Breaker",
|
|
35
|
+
scrap_heap: "Scrap Heap",
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const SKU_NAMES: Readonly<Record<string, string>> = {
|
|
39
|
+
overclock: "Overclock",
|
|
40
|
+
tempered_brass: "Tempered Brass",
|
|
41
|
+
spyglass: "Spyglass",
|
|
42
|
+
spare_bell: "Spare Bell",
|
|
43
|
+
skin_verdigris: "Verdigris finish",
|
|
44
|
+
skin_gilt: "Gilt finish",
|
|
45
|
+
brass_gear: "Brass gear",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Player colours by slot, and a highlight palette (the generated names module carries both). */
|
|
49
|
+
export const KEEPER_COLORS = [0xffb347, 0x5ec8ff, 0x9dff7a, 0xff7ab8, 0xc9a7ff, 0xffe066, 0x7affd4, 0xff9a5c];
|
|
50
|
+
export const AURORA = { body: 0x3cf2c4, glow: 0xa77bff };
|
package/llms.txt
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# COGSPIRE: the tower defense template (NetNet game runtime, board genre)
|
|
2
|
+
|
|
3
|
+
> A co-op tower defense for up to 4 players on one shared board. Creeps
|
|
4
|
+
> walk a fixed road in waves toward the spire; players build and upgrade
|
|
5
|
+
> towers beside the road with their own gold. Every kill pays its tower's
|
|
6
|
+
> builder a bounty and the rest of the team a share; the team shares the
|
|
7
|
+
> spire's bells (lives). Clear every wave to win the run.
|
|
8
|
+
> You change the game by editing src/game.ts only. The template already has
|
|
9
|
+
> the board, the road, waves, targeting, splash, slow and chain shots,
|
|
10
|
+
> gold, upgrades, selling, calling a wave early, co-op, netcode, rendering,
|
|
11
|
+
> touch controls, earn and the store. Do not write renderers, netcode or
|
|
12
|
+
> bot AI.
|
|
13
|
+
|
|
14
|
+
## Files
|
|
15
|
+
|
|
16
|
+
- src/game.ts: THE GAME. Declarations against @netforge/engine:
|
|
17
|
+
game(), towerBoard(), tower(), prefab() (the creeps), waves(), upgrade(),
|
|
18
|
+
earn.rule(), earn.stake(), store.sku().
|
|
19
|
+
- src/names.json: the title and every label players read. Towers go in
|
|
20
|
+
"weapons" ({ name, line }), creeps in "enemies", plus upgrades, earn
|
|
21
|
+
rules and store items.
|
|
22
|
+
- Everything else is template machinery.
|
|
23
|
+
|
|
24
|
+
## Rules the build enforces
|
|
25
|
+
|
|
26
|
+
- game({ template: "tower-defense", style, places, players: { prefab, max } }).
|
|
27
|
+
players.max is at most 4 (one board, four builders).
|
|
28
|
+
- Exactly one towerBoard(...) and one waves(...); 1 to 8 tower(...)s.
|
|
29
|
+
- The road: towerBoard path is tile waypoints; each step changes x or y,
|
|
30
|
+
never both; it starts and ends on the board's edge. blocked tiles are
|
|
31
|
+
rocks nobody builds on.
|
|
32
|
+
- Every waves group names a declared prefab (a creep, not a pickup).
|
|
33
|
+
- Tower kinds are "bolt", "splash", "frost" or "chain".
|
|
34
|
+
- Upgrades read four stats only: might (tower damage), haste (fire rate),
|
|
35
|
+
area (tower range) and maxHealth (bells for the team).
|
|
36
|
+
- No weapon(...): a tower defense game declares tower(...).
|
|
37
|
+
- Asset ids look like "style.lowpoly.turret", never URLs or paths, and must
|
|
38
|
+
be in the asset library. game({ style }) is the art style the creator
|
|
39
|
+
picked (lowpoly, toon, ps1, pixel, voxel or handdrawn); keep it.
|
|
40
|
+
- 4 playtest players must kill creeps, clear wave 3 and fire an earn rule
|
|
41
|
+
in 4 minutes, and must not lose the spire before wave 4.
|
|
42
|
+
|
|
43
|
+
## Units
|
|
44
|
+
|
|
45
|
+
A tile is 2 m. Board size, road, range and radius are in tiles; creep
|
|
46
|
+
speed is metres a second; rate is shots a second; gold is the run's own
|
|
47
|
+
money (never coins). Tick rate 30 Hz.
|
|
48
|
+
|
|
49
|
+
## The loop
|
|
50
|
+
|
|
51
|
+
Before wave 1 everyone gets towerBoard.prep seconds to build. A wave
|
|
52
|
+
spawns its groups along the road; it is cleared when the last creep dies
|
|
53
|
+
or leaks. Then there is a breather of towerBoard.breather seconds, which
|
|
54
|
+
everyone pressing Ready (Space) skips for a little gold. A leak costs the
|
|
55
|
+
creep's damage in bells; at zero bells the run is lost. Clearing the last
|
|
56
|
+
wave wins. Towers aim at the creep furthest along the road in range.
|
|
57
|
+
More builders make tougher creeps (+30% health a player past the first).
|
|
58
|
+
|
|
59
|
+
## Common changes
|
|
60
|
+
|
|
61
|
+
### A different road
|
|
62
|
+
towerBoard({ size: [20, 12], path: [[0, 5], [6, 5], [6, 1], [13, 1], [13, 10], [19, 10]], lives: 20, gold: 100 });
|
|
63
|
+
Longer roads with more turns give towers more time; a straight road is hard.
|
|
64
|
+
|
|
65
|
+
### Tune a tower
|
|
66
|
+
tower("bolt_post", { model: "style.lowpoly.turret", kind: "bolt", cost: 40, damage: 8, rate: 1.5, range: 3,
|
|
67
|
+
levels: [{ cost: 45, damage: 14 }, { cost: 80, damage: 24, range: 3.5 }] });
|
|
68
|
+
Each level is one upgrade: its gold cost and the stats it changes.
|
|
69
|
+
|
|
70
|
+
### Add a tower
|
|
71
|
+
tower("sky_harpoon", { model: "style.lowpoly.rocket", kind: "bolt", cost: 120, damage: 60, rate: 0.4, range: 6 });
|
|
72
|
+
splash: radius (tiles) around the target; frost: slow (0..0.9) for
|
|
73
|
+
slowSeconds; chain: chains (jumps), 30% weaker each jump.
|
|
74
|
+
Add its label to names.json weapons. The bar shows towers in order (keys 1-8).
|
|
75
|
+
|
|
76
|
+
### Add a creep
|
|
77
|
+
prefab("ironclad", { model: "style.lowpoly.robot_walker", health: 160, speed: 0.9, value: 14, damage: 3 });
|
|
78
|
+
value is the gold bounty, damage the bells a leak costs. Then bring it in:
|
|
79
|
+
waves({ ..., groups: [..., { prefab: "ironclad", from: 8, count: 2, grow: 0.5, gap: 2 }] });
|
|
80
|
+
|
|
81
|
+
### A boss
|
|
82
|
+
prefab("boilerback", { model: "style.lowpoly.robot_large", health: 1800, speed: 0.7, value: 80, damage: 5, boss: true });
|
|
83
|
+
{ prefab: "boilerback", from: 10, every: 10, count: 1, delay: 8 } (a boss every tenth wave).
|
|
84
|
+
Bosses get a health bar at the top of the screen.
|
|
85
|
+
|
|
86
|
+
### Harder or easier
|
|
87
|
+
Raise waves health (growth a wave), counts and grow, or lower gold and
|
|
88
|
+
lives. "More waves": waves total. "Faster": shorter prep and breather.
|
|
89
|
+
|
|
90
|
+
### Add an earn rule
|
|
91
|
+
earn.rule("clean_sweep", { when: "wave.cleared", n: 15, pay: 30, oncePer: "run" });
|
|
92
|
+
Events: enemy.killed (credits the tower's builder), boss.killed and
|
|
93
|
+
wave.cleared and run.won (everyone on the board), tower.built and
|
|
94
|
+
tower.upgraded (credits the builder). Each rule pays once per run per
|
|
95
|
+
player, in coins; the room server is the referee. A player who joins a run
|
|
96
|
+
in progress is never paid for waves cleared before they joined.
|
|
97
|
+
|
|
98
|
+
### The buy-in run (required)
|
|
99
|
+
earn.stake({ min: 20, max: 200, multiple: 2 });
|
|
100
|
+
One top-level line; a game without it does not build. A staked run pays
|
|
101
|
+
its normal earn plus a share of stake x multiple, by how much of the run's
|
|
102
|
+
most earn the player made. "stake" is reserved.
|
|
103
|
+
|
|
104
|
+
### Coins: earn and the store
|
|
105
|
+
Every game has coins: 1 coin = 1 of the game's own token, shown under the
|
|
106
|
+
token's own name, symbol and logo.
|
|
107
|
+
store.sku("overclock", { coins: 40 }); // one level of the "overclock" upgrade for the run
|
|
108
|
+
store.sku("skin_gilt", { coins: 500, grants: { cosmetic: 2 } }); // a finish for your tinker, owned for good
|
|
109
|
+
store.sku("brass_gear", { coins: 150, grants: { gear: "tempered_brass", loseBelow: 0.25 } }); // losable gear
|
|
110
|
+
A store item named after a declared upgrade is a run upgrade on the
|
|
111
|
+
buyer's own towers. Keep the SKUs the client is wired to (overclock,
|
|
112
|
+
tempered_brass, spyglass, spare_bell, skin_verdigris, skin_gilt,
|
|
113
|
+
brass_gear) at their prices. Gear brings one level of its upgrade into
|
|
114
|
+
every staked run and is lost when that run makes less than loseBelow of
|
|
115
|
+
its most earn. Price against what a run pays (the four rules here pay up
|
|
116
|
+
to 80).
|
|
117
|
+
|
|
118
|
+
### Change the look
|
|
119
|
+
players.prefab is your tinker (a real CC0 character). Tower and creep
|
|
120
|
+
models are library ids: turret, turret_double, satellite_dish, generator,
|
|
121
|
+
rocket, crystal_rock for towers; slime_*, crab, bee, frog, bat,
|
|
122
|
+
robot_walker, robot_large, skeleton_grunt for creeps.
|
|
123
|
+
|
|
124
|
+
## Test
|
|
125
|
+
|
|
126
|
+
engine.test.run({ ticks: 30 * 240, bots: 4 }) plays the board with 4
|
|
127
|
+
builders and reports kills, wave reached, runs won and lost, leaks (as
|
|
128
|
+
deaths), earned by rule, softlock, ms per tick, and the genre's own
|
|
129
|
+
checks under rules.checks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/template-tower",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "COGSPIRE: the tower defense template of the NetNet game runtime, a co-op board for up to four with waves, build and upgrade, shared lives. The game module, its browser client and its playtest bots.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/manifest.ts",
|
|
9
|
+
"./store": "./src/store.ts"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src",
|
|
13
|
+
"client",
|
|
14
|
+
"!client/vite.config.ts",
|
|
15
|
+
"llms.txt",
|
|
16
|
+
"README.md",
|
|
17
|
+
"NOTICE",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "git+https://github.com/netnetdev/net-forge.git",
|
|
26
|
+
"directory": "packages/template-tower"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-tower#readme",
|
|
29
|
+
"engines": {
|
|
30
|
+
"node": ">=20"
|
|
31
|
+
},
|
|
32
|
+
"dependencies": {
|
|
33
|
+
"three": "0.186.1",
|
|
34
|
+
"@netforge/asset-library": "0.1.0",
|
|
35
|
+
"@netforge/engine-sim": "0.1.0",
|
|
36
|
+
"@netforge/engine": "0.1.0",
|
|
37
|
+
"@netforge/tabletop": "0.1.0"
|
|
38
|
+
},
|
|
39
|
+
"devDependencies": {
|
|
40
|
+
"@types/node": "22.20.1",
|
|
41
|
+
"@types/three": "0.186.0",
|
|
42
|
+
"tsx": "4.23.0",
|
|
43
|
+
"typescript": "5.8.3",
|
|
44
|
+
"vite": "6.4.3",
|
|
45
|
+
"vitest": "3.2.7"
|
|
46
|
+
},
|
|
47
|
+
"bugs": {
|
|
48
|
+
"url": "https://github.com/netnetdev/net-forge/issues"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"build": "node ../engine/scripts/fresh-dist.mjs && vite build --config client/vite.config.ts",
|
|
52
|
+
"typecheck": "tsc -p tsconfig.json",
|
|
53
|
+
"test": "vitest run",
|
|
54
|
+
"dev": "vite --config client/vite.config.ts",
|
|
55
|
+
"preview": "vite preview --config client/vite.config.ts"
|
|
56
|
+
}
|
|
6
57
|
}
|
package/src/game.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* COGSPIRE: the tower defense template (play style: tower defense).
|
|
3
|
+
*
|
|
4
|
+
* A brass clockwork spire stands at the end of an old rail road across the
|
|
5
|
+
* moor. Rust swarms crawl the road toward it in waves. Up to four tinkers
|
|
6
|
+
* share the board: each builds and upgrades brass towers with their own
|
|
7
|
+
* gold, every kill pays its builder and a share to the rest, and the team
|
|
8
|
+
* shares the spire's bells (lives). Hold twenty waves to keep the spire.
|
|
9
|
+
*
|
|
10
|
+
* This file is the whole game as the model writes it: declarations only,
|
|
11
|
+
* against the typed API in @netforge/engine. The board, the waves,
|
|
12
|
+
* targeting, co-op, netcode, renderer and referee are the template's
|
|
13
|
+
* machinery.
|
|
14
|
+
*/
|
|
15
|
+
import { earn, game, prefab, store, tower, towerBoard, upgrade, waves } from "@netforge/engine";
|
|
16
|
+
|
|
17
|
+
export default game({
|
|
18
|
+
template: "tower-defense",
|
|
19
|
+
style: "lowpoly",
|
|
20
|
+
places: { moor: { chunks: "places/moor", spawn: [0, 0, 0] } },
|
|
21
|
+
players: { prefab: "style.lowpoly.worker", max: 4 },
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
// The board: 18 x 12 tiles of 2 m. The road enters at the west edge and winds to the spire on the east edge.
|
|
25
|
+
towerBoard({
|
|
26
|
+
size: [18, 12],
|
|
27
|
+
path: [
|
|
28
|
+
[0, 2],
|
|
29
|
+
[5, 2],
|
|
30
|
+
[5, 8],
|
|
31
|
+
[10, 8],
|
|
32
|
+
[10, 3],
|
|
33
|
+
[15, 3],
|
|
34
|
+
[15, 9],
|
|
35
|
+
[17, 9],
|
|
36
|
+
],
|
|
37
|
+
blocked: [
|
|
38
|
+
[2, 6],
|
|
39
|
+
[3, 9],
|
|
40
|
+
[8, 1],
|
|
41
|
+
[12, 10],
|
|
42
|
+
[13, 6],
|
|
43
|
+
],
|
|
44
|
+
lives: 20,
|
|
45
|
+
gold: 100,
|
|
46
|
+
prep: 15,
|
|
47
|
+
breather: 10,
|
|
48
|
+
share: 0.25,
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Creeps. Health in hit points, speed in metres a second (a tile is 2 m), value is the gold bounty, damage the bells a leak costs.
|
|
52
|
+
prefab("rustling", { model: "style.lowpoly.slime_green", health: 26, speed: 1.6, value: 4 });
|
|
53
|
+
prefab("gearbeetle", { model: "style.lowpoly.crab", health: 90, speed: 1, value: 9, damage: 2 });
|
|
54
|
+
prefab("cinder_moth", { model: "style.lowpoly.bee", health: 20, speed: 3, value: 4 });
|
|
55
|
+
prefab("boilerback", { model: "style.lowpoly.robot_large", health: 1800, speed: 0.7, value: 80, damage: 5, boss: true });
|
|
56
|
+
|
|
57
|
+
// Towers. Damage a hit, rate in shots a second, range and radius in tiles. Each level is an upgrade: its cost and what it changes.
|
|
58
|
+
tower("bolt_post", {
|
|
59
|
+
model: "style.lowpoly.turret",
|
|
60
|
+
kind: "bolt",
|
|
61
|
+
cost: 40,
|
|
62
|
+
damage: 8,
|
|
63
|
+
rate: 1.5,
|
|
64
|
+
range: 3,
|
|
65
|
+
levels: [
|
|
66
|
+
{ cost: 45, damage: 14 },
|
|
67
|
+
{ cost: 80, damage: 24, range: 3.5 },
|
|
68
|
+
{ cost: 140, damage: 40, rate: 2 },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
tower("tar_mortar", {
|
|
72
|
+
model: "style.lowpoly.turret_double",
|
|
73
|
+
kind: "splash",
|
|
74
|
+
cost: 70,
|
|
75
|
+
damage: 12,
|
|
76
|
+
rate: 0.6,
|
|
77
|
+
range: 3.5,
|
|
78
|
+
radius: 1.1,
|
|
79
|
+
levels: [
|
|
80
|
+
{ cost: 70, damage: 20 },
|
|
81
|
+
{ cost: 120, damage: 34, radius: 1.4 },
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
tower("frost_vane", {
|
|
85
|
+
model: "style.lowpoly.satellite_dish",
|
|
86
|
+
kind: "frost",
|
|
87
|
+
cost: 55,
|
|
88
|
+
damage: 3,
|
|
89
|
+
rate: 1,
|
|
90
|
+
range: 2.6,
|
|
91
|
+
slow: 0.45,
|
|
92
|
+
slowSeconds: 1.6,
|
|
93
|
+
levels: [
|
|
94
|
+
{ cost: 60, slow: 0.55, range: 3 },
|
|
95
|
+
{ cost: 100, damage: 8, slow: 0.65 },
|
|
96
|
+
],
|
|
97
|
+
});
|
|
98
|
+
tower("arc_coil", {
|
|
99
|
+
model: "style.lowpoly.generator",
|
|
100
|
+
kind: "chain",
|
|
101
|
+
cost: 90,
|
|
102
|
+
damage: 14,
|
|
103
|
+
rate: 0.8,
|
|
104
|
+
range: 3,
|
|
105
|
+
chains: 3,
|
|
106
|
+
levels: [
|
|
107
|
+
{ cost: 90, damage: 22, chains: 4 },
|
|
108
|
+
{ cost: 150, damage: 36, chains: 5 },
|
|
109
|
+
],
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// Twenty waves. Each group joins at wave `from`, starts with `count` and adds `grow` a wave; a boss every tenth wave.
|
|
113
|
+
waves({
|
|
114
|
+
total: 20,
|
|
115
|
+
health: 0.3,
|
|
116
|
+
groups: [
|
|
117
|
+
{ prefab: "rustling", count: 6, grow: 1.6, gap: 0.8 },
|
|
118
|
+
{ prefab: "gearbeetle", from: 3, count: 2, grow: 0.8, gap: 1.4, delay: 3 },
|
|
119
|
+
{ prefab: "cinder_moth", from: 5, count: 4, grow: 1.2, gap: 0.45, delay: 6 },
|
|
120
|
+
{ prefab: "boilerback", from: 10, every: 10, count: 1, delay: 8 },
|
|
121
|
+
],
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Store upgrades: one level each, for the rest of the run, on the buyer's own towers (spare_bell adds bells for the team).
|
|
125
|
+
upgrade("overclock", { stat: "haste", step: 0.15, maxLevel: 1 });
|
|
126
|
+
upgrade("tempered_brass", { stat: "might", step: 0.15, maxLevel: 1 });
|
|
127
|
+
upgrade("spyglass", { stat: "area", step: 0.12, maxLevel: 1 });
|
|
128
|
+
upgrade("spare_bell", { stat: "maxHealth", step: 5, maxLevel: 1 });
|
|
129
|
+
|
|
130
|
+
// Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each. Values are PLACEHOLDER.
|
|
131
|
+
earn.rule("first_gear", { when: "wave.cleared", n: 3, pay: 5, oncePer: "run" });
|
|
132
|
+
earn.rule("long_shift", { when: "wave.cleared", n: 10, pay: 20, oncePer: "run" });
|
|
133
|
+
earn.rule("boiler_breaker", { when: "boss.killed", n: 1, pay: 50, oncePer: "run" });
|
|
134
|
+
earn.rule("scrap_heap", { when: "enemy.killed", n: 60, pay: 5, oncePer: "run" });
|
|
135
|
+
// Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
|
|
136
|
+
// A staked run also pays up to stake x multiple, by the share of this run's earn the player makes. Values are PLACEHOLDER.
|
|
137
|
+
earn.stake({ min: 20, max: 200, multiple: 2 });
|
|
138
|
+
|
|
139
|
+
// Store (the Kit), priced in coins: four run upgrades and two cosmetic finishes owned for good (kept in the profile).
|
|
140
|
+
store.sku("overclock", { coins: 40 });
|
|
141
|
+
store.sku("tempered_brass", { coins: 40 });
|
|
142
|
+
store.sku("spyglass", { coins: 60 });
|
|
143
|
+
store.sku("spare_bell", { coins: 50 });
|
|
144
|
+
store.sku("skin_verdigris", { coins: 300, grants: { cosmetic: 1 } });
|
|
145
|
+
store.sku("skin_gilt", { coins: 500, grants: { cosmetic: 2 } });
|
|
146
|
+
// Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
|
|
147
|
+
// and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
|
|
148
|
+
store.sku("brass_gear", { coins: 150, grants: { gear: "tempered_brass", loseBelow: 0.25 } });
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the game module once per process and compiles it. The room server,
|
|
3
|
+
* the client, the bots and the tests all start here.
|
|
4
|
+
*/
|
|
5
|
+
import { collectManifest } from "@netforge/engine/game";
|
|
6
|
+
import type { GameManifest } from "@netforge/engine";
|
|
7
|
+
import { rulesAdapter, towerGenre } from "@netforge/engine/rules";
|
|
8
|
+
import type { RulesInput, RulesWorld, TowerState } from "@netforge/engine/rules";
|
|
9
|
+
import type { SimAdapter } from "@netforge/engine/net";
|
|
10
|
+
import "./game.js";
|
|
11
|
+
|
|
12
|
+
/** Content version: bump with any change to game.ts (hot swap compares it). */
|
|
13
|
+
export const GAME_VERSION = "cogspire-0.1.0";
|
|
14
|
+
export const GAME_NAME = "cogspire";
|
|
15
|
+
/** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
|
|
16
|
+
export const TEMPLATE = "tower";
|
|
17
|
+
export const PLACE = "moor";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Collected as soon as the module is imported: one process may load more
|
|
21
|
+
* than one game module (the room server hosts every template), and each
|
|
22
|
+
* manifest takes its own game before the next module declares.
|
|
23
|
+
*/
|
|
24
|
+
const MANIFEST = collectManifest();
|
|
25
|
+
let genre: ReturnType<typeof towerGenre> | null = null;
|
|
26
|
+
|
|
27
|
+
/** The game's manifest (every declaration). */
|
|
28
|
+
export function towerManifest(): GameManifest {
|
|
29
|
+
return MANIFEST;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The compiled tower defense genre (board, towers, waves). */
|
|
33
|
+
export function towerGame(): ReturnType<typeof towerGenre> {
|
|
34
|
+
genre ??= towerGenre(MANIFEST);
|
|
35
|
+
return genre;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The SimAdapter the room server and the client both run. */
|
|
39
|
+
export function towerAdapter(): SimAdapter<RulesWorld<TowerState>, RulesInput> {
|
|
40
|
+
return rulesAdapter(towerGame(), MANIFEST, { name: GAME_NAME, version: GAME_VERSION });
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Cosmetic ids for store SKUs (0 is the default finish). */
|
|
44
|
+
export const COSMETICS: Readonly<Record<string, number>> = { skin_verdigris: 1, skin_gilt: 2 };
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store hooks (the Coins model): every
|
|
3
|
+
* game has coins, 1 coin = 1 game token, shown under the token's own name,
|
|
4
|
+
* symbol and logo. COGSPIRE sells four run upgrades (a store command named
|
|
5
|
+
* after the upgrade grants one level for the rest of the run), two finishes
|
|
6
|
+
* (permanent) and one piece of losable gear, all priced in coins. The Kit
|
|
7
|
+
* bridge and its in-memory stand-in live in @netforge/tabletop.
|
|
8
|
+
*/
|
|
9
|
+
export { mockKit } from "@netforge/tabletop";
|
|
10
|
+
export type { KitStoreBridge, MockKit, SkuPrice } from "@netforge/tabletop";
|
|
11
|
+
|
|
12
|
+
/** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
|
|
13
|
+
export const COIN_PRICES = { overclock: 40, tempered_brass: 40, spyglass: 60, spare_bell: 50, skin_verdigris: 300, skin_gilt: 500, brass_gear: 150 } as const;
|
|
14
|
+
export type SkuId = keyof typeof COIN_PRICES;
|
|
15
|
+
/** Run upgrades: spent, applied by the room for the rest of the run. */
|
|
16
|
+
export const UPGRADE_SKUS = ["overclock", "tempered_brass", "spyglass", "spare_bell"] as const;
|
|
17
|
+
/** Cosmetic finishes: bought once, owned for good. */
|
|
18
|
+
export const COSMETIC_SKUS = ["skin_verdigris", "skin_gilt"] as const;
|
|
19
|
+
|
|
20
|
+
/** The template's own coin, as its token metadata names it until a creator renames it. */
|
|
21
|
+
export const COGSPIRE_COIN = {
|
|
22
|
+
name: "Cog",
|
|
23
|
+
symbol: "COG",
|
|
24
|
+
logo: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23f2b84b'/%3E%3Cpath d='M16 7l2 3 3-1 1 3 3 1-1 3 3 2-3 2 1 3-3 1-1 3-3-1-2 3-2-3-3 1-1-3-3-1 1-3-3-2 3-2-1-3 3-1 1-3 3 1z' fill='%233a2a14'/%3E%3Ccircle cx='16' cy='16' r='3.5' fill='%23f2b84b'/%3E%3C/svg%3E",
|
|
25
|
+
} as const;
|