@netforge/template-party 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/NOTICE +32 -0
- package/README.md +50 -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.txt +375 -0
- package/package.json +57 -3
- package/src/game.ts +326 -0
- package/src/manifest.ts +38 -0
- package/src/store.ts +107 -0
package/src/game.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ROMPHOUSE: the party co-op template ("party co-op").
|
|
3
|
+
*
|
|
4
|
+
* Two to eight friends in one big playroom, five quick rounds a show. Each
|
|
5
|
+
* round is a different minigame with one goal for the whole team: pop the
|
|
6
|
+
* party poppers before the timer, crash every gift box, catch the runaway
|
|
7
|
+
* pup, hold the floor against a tide, and as the finale burst the Big
|
|
8
|
+
* Balloon. Win three of five and the show is a hit. Weapons fire by themselves, so every round
|
|
9
|
+
* is about where you stand and who you help up.
|
|
10
|
+
*
|
|
11
|
+
* This file is the whole game as the model writes it: declarations only,
|
|
12
|
+
* against the typed API in @netforge/engine. The rounds are the "show"
|
|
13
|
+
* system below: a list of minigames, each a few lines of start, check and
|
|
14
|
+
* finish. Everything else (sim, netcode, renderer, referee) is the
|
|
15
|
+
* template's machinery. Design rules: packages/engine/GAME-DESIGN.md.
|
|
16
|
+
*/
|
|
17
|
+
import { achievement, earn, game, keeper, mission, prefab, store, system, title, upgrade, weapon } from "@netforge/engine";
|
|
18
|
+
|
|
19
|
+
/** The playroom: one 60 m room (its floor and four walls), with party tables to dodge round. */
|
|
20
|
+
const ROOM = 30;
|
|
21
|
+
|
|
22
|
+
export default game({
|
|
23
|
+
template: "party",
|
|
24
|
+
style: "lowpoly",
|
|
25
|
+
places: { playroom: { chunks: "places/playroom", spawn: [0, 1, 0] } },
|
|
26
|
+
field: {
|
|
27
|
+
halfSize: ROOM + 2,
|
|
28
|
+
pillars: [
|
|
29
|
+
[12, 12, 1.6], [-12, 12, 1.6], [12, -12, 1.6], [-12, -12, 1.6],
|
|
30
|
+
[0, 21, 2], [0, -21, 2], [21, 0, 2], [-21, 0, 2],
|
|
31
|
+
],
|
|
32
|
+
floors: [[0, 0, ROOM, ROOM]],
|
|
33
|
+
walls: [[0, ROOM + 1, ROOM + 2, 1], [0, -ROOM - 1, ROOM + 2, 1], [ROOM + 1, 0, 1, ROOM], [-ROOM - 1, 0, 1, ROOM]],
|
|
34
|
+
},
|
|
35
|
+
look: { preset: "studio", bloom: 1.1, saturation: 1.2, vignette: 0.25 },
|
|
36
|
+
players: {
|
|
37
|
+
prefab: "style.lowpoly.blocky_c",
|
|
38
|
+
max: 8,
|
|
39
|
+
weapon: "confetti_pop",
|
|
40
|
+
xp: { base: 2, growth: 2, exponent: 2 },
|
|
41
|
+
magnet: 4.5,
|
|
42
|
+
hitIframes: 0.8,
|
|
43
|
+
share: "all",
|
|
44
|
+
crit: { chance: 0.1, multiplier: 2 },
|
|
45
|
+
reviveBuff: { seconds: 5, damage: 1.3, speed: 1.2 },
|
|
46
|
+
bossHealthPerPlayer: 0.35,
|
|
47
|
+
// A party forgives: a short down, two free get-ups a show, back in 3 s.
|
|
48
|
+
downed: { seconds: 8, crawl: 0.5, selfRevives: 2, respawn: 3 },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// The party crowd. Health in hit points, speed in m/s (players run at 7), damage per touch.
|
|
53
|
+
const GIFT = [{ id: "cake_slice", chance: 0.02 }, { id: "party_magnet", chance: 0.01 }];
|
|
54
|
+
prefab("popper", { model: "style.lowpoly.slime_pink", appearance: { tint: "#ff7ab8" }, health: 16, speed: 3.6, radius: 0.4, damage: 5, ai: "chase", drops: ["star_small", ...GIFT] });
|
|
55
|
+
prefab("fizz", { model: "style.lowpoly.bee", appearance: { tint: "#7affd4" }, health: 8, speed: 5.4, radius: 0.28, damage: 3, ai: "runner", drops: ["star_small", ...GIFT] });
|
|
56
|
+
prefab("wobbler", { model: "style.lowpoly.blob_ninja", appearance: { tint: "#c9a7ff" }, health: 50, speed: 2.5, radius: 0.55, damage: 9, ai: "tank", mass: 3, drops: ["star_mid"] });
|
|
57
|
+
prefab("spritzer", { model: "style.lowpoly.frog", appearance: { tint: "#5ec8ff", scale: 0.45 }, health: 20, speed: 2.8, radius: 0.4, damage: 4, ai: "ranged", attack: { damage: 7, range: 9, speed: 7 }, drops: ["star_small"] });
|
|
58
|
+
prefab("party_pup", { model: "style.lowpoly.pug", appearance: { tint: "#ffd36b" }, health: 70, speed: 6, radius: 0.45, damage: 0, ai: "flee", drops: [{ id: "party_chest", chance: 1 }] });
|
|
59
|
+
prefab("gift_box", { model: "style.lowpoly.crate", appearance: { tint: "#ff5a7a", accent: "#ffe066" }, health: 30, speed: 0, radius: 0.55, damage: 0, prop: true, drops: ["star_mid", { id: "cake_slice", chance: 0.2 }] });
|
|
60
|
+
// The Big Balloon: the show's boss round. Two telegraphed attacks and a burst of prizes.
|
|
61
|
+
prefab("big_balloon", {
|
|
62
|
+
model: "style.lowpoly.jelly_flyer",
|
|
63
|
+
appearance: { tint: "#ff5a7a", accent: "#ffe066", scale: 1.4 },
|
|
64
|
+
health: 650,
|
|
65
|
+
speed: 2.4,
|
|
66
|
+
radius: 1.3,
|
|
67
|
+
damage: 12,
|
|
68
|
+
// It bounces after you: a slam ring around it, and a leap that lands on a circle at you.
|
|
69
|
+
boss: { attacks: ["slam", "leap"], rank: "mini" },
|
|
70
|
+
drops: ["star_large"],
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// Prizes: stars are XP; cake heals, the magnet pulls every star, the chest is a pick.
|
|
74
|
+
prefab("cake_slice", { model: "style.lowpoly.cell_heal", appearance: { tint: "#ffd1e8" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "heal", amount: 0.3 } });
|
|
75
|
+
prefab("party_magnet", { model: "style.lowpoly.crystal", appearance: { tint: "#7ad7ff" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "vacuum" } });
|
|
76
|
+
prefab("party_chest", { model: "style.lowpoly.chest", appearance: { tint: "#ffcf5a" }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 1 } });
|
|
77
|
+
prefab("prize_hoard", { model: "style.lowpoly.chest", appearance: { tint: "#ff7ab8", scale: 1.5 }, health: 1, speed: 0, kind: "pickup", pickup: { effect: "chest", amount: 2, evolve: true } });
|
|
78
|
+
prefab("star_small", { model: "style.lowpoly.gem_small", appearance: { tint: "#ffe066" }, health: 1, speed: 0, kind: "pickup", value: 1 });
|
|
79
|
+
prefab("star_mid", { model: "style.lowpoly.gem_mid", appearance: { tint: "#ffb347" }, health: 1, speed: 0, kind: "pickup", value: 6 });
|
|
80
|
+
prefab("star_large", { model: "style.lowpoly.gem_large", appearance: { tint: "#ff7ab8" }, health: 1, speed: 0, kind: "pickup", value: 40 });
|
|
81
|
+
|
|
82
|
+
// Weapons fire by themselves. Cooldowns in seconds, ranges in metres.
|
|
83
|
+
weapon("confetti_pop", {
|
|
84
|
+
kind: "nova", damage: 12, cooldown: 1, range: 3.6, knockback: 3,
|
|
85
|
+
levels: [{ damage: "+30%" }, { range: "+15%" }, { cooldown: "-15%" }, { damage: "+30%" }, { range: "+15%" }],
|
|
86
|
+
evolve: { into: "confetti_cannon", requires: "sugar_rush" },
|
|
87
|
+
});
|
|
88
|
+
weapon("streamer", {
|
|
89
|
+
kind: "bolt", damage: 13, cooldown: 0.6, range: 13, speed: 24, pierce: 1, knockback: 2,
|
|
90
|
+
levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { pierce: 1 }, { count: 1 }],
|
|
91
|
+
evolve: { into: "streamer_storm", requires: "party_hat" },
|
|
92
|
+
});
|
|
93
|
+
weapon("spinning_plates", {
|
|
94
|
+
kind: "orbit", damage: 8, cooldown: 0.1, range: 2.4, count: 2, speed: 3.8, knockback: 3,
|
|
95
|
+
levels: [{ count: 1 }, { damage: "+30%" }, { range: "+20%" }, { count: 1 }, { damage: "+40%" }],
|
|
96
|
+
evolve: { into: "plate_carousel", requires: "disco_ball" },
|
|
97
|
+
});
|
|
98
|
+
weapon("pie_toss", {
|
|
99
|
+
kind: "meteor", damage: 34, cooldown: 2, range: 11, count: 1, blast: 1.8, knockback: 6,
|
|
100
|
+
levels: [{ count: 1 }, { damage: "+40%" }, { cooldown: "-20%" }, { count: 1 }, { damage: "+40%" }],
|
|
101
|
+
});
|
|
102
|
+
weapon("limbo_bar", {
|
|
103
|
+
kind: "cone", damage: 16, cooldown: 1.1, range: 4, spread: 110, knockback: 6,
|
|
104
|
+
levels: [{ damage: "+30%" }, { spread: 40 }, { range: "+20%" }, { cooldown: "-20%" }, { damage: "+40%" }],
|
|
105
|
+
});
|
|
106
|
+
weapon("kazoo_chain", {
|
|
107
|
+
kind: "chain", damage: 15, cooldown: 1.3, range: 9, count: 2, speed: 4, knockback: 1,
|
|
108
|
+
levels: [{ count: 1 }, { damage: "+35%" }, { count: 1 }, { cooldown: "-20%" }, { count: 2 }],
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
// Evolutions: about x1.7 damage and a new shape. Never offered on their own.
|
|
112
|
+
weapon("confetti_cannon", { kind: "nova", damage: 36, cooldown: 0.8, range: 8, knockback: 9 });
|
|
113
|
+
weapon("streamer_storm", { kind: "bolt", damage: 30, cooldown: 0.4, range: 15, speed: 28, count: 6, pierce: 3, knockback: 3 });
|
|
114
|
+
weapon("plate_carousel", { kind: "orbit", damage: 18, cooldown: 0.1, range: 3.6, count: 6, speed: 4.6, knockback: 4 });
|
|
115
|
+
|
|
116
|
+
// Passive upgrades offered at level-up. Each evolution pairs with one.
|
|
117
|
+
upgrade("dance_shoes", { stat: "speed", step: 0.07, maxLevel: 5 });
|
|
118
|
+
upgrade("second_helping", { stat: "maxHealth", step: 20, maxLevel: 5 });
|
|
119
|
+
upgrade("party_hat", { stat: "amount", step: 1, maxLevel: 2 });
|
|
120
|
+
upgrade("sugar_rush", { stat: "haste", step: 0.06, maxLevel: 5 });
|
|
121
|
+
upgrade("disco_ball", { stat: "area", step: 0.1, maxLevel: 5 });
|
|
122
|
+
upgrade("lucky_sock", { stat: "might", step: 0.1, maxLevel: 5 });
|
|
123
|
+
upgrade("goodie_bag", { stat: "magnet", step: 0.8, maxLevel: 4 });
|
|
124
|
+
|
|
125
|
+
/** Seconds a round runs, the breather between rounds, and the rounds won that make the show a hit. */
|
|
126
|
+
const ROUND_SECONDS = 40;
|
|
127
|
+
const BREATHER = 6;
|
|
128
|
+
const HIT_AT = 3;
|
|
129
|
+
const SIDES = ["north", "south", "east", "west"] as const;
|
|
130
|
+
/** Everything a round brings in: cleared off the floor between rounds. */
|
|
131
|
+
const SWEEP = ["popper", "fizz", "wobbler", "spritzer", "party_pup", "gift_box", "big_balloon"];
|
|
132
|
+
|
|
133
|
+
system("show", ({ every, after, onTick, tick, players, spawnEdge, scatter, alive, clear, emit, announce, upcoming, respawnDead, random, on, end }) => {
|
|
134
|
+
// The five minigames, shuffled each show. start() runs as the round opens; won() is checked every half second
|
|
135
|
+
// (true ends it early), and at the bell the round is won if won() holds or `lastsOut` (nobody fell).
|
|
136
|
+
let kills = 0;
|
|
137
|
+
let downs = 0;
|
|
138
|
+
let pupCaught = false;
|
|
139
|
+
let balloonBurst = false;
|
|
140
|
+
const party = () => Math.max(1, players().length);
|
|
141
|
+
const poppers = () => 40 + 12 * (party() - 1);
|
|
142
|
+
interface Round {
|
|
143
|
+
readonly name: string;
|
|
144
|
+
readonly seconds: number;
|
|
145
|
+
start(): void;
|
|
146
|
+
won(): boolean;
|
|
147
|
+
readonly lastsOut?: boolean;
|
|
148
|
+
}
|
|
149
|
+
const ROUNDS: Round[] = [
|
|
150
|
+
{
|
|
151
|
+
name: "POP THE PARTY",
|
|
152
|
+
seconds: ROUND_SECONDS,
|
|
153
|
+
start: () => {
|
|
154
|
+
spawnEdge("popper", { count: 10 });
|
|
155
|
+
every(2, () => {
|
|
156
|
+
if (current === 0) spawnEdge(random() < 0.25 ? "fizz" : "popper", { count: 4 + party() });
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
won: () => kills >= poppers(),
|
|
160
|
+
},
|
|
161
|
+
{
|
|
162
|
+
name: "CRATE CRASH",
|
|
163
|
+
seconds: 40,
|
|
164
|
+
start: () => {
|
|
165
|
+
scatter("gift_box", 14 + 3 * party());
|
|
166
|
+
spawnEdge("popper", { count: 6 });
|
|
167
|
+
every(5, () => {
|
|
168
|
+
if (current === 1) spawnEdge("popper", { count: 3 + party() });
|
|
169
|
+
});
|
|
170
|
+
},
|
|
171
|
+
won: () => alive("gift_box") === 0,
|
|
172
|
+
},
|
|
173
|
+
{
|
|
174
|
+
name: "CATCH THE PUP",
|
|
175
|
+
seconds: 30,
|
|
176
|
+
start: () => {
|
|
177
|
+
spawnEdge("party_pup", { count: 1, side: "ahead", spread: 90 });
|
|
178
|
+
spawnEdge("wobbler", { count: 2 + Math.floor(party() / 2) });
|
|
179
|
+
// The pup's friends keep getting in the way.
|
|
180
|
+
every(5, () => {
|
|
181
|
+
if (current === 2) spawnEdge("fizz", { count: 2 + Math.floor(party() / 2) });
|
|
182
|
+
});
|
|
183
|
+
},
|
|
184
|
+
won: () => pupCaught,
|
|
185
|
+
},
|
|
186
|
+
{
|
|
187
|
+
name: "HOLD THE FLOOR",
|
|
188
|
+
seconds: 35,
|
|
189
|
+
lastsOut: true,
|
|
190
|
+
start: () => {
|
|
191
|
+
const tide = () => {
|
|
192
|
+
if (current !== 3) return;
|
|
193
|
+
const side = SIDES[Math.floor(random() * SIDES.length)] ?? "north";
|
|
194
|
+
spawnEdge("popper", { count: 18 + 4 * party(), side, spread: 80 });
|
|
195
|
+
spawnEdge("spritzer", { count: 2, side, spread: 60 });
|
|
196
|
+
};
|
|
197
|
+
tide();
|
|
198
|
+
every(9, tide);
|
|
199
|
+
},
|
|
200
|
+
won: () => false,
|
|
201
|
+
},
|
|
202
|
+
{
|
|
203
|
+
name: "BURST THE BIG BALLOON",
|
|
204
|
+
seconds: 60,
|
|
205
|
+
start: () => {
|
|
206
|
+
spawnEdge("big_balloon", { count: 1 });
|
|
207
|
+
spawnEdge("fizz", { count: 6 });
|
|
208
|
+
// Its fizz keep coming while it floats.
|
|
209
|
+
every(6, () => {
|
|
210
|
+
if (current === 4) spawnEdge("fizz", { count: 2 + Math.floor(party() / 2) });
|
|
211
|
+
});
|
|
212
|
+
},
|
|
213
|
+
won: () => balloonBurst,
|
|
214
|
+
},
|
|
215
|
+
];
|
|
216
|
+
// The show's order: the first four shuffled on the sim's random, so every show plays differently;
|
|
217
|
+
// the Big Balloon is always the finale.
|
|
218
|
+
const order = ROUNDS.map((_, i) => i);
|
|
219
|
+
for (let i = order.length - 2; i > 0; i--) {
|
|
220
|
+
const j = Math.floor(random() * (i + 1));
|
|
221
|
+
[order[i], order[j]] = [order[j] ?? 0, order[i] ?? 0];
|
|
222
|
+
}
|
|
223
|
+
let step = -1;
|
|
224
|
+
let current = -1;
|
|
225
|
+
let endsAt = 0;
|
|
226
|
+
let wins = 0;
|
|
227
|
+
const sweep = () => {
|
|
228
|
+
for (const id of SWEEP) clear(id);
|
|
229
|
+
};
|
|
230
|
+
const next = () => {
|
|
231
|
+
step++;
|
|
232
|
+
if (step >= order.length) {
|
|
233
|
+
const score = wins + " OF " + order.length;
|
|
234
|
+
announce(wins >= HIT_AT ? "A HIT · " + score : "THE SHOW IS OVER · " + score, { tone: wins >= HIT_AT ? "reward" : "info" });
|
|
235
|
+
if (wins === order.length) emit("show.perfect");
|
|
236
|
+
after(3, () => end(wins >= HIT_AT ? "won" : "lost"));
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
const r = ROUNDS[order[step] ?? 0] as Round;
|
|
240
|
+
current = order[step] ?? 0;
|
|
241
|
+
kills = 0;
|
|
242
|
+
downs = 0;
|
|
243
|
+
pupCaught = false;
|
|
244
|
+
balloonBurst = false;
|
|
245
|
+
announce("ROUND " + (step + 1) + " · " + r.name, { tone: "info" });
|
|
246
|
+
upcoming(r.name, r.seconds);
|
|
247
|
+
endsAt = tick() + r.seconds * 30;
|
|
248
|
+
r.start();
|
|
249
|
+
};
|
|
250
|
+
const finish = (won: boolean) => {
|
|
251
|
+
const r = ROUNDS[current];
|
|
252
|
+
current = -1;
|
|
253
|
+
if (won) {
|
|
254
|
+
wins++;
|
|
255
|
+
emit("round.won");
|
|
256
|
+
announce((r?.name ?? "ROUND") + " · WON", { tone: "reward" });
|
|
257
|
+
} else announce((r?.name ?? "ROUND") + " · MISSED", { tone: "danger" });
|
|
258
|
+
emit("wave.cleared");
|
|
259
|
+
// The round's clock gives way to the breather's (the last round has none: the show ends).
|
|
260
|
+
if (step + 1 < order.length) upcoming("NEXT ROUND", BREATHER);
|
|
261
|
+
after(1.5, sweep);
|
|
262
|
+
after(2, () => respawnDead());
|
|
263
|
+
after(BREATHER, next);
|
|
264
|
+
};
|
|
265
|
+
on("enemy.killed", (ids) => {
|
|
266
|
+
if (current >= 0) kills += Math.max(1, ids.length);
|
|
267
|
+
});
|
|
268
|
+
on("player.downed", () => {
|
|
269
|
+
if (current >= 0) downs++;
|
|
270
|
+
});
|
|
271
|
+
on("party_pup.killed", () => {
|
|
272
|
+
pupCaught = true;
|
|
273
|
+
});
|
|
274
|
+
on("miniboss.killed", () => {
|
|
275
|
+
balloonBurst = true;
|
|
276
|
+
});
|
|
277
|
+
onTick(() => {
|
|
278
|
+
if (current < 0 || tick() % 15 !== 0) return;
|
|
279
|
+
const r = ROUNDS[current] as Round;
|
|
280
|
+
if (r.won()) finish(true);
|
|
281
|
+
else if (tick() >= endsAt) finish(r.lastsOut === true && downs === 0);
|
|
282
|
+
});
|
|
283
|
+
// Doors open: a few poppers wander in while the first round is announced.
|
|
284
|
+
after(0.5, () => spawnEdge("popper", { count: 4 + party() }));
|
|
285
|
+
after(2, next);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// Keepers: pick one before your first level. New keepers unlock with achievements, never with coins.
|
|
289
|
+
keeper("host", { name: "Host", text: "Confetti all round and quick on the feet.", weapon: "confetti_pop", passive: "dance_shoes" });
|
|
290
|
+
keeper("juggler", { name: "Juggler", text: "Spinning plates and a wider spin.", model: "style.lowpoly.blocky_k", tint: "#7ad7ff", weapon: "spinning_plates", passive: "disco_ball", unlock: "first_hit" });
|
|
291
|
+
keeper("pie_chef", { name: "Pie Chef", text: "Pies from on high, and seconds.", model: "style.lowpoly.blocky_f", tint: "#ffb347", weapon: "pie_toss", passive: "second_helping", unlock: "balloon_popper" });
|
|
292
|
+
|
|
293
|
+
// Achievements (counted in a show; they unlock keepers and pay nothing).
|
|
294
|
+
achievement("first_hit", { title: "Crowd Pleaser", text: "Win three rounds in one show.", when: "round.won", n: 3 });
|
|
295
|
+
achievement("balloon_popper", { title: "Pop Goes", text: "Burst the Big Balloon.", when: "miniboss.killed" });
|
|
296
|
+
achievement("perfect_show", { title: "Standing Ovation", text: "Win every round of a show.", when: "show.perfect" });
|
|
297
|
+
|
|
298
|
+
// Daily goals: the HUD shows three a day. They pay nothing.
|
|
299
|
+
mission("rounds", { text: "Win 3 rounds", when: "round.won", n: 3 });
|
|
300
|
+
mission("pup", { text: "Catch the pup", when: "party_pup.killed" });
|
|
301
|
+
mission("revive", { text: "Help a friend up", when: "player.revived" });
|
|
302
|
+
mission("chest", { text: "Open 2 chests", when: "chest.collected", n: 2 });
|
|
303
|
+
mission("balloon", { text: "Burst the Big Balloon", when: "miniboss.killed" });
|
|
304
|
+
|
|
305
|
+
// End-screen titles: who stole the show at what.
|
|
306
|
+
title("life_of_party", { text: "Life of the Party", most: "kills" });
|
|
307
|
+
title("showstopper", { text: "Showstopper", most: "damage" });
|
|
308
|
+
title("good_friend", { text: "Good Friend", most: "revives" });
|
|
309
|
+
title("on_their_feet", { text: "On Their Feet", least: "downs" });
|
|
310
|
+
title("snacker", { text: "Snacker", most: "pickups" });
|
|
311
|
+
|
|
312
|
+
// Earn (mandatory). Pays the game's coins (1 coin = 1 token), once per show each.
|
|
313
|
+
// Pay amounts copy EMBERWATCH's (5, 20, 50, 5) until ROMPHOUSE's own are set: PLACEHOLDER.
|
|
314
|
+
earn.rule("first_win", { when: "round.won", n: 1, pay: 5, oncePer: "run" });
|
|
315
|
+
earn.rule("crowd_favourite", { when: "round.won", n: 4, pay: 20, oncePer: "run" });
|
|
316
|
+
earn.rule("full_house", { when: "show.perfect", n: 1, pay: 50, oncePer: "run" });
|
|
317
|
+
earn.rule("confetti_storm", { when: "enemy.killed", n: 70, pay: 5, oncePer: "run" });
|
|
318
|
+
// Buy-in run (the mandatory sink loop): stake coins at the start of a show to earn above free play.
|
|
319
|
+
// Values are PLACEHOLDER.
|
|
320
|
+
earn.stake({ min: 20, max: 200, multiple: 2 });
|
|
321
|
+
|
|
322
|
+
// Store (the Kit), priced in coins (the default coin model). Prices copy EMBERWATCH's: PLACEHOLDER.
|
|
323
|
+
store.sku("revive", { coins: 50 });
|
|
324
|
+
store.sku("skin_disco", { coins: 300, grants: { cosmetic: 1 } });
|
|
325
|
+
// Losable gear: owned for good until lost in a staked show that makes less than loseBelow of its most earn.
|
|
326
|
+
store.sku("lucky_charm", { coins: 150, grants: { gear: "lucky_sock", loseBelow: 0.25 } });
|
package/src/manifest.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Loads the game module once per process and compiles it. The room server,
|
|
3
|
+
* the client, the bots and the tests all start here.
|
|
4
|
+
*/
|
|
5
|
+
import { collectManifest, compileGame, engineSimAdapter } from "@netforge/engine/game";
|
|
6
|
+
import type { CompiledGame, EngineRoomWorld, RunnerOptions } from "@netforge/engine/game";
|
|
7
|
+
import type { SimAdapter } from "@netforge/engine/net";
|
|
8
|
+
import type { InputCommand } from "@netforge/engine-sim";
|
|
9
|
+
import "./game.js";
|
|
10
|
+
|
|
11
|
+
/** Content version: bump with any change to game.ts (hot swap compares it). */
|
|
12
|
+
export const GAME_VERSION = "romphouse-0.1.0";
|
|
13
|
+
export const GAME_NAME = "romphouse";
|
|
14
|
+
/** Room template and place used by the client and the bots. */
|
|
15
|
+
export const TEMPLATE = "party";
|
|
16
|
+
export const PLACE = "playroom";
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Collected as soon as the module is imported: one process may load more
|
|
20
|
+
* than one game module (the room server hosts every template), and each
|
|
21
|
+
* manifest takes its own game before the next module declares.
|
|
22
|
+
*/
|
|
23
|
+
const MANIFEST = collectManifest();
|
|
24
|
+
let compiled: CompiledGame | null = null;
|
|
25
|
+
|
|
26
|
+
/** The compiled game (prefabs, weapons, upgrades and the playroom's walls and floor as sim content). */
|
|
27
|
+
export function partyGame(): CompiledGame {
|
|
28
|
+
compiled ??= compileGame(MANIFEST);
|
|
29
|
+
return compiled;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The SimAdapter for this game. `runner` options are server-side measurement modes. */
|
|
33
|
+
export function partyAdapter(runner?: RunnerOptions): SimAdapter<EngineRoomWorld, InputCommand> {
|
|
34
|
+
return engineSimAdapter({ name: GAME_NAME, version: GAME_VERSION, game: partyGame(), ...(runner ? { runner } : {}) });
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Cosmetic ids for store SKUs (0 is the default palette). */
|
|
38
|
+
export const COSMETICS: Readonly<Record<string, number>> = { skin_disco: 1 };
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store hooks (the Coins model): every
|
|
3
|
+
* game has coins, 1 coin = 1 game token, shown under the token's own name,
|
|
4
|
+
* symbol and logo. The store sells a revive (spent while downed), a
|
|
5
|
+
* cosmetic disco palette (permanent) and a losable charm, priced in coins.
|
|
6
|
+
*
|
|
7
|
+
* `KitStoreBridge` is the slice of @netforge/kit's `Kit` this game
|
|
8
|
+
* calls, with the Kit's own names (`kit.coins.*`, `kit.store.owns/buy`), so
|
|
9
|
+
* the real Kit drops in unchanged. Until a game's token is live `mockKit()`
|
|
10
|
+
* stands in, in memory. Earned coins are spent first (off chain, no wallet
|
|
11
|
+
* prompt), then the wallet, exactly as the Kit does.
|
|
12
|
+
*/
|
|
13
|
+
export interface KitStoreBridge {
|
|
14
|
+
readonly store: {
|
|
15
|
+
owns(id: string): boolean;
|
|
16
|
+
count(id: string): number;
|
|
17
|
+
buy(id: string, opts?: { pay?: "usdg" | "token" | "coins" }): Promise<unknown>;
|
|
18
|
+
};
|
|
19
|
+
readonly coins: {
|
|
20
|
+
/** The token's own name, symbol and logo (its metadata). Never a generic "COIN". */
|
|
21
|
+
name(): string;
|
|
22
|
+
symbol(): string;
|
|
23
|
+
logo(): string | null;
|
|
24
|
+
/** Whole coins: earned (unclaimed) plus the wallet. */
|
|
25
|
+
balance(): number;
|
|
26
|
+
/** Earned first, then the wallet. `key` proves the paid room command (sent as the command's `proof`). */
|
|
27
|
+
spend(amount: number, reason: string): Promise<{ key: string | null }>;
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Coin prices the client is wired to (they match store.sku(...) in game.ts; the generator's gate keeps them). */
|
|
32
|
+
export const COIN_PRICES = { revive: 50, skin_disco: 300 } as const;
|
|
33
|
+
/** What a revive costs, in coins. */
|
|
34
|
+
export const REVIVE_PRICE = COIN_PRICES.revive;
|
|
35
|
+
/** What the Disco palette costs, in coins. */
|
|
36
|
+
export const COSMETIC_PRICE = COIN_PRICES.skin_disco;
|
|
37
|
+
|
|
38
|
+
/** The template's own coin, as its token metadata names it until a creator renames it. */
|
|
39
|
+
export const ROMP_COIN = {
|
|
40
|
+
name: "Romp",
|
|
41
|
+
symbol: "ROMP",
|
|
42
|
+
logo: "data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Ccircle cx='16' cy='16' r='15' fill='%23ff7ab8'/%3E%3Cpath d='M9 24 16 6l7 18z' fill='%23ffe066'/%3E%3Ccircle cx='13' cy='17' r='1.6' fill='%237ad7ff'/%3E%3Ccircle cx='18' cy='13' r='1.6' fill='%237affd4'/%3E%3Ccircle cx='19' cy='20' r='1.6' fill='%23c9a7ff'/%3E%3C/svg%3E",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
export interface SkuPrice {
|
|
46
|
+
readonly coins?: number;
|
|
47
|
+
readonly usdg?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface MockKit extends KitStoreBridge {
|
|
51
|
+
/** Dollars left (mock only). */
|
|
52
|
+
usdg(): number;
|
|
53
|
+
/** Earned, unclaimed coins left (mock only). */
|
|
54
|
+
earned(): number;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** An in-memory Kit for local play and tests: starting balances, prices from game.ts. */
|
|
58
|
+
export function mockKit(
|
|
59
|
+
start: { earned: number; wallet: number; usdg: number },
|
|
60
|
+
prices: Readonly<Record<string, SkuPrice>>,
|
|
61
|
+
coin: { name: string; symbol: string; logo: string | null } = ROMP_COIN,
|
|
62
|
+
): MockKit {
|
|
63
|
+
let earned = start.earned;
|
|
64
|
+
let wallet = start.wallet;
|
|
65
|
+
let usdg = start.usdg;
|
|
66
|
+
const owned = new Map<string, number>();
|
|
67
|
+
let spends = 0;
|
|
68
|
+
/** Earned first, then the wallet: the Kit's order. */
|
|
69
|
+
const spend = (amount: number, reason: string) => {
|
|
70
|
+
if (!Number.isInteger(amount) || amount <= 0 || earned + wallet < amount) throw new Error(`cannot spend ${amount} for ${reason}`);
|
|
71
|
+
const fromEarned = Math.min(earned, amount);
|
|
72
|
+
earned -= fromEarned;
|
|
73
|
+
wallet -= amount - fromEarned;
|
|
74
|
+
return { fromEarned, onChain: amount - fromEarned, key: `mock-spend-${++spends}` };
|
|
75
|
+
};
|
|
76
|
+
return {
|
|
77
|
+
usdg: () => usdg,
|
|
78
|
+
earned: () => earned,
|
|
79
|
+
store: {
|
|
80
|
+
owns: (id) => (owned.get(id) ?? 0) > 0,
|
|
81
|
+
count: (id) => owned.get(id) ?? 0,
|
|
82
|
+
async buy(id, opts) {
|
|
83
|
+
const p = prices[id];
|
|
84
|
+
if (!p) throw new Error(`no SKU "${id}"`);
|
|
85
|
+
const payUsdg = opts?.pay === "usdg" || (opts?.pay === undefined && p.coins === undefined);
|
|
86
|
+
if (payUsdg) {
|
|
87
|
+
if (p.usdg === undefined || usdg < p.usdg) throw new Error("not enough USDG");
|
|
88
|
+
usdg -= p.usdg;
|
|
89
|
+
} else {
|
|
90
|
+
if (p.coins === undefined) throw new Error(`"${id}" has no coin price`);
|
|
91
|
+
spend(p.coins, id);
|
|
92
|
+
}
|
|
93
|
+
owned.set(id, (owned.get(id) ?? 0) + 1);
|
|
94
|
+
return { id };
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
coins: {
|
|
98
|
+
name: () => coin.name,
|
|
99
|
+
symbol: () => coin.symbol,
|
|
100
|
+
logo: () => coin.logo,
|
|
101
|
+
balance: () => earned + wallet,
|
|
102
|
+
async spend(amount, reason) {
|
|
103
|
+
return spend(amount, reason);
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|