@netforge/template-platformer 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +65 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +34 -0
- package/README.md +74 -2
- package/client/audio.ts +34 -0
- package/client/decor.ts +22 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +217 -0
- package/client/index.html +181 -0
- package/client/input.ts +119 -0
- package/client/main.ts +468 -0
- package/client/names.ts +41 -0
- package/client/scene.ts +711 -0
- package/client/stake.ts +143 -0
- package/llms-full.txt +321 -0
- package/llms.txt +152 -0
- package/package.json +79 -3
- package/src/game.ts +154 -0
- package/src/manifest.ts +49 -0
- package/src/store.ts +109 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Extending SKYLEDGE
|
|
2
|
+
|
|
3
|
+
SKYLEDGE is the platformer template of the Net Forge game engine. This file tells a coding agent how to change the game safely. `llms.txt` in this package has the full recipe book for this genre, and `llms-full.txt` adds the whole `src/game.ts` as a worked example.
|
|
4
|
+
|
|
5
|
+
## The one file to edit
|
|
6
|
+
|
|
7
|
+
Change the game by editing `src/game.ts` only. It holds declarations, not engine code. src/names.json holds the title and every label players read (upgrades, earn rules, store items). Everything else (the client, the manifest, the store hooks, the simulation, the netcode) is template machinery.
|
|
8
|
+
|
|
9
|
+
## Import only @netforge/engine
|
|
10
|
+
|
|
11
|
+
`src/game.ts` imports from `@netforge/engine` and nothing else. The template's game imports:
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
import { earn, game, store, system, upgrade } from "@netforge/engine";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Also available for this genre: `economy`. `earn` has `earn.rule(...)` and `earn.stake(...)`; `store` has `store.sku(...)`. `system(id, ctx => ...)` gets the systems API described in `llms.txt`. The renderer, input, netcode and physics live elsewhere and are not reachable from game code.
|
|
18
|
+
|
|
19
|
+
## Rules the build enforces
|
|
20
|
+
|
|
21
|
+
- Ids are lower_snake_case.
|
|
22
|
+
- Every game declares at least one `earn.rule(...)` (mandatory earn) and one `earn.stake(...)` (the buy-in run), on one top-level line. A game without either does not build.
|
|
23
|
+
- Asset ids are library ids such as `style.lowpoly.turret`, never URLs or file paths, and must exist in the asset library.
|
|
24
|
+
- `game({ template: "platformer", style, levels: [...] })` with 1 to 8 levels.
|
|
25
|
+
- No `weapon()`, `director()`, `keeper()` or enemy prefabs: the challenge is the course.
|
|
26
|
+
- Every course must be finishable: the route planner runs from the start to the flag on every level, and a level with no route fails the build.
|
|
27
|
+
- Upgrades raise one of: speed (run speed), amount (extra air jumps), magnet (coin reach, m), maxHealth (shield hits per life), haste (dash cooldown).
|
|
28
|
+
- 4 players plus seat bots must all reach the flag, and an earn rule must fire, in a 4-minute playtest.
|
|
29
|
+
- `game({ style })` is the art style the creator picked (lowpoly, toon, ps1, pixel, voxel or handdrawn). Keep it.
|
|
30
|
+
- Keep the store SKUs the client is wired to, at their prices: feather, sprint, shield, skin_sunset and skin_frost (with the outfits' grants: sunset 1, frost 2). "stake" is reserved and is never a SKU id.
|
|
31
|
+
|
|
32
|
+
## Validate
|
|
33
|
+
|
|
34
|
+
- With the MCP server (`npx -y @netforge/mcp`, bin `netforge-mcp`): call `netforge_validate_game` on the game. It returns one line per problem.
|
|
35
|
+
- In this package: `npx vitest run` runs `test/harness.test.ts`, `test/host.test.ts`, `test/race.test.ts`, `test/store.test.ts`. A bad declaration throws `GameDefinitionError` with a line you can act on.
|
|
36
|
+
|
|
37
|
+
## Playtest
|
|
38
|
+
|
|
39
|
+
- With the MCP server: call `netforge_run_playtest`.
|
|
40
|
+
- In code: `import { engine } from "@netforge/engine"` and run `engine.test.run({ ticks: 60 * 240, bots: 4, assert: [] })`. It reports rounds (wave), runs.won (rounds every playtest climber finished), scorers, deaths (falls), earned by rule, softlock, ms per tick and fun.routes (the planner's route per course).
|
|
41
|
+
|
|
42
|
+
## Play locally
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
mkdir -p my-game/src && cd my-game
|
|
46
|
+
npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-platformer
|
|
47
|
+
cp node_modules/@netforge/template-platformer/src/game.ts src/
|
|
48
|
+
echo '{"template":"platformer"}' > netnet.json
|
|
49
|
+
npx kit-play # opens http://127.0.0.1:4663
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
The MCP tool `netforge_play_locally` does the same. Coins are play money; nothing pays out and no chain or wallet is involved. Edit `src/game.ts` and run it again to try a change.
|
|
53
|
+
|
|
54
|
+
## What not to do
|
|
55
|
+
|
|
56
|
+
- Do not write renderers, netcode, physics or bot AI. The template already has them.
|
|
57
|
+
- Do not use asset URLs or file paths. Use library ids.
|
|
58
|
+
- Do not import anything other than `@netforge/engine` in `src/game.ts`.
|
|
59
|
+
- Do not edit `client/`, `src/manifest.ts` or `src/store.ts`.
|
|
60
|
+
- Do not rename or reprice the store SKUs the client is wired to.
|
|
61
|
+
- Do not add weapons, enemies or a director; build challenge into the course instead.
|
|
62
|
+
|
|
63
|
+
## Docs
|
|
64
|
+
|
|
65
|
+
https://forge.netnet.capital/docs/templates/platformer
|
package/CLAUDE.md
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# CLAUDE.md
|
|
2
|
+
|
|
3
|
+
Read AGENTS.md before changing this game.
|
|
4
|
+
|
|
5
|
+
The five hard rules:
|
|
6
|
+
|
|
7
|
+
1. Edit only `src/game.ts`.
|
|
8
|
+
2. Import only `@netforge/engine`.
|
|
9
|
+
3. Ids are lower_snake_case.
|
|
10
|
+
4. Declare at least one `earn.rule(...)` and exactly one `earn.stake(...)`.
|
|
11
|
+
5. No renderers, netcode, physics or asset URLs.
|
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,34 @@
|
|
|
1
|
+
@netforge/template-platformer (SKYLEDGE)
|
|
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 Quaternius (via Poly
|
|
10
|
+
Pizza: the climbers and the coin) and Kenney (Nature Kit: trees, rocks,
|
|
11
|
+
bushes), each pack listed with its
|
|
12
|
+
source URL and licence verification in that package's NOTICE and
|
|
13
|
+
manifest/packs.json. The files are fetched and built, never committed
|
|
14
|
+
here; the build copies only the models this game uses.
|
|
15
|
+
- Blocks, ledges, thorns, springs, flags and the fallback climber
|
|
16
|
+
(client/scene.ts) are generated from three.js primitives; all textures
|
|
17
|
+
are drawn on a canvas at load. Sounds are the library's CC0 samples with
|
|
18
|
+
WebAudio-synthesized fallbacks (client/audio.ts).
|
|
19
|
+
|
|
20
|
+
Dependencies (unmodified, via npm)
|
|
21
|
+
- three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
|
|
22
|
+
- @netforge/engine and @netforge/engine-sim (this repository);
|
|
23
|
+
see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
|
|
24
|
+
zod MIT).
|
|
25
|
+
|
|
26
|
+
Patterns (ideas only)
|
|
27
|
+
- The platformer genre's general mechanics (run, jump, checkpoints, coins,
|
|
28
|
+
springs, moving platforms, a flag at the end, racing to it). Mechanics,
|
|
29
|
+
not expression; no level, character or text was copied from any game.
|
|
30
|
+
- Jump feel (coyote time, jump buffering, variable jump height, apex hang,
|
|
31
|
+
fall gravity, corner correction) as described in public game-feel talks
|
|
32
|
+
and articles (e.g. "Math for Game Programmers: Building a Better Jump",
|
|
33
|
+
GDC 2016; Maddy Thorson's public notes on Celeste's forgiveness
|
|
34
|
+
mechanics). Implemented from scratch in engine-sim src/platform.ts.
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @netforge/template-platformer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A multiplayer platformer game template for Net Forge: a side-view race to the flag for up to 8 players over cliffside courses (SKYLEDGE).
|
|
4
|
+
|
|
5
|
+
Climbers run, jump (hold for height, tap for a hop), dash across gaps, ride moving platforms, bounce off springs and stay off the thorns. Each climber collects their own coins. A round is one course, and the next round runs the next course. Seat bots fill empty places, so one person alone still has a race.
|
|
6
|
+
|
|
7
|
+
The simulation is the engine's deterministic platform mode: swept box movement on a 1/1024 m grid at 60 Hz, so a predicting client replays its own climber bit for bit. Climbers are ghosts to each other and coins are per climber, so nothing another player does can make your prediction wrong.
|
|
8
|
+
|
|
9
|
+
Jump feel: coyote time (0.1 s), a jump buffer (0.13 s), variable jump height, a lighter apex while Jump is held, a heavier fall, ceiling corner nudges and an air dash.
|
|
10
|
+
|
|
11
|
+
The race runner owns rounds (countdown, race, results, next course). It pays `player.finished` for your own finish, and `race.won` and `race.podium` only with another person in the round. Seat bots run routes found by the route planner and never earn. Every course must have a planner route to the flag, or the build fails.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @netforge/template-platformer
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
You normally do not import this package yourself. `kit play` runs it on your own computer, and the Net Forge MCP server and studio copy its `src/game.ts` as the starting point for a new game.
|
|
20
|
+
|
|
21
|
+
## Play it locally
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
mkdir -p my-game/src && cd my-game
|
|
25
|
+
npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-platformer
|
|
26
|
+
cp node_modules/@netforge/template-platformer/src/game.ts src/
|
|
27
|
+
echo '{"template":"platformer"}' > netnet.json
|
|
28
|
+
npx kit-play # opens http://127.0.0.1:4663
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Coins are play money and nothing pays out. Real-token earning, stakes and claims run only on the hosted Net Forge server.
|
|
32
|
+
|
|
33
|
+
## Change the game
|
|
34
|
+
|
|
35
|
+
Courses run three minutes and three seat bots fill the room. Field more bots and give each course a little longer:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
players: { prefab: "style.lowpoly.rogue", max: 8, bots: 5 },
|
|
39
|
+
round: { seconds: 210 },
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Save `src/game.ts` and run `npx kit-play` again.
|
|
43
|
+
|
|
44
|
+
## Files
|
|
45
|
+
|
|
46
|
+
| Path | What |
|
|
47
|
+
|---|---|
|
|
48
|
+
| `src/game.ts` | The game, declarations only: three courses, three round upgrades, earn rules, stake, store. |
|
|
49
|
+
| `src/manifest.ts` | Loads and compiles the game; `platformerAdapter()` and `platformerGame()` for the room host and the client. |
|
|
50
|
+
| `src/store.ts` | Store hooks on the Kit's names: three round upgrades (40, 40, 60 coins), two outfits (300, 500) and the shield charm gear (120). |
|
|
51
|
+
| `client/` | The browser client: a side-view three.js scene in every art style, HUD, input (keys, gamepad, touch), game feel. |
|
|
52
|
+
| `scripts/plan.ts` | The route planner run on every course (how long a good run takes). |
|
|
53
|
+
| `test/` | Playtest bots through `engine.test.run`, the race rules, prediction, the local host and the store. |
|
|
54
|
+
| `llms.txt` | How to change the game, for agents. |
|
|
55
|
+
|
|
56
|
+
## Controls
|
|
57
|
+
|
|
58
|
+
Keys: A D or arrows run, Space W or Up jump, Shift dash, Down plus Jump drops through a ledge, R or Enter readies the next course. The backquote key shows stats.
|
|
59
|
+
|
|
60
|
+
## For AI agents
|
|
61
|
+
|
|
62
|
+
Read `AGENTS.md` and `llms.txt` in this package before changing the game. `llms-full.txt` adds the whole `src/game.ts` as a worked example.
|
|
63
|
+
|
|
64
|
+
Agents can also drive this template through the Net Forge MCP server: run `npx -y @netforge/mcp` (package `@netforge/mcp`, bin `netforge-mcp`). Its tools are `netforge_list_templates`, `netforge_create_game`, `netforge_explain_api`, `netforge_search_docs`, `netforge_validate_game`, `netforge_run_playtest` and `netforge_play_locally`.
|
|
65
|
+
|
|
66
|
+
To set up a coding agent in a game project, run `npx netforge setup agent`. It writes AGENTS.md, CLAUDE.md, Cursor rules and `.mcp.json` into the project. In Claude Code you can also add the server directly with `claude mcp add netforge -- npx -y @netforge/mcp`.
|
|
67
|
+
|
|
68
|
+
## Docs
|
|
69
|
+
|
|
70
|
+
- https://forge.netnet.capital/docs/templates/platformer
|
|
71
|
+
- `llms.txt` and `llms-full.txt` in this package (also exported as `@netforge/template-platformer/llms.txt` and `@netforge/template-platformer/llms-full.txt`)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT. See `LICENSE` and `NOTICE`.
|
package/client/audio.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKYLEDGE's sound cues on the engine's FeelAudio. Coins, landings, dashes
|
|
3
|
+
* and falls play the asset library's CC0 samples (PLATFORM_SAMPLES, loaded
|
|
4
|
+
* by the engine's SoundPlayer with a music loop); jumps stay synthesized so
|
|
5
|
+
* a jump and an air jump are told apart by pitch. Every cue keeps a synth
|
|
6
|
+
* recipe as the fallback when a sample cannot load.
|
|
7
|
+
*/
|
|
8
|
+
import type { FeelAudio } from "@netforge/engine/runtime";
|
|
9
|
+
|
|
10
|
+
/** Cue name -> library sound id (soundBank's `cues` overrides). */
|
|
11
|
+
export const PLATFORM_SAMPLES: Readonly<Record<string, string>> = {
|
|
12
|
+
coin: "audio.sfx.coin",
|
|
13
|
+
land: "audio.sfx.land",
|
|
14
|
+
dash: "audio.sfx.dash",
|
|
15
|
+
fall: "audio.sfx.downed",
|
|
16
|
+
checkpoint: "audio.sfx.confirm",
|
|
17
|
+
finish: "audio.sfx.level_up",
|
|
18
|
+
shield: "audio.sfx.hit",
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function definePlatformCues(a: FeelAudio): void {
|
|
22
|
+
a.define("jump", { wave: "square", f0: 320, f1: 720, ms: 110, gain: 0.32 }, { minGapMs: 50, voices: 2, pitchVar: 0.03 });
|
|
23
|
+
a.define("airjump", { wave: "square", f0: 520, f1: 1100, ms: 120, gain: 0.32 }, { minGapMs: 50, voices: 2, pitchVar: 0.03 });
|
|
24
|
+
a.define("coin", { wave: "triangle", f0: 1320, f1: 1980, ms: 90, gain: 0.45 }, { minGapMs: 30, voices: 3, pitchVar: 0.06 });
|
|
25
|
+
a.define("land", { wave: "triangle", f0: 160, f1: 60, ms: 90, gain: 0.5 }, { minGapMs: 80, voices: 1 });
|
|
26
|
+
a.define("dash", { wave: "sawtooth", f0: 900, f1: 200, ms: 140, gain: 0.35 }, { minGapMs: 80, voices: 1 });
|
|
27
|
+
a.define("spring", { wave: "sine", f0: 200, f1: 900, ms: 260, gain: 0.55 }, { minGapMs: 120, voices: 1 });
|
|
28
|
+
a.define("fall", { wave: "sawtooth", f0: 420, f1: 60, ms: 520, gain: 0.6 }, { minGapMs: 300, voices: 1 });
|
|
29
|
+
a.define("checkpoint", { wave: "triangle", f0: 660, f1: 990, ms: 160, gain: 0.6 }, { minGapMs: 120, voices: 1, pitchVar: 0 });
|
|
30
|
+
a.define("finish", { wave: "triangle", f0: 523, f1: 1046, ms: 420, gain: 0.7 }, { minGapMs: 400, voices: 1, pitchVar: 0 });
|
|
31
|
+
a.define("shield", { wave: "square", f0: 300, f1: 200, ms: 120, gain: 0.45 }, { minGapMs: 100, voices: 1 });
|
|
32
|
+
a.define("tick", { wave: "square", f0: 880, f1: 880, ms: 70, gain: 0.35 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
|
|
33
|
+
a.define("go", { wave: "square", f0: 1320, f1: 1320, ms: 200, gain: 0.4 }, { minGapMs: 200, voices: 1, pitchVar: 0 });
|
|
34
|
+
}
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the SKYLEDGE scene draws on its own (template machinery,
|
|
3
|
+
* not game content). The build copies these with the models game.ts uses,
|
|
4
|
+
* in every art style.
|
|
5
|
+
*/
|
|
6
|
+
/** Seat bots: a different climber from the players' model. */
|
|
7
|
+
export const BOT_MODEL = "style.lowpoly.mini_female_c";
|
|
8
|
+
/** Every course's coins. */
|
|
9
|
+
export const COIN_MODEL = "style.lowpoly.coin";
|
|
10
|
+
/** Climbers are drawn this tall, m (the body box is 1.4 m). */
|
|
11
|
+
export const PLAYER_HEIGHT = 1.45;
|
|
12
|
+
|
|
13
|
+
/** Scenery on the cliff tops behind the course: [library id, height m, how far back it may stand m]. */
|
|
14
|
+
export const BACKDROP: readonly (readonly [string, number, number])[] = [
|
|
15
|
+
["style.lowpoly.tree_pine", 4.2, 2.5],
|
|
16
|
+
["style.lowpoly.tree_oak", 3.6, 2.5],
|
|
17
|
+
["style.lowpoly.rock_small", 0.7, 2],
|
|
18
|
+
["style.lowpoly.bush", 0.9, 1.5],
|
|
19
|
+
["style.lowpoly.mushroom_red", 0.5, 1],
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
export const SCENE_MODELS: readonly string[] = [BOT_MODEL, COIN_MODEL, ...BACKDROP.map(([id]) => id)];
|
package/client/host.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Net Forge as host. Framed by Net Forge, the page URL carries
|
|
3
|
+
* the Kit flags and the host posts one message; played on its own, none of
|
|
4
|
+
* this applies and the game behaves exactly as before.
|
|
5
|
+
*
|
|
6
|
+
* ?host=netnet the host draws Invite and sign-in; the game hides its own
|
|
7
|
+
* ?coin=TICKER the coin the HUD names (2 to 6 capitals or digits)
|
|
8
|
+
* ?title=NAME the game's own name for the wordmark and the tab title
|
|
9
|
+
* ?player=guest no wallet is connected: play pays nothing, and the HUD
|
|
10
|
+
* says "not earning" instead of a balance
|
|
11
|
+
* ?player=play the token has not launched: coins are play money
|
|
12
|
+
*
|
|
13
|
+
* The player flag changes while the game runs (a wallet connects), so the
|
|
14
|
+
* host also posts { kind: "netnet-kit", type: "player", mode }. Only the
|
|
15
|
+
* parent window is listened to, and only when the URL says it is the host.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
export type PlayerMode = "earning" | "guest" | "play";
|
|
19
|
+
|
|
20
|
+
export interface HostFlags {
|
|
21
|
+
/** Framed by Net Forge. */
|
|
22
|
+
readonly framed: boolean;
|
|
23
|
+
/** The coin's ticker, when the host names one. */
|
|
24
|
+
readonly coin: string | null;
|
|
25
|
+
/** The game's own name, when the host names one. */
|
|
26
|
+
readonly title: string | null;
|
|
27
|
+
/** What play pays the person at the screen. Standalone play is "earning" (the game's own kit decides). */
|
|
28
|
+
readonly player: PlayerMode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const TICKER_RE = /^[A-Z0-9]{2,6}$/;
|
|
32
|
+
|
|
33
|
+
export function hostFlags(params: URLSearchParams): HostFlags {
|
|
34
|
+
const framed = params.get("host") === "netnet";
|
|
35
|
+
const coin = params.get("coin");
|
|
36
|
+
// eslint-disable-next-line no-control-regex
|
|
37
|
+
const title = (params.get("title") ?? "").replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, 40);
|
|
38
|
+
const player = params.get("player");
|
|
39
|
+
return {
|
|
40
|
+
framed,
|
|
41
|
+
coin: coin && TICKER_RE.test(coin) ? coin : null,
|
|
42
|
+
title: title || null,
|
|
43
|
+
player: framed && (player === "guest" || player === "play") ? player : "earning",
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The player mode the host named on the URL, or null when it named none (a
|
|
49
|
+
* connected player on a live game gets no flag, only the posted message) or
|
|
50
|
+
* the page is not framed by Net Forge.
|
|
51
|
+
*/
|
|
52
|
+
export function namedPlayer(params: URLSearchParams): PlayerMode | null {
|
|
53
|
+
if (params.get("host") !== "netnet") return null;
|
|
54
|
+
const player = params.get("player");
|
|
55
|
+
return player === "earning" || player === "guest" || player === "play" ? player : null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The store's note under its prices, from one source of truth. Once the host
|
|
60
|
+
* has named the player mode (on the URL or in a message), that mode decides,
|
|
61
|
+
* exactly as it does for the coin box: a live game never calls its coins play
|
|
62
|
+
* money. Standalone play, or a host that names no mode (the studio's drafts),
|
|
63
|
+
* keeps the room's own word (`welcome.earnLive`).
|
|
64
|
+
*/
|
|
65
|
+
export function storeNote(hostMode: PlayerMode | null, earnLive: boolean, symbol: string): string {
|
|
66
|
+
if (hostMode === "play") return "Play money until launch";
|
|
67
|
+
if (hostMode === "guest") return `Connect a wallet to earn ${symbol}`;
|
|
68
|
+
if (hostMode === "earning") return "";
|
|
69
|
+
return earnLive ? "" : "Play money until launch";
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** A host message, checked: anything else is ignored. */
|
|
73
|
+
export function playerFrom(data: unknown): PlayerMode | null {
|
|
74
|
+
if (typeof data !== "object" || data === null) return null;
|
|
75
|
+
const m = data as Record<string, unknown>;
|
|
76
|
+
if (m["kind"] !== "netnet-kit" || m["type"] !== "player") return null;
|
|
77
|
+
const mode = m["mode"];
|
|
78
|
+
return mode === "earning" || mode === "guest" || mode === "play" ? mode : null;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Calls `cb` with each player mode the host posts. Framed pages only. */
|
|
82
|
+
export function onHostPlayer(flags: HostFlags, cb: (mode: PlayerMode) => void): void {
|
|
83
|
+
if (!flags.framed || window.parent === window) return;
|
|
84
|
+
window.addEventListener("message", (e: MessageEvent<unknown>) => {
|
|
85
|
+
if (e.source !== window.parent) return;
|
|
86
|
+
const mode = playerFrom(e.data);
|
|
87
|
+
if (mode) cb(mode);
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tells the host the game is up, so it can post the current player mode. */
|
|
92
|
+
export function postReady(flags: HostFlags): void {
|
|
93
|
+
if (!flags.framed || window.parent === window) return;
|
|
94
|
+
// The host's origin is not known to an opaque-origin frame; the message is one public flag.
|
|
95
|
+
window.parent.postMessage({ kind: "netnet-kit", type: "ready" }, "*");
|
|
96
|
+
}
|
package/client/hud.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SKYLEDGE's HUD (plain DOM over the canvas): the course and everyone's
|
|
3
|
+
* place along it, your coins, the race clock and your place, the countdown,
|
|
4
|
+
* the standings, the results with what this round paid, the coin box and
|
|
5
|
+
* toasts. Text updates are throttled to ten a second.
|
|
6
|
+
*/
|
|
7
|
+
import { PF } from "@netforge/engine/game";
|
|
8
|
+
import type { CompiledGame, RaceInfo } from "@netforge/engine/game";
|
|
9
|
+
import { BOT_PLAYER_BASE, platformCoinCount } from "@netforge/engine-sim";
|
|
10
|
+
import { RULE_NAMES } from "./names.js";
|
|
11
|
+
import type { SceneEntity } from "./scene.js";
|
|
12
|
+
import { climberColor } from "./scene.js";
|
|
13
|
+
|
|
14
|
+
const $ = <T extends HTMLElement = HTMLElement>(id: string): T => {
|
|
15
|
+
const el = document.getElementById(id);
|
|
16
|
+
if (!el) throw new Error(`missing #${id}`);
|
|
17
|
+
return el as T;
|
|
18
|
+
};
|
|
19
|
+
const ord = (n: number) => `${n}${n % 10 === 1 && n % 100 !== 11 ? "st" : n % 10 === 2 && n % 100 !== 12 ? "nd" : n % 10 === 3 && n % 100 !== 13 ? "rd" : "th"}`;
|
|
20
|
+
const clockText = (s: number) => `${Math.floor(s / 60)}:${String(Math.floor(s % 60)).padStart(2, "0")}`;
|
|
21
|
+
const nameOf = (id: number, self: number) => (id === self ? "You" : id >= BOT_PLAYER_BASE ? `Climber ${id - BOT_PLAYER_BASE + 1}` : `Player ${id}`);
|
|
22
|
+
|
|
23
|
+
export interface HudActions {
|
|
24
|
+
invite(): void;
|
|
25
|
+
toggleStore(): void;
|
|
26
|
+
toggleSound(): boolean;
|
|
27
|
+
ready(): void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class Hud {
|
|
31
|
+
private symbol = "";
|
|
32
|
+
private mode: "earning" | "guest" | "play" = "earning";
|
|
33
|
+
private lastText = 0;
|
|
34
|
+
private toastTimer = 0;
|
|
35
|
+
private lastRun = -1;
|
|
36
|
+
private readonly earnedThisRun = new Map<string, number>();
|
|
37
|
+
private readonly markers = new Map<number, HTMLElement>();
|
|
38
|
+
private lastCount = -1;
|
|
39
|
+
private lastAnnounce = 0;
|
|
40
|
+
|
|
41
|
+
constructor(
|
|
42
|
+
private readonly game: CompiledGame,
|
|
43
|
+
private readonly self: number,
|
|
44
|
+
actions: HudActions,
|
|
45
|
+
) {
|
|
46
|
+
$("invite").addEventListener("click", () => actions.invite());
|
|
47
|
+
$("store-btn").addEventListener("click", () => actions.toggleStore());
|
|
48
|
+
$("store-close").addEventListener("click", () => actions.toggleStore());
|
|
49
|
+
$("ready").addEventListener("click", () => actions.ready());
|
|
50
|
+
$("sound").addEventListener("click", (e) => {
|
|
51
|
+
const muted = actions.toggleSound();
|
|
52
|
+
(e.currentTarget as HTMLElement).textContent = muted ? "Sound off" : "Sound on";
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
coin(name: string, symbol: string, logo: string | null): void {
|
|
57
|
+
this.symbol = symbol;
|
|
58
|
+
const img = $<HTMLImageElement>("coin-logo");
|
|
59
|
+
if (logo) {
|
|
60
|
+
img.src = logo;
|
|
61
|
+
img.alt = name;
|
|
62
|
+
img.hidden = false;
|
|
63
|
+
} else img.hidden = true;
|
|
64
|
+
$("coin-name").textContent = name;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
title(name: string): void {
|
|
68
|
+
$("title").textContent = name.toUpperCase();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** What play pays the person at the screen (guest: "not earning"; play money: a caption). */
|
|
72
|
+
player(mode: "earning" | "guest" | "play"): void {
|
|
73
|
+
this.mode = mode;
|
|
74
|
+
const box = $("coin-box");
|
|
75
|
+
box.classList.toggle("guest", mode === "guest");
|
|
76
|
+
box.classList.toggle("play", mode === "play");
|
|
77
|
+
box.title = mode === "guest" ? `Connect a wallet to earn ${this.symbol}` : mode === "play" ? `${this.symbol} has not launched: coins are play money` : "";
|
|
78
|
+
this.lastText = 0;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private balanceText(balance: number): string {
|
|
82
|
+
if (this.mode === "guest") return `${this.symbol} · not earning`;
|
|
83
|
+
return `${Math.floor(balance).toLocaleString("en-US")} ${this.symbol}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
earned(rule: string, units: number): void {
|
|
87
|
+
this.earnedThisRun.set(rule, (this.earnedThisRun.get(rule) ?? 0) + units);
|
|
88
|
+
const pay = this.game.manifest.earnRules.get(rule)?.pay ?? 0;
|
|
89
|
+
const name = RULE_NAMES[rule] ?? rule;
|
|
90
|
+
this.toast(this.mode === "guest" ? `${name} Connect to earn ${pay * units} ${this.symbol}` : `${name} +${pay * units} ${this.symbol}`);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
toast(text: string): void {
|
|
94
|
+
const t = $("toast");
|
|
95
|
+
t.textContent = text;
|
|
96
|
+
t.classList.add("show");
|
|
97
|
+
clearTimeout(this.toastTimer);
|
|
98
|
+
this.toastTimer = window.setTimeout(() => t.classList.remove("show"), 2400);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Every frame: `self` our predicted climber, `everyone` all climbers in view. */
|
|
102
|
+
update(now: number, self: SceneEntity | undefined, everyone: readonly SceneEntity[], run: (RaceInfo & { level?: string | null }) | null, balance: number, buffs: string): void {
|
|
103
|
+
if (run && run.run !== this.lastRun) {
|
|
104
|
+
this.lastRun = run.run;
|
|
105
|
+
this.earnedThisRun.clear();
|
|
106
|
+
}
|
|
107
|
+
// The countdown is big and immediate: update it every frame.
|
|
108
|
+
const count = $("count");
|
|
109
|
+
const counting = run?.phase === "countdown";
|
|
110
|
+
count.classList.toggle("show", counting);
|
|
111
|
+
if (counting && run && run.countdown !== this.lastCount) {
|
|
112
|
+
this.lastCount = run.countdown;
|
|
113
|
+
$("count-n").textContent = run.countdown > 0 ? String(run.countdown) : "GO";
|
|
114
|
+
$("count-sub").textContent = run.level ? run.level.toUpperCase() : "";
|
|
115
|
+
}
|
|
116
|
+
// A system's announce() (and the finish window) shows as a toast.
|
|
117
|
+
if (run?.announce && run.announce.id !== this.lastAnnounce) {
|
|
118
|
+
this.lastAnnounce = run.announce.id;
|
|
119
|
+
this.toast(run.announce.text);
|
|
120
|
+
}
|
|
121
|
+
if (now - this.lastText < 100) return;
|
|
122
|
+
this.lastText = now;
|
|
123
|
+
$("coin-balance").textContent = this.balanceText(balance);
|
|
124
|
+
$("buffs").textContent = buffs;
|
|
125
|
+
const platformer = this.game.platformer;
|
|
126
|
+
const course = run?.course ?? 0;
|
|
127
|
+
const level = platformer?.levels[course];
|
|
128
|
+
$("course").textContent = run?.level ?? platformer?.names[course] ?? "";
|
|
129
|
+
if (self && level) {
|
|
130
|
+
const n = platformCoinCount({ coinsLo: self.values[PF.coinsLo] ?? 0, coinsHi: self.values[PF.coinsHi] ?? 0 });
|
|
131
|
+
$("coin-count").textContent = String(n);
|
|
132
|
+
$("coin-total").textContent = `/ ${level.coins.length}`;
|
|
133
|
+
}
|
|
134
|
+
// Everyone's place along the course: a marker on the track bar.
|
|
135
|
+
const track = $("track");
|
|
136
|
+
const seen = new Set<number>();
|
|
137
|
+
for (const p of run?.players ?? []) {
|
|
138
|
+
seen.add(p.id);
|
|
139
|
+
let m = this.markers.get(p.id);
|
|
140
|
+
if (!m) {
|
|
141
|
+
m = document.createElement("i");
|
|
142
|
+
m.style.setProperty("--c", `#${climberColor(p.id).toString(16).padStart(6, "0")}`);
|
|
143
|
+
track.append(m);
|
|
144
|
+
this.markers.set(p.id, m);
|
|
145
|
+
}
|
|
146
|
+
m.classList.toggle("me", p.id === this.self);
|
|
147
|
+
m.style.left = `${Math.min(100, p.progress * 100).toFixed(1)}%`;
|
|
148
|
+
}
|
|
149
|
+
for (const [id, m] of this.markers) {
|
|
150
|
+
if (seen.has(id)) continue;
|
|
151
|
+
m.remove();
|
|
152
|
+
this.markers.delete(id);
|
|
153
|
+
}
|
|
154
|
+
// Clock and place.
|
|
155
|
+
const timer = $("timer");
|
|
156
|
+
if (run) {
|
|
157
|
+
const left = run.left;
|
|
158
|
+
timer.textContent = run.phase === "racing" ? (left !== null ? clockText(left) : clockText(run.seconds)) : run.phase === "countdown" ? "READY" : "DONE";
|
|
159
|
+
timer.classList.toggle("low", run.phase === "racing" && left !== null && left <= 15);
|
|
160
|
+
const me = run.players.find((p) => p.id === this.self);
|
|
161
|
+
$("place").textContent = me ? (me.place !== null ? `FINISHED ${ord(me.place).toUpperCase()} · ${me.time?.toFixed(2)} s` : `${ord(me.rank).toUpperCase()} OF ${run.players.length}`) : "";
|
|
162
|
+
// Standings.
|
|
163
|
+
const board = $("board");
|
|
164
|
+
board.replaceChildren(
|
|
165
|
+
...run.players.slice(0, 8).map((p) => {
|
|
166
|
+
const li = document.createElement("li");
|
|
167
|
+
if (p.id === this.self) li.className = "me";
|
|
168
|
+
li.style.setProperty("--c", `#${climberColor(p.id).toString(16).padStart(6, "0")}`);
|
|
169
|
+
li.innerHTML = `<em></em><span class="dot"></span><span></span><b></b>`;
|
|
170
|
+
(li.children[0] as HTMLElement).textContent = String(p.rank);
|
|
171
|
+
(li.children[2] as HTMLElement).textContent = nameOf(p.id, this.self);
|
|
172
|
+
(li.children[3] as HTMLElement).textContent = p.place !== null ? `${p.time?.toFixed(1)}s` : `${Math.round(p.progress * 100)}%`;
|
|
173
|
+
return li;
|
|
174
|
+
}),
|
|
175
|
+
);
|
|
176
|
+
this.results(run);
|
|
177
|
+
}
|
|
178
|
+
void everyone;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
private results(run: RaceInfo & { level?: string | null }): void {
|
|
182
|
+
const end = $("end");
|
|
183
|
+
const show = run.phase === "results";
|
|
184
|
+
end.classList.toggle("show", show);
|
|
185
|
+
if (!show) return;
|
|
186
|
+
const me = run.players.find((p) => p.id === this.self);
|
|
187
|
+
$("end-title").textContent = me?.place === 1 ? "FIRST TO THE FLAG" : me?.place !== null && me?.place !== undefined ? `${ord(me.place).toUpperCase()} PLACE` : "OUT OF TIME";
|
|
188
|
+
$("end-board").replaceChildren(
|
|
189
|
+
...run.players.map((p) => {
|
|
190
|
+
const li = document.createElement("li");
|
|
191
|
+
if (p.id === this.self) li.className = "me";
|
|
192
|
+
li.innerHTML = `<em></em><span></span><b></b>`;
|
|
193
|
+
(li.children[0] as HTMLElement).textContent = p.place !== null ? String(p.place) : "-";
|
|
194
|
+
(li.children[1] as HTMLElement).textContent = nameOf(p.id, this.self);
|
|
195
|
+
(li.children[2] as HTMLElement).textContent = p.time !== null ? `${p.time.toFixed(2)} s` : "did not finish";
|
|
196
|
+
return li;
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
const mine = run.coins.find((c) => c.id === this.self)?.rules ?? [...this.earnedThisRun].map(([rule, units]) => ({ rule, units }));
|
|
200
|
+
$("end-earned").replaceChildren(
|
|
201
|
+
...mine.map((r) => {
|
|
202
|
+
const li = document.createElement("li");
|
|
203
|
+
if (this.mode === "guest") li.className = "guest";
|
|
204
|
+
const pay = (this.game.manifest.earnRules.get(r.rule)?.pay ?? 0) * r.units;
|
|
205
|
+
li.innerHTML = `<span></span><b></b>`;
|
|
206
|
+
(li.children[0] as HTMLElement).textContent = RULE_NAMES[r.rule] ?? r.rule;
|
|
207
|
+
(li.children[1] as HTMLElement).textContent = this.mode === "guest" ? `${pay} ${this.symbol} (connect to earn)` : `+${pay} ${this.symbol}`;
|
|
208
|
+
return li;
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
$("end-next").textContent = run.nextRunIn !== null ? `NEXT COURSE IN ${run.nextRunIn}` : "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
stats(text: string): void {
|
|
215
|
+
$("stats").textContent = text;
|
|
216
|
+
}
|
|
217
|
+
}
|