@genex-ai/cli-demo 0.62.0-dev.143 → 0.63.0-dev.146
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/dist/index.js +221 -81
- package/package.json +1 -1
- package/templates/skills/genex-ai-menu/SKILL.md +13 -0
- package/templates/skills/genex-threejs-character-controller/SKILL.md +24 -10
- package/templates/skills/genex-threejs-character-controller/references/animations.md +15 -5
- package/templates/skills/genex-threejs-embed-auth/SKILL.md +4 -0
- package/templates/skills/genex-threejs-game-content/SKILL.md +189 -0
- package/templates/skills/genex-threejs-game-content/references/content-tables.md +269 -0
- package/templates/skills/genex-threejs-multiplayer/SKILL.md +6 -3
- package/templates/skills/genex-threejs-multiplayer/references/host-physics.md +6 -2
- package/templates/skills/genex-threejs-open-world/SKILL.md +149 -0
- package/templates/skills/genex-threejs-open-world/references/terrain-streaming.md +215 -0
- package/templates/skills/genex-threejs-skill-router/SKILL.md +20 -0
- package/templates/skills/genex-threejs-skill-router/references/routing-map.md +38 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@genex-ai/cli-demo",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.63.0-dev.146",
|
|
4
4
|
"description": "Set up your project's agent workspace (.claude/.codex/.cursor in the game folder), authorize, create a game project, generate AI assets, and publish (genex CLI).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -126,6 +126,19 @@ after the UI plan gate, enqueue the video with `--no-wait`, ship the CSS menu
|
|
|
126
126
|
(R2) — permanent, public, CORS-open; you load them straight from the printed
|
|
127
127
|
URLs, nothing is downloaded or committed.
|
|
128
128
|
|
|
129
|
+
**Two failed video attempts = ship the still. Hard stop.** Video is the one
|
|
130
|
+
generation that fails server-side with real frequency (render timeouts), and
|
|
131
|
+
every attempt costs minutes of waiting. One retry is fair — shorten the clip
|
|
132
|
+
(4–6 s) and simplify the motion prompt. After a SECOND failure, stop
|
|
133
|
+
generating: keep the key-art still as the menu background and give it life
|
|
134
|
+
for free with a slow CSS pan/zoom (`transform: scale(1.06)` over ~20 s,
|
|
135
|
+
alternating), tell the user in one plain line ("the animated menu backdrop
|
|
136
|
+
kept failing, so your menu uses the key art — looks great, costs nothing"),
|
|
137
|
+
and spend those minutes in the game. A third attempt is how half an hour
|
|
138
|
+
disappears into chrome — the CLI counts failures and reminds you at the
|
|
139
|
+
second one. The still-image menu is a real menu: this rule is the built-in
|
|
140
|
+
fallback, not a downgrade to apologize for.
|
|
141
|
+
|
|
129
142
|
## Wire it as a phase screen
|
|
130
143
|
|
|
131
144
|
The menu is one `data-phase` screen in the `$genex-threejs-game-ui`
|
|
@@ -19,10 +19,12 @@ animation retargeting, capsule auto-fit, foot IK) and the 12-clip core
|
|
|
19
19
|
~1.3 MB) into `public/assets/`. Need more — swords, pistols, magic, climbing,
|
|
20
20
|
swimming, emotes? Install exactly what the game uses with
|
|
21
21
|
`npx genex controller anims <tags|clip names…>` (see Animations below). The
|
|
22
|
-
default command also writes
|
|
22
|
+
default command also writes a fallback avatar to `public/assets/avatar.vrm` —
|
|
23
23
|
**your** avatar when you're signed in,
|
|
24
24
|
otherwise a bundled CC0 default (attribution in `src/controllers/NOTICE.md`).
|
|
25
|
-
|
|
25
|
+
At runtime the character plays as the **visiting player's own** picked avatar
|
|
26
|
+
(`user.avatarUrl` from the embed identity — see the wiring below); the baked
|
|
27
|
+
file is only the fallback for local dev and load failures. The copied files are then owned by the game —
|
|
26
28
|
edit them freely; re-running skips existing files unless `--force`. Do not write
|
|
27
29
|
a character controller from scratch and do not swap in a kinematic-controller
|
|
28
30
|
tutorial: this one is a real dynamic body that pushes crates, rides moving
|
|
@@ -89,12 +91,20 @@ import { FollowCamera } from "./controllers/character/follow-camera.ts";
|
|
|
89
91
|
import { KeyboardInput } from "./controllers/character/keyboard-input.ts";
|
|
90
92
|
import { loadVrm } from "./controllers/character/vrm/vrm-loader.ts";
|
|
91
93
|
import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
|
|
94
|
+
import { waitForPlayer } from "@genex-ai/embed-sdk";
|
|
92
95
|
|
|
93
96
|
const physics = await PhysicsWorld.create(); // nothing RAPIER-related may run before this resolves
|
|
94
97
|
|
|
95
|
-
// Load the
|
|
96
|
-
//
|
|
97
|
-
|
|
98
|
+
// Load the PLAYING user's avatar — every visitor plays as the avatar THEY
|
|
99
|
+
// picked on their genex profile (guests get a per-session one), not the
|
|
100
|
+
// creator's. `user.avatarUrl` comes from the embed identity
|
|
101
|
+
// ($genex-threejs-embed-auth boots before this); the baked
|
|
102
|
+
// `./assets/avatar.vrm` is the fallback for local dev, old APIs, and load
|
|
103
|
+
// failures. Then load every animation the game has (the bundled core library +
|
|
104
|
+
// any packs installed by `genex controller anims`), retargeted onto the VRM.
|
|
105
|
+
const { user } = await waitForPlayer(); // from "@genex-ai/embed-sdk"
|
|
106
|
+
const { scene: avatar, vrm } = await loadVrm(user.avatarUrl ?? "./assets/avatar.vrm")
|
|
107
|
+
.catch(() => loadVrm("./assets/avatar.vrm")); // network failure → bundled fallback
|
|
98
108
|
const clips = await loadCharacterClips(vrm);
|
|
99
109
|
|
|
100
110
|
const fit = capsuleFromModel(avatar); // collider fits THIS avatar's bounds
|
|
@@ -266,11 +276,15 @@ and the style-matching rules live in `$genex-threejs-touch-controls`.
|
|
|
266
276
|
|
|
267
277
|
**The local player is physics-authoritative; remote players are interpolated
|
|
268
278
|
visuals only.** Exactly one `CharacterController` exists — yours. For every
|
|
269
|
-
remote player:
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
279
|
+
remote player: load **that player's own avatar** —
|
|
280
|
+
`loadVrm(p.avatarUrl || "./assets/avatar.vrm")` (the multiplayer SDK's
|
|
281
|
+
`player.avatarUrl` is their verified profile pick; fall back to the baked file
|
|
282
|
+
when it's empty or fails to load, and `VRMUtils.deepDispose` the model when
|
|
283
|
+
they leave) — move it with the interpolator from `$genex-threejs-multiplayer`,
|
|
284
|
+
and **never** create a rigid body, a `CharacterController`, or any physics for
|
|
285
|
+
it. Simulating remote players' physics locally guarantees divergence — every
|
|
286
|
+
client would compute a different world. Never render every remote with your
|
|
287
|
+
own avatar file: players picked their looks, show them.
|
|
274
288
|
|
|
275
289
|
- Publish your own `currPos` + `currQuat` as a four-number quaternion on the fixed 10–20 Hz tick,
|
|
276
290
|
not per frame. Never reduce multiplayer rotation to scalar yaw.
|
|
@@ -10,8 +10,10 @@ clips only on the exact matching generated rig revision.
|
|
|
10
10
|
|
|
11
11
|
`npx genex controller character` sets the game up to play as a **VRM avatar**:
|
|
12
12
|
|
|
13
|
-
- `public/assets/avatar.vrm` — the
|
|
14
|
-
bundled CC0 default). Always present; always one path.
|
|
13
|
+
- `public/assets/avatar.vrm` — the FALLBACK avatar (yours when signed in, else a
|
|
14
|
+
bundled CC0 default). Always present; always one path. At runtime the game
|
|
15
|
+
loads the visiting player's own picked avatar instead (`user.avatarUrl` from
|
|
16
|
+
the embed identity) — this file covers local dev and load failures.
|
|
15
17
|
- `public/assets/animation-library.glb` (~1.3 MB) — the 12-clip core
|
|
16
18
|
(idle/walk/jog/sprint, the jump trio, crouch idle+move, hit, death, interact)
|
|
17
19
|
on a shared Quaternius rig (provenance in `src/controllers/NOTICE.md`).
|
|
@@ -35,8 +37,13 @@ import { capsuleFromModel } from "./controllers/character/vrm/capsule-fit.ts";
|
|
|
35
37
|
import { CharacterController } from "./controllers/character/character-controller.ts";
|
|
36
38
|
import { CharacterAnimations } from "./controllers/character/character-animations.ts";
|
|
37
39
|
import { characterPresets } from "./controllers/character/presets.ts";
|
|
40
|
+
import { waitForPlayer } from "@genex-ai/embed-sdk";
|
|
38
41
|
|
|
39
|
-
|
|
42
|
+
// The playing user's OWN avatar (their profile pick; per-session for guests) —
|
|
43
|
+
// the baked file is only the local-dev / failure fallback.
|
|
44
|
+
const { user } = await waitForPlayer();
|
|
45
|
+
const { scene, vrm } = await loadVrm(user.avatarUrl ?? "./assets/avatar.vrm")
|
|
46
|
+
.catch(() => loadVrm("./assets/avatar.vrm"));
|
|
40
47
|
const clips = await loadCharacterClips(vrm); // core + every installed pack, retargeted
|
|
41
48
|
|
|
42
49
|
// capsuleFromModel derives the collider from the avatar's bounds — no manual
|
|
@@ -365,8 +372,11 @@ resolves correctly (a missing flag reads as not-crouched).
|
|
|
365
372
|
|
|
366
373
|
The mixer crossfades exactly as it does locally, so remote players animate
|
|
367
374
|
correctly without simulating anything. Use the same visual lane as the owner:
|
|
368
|
-
|
|
369
|
-
|
|
375
|
+
for a VRM game load **that remote player's own avatar** —
|
|
376
|
+
`loadVrm(p.avatarUrl || "./assets/avatar.vrm")` (the multiplayer SDK's verified
|
|
377
|
+
per-player field; empty means fall back) + retargeted UAL clips, call
|
|
378
|
+
`vrm.update(delta)` per remote, and `VRMUtils.deepDispose` the model on leave —
|
|
379
|
+
or `loadMeshyCharacter` for a Meshy game. Relay one-shot events (punch,
|
|
370
380
|
hit, validated planar-action start) alongside the flags and call the matching
|
|
371
381
|
`remoteAnims.playOneShot(...)` on receipt. Never run `MotionActionDriver` for a
|
|
372
382
|
remote: its smoothed owner-authored transform is the sole movement authority.
|
|
@@ -78,6 +78,10 @@ import { waitForPlayer, waitForAuth, getColyseusAuth, getEmbedToken } from "@gen
|
|
|
78
78
|
// everything wants.
|
|
79
79
|
const { user, guest } = await waitForPlayer();
|
|
80
80
|
// user.id / user.name — real account identity, or guest:<id> / "Guest-1234"
|
|
81
|
+
// user.avatarUrl — the player's OWN VRM avatar (profile pick; per-session for
|
|
82
|
+
// guests). Load it for the local player's visual in a VRM-lane game, with the
|
|
83
|
+
// baked ./assets/avatar.vrm as the fallback (absent on old APIs / local test
|
|
84
|
+
// mode). Peers see each other's via the multiplayer SDK's player.avatarUrl.
|
|
81
85
|
|
|
82
86
|
// ACCOUNT gate — resolves ONLY for signed-in players (stays pending for
|
|
83
87
|
// guests; resolves later if they sign in mid-game). Use ONLY for /state
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: genex-threejs-game-content
|
|
3
|
+
description: Turn a content-shaped request — quests, NPCs, dialogue, shops, loot, XP — into a countable content contract and the data-driven systems that ship it. Use when the ask names game content in the plural or a content genre (RPG, adventure, story, open world), BEFORE the asset batch, and before calling such a game done.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Genex Three.js Game Content
|
|
7
|
+
|
|
8
|
+
The most common way a content-shaped request fails is not bad code — it is the
|
|
9
|
+
ask silently shrinking: "quests" ships as one hardcoded integer, "NPCs" as
|
|
10
|
+
empty textured huts, "a big world" as one small fogged plane, while every
|
|
11
|
+
visual floor passes, because content is invisible to a screenshot. This skill
|
|
12
|
+
gives content the same teeth the look has: a countable contract written up
|
|
13
|
+
front, data-driven systems that make each contract line cheap to ship, and a
|
|
14
|
+
floor that gates publish.
|
|
15
|
+
|
|
16
|
+
## The content contract — write it before the asset batch
|
|
17
|
+
|
|
18
|
+
Run this the moment the game concept is locked, in the SAME plan message as
|
|
19
|
+
`$genex-threejs-game-ui`'s UI gate, whenever the request names content in the
|
|
20
|
+
plural (quests, enemies, bosses, locations, spells, items, factions…) or a
|
|
21
|
+
content genre (an RPG, an adventure, an open world, a story game, a survival
|
|
22
|
+
game). **Walk every plural noun of the request and make each one a countable
|
|
23
|
+
line.** The template:
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
CONTENT CONTRACT — <game>
|
|
27
|
+
world: <size class, as a number: one arena | a district (~500 m) | open world (km-class — $genex-threejs-open-world)>
|
|
28
|
+
locations: <N, named: village, crypt, bandit camp, watchtower, boss lair…>
|
|
29
|
+
quests: <N total — a main chain of M gated steps + side quests; each giver named>
|
|
30
|
+
NPCs: <N speaking (quest givers, merchants) + M flavor villagers>
|
|
31
|
+
enemies: <N types + where they spawn; which are bosses and what makes each boss FIGHT differently>
|
|
32
|
+
progression: <what grows: XP/levels, gear tiers, learnable abilities — the numbers and their rewards>
|
|
33
|
+
economy: <currency sources AND sinks — what the player earns and what they spend it on>
|
|
34
|
+
minute ten: <one sentence: what is the player DOING ten minutes in, and why is it different from minute one?>
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Rules that make the contract real:
|
|
38
|
+
|
|
39
|
+
- **It comes before the asset batch.** The asset set derives from the
|
|
40
|
+
contract — a world with six locations and four enemy types needs a
|
|
41
|
+
different `npx genex model` list than one arena, and finding that out after
|
|
42
|
+
the batch means the world gets shaped around the wrong assets.
|
|
43
|
+
- **Every line is a floor.** Publish and the final handoff wait for the
|
|
44
|
+
contract's countables exactly the way they wait for the sprite HUD: a
|
|
45
|
+
request that said "quests" is not done with one; a request that said "big
|
|
46
|
+
world" is not done with an arena.
|
|
47
|
+
- **Scope belongs to the user.** Building a small first slice is the right
|
|
48
|
+
ORDER (`$genex-threejs-game-ui`'s v0 beat still applies) — but the slice is
|
|
49
|
+
a milestone on the way to the contract, never a quiet replacement for it.
|
|
50
|
+
If the full ask genuinely doesn't fit, shrinking any line is a structured
|
|
51
|
+
question to the user with real options — never a silent cut justified as
|
|
52
|
+
"standard practice".
|
|
53
|
+
- **Minute ten is the design test.** If the honest answer is "the same sixty
|
|
54
|
+
seconds, again", the contract needs another beat (a new area unlocks, a
|
|
55
|
+
quest chain escalates, a build comes online) before any polish work.
|
|
56
|
+
|
|
57
|
+
## Data-driven, or you won't finish in one session
|
|
58
|
+
|
|
59
|
+
The reason a solo agent can ship seven quests and seventeen items in an
|
|
60
|
+
afternoon is architecture, not typing speed:
|
|
61
|
+
|
|
62
|
+
- **Systems read tables; content lives in tables.** The quest engine, the
|
|
63
|
+
dialogue walker, the merchant screen, and the spawner are each written
|
|
64
|
+
ONCE; quest #5, item #12, and enemy #4 are table rows. If adding a quest
|
|
65
|
+
means touching engine code, the engine is wrong.
|
|
66
|
+
- **Hand-author the beats, table-drive the bulk, seed the placement.** Quest
|
|
67
|
+
prose, boss mechanics, and location identities deserve human-quality
|
|
68
|
+
authoring; stats, stocks, and rewards are data; trees, rocks, and chest
|
|
69
|
+
scatter come from a seeded RNG so "more world" costs zero authoring.
|
|
70
|
+
- **One event bus.** Quests advance on events the game already emits
|
|
71
|
+
(`enemy:died`, `item:pickup`, `chest:opened`, `npc:talked`, `zone:entered`)
|
|
72
|
+
— the quest system subscribes; combat and loot never know quests exist.
|
|
73
|
+
This is also what makes quest logic testable in isolation.
|
|
74
|
+
|
|
75
|
+
The full engine — quest defs + state machine, dialogue trees with quest
|
|
76
|
+
hooks, merchant stocks, XP curve — is in
|
|
77
|
+
[references/content-tables.md](references/content-tables.md) as copy-paste
|
|
78
|
+
modules. Copy them and fill the tables; don't re-derive the shape.
|
|
79
|
+
|
|
80
|
+
## Quests: defs + events, never an integer
|
|
81
|
+
|
|
82
|
+
The minimum honest quest system is a table of defs and an event-driven state
|
|
83
|
+
machine (`locked → available → active → ready → done`), with prereq gating for
|
|
84
|
+
the main chain:
|
|
85
|
+
|
|
86
|
+
```ts
|
|
87
|
+
type Objective =
|
|
88
|
+
| { kind: "kill"; target: string; count: number }
|
|
89
|
+
| { kind: "collect"; item: string; count: number }
|
|
90
|
+
| { kind: "reach"; location: string }
|
|
91
|
+
| { kind: "talk"; npc: string };
|
|
92
|
+
|
|
93
|
+
type QuestDef = {
|
|
94
|
+
id: string;
|
|
95
|
+
giver: string; // NPC id — every quest has a face, not a board
|
|
96
|
+
prereq?: string; // quest id that must be done first (main chain)
|
|
97
|
+
title: string;
|
|
98
|
+
brief: string; // 2–3 sentences of authored prose, not filler
|
|
99
|
+
objective: Objective;
|
|
100
|
+
rewards: { gold?: number; xp?: number; items?: string[] };
|
|
101
|
+
};
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Progress comes only from the event bus (see the reference for the ~60-line
|
|
105
|
+
engine). A tracked quest gets a compass/journal marker
|
|
106
|
+
(`$genex-threejs-game-ui` inventories the journal as a screen element), and
|
|
107
|
+
turn-in happens in dialogue — handing in a quest should feel like talking to
|
|
108
|
+
a person, not watching a counter flip.
|
|
109
|
+
|
|
110
|
+
## Dialogue: trees with quest hooks
|
|
111
|
+
|
|
112
|
+
Quest givers and merchants speak. The walker is a dozen lines (reference file)
|
|
113
|
+
over nodes of `{ text, options: [{ label, next | action }] }`; what makes it a
|
|
114
|
+
QUEST system is three dynamic option states injected per NPC: **offer** (quest
|
|
115
|
+
available → "I might have work for you"), **remind** (active → restate the
|
|
116
|
+
objective), **turn-in** (ready → hand rewards, open the next chain link). Add
|
|
117
|
+
a couple of lore branches per named NPC — three authored lines is the
|
|
118
|
+
difference between a person and a signpost. A quest board is an acceptable
|
|
119
|
+
extra for arcade-shaped games; it is never the replacement for speaking
|
|
120
|
+
NPCs when the ask said "NPCs".
|
|
121
|
+
|
|
122
|
+
## Items, shops, and an economy that closes
|
|
123
|
+
|
|
124
|
+
An item catalog (id, kind, price, stats), stack-based inventory, and merchant
|
|
125
|
+
stock tables per vendor NPC — all data (reference file). The rule that keeps
|
|
126
|
+
it a game: **currency needs sinks.** Gold the player can only accumulate is a
|
|
127
|
+
score with a coin icon; gold that buys potions, a better sword, and a spell
|
|
128
|
+
tome is an economy. Price the first upgrade to be affordable after the first
|
|
129
|
+
quest, and let drops + chest loot + quest rewards all feed the same wallet.
|
|
130
|
+
|
|
131
|
+
## Progression: something must grow
|
|
132
|
+
|
|
133
|
+
Pick at least one growth axis and wire its rewards into the quest/enemy
|
|
134
|
+
tables: an XP curve (`xpNext = 100 * level ** 1.4` is a fine default) with
|
|
135
|
+
flat stat gains per level, gear tiers on the merchant, or learnable abilities
|
|
136
|
+
gated behind tomes/trainers. The contract names which; "nothing grows" is what
|
|
137
|
+
makes minute ten feel like minute one.
|
|
138
|
+
|
|
139
|
+
## NPCs: the minimum that reads as alive
|
|
140
|
+
|
|
141
|
+
A named NPC with a role, a home spot, an idle bob, a face-the-player turn
|
|
142
|
+
within a few meters, and dialogue reads as a person — schedules and pathing
|
|
143
|
+
are optional upgrades, speech is not. Place speaking NPCs (givers, merchants)
|
|
144
|
+
by hand at their locations; scatter flavor villagers with one-line barks from
|
|
145
|
+
a table. An empty textured hut village fails the "NPCs" line of any contract.
|
|
146
|
+
For host-simulated NPCs/enemies in multiplayer, `$genex-threejs-multiplayer`
|
|
147
|
+
owns the authority rules.
|
|
148
|
+
|
|
149
|
+
## Content that can't dead-end — check before shipping
|
|
150
|
+
|
|
151
|
+
- A kill/collect quest counts progress made BEFORE acceptance (or the brief
|
|
152
|
+
says why not) — "kill 5 bandits" accepted after clearing the camp must not
|
|
153
|
+
strand at 0/5.
|
|
154
|
+
- A quest item from a unique source (a boss drop, a one-time chest) must
|
|
155
|
+
persist until picked up — the source never respawns, so expiring the drop
|
|
156
|
+
dead-ends the chain.
|
|
157
|
+
- The main chain gates on prereqs, not on geography alone — reaching the
|
|
158
|
+
final lair early should show a locked door or a warning, not a sequence
|
|
159
|
+
break that skips the story.
|
|
160
|
+
- The turn-in NPC is reachable after the objective (didn't die in the wave,
|
|
161
|
+
isn't locked behind the boss arena).
|
|
162
|
+
- Saves restore quest state AND its world side-effects — the opened chest
|
|
163
|
+
stays open, the armed boss stays armed. Quest stages, inventory, XP, and
|
|
164
|
+
gold go in the per-player slot via `$genex-threejs-embed-auth`.
|
|
165
|
+
|
|
166
|
+
## Wiring into the rest of the pack
|
|
167
|
+
|
|
168
|
+
- World layout, terrain, and location placement at scale:
|
|
169
|
+
`$genex-threejs-open-world` (the contract's `world:` line decides whether
|
|
170
|
+
it loads).
|
|
171
|
+
- Quest journal, tracker, toasts, vendor screens: inventory them at
|
|
172
|
+
`$genex-threejs-game-ui`'s gate — they are screen elements like any other.
|
|
173
|
+
- Level-ups, quest completion, and boss kills are exactly the "moments" the
|
|
174
|
+
feel pass layers feedback on: `$genex-threejs-game-feel` + a real
|
|
175
|
+
`npx genex sfx` fanfare (`$genex-ai-sfx`).
|
|
176
|
+
- Location set pieces and speaking-NPC meshes: `$genex-ai-model` /
|
|
177
|
+
`$genex-ai-character` — generated assets decorate the contract's landmarks;
|
|
178
|
+
they never decide how many exist.
|
|
179
|
+
|
|
180
|
+
## Failure modes to catch
|
|
181
|
+
|
|
182
|
+
- "Quests" (plural, in the ask) shipped as one quest — a stage integer with
|
|
183
|
+
hardcoded strings and no giver.
|
|
184
|
+
- A village of textured huts where nobody speaks.
|
|
185
|
+
- A gold counter with nothing to spend it on.
|
|
186
|
+
- No growth axis: the player at minute thirty plays exactly like minute one.
|
|
187
|
+
- The contract posted, then quietly abandoned when the first slice previewed
|
|
188
|
+
well — the slice is a milestone, not the destination.
|
|
189
|
+
- Contract lines silently shrunk without a structured question to the user.
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
# Genex game content — copy-paste systems
|
|
2
|
+
|
|
3
|
+
Four small modules that turn contract lines into shipped content: an event
|
|
4
|
+
bus, a quest engine, a dialogue walker with quest hooks, and items/economy +
|
|
5
|
+
XP. Copy them, fill the tables, and keep content OUT of engine code — adding
|
|
6
|
+
a quest, an item, or a vendor must always be a table edit.
|
|
7
|
+
|
|
8
|
+
All snippets are plain vanilla-ts (no enums, no decorators) and assume a
|
|
9
|
+
single shared game-state object `G` you already have (player stats, wallet,
|
|
10
|
+
the scene). Adapt names, not shapes.
|
|
11
|
+
|
|
12
|
+
## 1. Event bus — the only coupling
|
|
13
|
+
|
|
14
|
+
```ts
|
|
15
|
+
// events.ts — combat, loot, and movement EMIT; quests and UI SUBSCRIBE.
|
|
16
|
+
type GameEvent =
|
|
17
|
+
| { type: "enemy:died"; enemyKind: string }
|
|
18
|
+
| { type: "item:pickup"; item: string; count: number }
|
|
19
|
+
| { type: "chest:opened"; chestId: string }
|
|
20
|
+
| { type: "npc:talked"; npc: string }
|
|
21
|
+
| { type: "zone:entered"; location: string };
|
|
22
|
+
|
|
23
|
+
type Handler = (e: GameEvent) => void;
|
|
24
|
+
const handlers: Handler[] = [];
|
|
25
|
+
|
|
26
|
+
export function onEvent(h: Handler): void {
|
|
27
|
+
handlers.push(h);
|
|
28
|
+
}
|
|
29
|
+
export function emit(e: GameEvent): void {
|
|
30
|
+
for (const h of handlers) h(e);
|
|
31
|
+
}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## 2. Quest engine — defs are data, progress is events
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
// quests.ts
|
|
38
|
+
import { onEvent } from "./events.ts";
|
|
39
|
+
|
|
40
|
+
type Objective =
|
|
41
|
+
| { kind: "kill"; target: string; count: number }
|
|
42
|
+
| { kind: "collect"; item: string; count: number }
|
|
43
|
+
| { kind: "reach"; location: string }
|
|
44
|
+
| { kind: "talk"; npc: string };
|
|
45
|
+
|
|
46
|
+
type QuestDef = {
|
|
47
|
+
id: string;
|
|
48
|
+
giver: string;
|
|
49
|
+
prereq?: string;
|
|
50
|
+
title: string;
|
|
51
|
+
brief: string;
|
|
52
|
+
turnIn: string; // authored prose for the hand-in moment
|
|
53
|
+
objective: Objective;
|
|
54
|
+
rewards: { gold?: number; xp?: number; items?: string[] };
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// CONTENT — the whole quest list lives here. Main chain via prereq; the
|
|
58
|
+
// prose is authored, not generated filler. Counts must match the contract.
|
|
59
|
+
export const QUESTS: QuestDef[] = [
|
|
60
|
+
{
|
|
61
|
+
id: "q_wolves",
|
|
62
|
+
giver: "elder",
|
|
63
|
+
title: "Teeth in the Tall Grass",
|
|
64
|
+
brief:
|
|
65
|
+
"Wolves have taken the shepherd's flock and now they circle the palisade at dusk. Thin the pack before someone's child is next.",
|
|
66
|
+
turnIn: "Five pelts. The flock sleeps easy tonight — and so do we. Take this.",
|
|
67
|
+
objective: { kind: "kill", target: "wolf", count: 5 },
|
|
68
|
+
rewards: { gold: 40, xp: 60 },
|
|
69
|
+
},
|
|
70
|
+
{
|
|
71
|
+
id: "q_amulet",
|
|
72
|
+
giver: "elder",
|
|
73
|
+
prereq: "q_wolves",
|
|
74
|
+
title: "What the Crypt Keeps",
|
|
75
|
+
brief:
|
|
76
|
+
"Our founder's amulet lies in the old crypt east of the fields. The dead have grown restless around it — bring it back, and mind the narrow dark.",
|
|
77
|
+
turnIn: "The amulet… after all these years. You have the village's gratitude, and its coin.",
|
|
78
|
+
objective: { kind: "collect", item: "founder_amulet", count: 1 },
|
|
79
|
+
rewards: { gold: 80, xp: 120, items: ["potion_health"] },
|
|
80
|
+
},
|
|
81
|
+
// …side quests: no prereq, different givers, different objective kinds.
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
type QuestStatus = "locked" | "available" | "active" | "ready" | "done";
|
|
85
|
+
type QuestState = { status: QuestStatus; progress: number };
|
|
86
|
+
|
|
87
|
+
export const questState = new Map<string, QuestState>();
|
|
88
|
+
for (const q of QUESTS) {
|
|
89
|
+
questState.set(q.id, { status: q.prereq ? "locked" : "available", progress: 0 });
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const def = (id: string): QuestDef => QUESTS.find((q) => q.id === id)!;
|
|
93
|
+
|
|
94
|
+
export function accept(id: string): void {
|
|
95
|
+
const s = questState.get(id)!;
|
|
96
|
+
if (s.status !== "available") return;
|
|
97
|
+
s.status = "active";
|
|
98
|
+
// Contract check "can't dead-end": count pre-acceptance progress where the
|
|
99
|
+
// fiction allows it (kills already made), or reset knowingly.
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function turnIn(id: string): { gold: number; xp: number; items: string[] } | null {
|
|
103
|
+
const s = questState.get(id)!;
|
|
104
|
+
if (s.status !== "ready") return null;
|
|
105
|
+
s.status = "done";
|
|
106
|
+
for (const q of QUESTS) {
|
|
107
|
+
if (q.prereq === id && questState.get(q.id)!.status === "locked") {
|
|
108
|
+
questState.get(q.id)!.status = "available"; // next chain link opens
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const r = def(id).rewards;
|
|
112
|
+
return { gold: r.gold ?? 0, xp: r.xp ?? 0, items: r.items ?? [] };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function bump(q: QuestDef, s: QuestState, amount = 1): void {
|
|
116
|
+
const needed = "count" in q.objective ? q.objective.count : 1;
|
|
117
|
+
s.progress = Math.min(needed, s.progress + amount);
|
|
118
|
+
if (s.progress >= needed) s.status = "ready"; // journal + marker flip to "return"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
onEvent((e) => {
|
|
122
|
+
for (const q of QUESTS) {
|
|
123
|
+
const s = questState.get(q.id)!;
|
|
124
|
+
if (s.status !== "active") continue;
|
|
125
|
+
const o = q.objective;
|
|
126
|
+
if (o.kind === "kill" && e.type === "enemy:died" && e.enemyKind === o.target) bump(q, s);
|
|
127
|
+
if (o.kind === "collect" && e.type === "item:pickup" && e.item === o.item) bump(q, s, e.count);
|
|
128
|
+
if (o.kind === "reach" && e.type === "zone:entered" && e.location === o.location) bump(q, s);
|
|
129
|
+
if (o.kind === "talk" && e.type === "npc:talked" && e.npc === o.npc) bump(q, s);
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Journal UI, the tracked-quest compass marker, and toasts subscribe to the
|
|
135
|
+
same state — inventory them as screen elements at `$genex-threejs-game-ui`'s
|
|
136
|
+
gate. Persist `[...questState]` (plus world side-effects like opened chests)
|
|
137
|
+
in the per-player slot via `$genex-threejs-embed-auth`.
|
|
138
|
+
|
|
139
|
+
## 3. Dialogue walker — quest states become options
|
|
140
|
+
|
|
141
|
+
```ts
|
|
142
|
+
// dialogue.ts
|
|
143
|
+
import { QUESTS, questState, accept, turnIn } from "./quests.ts";
|
|
144
|
+
|
|
145
|
+
type DialogueOption = { label: string; next?: DialogueNode; action?: () => void };
|
|
146
|
+
type DialogueNode = { text: string; options: DialogueOption[] };
|
|
147
|
+
|
|
148
|
+
// CONTENT — per-NPC roots: greeting + a couple of authored lore branches.
|
|
149
|
+
// Three real lines is the difference between a person and a signpost.
|
|
150
|
+
const ROOTS: Record<string, DialogueNode> = {
|
|
151
|
+
elder: {
|
|
152
|
+
text: "Maren watches the road as she talks. “Strangers used to mean trade. Lately they mean trouble.”",
|
|
153
|
+
options: [
|
|
154
|
+
{
|
|
155
|
+
label: "Tell me about this village.",
|
|
156
|
+
next: {
|
|
157
|
+
text: "“Three generations behind this palisade. The crypt east of here is older than all of it — and lately, louder.”",
|
|
158
|
+
options: [],
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
],
|
|
162
|
+
},
|
|
163
|
+
// blacksmith, herbalist… — every giver and merchant has a root.
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
// The whole walker: render node.text + numbered options; a click (or the
|
|
167
|
+
// 1–9 key) runs option.action?.(), then shows option.next or closes. Emit
|
|
168
|
+
// { type: "npc:talked", npc } when a conversation OPENS — that's what "talk"
|
|
169
|
+
// objectives listen for.
|
|
170
|
+
export function pickOption(node: DialogueNode, index: number): DialogueNode | null {
|
|
171
|
+
const opt = node.options[index];
|
|
172
|
+
if (!opt) return node;
|
|
173
|
+
opt.action?.();
|
|
174
|
+
return opt.next ?? null; // null = close the dialogue panel
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function openDialogue(npc: string): DialogueNode {
|
|
178
|
+
const root = ROOTS[npc];
|
|
179
|
+
const options = [...root.options];
|
|
180
|
+
for (const q of QUESTS) {
|
|
181
|
+
if (q.giver !== npc) continue;
|
|
182
|
+
const s = questState.get(q.id)!;
|
|
183
|
+
if (s.status === "available") {
|
|
184
|
+
options.unshift({
|
|
185
|
+
label: `[Quest] ${q.title}`,
|
|
186
|
+
next: {
|
|
187
|
+
text: q.brief,
|
|
188
|
+
options: [
|
|
189
|
+
{ label: "I'll do it.", action: () => accept(q.id) },
|
|
190
|
+
{ label: "Not now." },
|
|
191
|
+
],
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
} else if (s.status === "active") {
|
|
195
|
+
options.unshift({ label: `[${q.title}] Remind me.`, next: { text: q.brief, options: [] } });
|
|
196
|
+
} else if (s.status === "ready") {
|
|
197
|
+
options.unshift({
|
|
198
|
+
label: `[Complete] ${q.title}`,
|
|
199
|
+
next: { text: q.turnIn, options: [] },
|
|
200
|
+
action: () => {
|
|
201
|
+
const r = turnIn(q.id);
|
|
202
|
+
// …grant r.gold / r.xp / r.items through your wallet + inventory.
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return { text: root.text, options };
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
## 4. Items, merchants, XP — the economy that closes
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
// items.ts
|
|
215
|
+
type ItemDef = {
|
|
216
|
+
id: string;
|
|
217
|
+
kind: "weapon" | "armor" | "potion" | "quest" | "tome";
|
|
218
|
+
name: string;
|
|
219
|
+
price: number; // what merchants charge — the SINK side of the economy
|
|
220
|
+
stats?: { dmg?: number; armor?: number; heal?: number };
|
|
221
|
+
};
|
|
222
|
+
|
|
223
|
+
export const ITEMS: ItemDef[] = [
|
|
224
|
+
{ id: "sword_iron", kind: "weapon", name: "Iron Sword", price: 120, stats: { dmg: 18 } },
|
|
225
|
+
{ id: "potion_health", kind: "potion", name: "Health Draught", price: 25, stats: { heal: 40 } },
|
|
226
|
+
{ id: "founder_amulet", kind: "quest", name: "Founder's Amulet", price: 0 },
|
|
227
|
+
// …the catalog. Price the first upgrade to land right after the first quest's gold.
|
|
228
|
+
];
|
|
229
|
+
|
|
230
|
+
// Merchant stock is per-NPC data — a second vendor is one more entry.
|
|
231
|
+
export const STOCKS: Record<string, string[]> = {
|
|
232
|
+
blacksmith: ["sword_iron", "armor_leather"],
|
|
233
|
+
herbalist: ["potion_health", "potion_mana"],
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
// XP curve + level rewards: one growth axis, wired to quest/enemy rewards.
|
|
237
|
+
export const xpNext = (level: number): number => Math.round(100 * level ** 1.4);
|
|
238
|
+
export function addXp(player: { level: number; xp: number; maxHp: number; hp: number }, amount: number): void {
|
|
239
|
+
player.xp += amount;
|
|
240
|
+
while (player.xp >= xpNext(player.level)) {
|
|
241
|
+
player.xp -= xpNext(player.level);
|
|
242
|
+
player.level += 1;
|
|
243
|
+
player.maxHp += 12;
|
|
244
|
+
player.hp = player.maxHp; // level-up heals — a reward the player FEELS
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Seeded placement — bulk content for free
|
|
250
|
+
|
|
251
|
+
Scatter the non-authored bulk (chests, camps, flavor spawns) with a seeded
|
|
252
|
+
RNG so the world is deterministic across loads and machines:
|
|
253
|
+
|
|
254
|
+
```ts
|
|
255
|
+
export function seededRng(seed: number): () => number {
|
|
256
|
+
let s = seed >>> 0;
|
|
257
|
+
return () => {
|
|
258
|
+
s = (s * 1664525 + 1013904223) >>> 0;
|
|
259
|
+
return s / 0xffffffff;
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// const rng = seededRng(1337): place 10 chests at rng()-driven offsets around
|
|
263
|
+
// locations, skip water/steep slopes via the world's height/biome lookups
|
|
264
|
+
// ($genex-threejs-open-world), and hand-place only the authored few.
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
Hand-authored where it matters (quest prose, boss mechanics, unique loot),
|
|
268
|
+
tables for the bulk, seeds for the scatter — that split is what lets one
|
|
269
|
+
session ship the whole contract.
|
|
@@ -46,7 +46,7 @@ npm i @genex-ai/multiplayer@^0.11.0
|
|
|
46
46
|
> landed in 0.10; confirmed object controls, snaps, host-tick teardown, and reconnect rebasing
|
|
47
47
|
> in 0.9. An older resolve does not have those.
|
|
48
48
|
|
|
49
|
-
This skill targets `@genex-ai/multiplayer` **≥ 0.
|
|
49
|
+
This skill targets `@genex-ai/multiplayer` **≥ 0.12.0** (`objects`/`host` since 0.4; verified per-player `avatarUrl` since 0.12;
|
|
50
50
|
`matchmake()` since 0.5; private lobbies since 0.7; auto-reconnect + `inputs`/`onHostTick`
|
|
51
51
|
since 0.8; soft ownership handoff since 0.8.4; confirmed controls, snap epochs, and host-tick
|
|
52
52
|
lifecycle guarantees since 0.9; regional relay selection via `getColyseusUrls()` since 0.10;
|
|
@@ -519,6 +519,7 @@ of five kinds — put each on its channel and the game just works:
|
|
|
519
519
|
| Slow agreed facts (score, round, wave, seed) | `shared` | the **host** (`isHost`) |
|
|
520
520
|
| One-off actions (shot, emote, hit, chat) | `send` + `on` | whoever did it |
|
|
521
521
|
| Discrete per-player values (hp, ammo, flags) | in `me.set`, read via `stateRaw` | you |
|
|
522
|
+
| Which avatar MODEL a player is (VRM look) | already on `players` as `p.avatarUrl` — sync nothing | the **relay** (verified identity) |
|
|
522
523
|
|
|
523
524
|
Getting the channel right is the whole game. A ball on `shared` stutters (not smoothed) and
|
|
524
525
|
fights (many writers). A ball on `objects` glides and has one owner. That's the difference.
|
|
@@ -531,9 +532,11 @@ fights (many writers). A ball on `objects` glides and has one owner. That's the
|
|
|
531
532
|
- `room.me.snap(state)` — respawn/teleport/mode edge. Publishes a discontinuity epoch so remotes
|
|
532
533
|
hard-reseed instead of interpolating from the old pose. Never use for ordinary movement.
|
|
533
534
|
- `room.players` — fresh `Map` each read, **includes you** (skip `id === room.id`). Each value is
|
|
534
|
-
`{ id, name, connected, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you);
|
|
535
|
+
`{ id, name, avatarUrl, connected, state, stateRaw }`: `state` is auto-smoothed (remotes) / live (you);
|
|
535
536
|
`stateRaw` is the raw latest (hit-tests, discrete values). A reconnect-grace seat remains in this
|
|
536
|
-
map with `connected: false`.
|
|
537
|
+
map with `connected: false`. `avatarUrl` is that player's verified VRM pick (server-set, `''`
|
|
538
|
+
when unknown) — in a VRM-lane game render each remote with
|
|
539
|
+
`loadVrm(p.avatarUrl || "./assets/avatar.vrm")`; never publish avatar URLs through `me.set`.
|
|
537
540
|
- `room.activePlayers` — the connected-only subset of `room.players`; use its size for live quorum.
|
|
538
541
|
- `room.objects` — shared objects nobody owns until claimed (a ball, an NPC):
|
|
539
542
|
- `claim(id)` — **legacy** optimistic request. It flips local ownership immediately and is corrected
|
|
@@ -274,8 +274,12 @@ room.me.set({
|
|
|
274
274
|
});
|
|
275
275
|
|
|
276
276
|
// Remote players: a VISUAL-ONLY avatar — NO Rapier body, NO controller instance for remotes.
|
|
277
|
-
//
|
|
278
|
-
//
|
|
277
|
+
// Build each remote's visual from THEIR OWN model: in a VRM-lane game that is
|
|
278
|
+
// loadVrm(pl.avatarUrl || "./assets/avatar.vrm") — the verified per-player pick the relay
|
|
279
|
+
// replicates ('' = unknown → fall back; deepDispose the model on 'leave'). Never reuse your
|
|
280
|
+
// own avatar file for every remote. Position/rotation from smoothed state; animation from the
|
|
281
|
+
// synced flags via the avatar's own update(flags, dt). The character-controller skill's
|
|
282
|
+
// animations reference owns the flag set.
|
|
279
283
|
const pl = room.players.get(id)!;
|
|
280
284
|
remoteAvatar.group.position.set(pl.state.x, pl.state.y, pl.state.z);
|
|
281
285
|
remoteAvatar.group.quaternion.fromArray(pl.state.q);
|