@netforge/template-party 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 +63 -0
- package/CLAUDE.md +11 -0
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +74 -2
- package/client/debris.ts +267 -0
- package/client/decor.ts +13 -0
- package/client/host.ts +96 -0
- package/client/hud.ts +576 -0
- package/client/icons.ts +42 -0
- package/client/index.html +277 -0
- package/client/input.ts +172 -0
- package/client/library.ts +250 -0
- package/client/main.ts +607 -0
- package/client/names.ts +47 -0
- package/client/palette.ts +7 -0
- package/client/scene.ts +941 -0
- package/client/stage.ts +266 -0
- package/client/stake.ts +101 -0
- package/client/threats.ts +157 -0
- package/llms-full.txt +716 -0
- package/llms.txt +375 -0
- package/package.json +82 -3
- package/src/game.ts +326 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
package/AGENTS.md
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
# Extending ROMPHOUSE
|
|
2
|
+
|
|
3
|
+
ROMPHOUSE is the party co-op 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. client/names.ts holds the display labels for weapon, upgrade and earn ids. In a downloaded game folder, labels players read live in src/names.json. 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 { achievement, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Also available for this genre: `economy`, `metaUpgrade`. `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: "party" })` with one room in `game({ field })`: a floor and four walls.
|
|
25
|
+
- A party game needs one enemy prefab, one weapon and `players.weapon` (or keepers).
|
|
26
|
+
- Drops must be pickup prefabs. At most 15 weapons (evolutions count) and 8 upgrades.
|
|
27
|
+
- `game({ style })` is the art style the creator picked (lowpoly, toon, ps1, pixel, voxel or handdrawn). Keep it.
|
|
28
|
+
- Keep the store SKUs the client is wired to, at their prices: revive, skin_disco and the lucky_charm gear. "stake" is reserved and is never a SKU id.
|
|
29
|
+
|
|
30
|
+
## Validate
|
|
31
|
+
|
|
32
|
+
- With the MCP server (`npx -y @netforge/mcp`, bin `netforge-mcp`): call `netforge_validate_game` on the game. It returns one line per problem.
|
|
33
|
+
- In this package: `npx vitest run` runs `test/harness.test.ts`, `test/host.test.ts`, `test/store.test.ts`. A bad declaration throws `GameDefinitionError` with a line you can act on.
|
|
34
|
+
|
|
35
|
+
## Playtest
|
|
36
|
+
|
|
37
|
+
- With the MCP server: call `netforge_run_playtest`.
|
|
38
|
+
- In code: `import { engine } from "@netforge/engine"` and run `engine.test.run({ ticks: 30 * 180, bots: 4, assert: [] })`. It reports kills, wave (1 plus the rounds played), maxLevel, earned by rule, deaths, runs won and lost, softlock and ms per tick. With `bot: "human"` a human-like bot plays and `report.fun` holds the fun metrics the gate scores.
|
|
39
|
+
|
|
40
|
+
## Play locally
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
mkdir -p my-game/src && cd my-game
|
|
44
|
+
npm init -y && npm install @netforge/kit-play @netforge/engine @netforge/template-party
|
|
45
|
+
cp node_modules/@netforge/template-party/src/game.ts src/
|
|
46
|
+
echo '{"template":"party"}' > netnet.json
|
|
47
|
+
npx kit-play # opens http://127.0.0.1:4663
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
## What not to do
|
|
53
|
+
|
|
54
|
+
- Do not write renderers, netcode, physics or bot AI. The template already has them.
|
|
55
|
+
- Do not use asset URLs or file paths. Use library ids.
|
|
56
|
+
- Do not import anything other than `@netforge/engine` in `src/game.ts`.
|
|
57
|
+
- Do not edit `client/`, `src/manifest.ts` or `src/store.ts`.
|
|
58
|
+
- Do not rename or reprice the store SKUs the client is wired to.
|
|
59
|
+
- Do not remove the `show` system's breather or the finale; add minigames to its list instead.
|
|
60
|
+
|
|
61
|
+
## Docs
|
|
62
|
+
|
|
63
|
+
https://forge.netnet.capital/docs/templates/party
|
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,32 @@
|
|
|
1
|
+
@netforge/template-party (ROMPHOUSE)
|
|
2
|
+
Copyright (c) 2026 NetNet Capital Management
|
|
3
|
+
|
|
4
|
+
An original game. No names, characters, sprites, models, sounds or code
|
|
5
|
+
from any other game or franchise. Nothing from PokéSurvivor (its code and
|
|
6
|
+
sprites are non-commercial) or SpriteCollab was used or consulted for code.
|
|
7
|
+
|
|
8
|
+
Art and sound
|
|
9
|
+
- Models come from the per-style asset library
|
|
10
|
+
(@netforge/asset-library): CC0 1.0 models by Quaternius (party-goers,
|
|
11
|
+
the party crowd) and Kenney (crates, chests and pickups), each pack listed
|
|
12
|
+
with its 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
|
+
- Fallback shapes (client/library.ts) are generated from three.js
|
|
16
|
+
primitives; textures are drawn on a canvas at load.
|
|
17
|
+
|
|
18
|
+
Dependencies (unmodified, via npm)
|
|
19
|
+
- three.js 0.186.1 (https://github.com/mrdoob/three.js), MIT.
|
|
20
|
+
- nipplejs 1.0.4 (https://github.com/yoannmoinet/nipplejs), MIT.
|
|
21
|
+
- @netforge/engine and @netforge/engine-sim (this repository);
|
|
22
|
+
see their NOTICE files (bitECS MPL-2.0, Rapier Apache-2.0, Colyseus MIT,
|
|
23
|
+
zod MIT).
|
|
24
|
+
|
|
25
|
+
Patterns (ideas only)
|
|
26
|
+
- The party-game genre's general mechanics (short rounds of different
|
|
27
|
+
minigames, a shared team goal per round, a show that is won by winning
|
|
28
|
+
most rounds) and the survivor-like's auto-firing weapons and
|
|
29
|
+
pick-one-of-three level-ups. Mechanics, not expression; no minigame,
|
|
30
|
+
character, level or text was copied from any game.
|
|
31
|
+
- Hit-stop, screen shake and damage numbers as described in common
|
|
32
|
+
game-feel talks and articles (e.g. "Juice it or lose it", GDC 2012).
|
package/README.md
CHANGED
|
@@ -1,3 +1,75 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @netforge/template-party
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A party game template for Net Forge: 2 to 8 friends in one playroom, five quick minigame rounds a show (ROMPHOUSE).
|
|
4
|
+
|
|
5
|
+
Each round is a minigame with one goal for the whole team: pop the party poppers before the timer, crash every gift box, catch the runaway pup, hold the floor against a tide, and, as the finale, burst the Big Balloon. Win three and the show is a hit. Weapons fire by themselves, so every round is about where you stand and who you help up.
|
|
6
|
+
|
|
7
|
+
The room server is authoritative at 30 Hz on the survivor machinery (auto-firing weapons, stars and level-up cards, co-op downs and revives, keepers, a boss with telegraphed attacks). Your own player is predicted. The playroom is one floor and four walls, bigger than the screen, so every crowd arrives off screen.
|
|
8
|
+
|
|
9
|
+
The show is the `show` system in `src/game.ts`: a list of minigames (start, won, lastsOut), shuffled each show with the finale last, with a breather between rounds that clears the floor and brings back anyone who is out.
|
|
10
|
+
|
|
11
|
+
Earn: a round won, four rounds won, a clean sweep, and 70 kills by one player, once per show each. Seat stakes and real-token earn run only on the hosted Net Forge server.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
npm install @netforge/template-party
|
|
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-party
|
|
26
|
+
cp node_modules/@netforge/template-party/src/game.ts src/
|
|
27
|
+
echo '{"template":"party"}' > 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
|
+
Rounds run 40 seconds and three wins make the show a hit. Give each round more time and ask for four wins:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
const ROUND_SECONDS = 50;
|
|
39
|
+
const BREATHER = 6;
|
|
40
|
+
const HIT_AT = 4;
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Save `src/game.ts` and run `npx kit-play` again.
|
|
44
|
+
|
|
45
|
+
## Files
|
|
46
|
+
|
|
47
|
+
| Path | What |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `src/game.ts` | The game, declarations only: the playroom, the party crowd, weapons, the `show` system with its rounds, earn, store. |
|
|
50
|
+
| `src/manifest.ts` | Loads and compiles the game; `partyAdapter()` and `partyGame()` for the room host and the client. |
|
|
51
|
+
| `src/store.ts` | Store hooks on the Kit's names: a revive (50 coins), a cosmetic (300 coins) and the lucky charm gear (150 coins). |
|
|
52
|
+
| `client/` | The browser client: three.js scene, the playroom stage, HUD, input (keys, gamepad, touch), game feel. `client/names.ts` holds display labels. |
|
|
53
|
+
| `test/` | Playtest bots through `engine.test.run`, the local host and the store. |
|
|
54
|
+
| `llms.txt` | How to change the game, for agents. |
|
|
55
|
+
|
|
56
|
+
## Controls
|
|
57
|
+
|
|
58
|
+
Keys: WASD or arrows move, Space dash, 1 2 3 pick a card. Touch: drag on the left half, the DASH button, tap a card. The backquote key shows stats. Open the Invite link in another tab or on a phone on the same network to play together (`?room=<id>`).
|
|
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/party
|
|
71
|
+
- `llms.txt` and `llms-full.txt` in this package (also exported as `@netforge/template-party/llms.txt` and `@netforge/template-party/llms-full.txt`)
|
|
72
|
+
|
|
73
|
+
## License
|
|
74
|
+
|
|
75
|
+
MIT. See `LICENSE` and `NOTICE`.
|
package/client/debris.ts
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client-side physics dressing on engine-sim's PhysicsWorld (Rapier, the
|
|
3
|
+
* same build the server runs): chunks that fly off killed enemies and
|
|
4
|
+
* bounce off the floor, walls and pillars, and a scatter of rocks and stumps
|
|
5
|
+
* the keepers shove around and novas blast away.
|
|
6
|
+
*
|
|
7
|
+
* Presentation only. None of it feeds back into the authoritative sim, so
|
|
8
|
+
* it never needs to match the server or other players exactly; every
|
|
9
|
+
* client drives the props from the same interpolated keeper positions, so
|
|
10
|
+
* what they see agrees closely anyway.
|
|
11
|
+
*/
|
|
12
|
+
import { Color, InstancedMesh, Matrix4, MeshStandardMaterial, Quaternion, Vector3 } from "three/webgpu";
|
|
13
|
+
import type { BufferGeometry, Material, Scene } from "three/webgpu";
|
|
14
|
+
import { CylinderGeometry, DodecahedronGeometry } from "three/webgpu";
|
|
15
|
+
import { Layer, createPhysics } from "@netforge/engine-sim";
|
|
16
|
+
import type { BodyId, PhysicsWorld, ResolvedSimConfig } from "@netforge/engine-sim";
|
|
17
|
+
|
|
18
|
+
const HZ = 60;
|
|
19
|
+
const MAX_CHUNKS = 220;
|
|
20
|
+
const MAX_STEPS = 4;
|
|
21
|
+
const CHUNK_LIFE = 2.2;
|
|
22
|
+
|
|
23
|
+
interface Chunk {
|
|
24
|
+
id: BodyId;
|
|
25
|
+
life: number;
|
|
26
|
+
max: number;
|
|
27
|
+
size: number;
|
|
28
|
+
color: Color;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface Prop {
|
|
32
|
+
id: BodyId;
|
|
33
|
+
barrel: boolean;
|
|
34
|
+
/** Last speed, for impact thuds. */
|
|
35
|
+
speed: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Seeded PRNG so every client scatters the props in the same places. */
|
|
39
|
+
function rng(seed: number): () => number {
|
|
40
|
+
let s = seed >>> 0;
|
|
41
|
+
return () => {
|
|
42
|
+
s = (s + 0x6d2b79f5) >>> 0;
|
|
43
|
+
let t = s;
|
|
44
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
45
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
46
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** An art style's own drawing of a debris shape (the voxel style's cube chunks, rocks and stumps); null keeps the primitive. */
|
|
51
|
+
export type DebrisShape = (name: "chunk" | "rock" | "stump") => { readonly geometry: BufferGeometry; readonly material: Material } | null;
|
|
52
|
+
|
|
53
|
+
function instanced(geometry: BufferGeometry, color: number, capacity: number, emissive = 0x000000, styled?: ReturnType<DebrisShape>): InstancedMesh {
|
|
54
|
+
const material = styled?.material ?? new MeshStandardMaterial({ color, roughness: 0.75, flatShading: true, emissive, emissiveIntensity: 0.6 });
|
|
55
|
+
const mesh = new InstancedMesh(styled?.geometry ?? geometry, material, capacity);
|
|
56
|
+
mesh.count = 0;
|
|
57
|
+
mesh.frustumCulled = false;
|
|
58
|
+
mesh.castShadow = true;
|
|
59
|
+
mesh.receiveShadow = true;
|
|
60
|
+
const white = new Color(1, 1, 1);
|
|
61
|
+
for (let i = 0; i < capacity; i++) mesh.setColorAt(i, white);
|
|
62
|
+
return mesh;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export class PhysicsDressing {
|
|
66
|
+
readonly physics: PhysicsWorld;
|
|
67
|
+
private readonly chunks: Chunk[] = [];
|
|
68
|
+
private readonly props: Prop[] = [];
|
|
69
|
+
private readonly keepers = new Map<number, BodyId>();
|
|
70
|
+
private readonly chunkMesh: InstancedMesh;
|
|
71
|
+
private readonly crateMesh: InstancedMesh;
|
|
72
|
+
private readonly barrelMesh: InstancedMesh;
|
|
73
|
+
private acc = 0;
|
|
74
|
+
private readonly m = new Matrix4();
|
|
75
|
+
private readonly q = new Quaternion();
|
|
76
|
+
private readonly p = new Vector3();
|
|
77
|
+
private readonly s = new Vector3();
|
|
78
|
+
private readonly random = rng(0xe3b1);
|
|
79
|
+
/** Impacts this frame (world position and speed), for thud sounds. */
|
|
80
|
+
readonly impacts: { x: number; y: number; z: number; speed: number }[] = [];
|
|
81
|
+
/** Effects fired so far (playtest checks). */
|
|
82
|
+
readonly counts = { chunks: 0, blasts: 0 };
|
|
83
|
+
|
|
84
|
+
constructor(scene: Scene, config: ResolvedSimConfig, opts: { props?: boolean; shape?: DebrisShape } = {}) {
|
|
85
|
+
this.physics = createPhysics({ gravity: [0, -24, 0], tickRate: HZ });
|
|
86
|
+
const P = this.physics;
|
|
87
|
+
const h = config.arena.halfExtent;
|
|
88
|
+
const wall = config.arena.wallHeight;
|
|
89
|
+
P.addStaticBox([h + 1, 0.5, h + 1], [0, -0.5, 0], { friction: 0.8, layer: Layer.Level });
|
|
90
|
+
P.addStaticBox([h + 1, wall, 0.5], [0, wall, h + 0.5], { layer: Layer.Level });
|
|
91
|
+
P.addStaticBox([h + 1, wall, 0.5], [0, wall, -h - 0.5], { layer: Layer.Level });
|
|
92
|
+
P.addStaticBox([0.5, wall, h + 1], [h + 0.5, wall, 0], { layer: Layer.Level });
|
|
93
|
+
P.addStaticBox([0.5, wall, h + 1], [-h - 0.5, wall, 0], { layer: Layer.Level });
|
|
94
|
+
for (const pl of config.arena.pillars) {
|
|
95
|
+
P.addBody({ type: "static", shape: { cylinder: [pl.height / 2, pl.radius] }, position: [pl.x, pl.height / 2, pl.z], layer: Layer.Level, friction: 0.6 });
|
|
96
|
+
}
|
|
97
|
+
// The playroom's walls stop rubble and chunks too.
|
|
98
|
+
for (const w of config.arena.walls ?? []) {
|
|
99
|
+
P.addStaticBox([w.hx, wall / 2, w.hz], [w.x, wall / 2, w.z], { layer: Layer.Level, friction: 0.6 });
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Props: crates and barrels around the pillars and in the open, the same scatter on every client.
|
|
103
|
+
const props = rng(0x51f7);
|
|
104
|
+
const spots: [number, number][] = [];
|
|
105
|
+
for (const pl of config.arena.pillars) {
|
|
106
|
+
for (let k = 0; k < 3; k++) {
|
|
107
|
+
const a = props() * Math.PI * 2;
|
|
108
|
+
const d = pl.radius + 1.2 + props() * 1.5;
|
|
109
|
+
spots.push([pl.x + Math.cos(a) * d, pl.z + Math.sin(a) * d]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// Loose rubble in the rooms (never in the rock between them): two or three per room, off its middle.
|
|
113
|
+
for (const f of config.arena.floors ?? []) {
|
|
114
|
+
if (f.hx < 4 || f.hz < 4) continue;
|
|
115
|
+
const n = 2 + Math.floor(props() * 2);
|
|
116
|
+
for (let k = 0; k < n; k++) {
|
|
117
|
+
const x = f.x + (props() * 2 - 1) * (f.hx - 1.2);
|
|
118
|
+
const z = f.z + (props() * 2 - 1) * (f.hz - 1.2);
|
|
119
|
+
if (Math.abs(x - f.x) < 2.5 && Math.abs(z - f.z) < 2.5) continue;
|
|
120
|
+
spots.push([x, z]);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (opts.props === false) spots.length = 0;
|
|
124
|
+
for (const [x, z] of spots) {
|
|
125
|
+
const barrel = props() < 0.4;
|
|
126
|
+
const yaw = props() * Math.PI;
|
|
127
|
+
const id = P.addBody({
|
|
128
|
+
type: "dynamic",
|
|
129
|
+
shape: barrel ? { cylinder: [0.45, 0.35] } : { box: [0.4, 0.4, 0.4] },
|
|
130
|
+
position: [x, barrel ? 0.45 : 0.4, z],
|
|
131
|
+
rotation: [0, Math.sin(yaw / 2), 0, Math.cos(yaw / 2)],
|
|
132
|
+
density: 40,
|
|
133
|
+
friction: 0.7,
|
|
134
|
+
restitution: 0.15,
|
|
135
|
+
linearDamping: 0.4,
|
|
136
|
+
angularDamping: 0.6,
|
|
137
|
+
layer: Layer.Prop,
|
|
138
|
+
mask: Layer.Level | Layer.Prop | Layer.Player | Layer.Debris,
|
|
139
|
+
});
|
|
140
|
+
this.props.push({ id, barrel, speed: 0 });
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
this.chunkMesh = instanced(new DodecahedronGeometry(1, 0), 0xffffff, MAX_CHUNKS, 0, opts.shape?.("chunk"));
|
|
144
|
+
// Party clutter: bouncy balls and paper cups to kick about.
|
|
145
|
+
this.crateMesh = instanced(new DodecahedronGeometry(0.5), 0xff8ac8, Math.max(1, this.props.length), 0, opts.shape?.("rock"));
|
|
146
|
+
this.barrelMesh = instanced(new CylinderGeometry(0.33, 0.38, 0.9, 7), 0x7ad7ff, Math.max(1, this.props.length), 0, opts.shape?.("stump"));
|
|
147
|
+
scene.add(this.chunkMesh, this.crateMesh, this.barrelMesh);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Keepers push props: one kinematic capsule per player, moved to where it is drawn. */
|
|
151
|
+
keeper(owner: number, x: number, y: number, z: number, up: boolean): void {
|
|
152
|
+
let id = this.keepers.get(owner);
|
|
153
|
+
if (id === undefined) {
|
|
154
|
+
id = this.physics.addBody({ type: "kinematic", shape: { capsule: [0.5, 0.4] }, position: [x, y, z], layer: Layer.Player, mask: Layer.Prop | Layer.Debris });
|
|
155
|
+
this.keepers.set(owner, id);
|
|
156
|
+
}
|
|
157
|
+
this.physics.setKinematicTarget(id, [x, up ? y : -5, z]);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Forget keepers not seen this frame. */
|
|
161
|
+
keepOnly(owners: ReadonlySet<number>): void {
|
|
162
|
+
for (const [owner, id] of this.keepers) {
|
|
163
|
+
if (owners.has(owner)) continue;
|
|
164
|
+
this.physics.removeBody(id);
|
|
165
|
+
this.keepers.delete(owner);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** Chunks off a killed enemy, thrown along the push direction. */
|
|
170
|
+
shatter(x: number, z: number, radius: number, color: Color, count: number, dirX = 0, dirZ = 0): void {
|
|
171
|
+
const P = this.physics;
|
|
172
|
+
this.counts.chunks += count;
|
|
173
|
+
for (let i = 0; i < count; i++) {
|
|
174
|
+
if (this.chunks.length >= MAX_CHUNKS) this.retire(0);
|
|
175
|
+
const size = radius * (0.22 + this.random() * 0.25);
|
|
176
|
+
const a = this.random() * Math.PI * 2;
|
|
177
|
+
const sp = 2 + this.random() * 3.5;
|
|
178
|
+
const id = P.addBody({
|
|
179
|
+
type: "dynamic",
|
|
180
|
+
shape: { box: [size, size, size] },
|
|
181
|
+
position: [x + Math.cos(a) * radius * 0.5, radius + 0.2 + this.random() * radius, z + Math.sin(a) * radius * 0.5],
|
|
182
|
+
density: 8,
|
|
183
|
+
friction: 0.6,
|
|
184
|
+
restitution: 0.35,
|
|
185
|
+
angularDamping: 0.3,
|
|
186
|
+
linearVelocity: [Math.cos(a) * sp + dirX * 5, 3.5 + this.random() * 4, Math.sin(a) * sp + dirZ * 5],
|
|
187
|
+
angularVelocity: [(this.random() - 0.5) * 18, (this.random() - 0.5) * 18, (this.random() - 0.5) * 18],
|
|
188
|
+
layer: Layer.Debris,
|
|
189
|
+
mask: Layer.Level | Layer.Prop,
|
|
190
|
+
});
|
|
191
|
+
const life = CHUNK_LIFE * (0.7 + this.random() * 0.6);
|
|
192
|
+
this.chunks.push({ id, life, max: life, size, color });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A blast (nova) shoves props and chunks out of `radius`. */
|
|
197
|
+
blast(x: number, z: number, radius: number, strength: number): void {
|
|
198
|
+
const P = this.physics;
|
|
199
|
+
this.counts.blasts++;
|
|
200
|
+
const hits = P.overlap({ cylinder: [1.5, radius] }, [x, 1.5, z], { mask: Layer.Prop | Layer.Debris });
|
|
201
|
+
for (const id of hits) P.applyKnockback(id, [x, 0, z], strength, { lift: strength * 0.35 });
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
private retire(i: number): void {
|
|
205
|
+
const c = this.chunks[i];
|
|
206
|
+
if (!c) return;
|
|
207
|
+
this.physics.removeBody(c.id);
|
|
208
|
+
this.chunks[i] = this.chunks[this.chunks.length - 1] as Chunk;
|
|
209
|
+
this.chunks.pop();
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
update(dt: number): void {
|
|
213
|
+
this.impacts.length = 0;
|
|
214
|
+
this.acc = Math.min(this.acc + dt, MAX_STEPS / HZ);
|
|
215
|
+
while (this.acc >= 1 / HZ) {
|
|
216
|
+
this.acc -= 1 / HZ;
|
|
217
|
+
this.physics.step();
|
|
218
|
+
}
|
|
219
|
+
for (let i = this.chunks.length - 1; i >= 0; i--) {
|
|
220
|
+
const c = this.chunks[i] as Chunk;
|
|
221
|
+
c.life -= dt;
|
|
222
|
+
if (c.life <= 0) this.retire(i);
|
|
223
|
+
}
|
|
224
|
+
// Draw.
|
|
225
|
+
const P = this.physics;
|
|
226
|
+
let n = 0;
|
|
227
|
+
for (const c of this.chunks) {
|
|
228
|
+
const [x, y, z] = P.position(c.id);
|
|
229
|
+
const [qx, qy, qz, qw] = P.rotation(c.id);
|
|
230
|
+
const k = Math.min(1, c.life / (c.max * 0.35));
|
|
231
|
+
this.p.set(x, y, z);
|
|
232
|
+
this.q.set(qx, qy, qz, qw);
|
|
233
|
+
this.s.setScalar(c.size * 1.25 * k);
|
|
234
|
+
this.m.compose(this.p, this.q, this.s);
|
|
235
|
+
this.chunkMesh.setMatrixAt(n, this.m);
|
|
236
|
+
this.chunkMesh.setColorAt(n, c.color);
|
|
237
|
+
n++;
|
|
238
|
+
}
|
|
239
|
+
this.chunkMesh.count = n;
|
|
240
|
+
this.chunkMesh.instanceMatrix.needsUpdate = true;
|
|
241
|
+
if (this.chunkMesh.instanceColor) this.chunkMesh.instanceColor.needsUpdate = true;
|
|
242
|
+
let crates = 0;
|
|
243
|
+
let barrels = 0;
|
|
244
|
+
this.s.setScalar(1);
|
|
245
|
+
for (const prop of this.props) {
|
|
246
|
+
const [x, y, z] = P.position(prop.id);
|
|
247
|
+
const [vx, vy, vz] = P.velocity(prop.id);
|
|
248
|
+
const speed = Math.hypot(vx, vy, vz);
|
|
249
|
+
if (prop.speed - speed > 3) this.impacts.push({ x, y, z, speed: prop.speed - speed });
|
|
250
|
+
prop.speed = speed;
|
|
251
|
+
const [qx, qy, qz, qw] = P.rotation(prop.id);
|
|
252
|
+
this.p.set(x, y, z);
|
|
253
|
+
this.q.set(qx, qy, qz, qw);
|
|
254
|
+
this.m.compose(this.p, this.q, this.s);
|
|
255
|
+
if (prop.barrel) this.barrelMesh.setMatrixAt(barrels++, this.m);
|
|
256
|
+
else this.crateMesh.setMatrixAt(crates++, this.m);
|
|
257
|
+
}
|
|
258
|
+
this.crateMesh.count = crates;
|
|
259
|
+
this.barrelMesh.count = barrels;
|
|
260
|
+
this.crateMesh.instanceMatrix.needsUpdate = true;
|
|
261
|
+
this.barrelMesh.instanceMatrix.needsUpdate = true;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
get liveChunks(): number {
|
|
265
|
+
return this.chunks.length;
|
|
266
|
+
}
|
|
267
|
+
}
|
package/client/decor.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library models the ROMPHOUSE scene draws on its own (template machinery,
|
|
3
|
+
* not game content). The playroom draws its walls, floor and lights itself.
|
|
4
|
+
* The build copies these with the models game.ts uses.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** Enemy models are drawn about this many hit radii tall. */
|
|
8
|
+
export const ENEMY_HEIGHT_PER_RADIUS = 3.2;
|
|
9
|
+
/** Keepers are drawn this tall, m. */
|
|
10
|
+
export const KEEPER_HEIGHT = 1.8;
|
|
11
|
+
|
|
12
|
+
/** The playroom draws its own walls, floor and lights: no library dressing of its own. */
|
|
13
|
+
export const SCENE_MODELS: readonly string[] = [];
|
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
|
+
}
|