@netforge/template-collector 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 +29 -0
- package/README.md +49 -2
- package/client/decor.ts +13 -0
- package/client/index.html +11 -0
- package/client/main.ts +626 -0
- package/client/names.ts +52 -0
- package/llms.txt +113 -0
- package/package.json +54 -3
- package/src/game.ts +101 -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,29 @@
|
|
|
1
|
+
@netforge/template-collector (MOSSBOUND)
|
|
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 (animals, monsters, Nature Kit), 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 creature-collecting RPG genre's general mechanics (meeting creatures
|
|
26
|
+
in tall grass, turn-based battles, a small team, catching, levels).
|
|
27
|
+
Mechanics, not expression: the creatures, names, elements, items, map
|
|
28
|
+
and text are original, and no creature, design, type chart or item was
|
|
29
|
+
copied from any game.
|
package/README.md
CHANGED
|
@@ -1,3 +1,50 @@
|
|
|
1
|
-
#
|
|
1
|
+
# MOSSBOUND (monster collector template)
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Fen spirits live in the tall grass of a flooded marsh. Each keeper picks a
|
|
4
|
+
starter at camp, walks the reeds, meets wild spirits in the grass and
|
|
5
|
+
battles them turn by turn with a team of three, snaring the ones worth
|
|
6
|
+
keeping in a reed lantern. At the far end of the marsh the Fenwarden waits:
|
|
7
|
+
every keeper in the marsh wears down the same warden, and it must fall
|
|
8
|
+
before dusk.
|
|
9
|
+
|
|
10
|
+
A board genre (`packages/engine/src/rules/collector.ts`) on the room
|
|
11
|
+
server at 30 Hz behind the same SimAdapter seam as every template. The
|
|
12
|
+
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; `collectorAdapter()` for the room server and client. Room template `collector`. |
|
|
18
|
+
| `src/store.ts` | Coin prices the client is wired to, the template's own coin (MOSS). |
|
|
19
|
+
| `client/` | The browser client: the marsh, keepers, companions, the battle panel, the starter pick. |
|
|
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
|
+
- Your keeper walks with a stick (WASD, arrows, or the left thumb) and is
|
|
26
|
+
predicted with the same tile collision the room runs, so walking never
|
|
27
|
+
waits. Every roll (encounters, accuracy, damage spread, snares, the foe's
|
|
28
|
+
move) is the room's PRNG, so a run replays exactly from its inputs.
|
|
29
|
+
- Battles are private: your team, your foe and the battle log reach only
|
|
30
|
+
you (SimAdapter.infoFor). Others see the creature beside you.
|
|
31
|
+
- The warden's health is one pool for the whole marsh; when it falls,
|
|
32
|
+
everyone who hurt it is paid. Dusk ends the run.
|
|
33
|
+
- Earn (the room is the referee): the first catch, three catches, the
|
|
34
|
+
warden, a creature at level 5. All are a player's own acts except the
|
|
35
|
+
warden, which pays only those who hurt it.
|
|
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-collector/src && cp packages/template-collector/src/game.ts play-collector/src/
|
|
45
|
+
echo '{"template":"collector"}' > play-collector/netnet.json
|
|
46
|
+
node packages/kit-play/dist/cli.js play-collector # opens http://127.0.0.1:4663
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Edit `play-collector/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.
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the scene draws on its own (not named in game.ts): the
|
|
3
|
+
* camp, the reeds, the rocks and trees of the marsh, the warden's mound.
|
|
4
|
+
* The build copies these into ./library, in every art style.
|
|
5
|
+
*/
|
|
6
|
+
export const TENT_MODEL = "style.lowpoly.tent";
|
|
7
|
+
export const FIRE_MODEL = "style.lowpoly.campfire";
|
|
8
|
+
export const REED_MODEL = "style.lowpoly.bush";
|
|
9
|
+
export const ROCK_MODEL = "style.lowpoly.rock_tall";
|
|
10
|
+
export const TREE_MODELS = ["style.lowpoly.tree_pine", "style.lowpoly.tree_dark", "style.lowpoly.tree_fat"] as const;
|
|
11
|
+
export const MOUND_MODEL = "style.lowpoly.column_broken";
|
|
12
|
+
export const SMALL_DECOR = ["style.lowpoly.mushroom_red", "style.lowpoly.flower_red", "style.lowpoly.stump", "style.lowpoly.log"] as const;
|
|
13
|
+
export const SCENE_MODELS: readonly string[] = [TENT_MODEL, FIRE_MODEL, REED_MODEL, ROCK_MODEL, ...TREE_MODELS, MOUND_MODEL, ...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>Mossbound</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,626 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MOSSBOUND client: pick a starter at camp, walk the marsh (WASD, arrows or
|
|
3
|
+
* the left thumb), meet wild spirits in the tall grass, fight them turn by
|
|
4
|
+
* turn, snare the ones worth keeping, and bring down the Fenwarden with
|
|
5
|
+
* everyone else before dusk. Your keeper is predicted, so walking never
|
|
6
|
+
* waits for the server; every roll is the room's.
|
|
7
|
+
*
|
|
8
|
+
* URL parameters: see @netforge/tabletop boot.ts (?room, ?server,
|
|
9
|
+
* ?style, ?autopilot, ?preview, ?stats, ?shapes, ?webgl, Net Forge flags).
|
|
10
|
+
*/
|
|
11
|
+
import { AdditiveBlending, CanvasTexture, CylinderGeometry, DoubleSide, Group, Mesh, MeshBasicMaterial, MeshStandardMaterial, NearestFilter, PlaneGeometry, RingGeometry, SRGBColorSpace } from "three/webgpu";
|
|
12
|
+
import type { InterpolatedEntity } from "@netforge/engine/net";
|
|
13
|
+
import { CollectorAct, effectiveness, LogKind, MARSH_TILE, marshCenter, marshRoute, marshTile, RF, RulesKind, UNITS } from "@netforge/engine/rules";
|
|
14
|
+
import type { LogLine, RulesInput } from "@netforge/engine/rules";
|
|
15
|
+
import { bootBoard, cssColor, type BoardView, type Placed } from "@netforge/tabletop";
|
|
16
|
+
import { collectorAdapter, collectorGame, collectorManifest, COSMETICS, GAME_VERSION, PLACE, TEMPLATE } from "../src/manifest.js";
|
|
17
|
+
import { MOSSBOUND_COIN } from "../src/store.js";
|
|
18
|
+
import { FIRE_MODEL, MOUND_MODEL, REED_MODEL, ROCK_MODEL, SMALL_DECOR, TENT_MODEL, TREE_MODELS } from "./decor.js";
|
|
19
|
+
import { KEEPER_COLORS, RULE_NAMES, SKU_NAMES, TITLE, UPGRADE_NAMES, WEAPON_NAMES } from "./names.js";
|
|
20
|
+
|
|
21
|
+
interface MonView {
|
|
22
|
+
readonly species: number;
|
|
23
|
+
readonly level: number;
|
|
24
|
+
readonly hp: number;
|
|
25
|
+
readonly max: number;
|
|
26
|
+
readonly xp: number;
|
|
27
|
+
readonly next: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface CollectorView extends BoardView {
|
|
31
|
+
readonly dusk: number;
|
|
32
|
+
readonly warden: { hp: number; max: number; down: boolean; fighting: number };
|
|
33
|
+
readonly players: readonly { id: number; catches: number; level: number; battling: boolean }[];
|
|
34
|
+
readonly team?: readonly MonView[];
|
|
35
|
+
readonly active?: number;
|
|
36
|
+
readonly box?: number;
|
|
37
|
+
readonly snares?: number;
|
|
38
|
+
readonly picked?: boolean;
|
|
39
|
+
readonly battle?: { foe: MonView; warden: boolean; turn: number } | null;
|
|
40
|
+
readonly log?: readonly LogLine[];
|
|
41
|
+
readonly catches?: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const genre = collectorGame();
|
|
45
|
+
const g = genre.game;
|
|
46
|
+
const map = g.map;
|
|
47
|
+
const manifest = collectorManifest();
|
|
48
|
+
const W = map.w * MARSH_TILE;
|
|
49
|
+
const H = map.h * MARSH_TILE;
|
|
50
|
+
const ELEMENT_COLOR = [0x7bd96a, 0x4fb6ff, 0xffd84a, 0xb59a7a];
|
|
51
|
+
const ELEMENT_NAME = ["Root", "Tide", "Spark", "Stone"];
|
|
52
|
+
const FINISH = [0x6d5a3a, 0x7fa86a, 0xffb347];
|
|
53
|
+
const nameOf = (species: number) => {
|
|
54
|
+
const c = g.creatures[species];
|
|
55
|
+
return c ? (WEAPON_NAMES[c.id]?.name ?? c.id) : "?";
|
|
56
|
+
};
|
|
57
|
+
const moveName = (i: number) => (g.moves[i]?.id ?? "").split("_").map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join(" ");
|
|
58
|
+
const m = (e: InterpolatedEntity, f: number) => (e.values[f] ?? 0) / UNITS;
|
|
59
|
+
|
|
60
|
+
// The browser's autopilot (?autopilot=1): the genre's own playtest habits over what this page can see.
|
|
61
|
+
let lastView: CollectorView | null = null;
|
|
62
|
+
let selfPos = { x: 0, z: 0 };
|
|
63
|
+
let route: { goal: number; path: number[]; until: number } | null = null;
|
|
64
|
+
const autopilot = (tick: number): RulesInput | null => {
|
|
65
|
+
const v = lastView;
|
|
66
|
+
const idle: RulesInput = { mx: 0, mz: 0, cx: 0, cz: 0, act: 0, a: 0, b: 0, c: 0 };
|
|
67
|
+
if (!v) return null;
|
|
68
|
+
if (!v.picked) return tick % 30 === 0 ? { ...idle, act: CollectorAct.Pick, a: tick % g.starters.length } : idle;
|
|
69
|
+
const b = v.battle;
|
|
70
|
+
const team = v.team ?? [];
|
|
71
|
+
if (b) {
|
|
72
|
+
if (tick % 20 !== 0) return idle;
|
|
73
|
+
const mine = team[v.active ?? 0];
|
|
74
|
+
const c = mine ? g.creatures[mine.species] : undefined;
|
|
75
|
+
if (!mine || !c) return idle;
|
|
76
|
+
if (!b.warden && (v.snares ?? 0) > 0 && b.foe.hp < b.foe.max * 0.5) return { ...idle, act: CollectorAct.Snare };
|
|
77
|
+
let best = 0;
|
|
78
|
+
let score = -1;
|
|
79
|
+
c.moveIdx.forEach((mi, j) => {
|
|
80
|
+
const mv = g.moves[mi];
|
|
81
|
+
const foe = g.creatures[b.foe.species];
|
|
82
|
+
if (!mv || !foe) return;
|
|
83
|
+
const eff = effectiveness(mv.el, foe.el);
|
|
84
|
+
const val = mv.effect === "heal" ? (mine.hp < mine.max * 0.4 ? 999 : 0) : mv.power * (eff > 0 ? 1.5 : eff < 0 ? 0.67 : 1);
|
|
85
|
+
if (val > score) {
|
|
86
|
+
score = val;
|
|
87
|
+
best = j;
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
return { ...idle, act: CollectorAct.Move, a: best };
|
|
91
|
+
}
|
|
92
|
+
const here = marshTile(map, selfPos.x * UNITS, selfPos.z * UNITS);
|
|
93
|
+
const level = team.reduce((a, x) => Math.max(a, x.level), 0);
|
|
94
|
+
const hurt = team.reduce((a, x) => a + x.hp, 0) < team.reduce((a, x) => a + x.max, 0) * 0.45;
|
|
95
|
+
let goal: number;
|
|
96
|
+
const wardenGoal = map.warden[1] * map.w + map.warden[0];
|
|
97
|
+
if (!v.warden.down && level >= g.marsh.warden.level - 1 && !hurt) {
|
|
98
|
+
goal = wardenGoal;
|
|
99
|
+
const wc = marshCenter(map, map.warden[0], map.warden[1]);
|
|
100
|
+
if (Math.hypot(wc.x / UNITS - selfPos.x, wc.z / UNITS - selfPos.z) < 4.5 && tick % 15 === 0) return { ...idle, act: CollectorAct.Challenge };
|
|
101
|
+
} else if (hurt) goal = map.camp[1] * map.w + map.camp[0];
|
|
102
|
+
else {
|
|
103
|
+
const tiles: number[] = [];
|
|
104
|
+
for (let t = 0; t < map.grass.length; t++) if (map.grass[t] && !map.blocked[t]) tiles.push(t);
|
|
105
|
+
goal = tiles[(Math.floor(tick / 120) * 2654435761) % Math.max(1, tiles.length)] ?? here;
|
|
106
|
+
}
|
|
107
|
+
if (!route || route.goal !== goal || tick >= route.until || route.path.length === 0) route = { goal, path: here >= 0 ? marshRoute(map, here, goal) : [], until: tick + 90 };
|
|
108
|
+
while (route.path.length > 0 && route.path[0] === here) route.path.shift();
|
|
109
|
+
const next = route.path[0] ?? goal;
|
|
110
|
+
const c = marshCenter(map, next % map.w, Math.floor(next / map.w));
|
|
111
|
+
const dx = c.x / UNITS - selfPos.x;
|
|
112
|
+
const dz = c.z / UNITS - selfPos.z;
|
|
113
|
+
const d = Math.hypot(dx, dz);
|
|
114
|
+
if (d < 0.1) return idle;
|
|
115
|
+
return { ...idle, mx: Math.round((dx / d) * 127), mz: Math.round((dz / d) * 127) };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
async function main(): Promise<void> {
|
|
119
|
+
const app = await bootBoard({
|
|
120
|
+
adapter: collectorAdapter(),
|
|
121
|
+
manifest,
|
|
122
|
+
template: TEMPLATE,
|
|
123
|
+
place: PLACE,
|
|
124
|
+
version: GAME_VERSION,
|
|
125
|
+
labels: {
|
|
126
|
+
title: TITLE,
|
|
127
|
+
rules: RULE_NAMES,
|
|
128
|
+
skus: SKU_NAMES,
|
|
129
|
+
upgrades: UPGRADE_NAMES,
|
|
130
|
+
loading: "Lighting the reed lanterns",
|
|
131
|
+
help: "WASD walk · in battle 1-4 moves, S snare, F flee, Tab swap · E challenge the warden · ` stats",
|
|
132
|
+
},
|
|
133
|
+
theme: {
|
|
134
|
+
accent: "#9de07a",
|
|
135
|
+
gold: "#ffd76a",
|
|
136
|
+
ink: "#eef6e4",
|
|
137
|
+
dim: "#a6b79a",
|
|
138
|
+
panel: "rgba(18, 32, 22, 0.86)",
|
|
139
|
+
bg: "#101a12",
|
|
140
|
+
font: '"Gill Sans", "Trebuchet MS", system-ui, sans-serif',
|
|
141
|
+
display: '"Palatino Linotype", Georgia, serif',
|
|
142
|
+
radius: 10,
|
|
143
|
+
},
|
|
144
|
+
coin: MOSSBOUND_COIN,
|
|
145
|
+
cosmetics: COSMETICS,
|
|
146
|
+
swatch: (c) => `linear-gradient(135deg, ${cssColor(FINISH[c] ?? 0x6d5a3a)}, #1c2a18)`,
|
|
147
|
+
ground: { w: 26, h: 16 },
|
|
148
|
+
preset: "day",
|
|
149
|
+
theme3d: "meadow",
|
|
150
|
+
groundColor: 0x3d5a2e,
|
|
151
|
+
pitch: 0.95,
|
|
152
|
+
autopilot,
|
|
153
|
+
soundKey: "mossbound:sound",
|
|
154
|
+
});
|
|
155
|
+
const { stage, hud, clock, sfx } = app;
|
|
156
|
+
sfx.define("snare", { wave: "triangle", f0: 520, f1: 1560, ms: 320, gain: 0.6 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
|
|
157
|
+
sfx.define("slip", { wave: "sawtooth", f0: 600, f1: 200, ms: 220, gain: 0.4 }, { minGapMs: 150, voices: 1 });
|
|
158
|
+
sfx.define("rustle", { wave: "noise", f0: 1800, f1: 600, ms: 260, gain: 0.35 }, { minGapMs: 300, voices: 1 });
|
|
159
|
+
|
|
160
|
+
// ---- The marsh: one painted plate (meadow, tall grass, mud), water, reeds, rocks and trees, the camp, the mound.
|
|
161
|
+
const plate = new Mesh(new PlaneGeometry(W, H, map.w * 2, map.h * 2), new MeshStandardMaterial({ map: marshTexture(), roughness: 0.95, metalness: 0 }));
|
|
162
|
+
plate.rotation.x = -Math.PI / 2;
|
|
163
|
+
plate.position.y = stage.groundAt(0, 0) + 0.03;
|
|
164
|
+
plate.receiveShadow = true;
|
|
165
|
+
stage.add(plate);
|
|
166
|
+
const waterMat = new MeshStandardMaterial({ color: 0x3b7fa8, roughness: 0.15, metalness: 0.2, transparent: true, opacity: 0.85 });
|
|
167
|
+
for (const [x, y, w, h] of g.marsh.water) {
|
|
168
|
+
const water = new Mesh(new PlaneGeometry(w * MARSH_TILE, h * MARSH_TILE), waterMat);
|
|
169
|
+
water.rotation.x = -Math.PI / 2;
|
|
170
|
+
water.position.set((x + w / 2 - map.w / 2) * MARSH_TILE, plate.position.y + 0.06, (y + h / 2 - map.h / 2) * MARSH_TILE);
|
|
171
|
+
stage.add(water);
|
|
172
|
+
}
|
|
173
|
+
const at = (tx: number, ty: number) => ({ x: (tx + 0.5 - map.w / 2) * MARSH_TILE, z: (ty + 0.5 - map.h / 2) * MARSH_TILE });
|
|
174
|
+
// Reeds over the tall grass, rocks and trees on the blocked tiles: instanced, one draw a model.
|
|
175
|
+
const reeds: { x: number; z: number; yaw: number; s: number }[] = [];
|
|
176
|
+
const rocks: { x: number; z: number; yaw: number; s: number }[] = [];
|
|
177
|
+
const trees: { x: number; z: number; yaw: number; s: number }[][] = TREE_MODELS.map(() => []);
|
|
178
|
+
let seed = 7;
|
|
179
|
+
const rnd = () => ((seed = (seed * 16807) % 2147483647) / 2147483647);
|
|
180
|
+
for (let t = 0; t < map.w * map.h; t++) {
|
|
181
|
+
const p = at(t % map.w, Math.floor(t / map.w));
|
|
182
|
+
if (map.grass[t]) for (let k = 0; k < 2; k++) reeds.push({ x: p.x + (rnd() - 0.5) * 1.6, z: p.z + (rnd() - 0.5) * 1.6, yaw: rnd() * 6.28, s: 0.5 + rnd() * 0.35 });
|
|
183
|
+
if (map.blocked[t] && !map.water[t]) {
|
|
184
|
+
if (rnd() < 0.5) rocks.push({ x: p.x, z: p.z, yaw: rnd() * 6.28, s: 0.8 + rnd() * 0.4 });
|
|
185
|
+
else trees[Math.floor(rnd() * trees.length)]?.push({ x: p.x, z: p.z, yaw: rnd() * 6.28, s: 0.8 + rnd() * 0.4 });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
// The land past the marsh's edge: a ring of trees and small things.
|
|
189
|
+
const small: { x: number; z: number; yaw: number; s: number }[][] = SMALL_DECOR.map(() => []);
|
|
190
|
+
for (let i = 0; i < 90; i++) {
|
|
191
|
+
const side = rnd() * 4;
|
|
192
|
+
const t = rnd() * 2 - 1;
|
|
193
|
+
const out = 1.5 + rnd() * 8;
|
|
194
|
+
const x = side < 1 ? -W / 2 - out : side < 2 ? W / 2 + out : t * (W / 2 + 8);
|
|
195
|
+
const z = side < 2 ? t * (H / 2 + 8) : side < 3 ? -H / 2 - out : H / 2 + out;
|
|
196
|
+
(i % 3 === 0 ? small[i % small.length] : trees[i % trees.length])?.push({ x, z, yaw: rnd() * 6.28, s: 0.8 + rnd() * 0.5 });
|
|
197
|
+
}
|
|
198
|
+
const scatter = [
|
|
199
|
+
{ crowd: stage.crowd(REED_MODEL, 0.7, reeds.length, 0x6f9a4a), at: reeds },
|
|
200
|
+
{ crowd: stage.crowd(ROCK_MODEL, 1.6, Math.max(1, rocks.length), 0x8a8478), at: rocks },
|
|
201
|
+
...TREE_MODELS.map((id, i) => ({ crowd: stage.crowd(id, 4, Math.max(1, trees[i]?.length ?? 1), 0x3f6a34), at: trees[i] ?? [] })),
|
|
202
|
+
...SMALL_DECOR.map((id, i) => ({ crowd: stage.crowd(id, 0.6, Math.max(1, small[i]?.length ?? 1), 0x9a6a4a), at: small[i] ?? [] })),
|
|
203
|
+
];
|
|
204
|
+
const camp = at(map.camp[0], map.camp[1]);
|
|
205
|
+
const tent = stage.model(TENT_MODEL, 2.2, 0xa0805a);
|
|
206
|
+
tent.root.position.set(camp.x - 1.6, 0, camp.z - 1.4);
|
|
207
|
+
const fire = stage.model(FIRE_MODEL, 0.7, 0xff9a3c);
|
|
208
|
+
fire.root.position.set(camp.x + 0.6, 0, camp.z - 0.4);
|
|
209
|
+
stage.add(tent.root, fire.root);
|
|
210
|
+
const glow = new Mesh(new CylinderGeometry(1, 1, 0.04, 24), new MeshBasicMaterial({ color: 0xffb347, transparent: true, opacity: 0.35, blending: AdditiveBlending, depthWrite: false }));
|
|
211
|
+
glow.position.set(camp.x, 0.08, camp.z);
|
|
212
|
+
glow.scale.setScalar(3);
|
|
213
|
+
stage.add(glow);
|
|
214
|
+
const wardenAt = at(map.warden[0], map.warden[1]);
|
|
215
|
+
for (let i = 0; i < 6; i++) {
|
|
216
|
+
const a = (i / 6) * Math.PI * 2;
|
|
217
|
+
const stone = stage.model(MOUND_MODEL, 1.8, 0x8a8478);
|
|
218
|
+
stone.root.position.set(wardenAt.x + Math.cos(a) * 3.2, 0, wardenAt.z + Math.sin(a) * 3.2);
|
|
219
|
+
stone.root.rotation.y = a;
|
|
220
|
+
stage.add(stone.root);
|
|
221
|
+
}
|
|
222
|
+
stage.frameRect(camp.x + 6, camp.z, 26, 16);
|
|
223
|
+
|
|
224
|
+
// ---- Keepers (one character each) and creatures (one model each: companions, wild foes, the warden).
|
|
225
|
+
const keepers = new Map<number, { root: Group; ring: Mesh }>();
|
|
226
|
+
const keeperFor = (owner: number) => {
|
|
227
|
+
let k = keepers.get(owner);
|
|
228
|
+
if (k) return k;
|
|
229
|
+
const root = new Group();
|
|
230
|
+
const model = stage.model(manifest.game.players.prefab, 1.7, KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length]);
|
|
231
|
+
root.add(model.root);
|
|
232
|
+
const ring = new Mesh(new RingGeometry(0.45, 0.62, 24), new MeshBasicMaterial({ color: KEEPER_COLORS[(owner - 1) % KEEPER_COLORS.length] ?? 0xffffff, side: DoubleSide, transparent: true, opacity: 0.85 }));
|
|
233
|
+
ring.rotation.x = -Math.PI / 2;
|
|
234
|
+
ring.position.y = 0.06;
|
|
235
|
+
root.add(ring);
|
|
236
|
+
stage.add(root);
|
|
237
|
+
k = { root, ring };
|
|
238
|
+
keepers.set(owner, k);
|
|
239
|
+
return k;
|
|
240
|
+
};
|
|
241
|
+
const creatures = new Map<number, { species: number; placed: Placed; hp: number; x: number; z: number }>();
|
|
242
|
+
const creatureFor = (id: number, species: number) => {
|
|
243
|
+
let c = creatures.get(id);
|
|
244
|
+
if (c && c.species === species) return c;
|
|
245
|
+
if (c) stage.scene.remove(c.placed.root);
|
|
246
|
+
const def = g.creatures[species];
|
|
247
|
+
const placed = stage.model(def?.model ?? "style.lowpoly.frog", (def?.size ?? 1) * 0.8, ELEMENT_COLOR[def?.el ?? 0], (def?.size ?? 1) * 1.1);
|
|
248
|
+
stage.add(placed.root);
|
|
249
|
+
stage.burst("magic", 0, 0, 0, 0, 0);
|
|
250
|
+
c = { species, placed, hp: -1, x: 0, z: 0 };
|
|
251
|
+
creatures.set(id, c);
|
|
252
|
+
return c;
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
// ---- HUD: the team, the battle panel, the starter pick, the warden's call.
|
|
256
|
+
const style = document.createElement("style");
|
|
257
|
+
style.textContent = `
|
|
258
|
+
#team { left: 12px; bottom: max(12px, env(safe-area-inset-bottom)); display: flex; gap: 6px; }
|
|
259
|
+
#team .mon { width: 128px; padding: 6px 8px; border-radius: var(--r); background: var(--panel); border: 1px solid rgba(255,255,255,0.08); font-size: 12px; }
|
|
260
|
+
#team .mon.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
|
261
|
+
#team .mon.out { opacity: 0.45; }
|
|
262
|
+
#team .mon b { display: block; font-size: 13px; }
|
|
263
|
+
.hp { height: 6px; border-radius: 3px; background: rgba(0,0,0,0.45); overflow: hidden; margin-top: 4px; }
|
|
264
|
+
.hp > i { display: block; height: 100%; background: linear-gradient(90deg, #7bd96a, #c6f27a); transform-origin: left; }
|
|
265
|
+
.hp.low > i { background: linear-gradient(90deg, #ff5a3c, #ffb347); }
|
|
266
|
+
.xp { height: 3px; margin-top: 3px; background: rgba(255,255,255,0.08); }
|
|
267
|
+
.xp > i { display: block; height: 100%; background: var(--gold); transform-origin: left; }
|
|
268
|
+
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: 5px; vertical-align: middle; }
|
|
269
|
+
#battle { left: 50%; bottom: max(12px, env(safe-area-inset-bottom)); transform: translateX(-50%); width: min(560px, 94vw); display: none; pointer-events: auto; background: var(--panel); border: 1px solid var(--line); border-radius: calc(var(--r) * 1.4); padding: 10px 12px; }
|
|
270
|
+
#battle.show { display: block; }
|
|
271
|
+
#battle .foe { display: flex; justify-content: space-between; align-items: baseline; font: 700 15px var(--display, inherit); }
|
|
272
|
+
#battle .log { margin: 8px 0; min-height: 50px; font-size: 13px; color: var(--dim); display: grid; gap: 2px; }
|
|
273
|
+
#battle .log div:last-child { color: var(--ink); }
|
|
274
|
+
#battle .moves { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; }
|
|
275
|
+
#battle .moves button { text-align: left; }
|
|
276
|
+
#battle .moves small { color: var(--dim); margin-left: 6px; }
|
|
277
|
+
#battle .row { display: flex; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
|
|
278
|
+
#pick { inset: 0; display: none; place-items: center; background: rgba(6, 12, 8, 0.6); pointer-events: auto; z-index: 7; }
|
|
279
|
+
#pick.show { display: grid; }
|
|
280
|
+
#pick .box { text-align: center; }
|
|
281
|
+
#pick h2 { font: 900 24px var(--display, inherit); letter-spacing: 0.12em; color: var(--accent); margin: 0 0 12px; }
|
|
282
|
+
#pick .cards { display: flex; gap: 10px; flex-wrap: wrap; justify-content: center; }
|
|
283
|
+
#pick button { width: 160px; padding: 14px 10px; display: grid; gap: 4px; }
|
|
284
|
+
#pick button b { font-size: 16px; }
|
|
285
|
+
#pick button small { color: var(--dim); }
|
|
286
|
+
#call { left: 50%; bottom: max(120px, calc(env(safe-area-inset-bottom) + 110px)); transform: translateX(-50%); display: none; }
|
|
287
|
+
#call.show { display: block; }
|
|
288
|
+
#wardenbar { top: max(12px, env(safe-area-inset-top)); left: 50%; transform: translateX(-50%); width: min(340px, 44vw); text-align: center; font: 800 11px var(--display, inherit); letter-spacing: 0.2em; color: #ffd0a0; }
|
|
289
|
+
#wardenbar > div { height: 8px; margin-top: 4px; background: rgba(0,0,0,0.5); border-radius: 3px; overflow: hidden; }
|
|
290
|
+
#wardenbar > div > i { display: block; height: 100%; background: linear-gradient(90deg, #b56a3c, #ffd76a); transform-origin: left; }
|
|
291
|
+
#stickzone { position: fixed; left: 0; top: 30%; bottom: 0; width: 45%; z-index: 3; touch-action: none; display: none; }
|
|
292
|
+
@media (pointer: coarse) { #stickzone { display: block; } }
|
|
293
|
+
@media (max-height: 500px) { #team .mon { width: 104px; padding: 4px 6px; } #battle { padding: 6px 8px; } #battle .log { min-height: 0; } #battle .log div:not(:last-child) { display: none; } }
|
|
294
|
+
`;
|
|
295
|
+
document.head.append(style);
|
|
296
|
+
const el = (id: string, cls = "hud") => {
|
|
297
|
+
const d = document.createElement("div");
|
|
298
|
+
d.id = id;
|
|
299
|
+
d.className = cls;
|
|
300
|
+
document.body.append(d);
|
|
301
|
+
return d;
|
|
302
|
+
};
|
|
303
|
+
const teamEl = el("team");
|
|
304
|
+
const battleEl = el("battle");
|
|
305
|
+
const pickEl = el("pick");
|
|
306
|
+
const callEl = el("call");
|
|
307
|
+
const wardenEl = el("wardenbar");
|
|
308
|
+
const stickZone = el("stickzone", "");
|
|
309
|
+
wardenEl.innerHTML = `<span>${(WEAPON_NAMES[g.marsh.warden.creature]?.name ?? "Warden").toUpperCase()}</span><div><i></i></div>`;
|
|
310
|
+
const callBtn = document.createElement("button");
|
|
311
|
+
callBtn.className = "go";
|
|
312
|
+
callBtn.textContent = `Challenge the ${WEAPON_NAMES[g.marsh.warden.creature]?.name ?? "warden"}`;
|
|
313
|
+
callBtn.addEventListener("click", () => clock.act(CollectorAct.Challenge));
|
|
314
|
+
callEl.append(callBtn);
|
|
315
|
+
pickEl.innerHTML = `<div class="box"><h2>CHOOSE YOUR FIRST SPIRIT</h2><div class="cards"></div></div>`;
|
|
316
|
+
g.starters.forEach((species, i) => {
|
|
317
|
+
const c = g.creatures[species];
|
|
318
|
+
const b = document.createElement("button");
|
|
319
|
+
b.innerHTML = `<b></b><span></span><small></small>`;
|
|
320
|
+
(b.querySelector("b") as HTMLElement).textContent = nameOf(species);
|
|
321
|
+
(b.querySelector("span") as HTMLElement).innerHTML = `<i class="dot" style="background:${cssColor(ELEMENT_COLOR[c?.el ?? 0] ?? 0)}"></i>${ELEMENT_NAME[c?.el ?? 0] ?? ""}`;
|
|
322
|
+
(b.querySelector("small") as HTMLElement).textContent = WEAPON_NAMES[c?.id ?? ""]?.line ?? "";
|
|
323
|
+
b.addEventListener("click", () => {
|
|
324
|
+
sfx.unlock();
|
|
325
|
+
clock.act(CollectorAct.Pick, i);
|
|
326
|
+
sfx.play("pick");
|
|
327
|
+
});
|
|
328
|
+
pickEl.querySelector(".cards")?.append(b);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// ---- Walking: keys, or the left thumb on a phone.
|
|
332
|
+
const keys = new Set<string>();
|
|
333
|
+
addEventListener("keydown", (e) => {
|
|
334
|
+
keys.add(e.code);
|
|
335
|
+
const v = lastView;
|
|
336
|
+
if (v?.battle) {
|
|
337
|
+
const n = Number(e.key);
|
|
338
|
+
if (n >= 1 && n <= 4) clock.act(CollectorAct.Move, n - 1);
|
|
339
|
+
if (e.code === "KeyS") clock.act(CollectorAct.Snare);
|
|
340
|
+
if (e.code === "KeyF") clock.act(CollectorAct.Flee);
|
|
341
|
+
if (e.code === "Tab") {
|
|
342
|
+
e.preventDefault();
|
|
343
|
+
const team = v.team ?? [];
|
|
344
|
+
const next = team.findIndex((x, i) => i !== v.active && x.hp > 0);
|
|
345
|
+
if (next >= 0) clock.act(CollectorAct.Swap, next);
|
|
346
|
+
}
|
|
347
|
+
} else if (e.code === "KeyE") clock.act(CollectorAct.Challenge);
|
|
348
|
+
});
|
|
349
|
+
addEventListener("keyup", (e) => keys.delete(e.code));
|
|
350
|
+
addEventListener("blur", () => keys.clear());
|
|
351
|
+
let thumb: { id: number; x: number; y: number; dx: number; dy: number } | null = null;
|
|
352
|
+
stickZone.addEventListener("pointerdown", (e) => {
|
|
353
|
+
sfx.unlock();
|
|
354
|
+
thumb = { id: e.pointerId, x: e.clientX, y: e.clientY, dx: 0, dy: 0 };
|
|
355
|
+
stickZone.setPointerCapture(e.pointerId);
|
|
356
|
+
});
|
|
357
|
+
stickZone.addEventListener("pointermove", (e) => {
|
|
358
|
+
if (!thumb || e.pointerId !== thumb.id) return;
|
|
359
|
+
thumb.dx = Math.max(-1, Math.min(1, (e.clientX - thumb.x) / 50));
|
|
360
|
+
thumb.dy = Math.max(-1, Math.min(1, (e.clientY - thumb.y) / 50));
|
|
361
|
+
});
|
|
362
|
+
const endThumb = () => (thumb = null);
|
|
363
|
+
stickZone.addEventListener("pointerup", endThumb);
|
|
364
|
+
stickZone.addEventListener("pointercancel", endThumb);
|
|
365
|
+
|
|
366
|
+
// ---- Every frame.
|
|
367
|
+
let seenLog = 0;
|
|
368
|
+
let seenFoe = false;
|
|
369
|
+
const hpBar = (hp: number, max: number) => `<div class="hp${hp < max * 0.3 ? " low" : ""}"><i style="transform:scaleX(${Math.max(0, Math.min(1, hp / Math.max(1, max)))})"></i></div>`;
|
|
370
|
+
app.loop((dt, now) => {
|
|
371
|
+
const conn = app.conn();
|
|
372
|
+
const v = app.view<CollectorView>();
|
|
373
|
+
lastView = v;
|
|
374
|
+
const view = conn.net.render(now);
|
|
375
|
+
const t = now / 1000;
|
|
376
|
+
const inBattle = !!v?.battle;
|
|
377
|
+
// The stick: keys or thumb, never in a battle (the room ignores walking there and so does the prediction).
|
|
378
|
+
let sx = (keys.has("KeyD") || keys.has("ArrowRight") ? 1 : 0) - (keys.has("KeyA") || keys.has("ArrowLeft") ? 1 : 0);
|
|
379
|
+
let sz = (keys.has("KeyS") || keys.has("ArrowDown") ? 1 : 0) - (keys.has("KeyW") || keys.has("ArrowUp") ? 1 : 0);
|
|
380
|
+
if (thumb) {
|
|
381
|
+
sx = thumb.dx;
|
|
382
|
+
sz = thumb.dy;
|
|
383
|
+
}
|
|
384
|
+
if (inBattle || !v?.picked) {
|
|
385
|
+
sx = 0;
|
|
386
|
+
sz = 0;
|
|
387
|
+
}
|
|
388
|
+
const len = Math.hypot(sx, sz);
|
|
389
|
+
clock.stick(len > 1 ? sx / len : sx, len > 1 ? sz / len : sz);
|
|
390
|
+
|
|
391
|
+
// Keepers: ours predicted, the others interpolated.
|
|
392
|
+
const seen = new Set<number>();
|
|
393
|
+
const place = (e: InterpolatedEntity | undefined, owner: number) => {
|
|
394
|
+
if (!e) return;
|
|
395
|
+
seen.add(owner);
|
|
396
|
+
const k = keeperFor(owner);
|
|
397
|
+
const x = m(e, RF.x);
|
|
398
|
+
const z = m(e, RF.z);
|
|
399
|
+
k.root.position.set(x, stage.groundAt(x, z) + 0.03, z);
|
|
400
|
+
k.root.rotation.y = e.values[RF.yaw] ?? 0;
|
|
401
|
+
if (owner === app.self) selfPos = { x, z };
|
|
402
|
+
};
|
|
403
|
+
place(view.self, app.self);
|
|
404
|
+
for (const e of view.others) if (e.kind === RulesKind.Avatar) place(e, e.owner);
|
|
405
|
+
for (const [owner, k] of keepers) {
|
|
406
|
+
if (seen.has(owner)) continue;
|
|
407
|
+
stage.scene.remove(k.root);
|
|
408
|
+
keepers.delete(owner);
|
|
409
|
+
}
|
|
410
|
+
// Creatures: companions, foes beside battling keepers, the warden on its mound.
|
|
411
|
+
const live = new Set<number>();
|
|
412
|
+
let foePos: { x: number; z: number } | null = null;
|
|
413
|
+
let minePos: { x: number; z: number } | null = null;
|
|
414
|
+
for (const e of view.others) {
|
|
415
|
+
if (e.kind !== RulesKind.Unit && e.kind !== RulesKind.Structure) continue;
|
|
416
|
+
live.add(e.id);
|
|
417
|
+
const species = Math.round(e.values[RF.type] ?? 0);
|
|
418
|
+
const c = creatureFor(e.id, species);
|
|
419
|
+
const x = m(e, RF.x);
|
|
420
|
+
const z = m(e, RF.z);
|
|
421
|
+
const flags = Math.round(e.values[RF.flags] ?? 0);
|
|
422
|
+
const hp = e.values[RF.hp] ?? 0;
|
|
423
|
+
const fainted = e.kind === RulesKind.Unit ? (flags & 1) !== 0 : (flags & 1) !== 0;
|
|
424
|
+
if (c.hp >= 0 && hp < c.hp && e.kind === RulesKind.Structure) stage.burst("hitSpark", x, 2, z, 0xffd76a, 8);
|
|
425
|
+
c.hp = hp;
|
|
426
|
+
c.x = x;
|
|
427
|
+
c.z = z;
|
|
428
|
+
const bob = fainted ? 0 : Math.abs(Math.sin(t * 5 + e.id)) * 0.12;
|
|
429
|
+
c.placed.root.position.set(x, stage.groundAt(x, z) + 0.04 + bob, z);
|
|
430
|
+
c.placed.root.rotation.y = e.values[RF.yaw] ?? 0;
|
|
431
|
+
c.placed.root.rotation.z = fainted ? Math.PI / 2 : 0;
|
|
432
|
+
if (e.owner === app.self && (flags & 2) !== 0) foePos = { x, z };
|
|
433
|
+
if (e.owner === app.self && (flags & 2) === 0 && e.kind === RulesKind.Unit) minePos = { x, z };
|
|
434
|
+
}
|
|
435
|
+
for (const [id, c] of creatures) {
|
|
436
|
+
if (live.has(id)) continue;
|
|
437
|
+
stage.burst("magic", c.x, 0.6, c.z, 0xc6f27a, 10);
|
|
438
|
+
stage.scene.remove(c.placed.root);
|
|
439
|
+
creatures.delete(id);
|
|
440
|
+
}
|
|
441
|
+
for (const s of scatter) {
|
|
442
|
+
s.crowd.begin();
|
|
443
|
+
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);
|
|
444
|
+
s.crowd.end(t);
|
|
445
|
+
}
|
|
446
|
+
glow.scale.setScalar(3 + Math.sin(t * 3) * 0.2);
|
|
447
|
+
// The camera: our keeper, closer in a battle.
|
|
448
|
+
if (v?.battle && (v.battle.warden || foePos)) stage.frameRect(selfPos.x, selfPos.z, 14, 9);
|
|
449
|
+
else if (!app.preview) stage.frameRect(selfPos.x, selfPos.z, 26, 16);
|
|
450
|
+
|
|
451
|
+
if (!v) return;
|
|
452
|
+
// Battle log: new lines become effects and sounds.
|
|
453
|
+
const log = v.log ?? [];
|
|
454
|
+
if (inBattle && !seenFoe) {
|
|
455
|
+
sfx.play("rustle");
|
|
456
|
+
stage.ring(selfPos.x, selfPos.z, 3, 0xc6f27a);
|
|
457
|
+
}
|
|
458
|
+
seenFoe = inBattle;
|
|
459
|
+
const wardenPos = at(map.warden[0], map.warden[1]);
|
|
460
|
+
for (const line of log) {
|
|
461
|
+
if (line.n <= seenLog) continue;
|
|
462
|
+
seenLog = line.n;
|
|
463
|
+
const target = line.who === 0 ? (v.battle?.warden ? wardenPos : (foePos ?? selfPos)) : (minePos ?? selfPos);
|
|
464
|
+
if (line.kind === LogKind.Hit) {
|
|
465
|
+
stage.burst(line.eff && line.eff > 0 ? "sparks" : "hitSpark", target.x, 0.8, target.z, ELEMENT_COLOR[g.moves[line.move ?? 0]?.el ?? 0], line.eff && line.eff > 0 ? 18 : 8);
|
|
466
|
+
sfx.play(line.who === 0 ? "hit" : "hurt", { gain: line.who === 0 ? 0.8 : 0.5 });
|
|
467
|
+
if (line.who === 1) stage.kick(0.25);
|
|
468
|
+
} else if (line.kind === LogKind.Snared) {
|
|
469
|
+
stage.burst("magic", target.x, 0.8, target.z, 0xffd76a, 30);
|
|
470
|
+
sfx.play("snare");
|
|
471
|
+
hud.toast(`${nameOf(line.value ?? 0)} snared`);
|
|
472
|
+
} else if (line.kind === LogKind.Slipped) sfx.play("slip");
|
|
473
|
+
else if (line.kind === LogKind.Won) {
|
|
474
|
+
stage.burst("kill", target.x, 0.6, target.z, 0xc6f27a, 16);
|
|
475
|
+
sfx.play("kill");
|
|
476
|
+
} else if (line.kind === LogKind.Level) {
|
|
477
|
+
sfx.play("levelUp");
|
|
478
|
+
hud.toast(`Level ${line.value}`);
|
|
479
|
+
} else if (line.kind === LogKind.Wiped) {
|
|
480
|
+
stage.kick(0.6);
|
|
481
|
+
sfx.play("bigKill");
|
|
482
|
+
hud.toast("Your team fainted. Back to camp");
|
|
483
|
+
} else if (line.kind === LogKind.Heal) sfx.play("pick");
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// HUD chips and panels.
|
|
487
|
+
const mmss = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
488
|
+
hud.status([
|
|
489
|
+
["DUSK IN", mmss(v.dusk)],
|
|
490
|
+
["SNARES", `${v.snares ?? 0}`],
|
|
491
|
+
["CAUGHT", `${v.catches ?? 0}${v.box ? ` (${v.box} in lantern)` : ""}`],
|
|
492
|
+
]);
|
|
493
|
+
((wardenEl.lastChild as HTMLElement).firstChild as HTMLElement).style.transform = `scaleX(${Math.max(0, v.warden.hp / Math.max(1, v.warden.max))})`;
|
|
494
|
+
(wardenEl.firstChild as HTMLElement).textContent = `${(WEAPON_NAMES[g.marsh.warden.creature]?.name ?? "Warden").toUpperCase()}${v.warden.fighting ? ` · ${v.warden.fighting} FIGHTING` : ""}`;
|
|
495
|
+
pickEl.classList.toggle("show", !v.picked && v.phase !== "ended" && !app.preview);
|
|
496
|
+
const near = Math.hypot(wardenPos.x - selfPos.x, wardenPos.z - selfPos.z) < 5.5;
|
|
497
|
+
callEl.classList.toggle("show", near && !inBattle && !v.warden.down && !!v.picked && !app.preview);
|
|
498
|
+
const team = v.team ?? [];
|
|
499
|
+
const teamKey = JSON.stringify([team, v.active]);
|
|
500
|
+
if (teamEl.dataset.k !== teamKey) {
|
|
501
|
+
teamEl.dataset.k = teamKey;
|
|
502
|
+
teamEl.innerHTML = team
|
|
503
|
+
.map((x, i) => {
|
|
504
|
+
const c = g.creatures[x.species];
|
|
505
|
+
return `<div class="mon${i === v.active ? " on" : ""}${x.hp <= 0 ? " out" : ""}"><b><i class="dot" style="background:${cssColor(ELEMENT_COLOR[c?.el ?? 0] ?? 0)}"></i>${nameOf(x.species)}</b>Lv ${x.level} · ${x.hp}/${x.max}${hpBar(x.hp, x.max)}<div class="xp"><i style="transform:scaleX(${Math.min(1, x.xp / Math.max(1, x.next))})"></i></div></div>`;
|
|
506
|
+
})
|
|
507
|
+
.join("");
|
|
508
|
+
}
|
|
509
|
+
const b = v.battle;
|
|
510
|
+
battleEl.classList.toggle("show", !!b && !app.preview);
|
|
511
|
+
if (b) {
|
|
512
|
+
const mine = team[v.active ?? 0];
|
|
513
|
+
const key = JSON.stringify([b, mine, v.snares, log.at(-1)?.n, team.map((x) => x.hp)]);
|
|
514
|
+
if (battleEl.dataset.k !== key) {
|
|
515
|
+
battleEl.dataset.k = key;
|
|
516
|
+
const foe = g.creatures[b.foe.species];
|
|
517
|
+
const lines = log.slice(-3).map((l) => `<div>${sentence(l)}</div>`).join("");
|
|
518
|
+
const c = mine ? g.creatures[mine.species] : undefined;
|
|
519
|
+
battleEl.innerHTML = `<div class="foe"><span><i class="dot" style="background:${cssColor(ELEMENT_COLOR[foe?.el ?? 0] ?? 0)}"></i>${b.warden ? "THE " : "Wild "}${nameOf(b.foe.species)} Lv ${b.foe.level}</span><small>${b.foe.hp}/${b.foe.max}</small></div>${hpBar(b.foe.hp, b.foe.max)}<div class="log">${lines}</div><div class="moves"></div><div class="row"></div>`;
|
|
520
|
+
const movesEl = battleEl.querySelector(".moves") as HTMLElement;
|
|
521
|
+
c?.moveIdx.forEach((mi, j) => {
|
|
522
|
+
const mv = g.moves[mi];
|
|
523
|
+
const btn = document.createElement("button");
|
|
524
|
+
const eff = mv && foe ? effectiveness(mv.el, foe.el) : 0;
|
|
525
|
+
btn.innerHTML = `<i class="dot" style="background:${cssColor(ELEMENT_COLOR[mv?.el ?? 0] ?? 0)}"></i>${j + 1} ${moveName(mi)}<small>${mv?.power ? `${mv.power}${eff > 0 ? " · strong" : eff < 0 ? " · weak" : ""}` : (mv?.effect ?? "")}</small>`;
|
|
526
|
+
btn.addEventListener("click", () => clock.act(CollectorAct.Move, j));
|
|
527
|
+
movesEl.append(btn);
|
|
528
|
+
});
|
|
529
|
+
const row = battleEl.querySelector(".row") as HTMLElement;
|
|
530
|
+
if (!b.warden) {
|
|
531
|
+
const snare = document.createElement("button");
|
|
532
|
+
snare.className = "go";
|
|
533
|
+
snare.textContent = `Snare (${v.snares ?? 0})`;
|
|
534
|
+
snare.disabled = (v.snares ?? 0) <= 0;
|
|
535
|
+
snare.addEventListener("click", () => clock.act(CollectorAct.Snare));
|
|
536
|
+
row.append(snare);
|
|
537
|
+
}
|
|
538
|
+
team.forEach((x, i) => {
|
|
539
|
+
if (i === v.active || x.hp <= 0) return;
|
|
540
|
+
const sw = document.createElement("button");
|
|
541
|
+
sw.textContent = `Swap to ${nameOf(x.species)}`;
|
|
542
|
+
sw.addEventListener("click", () => clock.act(CollectorAct.Swap, i));
|
|
543
|
+
row.append(sw);
|
|
544
|
+
});
|
|
545
|
+
const flee = document.createElement("button");
|
|
546
|
+
flee.textContent = "Flee";
|
|
547
|
+
flee.addEventListener("click", () => clock.act(CollectorAct.Flee));
|
|
548
|
+
row.append(flee);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
hud.end(v.phase === "ended", v.outcome === "won" ? "THE WARDEN FELL" : "DUSK", v.outcome === "won" ? "The marsh is quiet" : "The fen floods at dusk", v.endsIn ?? null);
|
|
552
|
+
});
|
|
553
|
+
app.extraStats(() => ({ creatures: creatures.size, keepers: keepers.size, battle: lastView?.battle ? 1 : 0, catches: lastView?.catches ?? 0, team: lastView?.team?.length ?? 0 }));
|
|
554
|
+
|
|
555
|
+
function sentence(l: LogLine): string {
|
|
556
|
+
const who = l.who === 0 ? "Yours" : "Foe";
|
|
557
|
+
switch (l.kind) {
|
|
558
|
+
case LogKind.Hit:
|
|
559
|
+
return `${who}: ${moveName(l.move ?? 0)} for ${l.dmg}${l.eff && l.eff > 0 ? ", strong" : l.eff && l.eff < 0 ? ", weak" : ""}`;
|
|
560
|
+
case LogKind.Miss:
|
|
561
|
+
return `${who}: ${moveName(l.move ?? 0)} missed`;
|
|
562
|
+
case LogKind.Heal:
|
|
563
|
+
return `${who}: ${moveName(l.move ?? 0)}, +${l.value} health`;
|
|
564
|
+
case LogKind.Guard:
|
|
565
|
+
return `${who}: ${moveName(l.move ?? 0)}, guarding`;
|
|
566
|
+
case LogKind.Swap:
|
|
567
|
+
return `${nameOf(l.value ?? 0)} steps in`;
|
|
568
|
+
case LogKind.Snared:
|
|
569
|
+
return `Snared ${nameOf(l.value ?? 0)}`;
|
|
570
|
+
case LogKind.Slipped:
|
|
571
|
+
return `${nameOf(l.value ?? 0)} slipped the snare`;
|
|
572
|
+
case LogKind.Fled:
|
|
573
|
+
return "You ran";
|
|
574
|
+
case LogKind.Won:
|
|
575
|
+
return `${nameOf(l.value ?? 0)} is beaten`;
|
|
576
|
+
case LogKind.Fainted:
|
|
577
|
+
return `${nameOf(l.value ?? 0)} fainted`;
|
|
578
|
+
case LogKind.Wiped:
|
|
579
|
+
return "Your team fainted";
|
|
580
|
+
case LogKind.Level:
|
|
581
|
+
return `Level ${l.value}`;
|
|
582
|
+
case LogKind.Met:
|
|
583
|
+
return `A wild ${nameOf(l.value ?? 0)} rises from the reeds`;
|
|
584
|
+
case LogKind.Warden:
|
|
585
|
+
return `The ${nameOf(l.value ?? 0)} wakes`;
|
|
586
|
+
default:
|
|
587
|
+
return "";
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** The marsh plate: meadow, tall grass, mud under rocks, drawn once on a canvas. */
|
|
592
|
+
function marshTexture(): CanvasTexture {
|
|
593
|
+
const px = 32;
|
|
594
|
+
const c = document.createElement("canvas");
|
|
595
|
+
c.width = map.w * px;
|
|
596
|
+
c.height = map.h * px;
|
|
597
|
+
const ctx = c.getContext("2d");
|
|
598
|
+
if (!ctx) throw new Error("no 2d canvas");
|
|
599
|
+
let s = 5;
|
|
600
|
+
const r = () => ((s = (s * 16807) % 2147483647) / 2147483647);
|
|
601
|
+
for (let y = 0; y < map.h; y++)
|
|
602
|
+
for (let x = 0; x < map.w; x++) {
|
|
603
|
+
const t = y * map.w + x;
|
|
604
|
+
ctx.fillStyle = map.water[t] ? "#2c5a70" : map.blocked[t] ? "#5a5040" : map.grass[t] ? ((x + y) % 2 ? "#36602c" : "#3a6630") : (x + y) % 2 ? "#6e8a4a" : "#729050";
|
|
605
|
+
ctx.fillRect(x * px, y * px, px, px);
|
|
606
|
+
for (let i = 0; i < (map.grass[t] ? 10 : 4); i++) {
|
|
607
|
+
ctx.fillStyle = map.grass[t] ? `rgba(150, 200, 90, ${0.3 + r() * 0.3})` : `rgba(60, 80, 40, ${0.15 + r() * 0.2})`;
|
|
608
|
+
ctx.fillRect(x * px + r() * px, y * px + r() * px, 2, map.grass[t] ? 5 : 2);
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
// A trodden path from camp to the warden's mound.
|
|
612
|
+
ctx.fillStyle = "rgba(150, 120, 80, 0.55)";
|
|
613
|
+
const cy = (map.camp[1] + 0.5) * px;
|
|
614
|
+
ctx.fillRect((map.camp[0] + 0.5) * px, cy - 6, (map.warden[0] - map.camp[0]) * px, 12);
|
|
615
|
+
const tex = new CanvasTexture(c);
|
|
616
|
+
tex.colorSpace = SRGBColorSpace;
|
|
617
|
+
tex.magFilter = NearestFilter;
|
|
618
|
+
return tex;
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
main().catch((e: unknown) => {
|
|
623
|
+
const t = document.getElementById("banner-text");
|
|
624
|
+
if (t) t.textContent = `Could not start: ${e instanceof Error ? e.message : String(e)}`;
|
|
625
|
+
console.error(e);
|
|
626
|
+
});
|
package/client/names.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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. Creatures are
|
|
6
|
+
* the "weapons" table ({ name, line }); moves read as their ids.
|
|
7
|
+
*/
|
|
8
|
+
export const TITLE = "Mossbound";
|
|
9
|
+
|
|
10
|
+
export const WEAPON_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
11
|
+
mossling: { name: "Mossling", line: "Steady and hard to tire" },
|
|
12
|
+
cindertail: { name: "Cindertail", line: "Quick, hits first" },
|
|
13
|
+
puddlewump: { name: "Puddlewump", line: "Soaks hits, heals itself" },
|
|
14
|
+
sporecap: { name: "Sporecap", line: "A cloud of spores" },
|
|
15
|
+
voltmoth: { name: "Voltmoth", line: "Fastest in the reeds" },
|
|
16
|
+
reedcrab: { name: "Reedcrab", line: "Shell like a door" },
|
|
17
|
+
pebblewing: { name: "Pebblewing", line: "A flying stone" },
|
|
18
|
+
bogling: { name: "Bogling", line: "Slow, heavy fists" },
|
|
19
|
+
fenwarden: { name: "Fenwarden", line: "Keeper of the mound" },
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export const UPGRADE_NAMES: Readonly<Record<string, { name: string; line: string }>> = {
|
|
23
|
+
thornbark: { name: "Thornbark", line: "Your team hits harder" },
|
|
24
|
+
mossmail: { name: "Mossmail", line: "Your team takes less" },
|
|
25
|
+
spare_snares: { name: "Spare snares", line: "+3 snares this run" },
|
|
26
|
+
fen_tonic: { name: "Fen tonic", line: "Heal while you walk" },
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const ENEMY_NAMES: Readonly<Record<string, string>> = {
|
|
30
|
+
fenwarden: "Fenwarden",
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export const RULE_NAMES: Readonly<Record<string, string>> = {
|
|
34
|
+
first_snare: "First Snare",
|
|
35
|
+
full_lantern: "Full Lantern",
|
|
36
|
+
warden_breaker: "Warden Breaker",
|
|
37
|
+
seasoned: "Seasoned",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const SKU_NAMES: Readonly<Record<string, string>> = {
|
|
41
|
+
thornbark: "Thornbark",
|
|
42
|
+
mossmail: "Mossmail",
|
|
43
|
+
spare_snares: "Spare snares",
|
|
44
|
+
fen_tonic: "Fen tonic",
|
|
45
|
+
skin_lichen: "Lichen cloak",
|
|
46
|
+
skin_amber: "Amber cloak",
|
|
47
|
+
lucky_reed: "Lucky reed",
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/** Player colours by slot, and a highlight palette (the generated names module carries both). */
|
|
51
|
+
export const KEEPER_COLORS = [0x9dff7a, 0x5ec8ff, 0xffb347, 0xff7ab8, 0xc9a7ff, 0xffe066, 0x7affd4, 0xff9a5c];
|
|
52
|
+
export const AURORA = { body: 0x3cf2c4, glow: 0xa77bff };
|
package/llms.txt
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# MOSSBOUND: the monster collector template (NetNet game runtime, board genre)
|
|
2
|
+
|
|
3
|
+
> A shared marsh for up to 8 keepers. Each picks one of the starters at
|
|
4
|
+
> camp, walks the marsh, meets wild creatures in tall grass and battles them
|
|
5
|
+
> turn by turn with a team of three, snaring the ones worth keeping. Every
|
|
6
|
+
> keeper in the marsh wears down the same warden; it must fall before dusk.
|
|
7
|
+
> You change the game by editing src/game.ts only. The template already has
|
|
8
|
+
> walking and collision, encounters, turn order, damage, the element cycle,
|
|
9
|
+
> snaring, team swaps, XP and levels, the camp, the warden fight, dusk,
|
|
10
|
+
> netcode, rendering, touch controls, earn and the store. Do not write
|
|
11
|
+
> renderers, netcode or battle AI.
|
|
12
|
+
|
|
13
|
+
## Files
|
|
14
|
+
|
|
15
|
+
- src/game.ts: THE GAME. Declarations against @netforge/engine:
|
|
16
|
+
game(), marsh(), creature(), move(), upgrade(), earn.rule(), earn.stake(), store.sku().
|
|
17
|
+
- src/names.json: the title and every label players read. Creatures go in
|
|
18
|
+
"weapons" ({ name, line }), the warden in "enemies", plus upgrades, earn
|
|
19
|
+
rules and store items. Moves are shown from their ids ("leaf_lash" reads
|
|
20
|
+
"Leaf Lash"), so name moves the way players should read them.
|
|
21
|
+
- Everything else is template machinery.
|
|
22
|
+
|
|
23
|
+
## Rules the build enforces
|
|
24
|
+
|
|
25
|
+
- game({ template: "collector", style, places, players: { prefab, max } }).
|
|
26
|
+
- Exactly one marsh(...); 1 to 24 creature(...)s; at most 48 move(...)s.
|
|
27
|
+
- Every creature's moves are declared moves (1 to 4 each).
|
|
28
|
+
- marsh starters and grass pools name declared creatures that are not
|
|
29
|
+
bosses; the warden names a creature with boss: true.
|
|
30
|
+
- The camp and the warden stand inside the marsh on tiles that are not
|
|
31
|
+
blocked; grass and blocked rects stay inside the marsh.
|
|
32
|
+
- Elements are "root", "tide", "spark", "stone": a cycle where each beats
|
|
33
|
+
the next (root soaks tide, tide shorts spark, spark cracks stone, stone
|
|
34
|
+
crushes root). Strong hits deal 1.5x, weak ones 0.67x.
|
|
35
|
+
- Upgrades read five stats only: might (team attack), maxHealth (team
|
|
36
|
+
health, a fraction: 0.15 is +15%), armor (team defence), amount (extra
|
|
37
|
+
snares) and regen (share of max health healed each second out of battle).
|
|
38
|
+
- No weapon(...): a collector game declares creature(...) and move(...).
|
|
39
|
+
- Asset ids look like "style.lowpoly.frog", never URLs or paths, and must be
|
|
40
|
+
in the asset library. game({ style }) is the art style the creator picked
|
|
41
|
+
(lowpoly, toon, ps1, pixel, voxel or handdrawn); keep it.
|
|
42
|
+
- 4 playtest keepers must win battles, catch creatures and fire an earn rule
|
|
43
|
+
in 4 minutes, and must not lose their whole team more than twice each.
|
|
44
|
+
|
|
45
|
+
## Units
|
|
46
|
+
|
|
47
|
+
A tile is 2 m; marsh rects are [x, y, width, height] in tiles. Stats are
|
|
48
|
+
base values at level 1 and grow 12% a level. power 40 is a plain hit.
|
|
49
|
+
Walking speed is metres a second. Tick rate 30 Hz.
|
|
50
|
+
|
|
51
|
+
## The loop
|
|
52
|
+
|
|
53
|
+
A keeper picks a starter at camp (starterLevel). Every step into a new tile
|
|
54
|
+
of tall grass rolls the zone's rate; a hit starts a battle with a creature
|
|
55
|
+
from the zone's pool at a level in its range. A turn: you pick a move, a
|
|
56
|
+
swap, a snare or flee; the faster creature acts first; the foe answers
|
|
57
|
+
with its best move. A beaten foe gives XP (the active creature full, the
|
|
58
|
+
rest half). A snare's chance is catchRate x (0.25 + 0.75 x the foe's
|
|
59
|
+
missing health); a caught creature joins the team (three at most; more go
|
|
60
|
+
into the lantern). Wild creatures hit at 80%. If your whole team faints you
|
|
61
|
+
are sent to camp with half health; standing at camp heals. Near the
|
|
62
|
+
warden, Challenge starts the warden fight: its health is one pool for the
|
|
63
|
+
whole marsh, it hits at full strength, and when it falls everyone who hurt
|
|
64
|
+
it shares the win. At dusk the run is lost.
|
|
65
|
+
|
|
66
|
+
## Common changes
|
|
67
|
+
|
|
68
|
+
### A new creature
|
|
69
|
+
creature("thistlehog", { model: "style.lowpoly.fox", element: "root", hp: 50, atk: 52, def: 48, spd: 46, moves: ["leaf_lash", "thorn_curl"], catchRate: 0.7 });
|
|
70
|
+
move("thorn_curl", { element: "root", power: 0, effect: "guard" });
|
|
71
|
+
Then put it in a grass pool, and its label in names.json weapons.
|
|
72
|
+
|
|
73
|
+
### Moves
|
|
74
|
+
move("tide_crash", { element: "tide", power: 70, accuracy: 0.8 });
|
|
75
|
+
effect "drain" heals half the damage dealt; "heal" (power 0) restores
|
|
76
|
+
amount of max health; "guard" halves the next hit taken.
|
|
77
|
+
|
|
78
|
+
### The map
|
|
79
|
+
marsh({ size: [30, 20], camp: [2, 10], water: [[11, 0, 2, 6]], blocked: [[19, 0, 1, 8]],
|
|
80
|
+
grass: [{ at: [4, 2, 6, 5], rate: 0.22, pool: [{ creature: "sporecap", weight: 3, level: [2, 3] }] }], ... });
|
|
81
|
+
Put weak creatures near camp and stronger ones toward the warden. Leave a
|
|
82
|
+
walkable way from camp to every grass zone and to the warden.
|
|
83
|
+
|
|
84
|
+
### Harder or easier
|
|
85
|
+
Raise pool levels, the warden's level or hp, or lower snares and grass
|
|
86
|
+
rates. "Longer": a later dusk. "More catching": higher catchRate and snares.
|
|
87
|
+
|
|
88
|
+
### Add an earn rule
|
|
89
|
+
earn.rule("ten_wins", { when: "creature.defeated", n: 10, pay: 15, oncePer: "run" });
|
|
90
|
+
Events: creature.caught, creature.defeated, creature.levelup, level.five
|
|
91
|
+
and level.ten (each credits the keeper), warden.defeated (everyone who hurt
|
|
92
|
+
it), run.won (everyone in the marsh). Each rule pays once per run per player,
|
|
93
|
+
in coins; the room server is the referee.
|
|
94
|
+
|
|
95
|
+
### The buy-in run (required)
|
|
96
|
+
earn.stake({ min: 20, max: 200, multiple: 2 });
|
|
97
|
+
One top-level line; a game without it does not build. "stake" is reserved.
|
|
98
|
+
|
|
99
|
+
### Coins: earn and the store
|
|
100
|
+
Every game has coins: 1 coin = 1 of the game's own token, shown under the
|
|
101
|
+
token's own name, symbol and logo.
|
|
102
|
+
store.sku("thornbark", { coins: 40 }); // one level of the "thornbark" upgrade for the run
|
|
103
|
+
store.sku("skin_amber", { coins: 500, grants: { cosmetic: 2 } }); // a keeper's cloak, owned for good
|
|
104
|
+
store.sku("lucky_reed", { coins: 150, grants: { gear: "thornbark", loseBelow: 0.25 } }); // losable gear
|
|
105
|
+
Keep the SKUs the client is wired to (thornbark, mossmail, spare_snares,
|
|
106
|
+
fen_tonic, skin_lichen, skin_amber, lucky_reed) at their prices.
|
|
107
|
+
|
|
108
|
+
## Test
|
|
109
|
+
|
|
110
|
+
engine.test.run({ ticks: 30 * 240, bots: 4 }) plays the marsh with 4
|
|
111
|
+
keepers and reports battles won plus catches (kills), 1 + catches (wave),
|
|
112
|
+
the highest level, team wipes (deaths), earned by rule, softlock, ms per
|
|
113
|
+
tick, and the genre's own checks under rules.checks.
|
package/package.json
CHANGED
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@netforge/template-collector",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MOSSBOUND: the monster collector template of the NetNet game runtime, a shared marsh where keepers catch fen spirits, battle turn by turn with a team of three and bring down the warden before dusk. 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-collector"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://github.com/netnetdev/net-forge/tree/main/packages/template-collector#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/tabletop": "0.1.0",
|
|
37
|
+
"@netforge/engine": "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,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MOSSBOUND: the monster collector template (play style: monster-collector RPG).
|
|
3
|
+
*
|
|
4
|
+
* Fen spirits live in the tall grass of a flooded marsh. Each keeper picks
|
|
5
|
+
* one of three starters at camp, walks the reeds, meets wild spirits in the
|
|
6
|
+
* grass and battles them turn by turn with a team of three, snaring the
|
|
7
|
+
* ones worth keeping in a reed lantern. At the far end of the marsh the
|
|
8
|
+
* Fenwarden waits: every keeper in the marsh wears down the same warden,
|
|
9
|
+
* and it must fall before dusk.
|
|
10
|
+
*
|
|
11
|
+
* This file is the whole game as the model writes it: declarations only,
|
|
12
|
+
* against the typed API in @netforge/engine. Walking, encounters,
|
|
13
|
+
* battles, catching, levels, netcode, renderer and referee are the
|
|
14
|
+
* template's machinery.
|
|
15
|
+
*/
|
|
16
|
+
import { creature, earn, game, marsh, move, store, upgrade } from "@netforge/engine";
|
|
17
|
+
|
|
18
|
+
export default game({
|
|
19
|
+
template: "collector",
|
|
20
|
+
style: "lowpoly",
|
|
21
|
+
places: { fen: { chunks: "places/fen", spawn: [0, 0, 0] } },
|
|
22
|
+
players: { prefab: "style.lowpoly.hooded_adventurer", max: 8 },
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// The marsh: 30 x 20 tiles of 2 m. Camp in the west, the warden's mound in the east, water and rocks between.
|
|
26
|
+
marsh({
|
|
27
|
+
size: [30, 20],
|
|
28
|
+
camp: [2, 10],
|
|
29
|
+
starters: ["mossling", "cindertail", "puddlewump"],
|
|
30
|
+
starterLevel: 4,
|
|
31
|
+
water: [
|
|
32
|
+
[11, 0, 2, 6],
|
|
33
|
+
[11, 14, 2, 6],
|
|
34
|
+
[24, 0, 3, 3],
|
|
35
|
+
],
|
|
36
|
+
blocked: [
|
|
37
|
+
[19, 0, 1, 8],
|
|
38
|
+
[19, 12, 1, 8],
|
|
39
|
+
[8, 9, 1, 2],
|
|
40
|
+
[15, 2, 1, 1],
|
|
41
|
+
[16, 16, 1, 1],
|
|
42
|
+
],
|
|
43
|
+
grass: [
|
|
44
|
+
{ at: [4, 2, 6, 5], rate: 0.22, pool: [{ creature: "sporecap", weight: 3, level: [2, 3] }, { creature: "voltmoth", weight: 2, level: [2, 3] }, { creature: "reedcrab", weight: 1, level: [2, 4] }] },
|
|
45
|
+
{ at: [4, 13, 6, 5], rate: 0.22, pool: [{ creature: "reedcrab", weight: 2, level: [2, 4] }, { creature: "sporecap", weight: 1, level: [3, 4] }, { creature: "pebblewing", weight: 1, level: [3, 4] }] },
|
|
46
|
+
{ at: [14, 7, 4, 6], rate: 0.2, pool: [{ creature: "pebblewing", weight: 2, level: [4, 6] }, { creature: "voltmoth", weight: 2, level: [4, 6] }, { creature: "bogling", weight: 1, level: [5, 6] }] },
|
|
47
|
+
{ at: [21, 4, 4, 12], rate: 0.18, pool: [{ creature: "bogling", weight: 2, level: [6, 8] }, { creature: "pebblewing", weight: 1, level: [6, 8] }, { creature: "reedcrab", weight: 1, level: [6, 8] }] },
|
|
48
|
+
],
|
|
49
|
+
warden: { creature: "fenwarden", at: [27, 10], level: 9, hp: 700 },
|
|
50
|
+
dusk: 600,
|
|
51
|
+
snares: 6,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Creatures. Base stats at level 1 (each grows 12% a level); catchRate scales every snare.
|
|
55
|
+
creature("mossling", { model: "style.lowpoly.frog", element: "root", hp: 46, atk: 48, def: 50, spd: 44, moves: ["leaf_lash", "sap_drink"] });
|
|
56
|
+
creature("cindertail", { model: "style.lowpoly.fox", element: "spark", hp: 40, atk: 54, def: 42, spd: 58, moves: ["ember_nip", "static_dash"] });
|
|
57
|
+
creature("puddlewump", { model: "style.lowpoly.slime_drop", element: "tide", hp: 52, atk: 44, def: 52, spd: 40, moves: ["splash_jab", "bubble_ward"] });
|
|
58
|
+
creature("sporecap", { model: "style.lowpoly.mushroom_imp", element: "root", hp: 42, atk: 46, def: 46, spd: 38, moves: ["spore_puff", "leaf_lash"], catchRate: 0.9 });
|
|
59
|
+
creature("voltmoth", { model: "style.lowpoly.bee", element: "spark", hp: 36, atk: 50, def: 38, spd: 64, moves: ["static_dash", "ember_nip"], catchRate: 0.85 });
|
|
60
|
+
creature("reedcrab", { model: "style.lowpoly.crab", element: "tide", hp: 48, atk: 50, def: 60, spd: 36, moves: ["splash_jab", "shell_up"], catchRate: 0.8 });
|
|
61
|
+
creature("pebblewing", { model: "style.lowpoly.pebble_flyer", element: "stone", hp: 44, atk: 48, def: 56, spd: 50, moves: ["gravel_toss", "shell_up"], catchRate: 0.7 });
|
|
62
|
+
creature("bogling", { model: "style.lowpoly.blob_orc", element: "stone", hp: 60, atk: 52, def: 54, spd: 30, moves: ["gravel_toss", "mire_slam"], catchRate: 0.6, size: 1.3 });
|
|
63
|
+
creature("fenwarden", { model: "style.lowpoly.mushroom_king", element: "stone", hp: 120, atk: 66, def: 64, spd: 40, moves: ["mire_slam", "gravel_toss", "sap_drink"], boss: true, size: 3.4 });
|
|
64
|
+
|
|
65
|
+
// Moves. power 40 is a plain hit; drain heals half the damage, heal restores a share of max health, guard halves the next hit.
|
|
66
|
+
move("leaf_lash", { element: "root", power: 40 });
|
|
67
|
+
move("sap_drink", { element: "root", power: 30, effect: "drain" });
|
|
68
|
+
move("spore_puff", { element: "root", power: 45, accuracy: 0.9 });
|
|
69
|
+
move("ember_nip", { element: "spark", power: 40 });
|
|
70
|
+
move("static_dash", { element: "spark", power: 55, accuracy: 0.85 });
|
|
71
|
+
move("splash_jab", { element: "tide", power: 40 });
|
|
72
|
+
move("bubble_ward", { element: "tide", power: 0, effect: "heal", amount: 0.3 });
|
|
73
|
+
move("gravel_toss", { element: "stone", power: 40 });
|
|
74
|
+
move("shell_up", { element: "stone", power: 0, effect: "guard" });
|
|
75
|
+
move("mire_slam", { element: "stone", power: 70, accuracy: 0.8 });
|
|
76
|
+
|
|
77
|
+
// Store upgrades: one level each, for the rest of the run, on the buyer's own team.
|
|
78
|
+
upgrade("thornbark", { stat: "might", step: 0.15, maxLevel: 1 });
|
|
79
|
+
upgrade("mossmail", { stat: "armor", step: 0.15, maxLevel: 1 });
|
|
80
|
+
upgrade("spare_snares", { stat: "amount", step: 3, maxLevel: 1 });
|
|
81
|
+
upgrade("fen_tonic", { stat: "regen", step: 0.03, maxLevel: 1 });
|
|
82
|
+
|
|
83
|
+
// Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per run each. Values are PLACEHOLDER.
|
|
84
|
+
earn.rule("first_snare", { when: "creature.caught", n: 1, pay: 5, oncePer: "run" });
|
|
85
|
+
earn.rule("full_lantern", { when: "creature.caught", n: 3, pay: 20, oncePer: "run" });
|
|
86
|
+
earn.rule("warden_breaker", { when: "warden.defeated", n: 1, pay: 50, oncePer: "run" });
|
|
87
|
+
earn.rule("seasoned", { when: "level.five", n: 1, pay: 5, oncePer: "run" });
|
|
88
|
+
// Buy-in run (the mandatory sink loop): stake coins at run start to earn above free play.
|
|
89
|
+
// A staked run also pays up to stake x multiple, by the share of this run's earn the player makes. Values are PLACEHOLDER.
|
|
90
|
+
earn.stake({ min: 20, max: 200, multiple: 2 });
|
|
91
|
+
|
|
92
|
+
// Store (the Kit), priced in coins: four run upgrades and two keeper finishes owned for good (kept in the profile).
|
|
93
|
+
store.sku("thornbark", { coins: 40 });
|
|
94
|
+
store.sku("mossmail", { coins: 40 });
|
|
95
|
+
store.sku("spare_snares", { coins: 60 });
|
|
96
|
+
store.sku("fen_tonic", { coins: 50 });
|
|
97
|
+
store.sku("skin_lichen", { coins: 300, grants: { cosmetic: 1 } });
|
|
98
|
+
store.sku("skin_amber", { coins: 500, grants: { cosmetic: 2 } });
|
|
99
|
+
// Losable gear: owned for good until lost. It brings one level of its upgrade into every staked run,
|
|
100
|
+
// and is lost when that run makes less than loseBelow of its most earn. Values are PLACEHOLDER.
|
|
101
|
+
store.sku("lucky_reed", { coins: 150, grants: { gear: "thornbark", 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, collectorGenre } from "@netforge/engine/rules";
|
|
8
|
+
import type { RulesInput, RulesWorld, CollectorState } 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 = "mossbound-0.1.0";
|
|
14
|
+
export const GAME_NAME = "mossbound";
|
|
15
|
+
/** Room template (as the Net Forge room server names it) and place used by the client and the bots. */
|
|
16
|
+
export const TEMPLATE = "collector";
|
|
17
|
+
export const PLACE = "fen";
|
|
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 collectorGenre> | null = null;
|
|
26
|
+
|
|
27
|
+
/** The game's manifest (every declaration). */
|
|
28
|
+
export function collectorManifest(): GameManifest {
|
|
29
|
+
return MANIFEST;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The compiled collector genre (marsh, creatures, moves). */
|
|
33
|
+
export function collectorGame(): ReturnType<typeof collectorGenre> {
|
|
34
|
+
genre ??= collectorGenre(MANIFEST);
|
|
35
|
+
return genre;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The SimAdapter the room server and the client both run. */
|
|
39
|
+
export function collectorAdapter(): SimAdapter<RulesWorld<CollectorState>, RulesInput> {
|
|
40
|
+
return rulesAdapter(collectorGame(), 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_lichen: 1, skin_amber: 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. MOSSBOUND 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 = { thornbark: 40, mossmail: 40, spare_snares: 60, fen_tonic: 50, skin_lichen: 300, skin_amber: 500, lucky_reed: 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 = ["thornbark", "mossmail", "spare_snares", "fen_tonic"] as const;
|
|
17
|
+
/** Cosmetic finishes: bought once, owned for good. */
|
|
18
|
+
export const COSMETIC_SKUS = ["skin_lichen", "skin_amber"] as const;
|
|
19
|
+
|
|
20
|
+
/** The template's own coin, as its token metadata names it until a creator renames it. */
|
|
21
|
+
export const MOSSBOUND_COIN = {
|
|
22
|
+
name: "Moss",
|
|
23
|
+
symbol: "MOSS",
|
|
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='%237bc96f'/%3E%3Cpath d='M16 6c6 4 8 10 4 16-2 3-6 4-8 2-4-4-2-12 4-18z' fill='%23234a1e'/%3E%3Cpath d='M15 26c0-6 1-10 4-14' stroke='%237bc96f' stroke-width='1.5' fill='none'/%3E%3C/svg%3E",
|
|
25
|
+
} as const;
|