@homie-rocks/studio 0.20.0 → 0.22.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/CHANGELOG.md +78 -1
- package/README.md +55 -0
- package/assets/assets.ts +500 -0
- package/assets/render-page.ts +629 -0
- package/assets/safety.mjs +194 -0
- package/bin/homie-studio.mjs +52 -3
- package/lib/art-cli.mjs +296 -0
- package/lib/art-tools.mjs +331 -0
- package/lib/asset-check.mjs +189 -0
- package/lib/asset-import.mjs +136 -0
- package/lib/asset-manifest.mjs +318 -0
- package/lib/build.mjs +25 -2
- package/lib/check.mjs +3 -1
- package/lib/codex.mjs +43 -1
- package/lib/colour.mjs +160 -0
- package/lib/decisions.mjs +800 -0
- package/lib/directory.mjs +9 -1
- package/lib/library.mjs +169 -0
- package/lib/mcp-tools.mjs +22 -4
- package/lib/mcp.mjs +6 -3
- package/lib/office.mjs +30 -8
- package/lib/optimise.mjs +380 -0
- package/lib/port-check.mjs +3 -1
- package/lib/progress.mjs +16 -1
- package/lib/remix-assets.mjs +84 -0
- package/lib/render3d.mjs +81 -0
- package/lib/scaffold.mjs +32 -1
- package/lib/site.mjs +6 -1
- package/lib/studio.mjs +136 -9
- package/lib/style-board.mjs +227 -0
- package/lib/style-presets.mjs +121 -0
- package/lib/template-history.json +75 -0
- package/lib/thumb.mjs +82 -0
- package/mcp/ui/card.css +15 -0
- package/mcp/ui/cast.js +62 -0
- package/mcp/ui/decision.js +61 -0
- package/mcp/ui/lineup.js +68 -0
- package/mcp/ui/rights.js +30 -0
- package/mcp/ui/style.js +154 -0
- package/package.json +14 -2
- package/starters/gem-rush-3d/assets/RIGHTS.md +203 -0
- package/starters/gem-rush-3d/assets/manifest.json +2318 -0
- package/starters/gem-rush-3d/credits.json +31 -0
- package/starters/gem-rush-3d/game.json +11 -0
- package/starters/gem-rush-3d/index.html +20 -0
- package/starters/gem-rush-3d/lab.json +19 -0
- package/starters/gem-rush-3d/src/main.ts +2086 -0
- package/starters/gem-rush-3d/style.json +66 -0
- package/starters/gem-rush-3d/tunables.json +13 -0
- package/worker/index.mjs +5 -3
- package/worker/pages.mjs +13 -5
- package/worker/qr.mjs +11 -0
- package/worker/site.mjs +5 -3
- package/worker/version.mjs +1 -1
|
@@ -0,0 +1,2086 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* GEM RUSH 3D — Gem Rush (the netplay v1 reference game: the gem-rush starter and
|
|
3
|
+
* @homie-rocks/studio/netplay/NETPLAY.md) drawn in three.js and dressed with free CC0 models.
|
|
4
|
+
*
|
|
5
|
+
* THE RULES ARE GEM RUSH'S, line for line: move, collect gems, score; 60 s rounds that cycle forever; the round
|
|
6
|
+
* starts the moment the first browser arrives, with two bots, and every human who arrives takes a bot's body. One
|
|
7
|
+
* browser hosts (pickups, scoring, bots, the clock) and broadcasts snapshots; everyone else moves their own animal
|
|
8
|
+
* locally and draws the rest from interpolated snapshots; a promoted host continues the same round. A wave knocks
|
|
9
|
+
* nearby bodies back (the host TAKES a client-owned body, drives the knockback, and GIVES it back); the hot zone
|
|
10
|
+
* (keyed state) scores double; a watcher follows any player (`net.viewSeat`, `net.spotlight`); bots play at the
|
|
11
|
+
* room's skill dial; the knock is tuned in the Game Lab (tunables.json, lab.json). Only the units changed: the
|
|
12
|
+
* world is in metres, a 26 x 16 m clearing with a few bushes, rocks and a tree in it (solid: bodies go round them), x across and y deep (y is
|
|
13
|
+
* three.js's z; the ground is y = 0 in three.js).
|
|
14
|
+
*
|
|
15
|
+
* THE LOOK comes from files beside game.json:
|
|
16
|
+
* style.json the palette, fonts, light and camera (the art direction's tokens: `homie-studio style` writes it);
|
|
17
|
+
* everything the code draws, and the repainted models, take their colours from it
|
|
18
|
+
* public/models/ the animals, the gem, trees, rocks, flowers and the fence: Kenney's CC0 packs from the Homie
|
|
19
|
+
* starter library, phone-sized, fetched by `game new` (the repository keeps no model files).
|
|
20
|
+
* assets/manifest.json names each library item and its SHA-256; assets/RIGHTS.md and credits.json
|
|
21
|
+
* say what its licence allows.
|
|
22
|
+
* Every model loads through @homie-rocks/studio/assets (createModels: each file is checked before three.js reads a
|
|
23
|
+
* byte). A model that is refused or not there is drawn as a stand-in in the same colours (see "models" below): a
|
|
24
|
+
* round never waits on, or breaks for, a model.
|
|
25
|
+
*
|
|
26
|
+
* PHONE BUDGETS: under 100 draw calls and 150k triangles a frame (the `drawCalls` and `triangles` probes read
|
|
27
|
+
* renderer.info), the pixel ratio at most 1.5 on a phone and 2 on a computer, and no shadow map on a phone: a soft
|
|
28
|
+
* disc under each body, gem, tree and rock (a computer draws the sun's real shadows). Repeated things (gems, trees, flowers, the fence, the discs) are one
|
|
29
|
+
* InstancedMesh per model.
|
|
30
|
+
*
|
|
31
|
+
* The HUD (clock, scores, names, results, "Reconnecting…", the controls hint, the role badge) is a 2D canvas over
|
|
32
|
+
* the world, in style.json's fonts and colours.
|
|
33
|
+
*/
|
|
34
|
+
import {
|
|
35
|
+
AmbientLight, AnimationClip, AnimationMixer, Box3, BoxGeometry, BufferAttribute, BufferGeometry, CanvasTexture, CircleGeometry, Color, ConeGeometry, CylinderGeometry,
|
|
36
|
+
DodecahedronGeometry, IcosahedronGeometry, OctahedronGeometry,
|
|
37
|
+
DirectionalLight, DynamicDrawUsage, Euler, ExtrudeGeometry, Fog, Group, HemisphereLight, InstancedMesh, LineBasicMaterial,
|
|
38
|
+
LineSegments, Matrix4, Mesh, MeshBasicMaterial, MeshLambertMaterial, MeshStandardMaterial, NoToneMapping, Object3D, Path,
|
|
39
|
+
PCFSoftShadowMap, PerspectiveCamera, PlaneGeometry, Quaternion, RingGeometry, Scene, Shape, SRGBColorSpace, Vector3, WebGLRenderer,
|
|
40
|
+
type AnimationAction, type Material,
|
|
41
|
+
} from 'three';
|
|
42
|
+
import { mergeGeometries } from 'three/examples/jsm/utils/BufferGeometryUtils.js';
|
|
43
|
+
// The one model loader every studio game uses: checks each file, decodes meshopt and WebP, copies for placing.
|
|
44
|
+
import { createModels, instancedCopies, placeCopy, repaint, stylize, type Copies } from '@homie-rocks/studio/assets';
|
|
45
|
+
import { createNetplay, Roster, q, lerp, capMove, PALETTE, AI_MARK, type RoleChange, type RoundInfo, type RoundResult, type Skill, type Slot, type Snapshot } from '@homie-rocks/studio/netplay';
|
|
46
|
+
// The port toolkit: its probe (what `homie-studio port check` and `perf` read, and sandbox + audio shims), and name
|
|
47
|
+
// labels that never pile up (port/view.ts).
|
|
48
|
+
import { createLabels, exposePort, type LabelIn, type LabelOut } from '@homie-rocks/studio/port';
|
|
49
|
+
// The Game Lab: tunables, phases, tracks and overlays (no-ops outside the lab).
|
|
50
|
+
import { lab } from '@homie-rocks/studio/lab';
|
|
51
|
+
import tuning from '../tunables.json';
|
|
52
|
+
import styleFile from '../style.json';
|
|
53
|
+
import gameFile from '../game.json';
|
|
54
|
+
|
|
55
|
+
/* ------------------------------------------------------------------ rules */
|
|
56
|
+
const W = 26;
|
|
57
|
+
const H = 16;
|
|
58
|
+
const R_AV = 0.44;
|
|
59
|
+
/**
|
|
60
|
+
* The clearing's own features, solid (rules coordinates, metres): bodies slide round them, gems never land in them.
|
|
61
|
+
* Away from the middle row, where bodies start and the Game Lab stages its knock.
|
|
62
|
+
*/
|
|
63
|
+
const OBSTACLES: readonly { x: number; y: number; r: number; kind: 'camp' | 'bush' | 'rock' | 'stone' | 'tree' }[] = [
|
|
64
|
+
// The clearing's heart: a campfire ring with log seats (a game makes it its den, its camp, its well).
|
|
65
|
+
{ x: 13.0, y: 4.0, r: 1.05, kind: 'camp' },
|
|
66
|
+
{ x: 4.6, y: 3.8, r: 0.75, kind: 'bush' }, { x: 21.4, y: 4.6, r: 0.55, kind: 'rock' }, { x: 6.0, y: 12.6, r: 0.5, kind: 'rock' },
|
|
67
|
+
{ x: 20.2, y: 12.2, r: 0.75, kind: 'bush' }, { x: 9.6, y: 13.4, r: 0.45, kind: 'stone' }, { x: 23.6, y: 2.0, r: 0.5, kind: 'tree' },
|
|
68
|
+
];
|
|
69
|
+
/** A position kept in the clearing and out of its features (pushed to their edge, so a body slides round them). */
|
|
70
|
+
function bound(x: number, y: number): { x: number; y: number } {
|
|
71
|
+
x = Math.max(R_AV, Math.min(W - R_AV, x)); y = Math.max(R_AV, Math.min(H - R_AV, y));
|
|
72
|
+
for (const o of OBSTACLES) {
|
|
73
|
+
const dx = x - o.x; const dy = y - o.y; const d = Math.hypot(dx, dy); const min = o.r + R_AV;
|
|
74
|
+
if (d >= min) continue;
|
|
75
|
+
if (d > 1e-6) { x = o.x + (dx / d) * min; y = o.y + (dy / d) * min; } else x = o.x + min;
|
|
76
|
+
}
|
|
77
|
+
return { x, y };
|
|
78
|
+
}
|
|
79
|
+
const R_GEM = 0.26;
|
|
80
|
+
const SPEED = 6.8;
|
|
81
|
+
const BOT_SPEED = 5;
|
|
82
|
+
const ROUND_MS = 60_000;
|
|
83
|
+
const BREAK_MS = 7_000;
|
|
84
|
+
const GEM_COUNT = 14;
|
|
85
|
+
const MIN_SLOTS = 3; // 1 human + 2 bots from the first frame
|
|
86
|
+
const MAX_SLOTS = 8;
|
|
87
|
+
/** The knock's numbers (tunables.json): the file's values, or the Game Lab's sliders while it plays a take. */
|
|
88
|
+
const T = lab.tunables(tuning);
|
|
89
|
+
const ZONE_MS = 12_000;
|
|
90
|
+
/** The contract's 12 colours (PALETTE, NETPLAY.md section 3): a person wears their seat's, so the watch page's strip matches. */
|
|
91
|
+
const colourOf = (slot: number, seat: number | null): string => PALETTE[(seat ?? slot) % PALETTE.length] as string;
|
|
92
|
+
const BOT_NAMES = ['Rook', 'Vex', 'Moth', 'Kilo', 'Juno', 'Pike', 'Nyx', 'Ash'];
|
|
93
|
+
const botName = (slot: number): string => BOT_NAMES[slot % BOT_NAMES.length] as string;
|
|
94
|
+
/** An AI's name already ends in " · AI" (the relay sees to it); a plain bot says bot. */
|
|
95
|
+
const label = (name: string, bot: boolean): string => (name.endsWith(AI_MARK) ? name : bot ? `${name} · bot` : name);
|
|
96
|
+
|
|
97
|
+
/* ------------------------------------------------------------ wire shapes */
|
|
98
|
+
/** Snapshot: compact arrays, quantized to the centimetre. (Reset epochs ride in the helper's `c` table.) */
|
|
99
|
+
type P = [slot: number, seat: number, x: number, y: number, score: number, vx: number, vy: number];
|
|
100
|
+
type G = [id: number, x: number, y: number];
|
|
101
|
+
interface Snap { r: [n: number, phase: number, startedAt: number, endsAt: number]; p: P[]; g: G[] }
|
|
102
|
+
/**
|
|
103
|
+
* Replica input: the avatar (owner movement) AND the stick intent (host movement), so either mode reads the same
|
|
104
|
+
* frame. The helper stamps the reset epoch the replica has adopted.
|
|
105
|
+
*/
|
|
106
|
+
type Avatar = [x: number, y: number, vx: number, vy: number, mx: number, my: number];
|
|
107
|
+
/** A body's bump, while it lasts: its direction (kvx, kvy), where its slide starts (kx, ky) and when (kat, after the hit-stop). */
|
|
108
|
+
interface Knock { kvx: number; kvy: number; kx: number; ky: number; kat: number; knockUntil: number }
|
|
109
|
+
interface Body extends Knock { slot: number; seat: number | null; name: string; bot: boolean; x: number; y: number; vx: number; vy: number; score: number; tx: number; ty: number }
|
|
110
|
+
interface Gem { id: number; x: number; y: number }
|
|
111
|
+
/** Slow state, on the keyed state channel (net.state('zone', ...)), not in the 20 Hz snapshot. */
|
|
112
|
+
interface Zone { n: number; x: number; y: number; r: number; until: number }
|
|
113
|
+
interface Ckpt { round: RoundInfo; bodies: Body[]; gems: Gem[]; roster: Slot[]; tick: number; gemSeq: number }
|
|
114
|
+
|
|
115
|
+
/* --------------------------------------------------------------- the net */
|
|
116
|
+
/**
|
|
117
|
+
* `?movement=host` plays host movement: the host moves every body from the seats' stick intents (its rules own
|
|
118
|
+
* movement), and each replica PREDICTS its own body by replaying its unacknowledged intents on the host's position.
|
|
119
|
+
* Default `owner`: each browser moves its own body and the host bounds it.
|
|
120
|
+
*/
|
|
121
|
+
const MOVEMENT: 'owner' | 'host' = (() => { try { return new URLSearchParams(location.search).get('movement') === 'host' ? 'host' : 'owner'; } catch { return 'owner'; } })();
|
|
122
|
+
// caps: its bots read the dial ('skill'), and its Roster takes an AI's slot for it ('agents': the join passes p.agent).
|
|
123
|
+
const net = createNetplay<Snap, Avatar, Ckpt>({ game: 'gem-rush-3d', maxPlayers: MAX_SLOTS, movement: MOVEMENT, snapshotHz: 20, inputHz: 20, checkpointMs: 1000, caps: ['skill', 'agents'], checkpoint: () => checkpoint() });
|
|
124
|
+
/** The Roster keeps the server's AI seats (revision 6): the policy is read whenever it fills. */
|
|
125
|
+
const policy = () => net.policy;
|
|
126
|
+
|
|
127
|
+
/* ------------------------------------------------------------ host state */
|
|
128
|
+
let roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
129
|
+
let bodies = new Map<number, Body>();
|
|
130
|
+
let gems: Gem[] = [];
|
|
131
|
+
let gemSeq = 0;
|
|
132
|
+
let round: RoundInfo | null = null;
|
|
133
|
+
let zone: Zone | null = null;
|
|
134
|
+
let tick = 0;
|
|
135
|
+
let hosting = false;
|
|
136
|
+
let cheatResets = 0;
|
|
137
|
+
let controlResets = 0;
|
|
138
|
+
let predictionError = 0;
|
|
139
|
+
/** Gems each slot picked up this round (a gem, whatever it scored): the dial's numbers (`pickups` probe). */
|
|
140
|
+
let pickups = new Map<number, number>();
|
|
141
|
+
|
|
142
|
+
/* ------------------------------------------------ this browser's avatar */
|
|
143
|
+
const me = { x: W / 2, y: H / 2, vx: 0, vy: 0, kvx: 0, kvy: 0, kx: 0, ky: 0, kat: 0, knockUntil: 0, has: false };
|
|
144
|
+
/** Offline (no shell) plays as a local seat 0, with keys or touch. */
|
|
145
|
+
const mySeat = (): number | null => (net.offline ? 0 : net.seat);
|
|
146
|
+
/** Whose view to draw: my own seat, or for a watcher the player it follows (null: the whole arena). */
|
|
147
|
+
const viewSeat = (): number | null => (net.offline ? 0 : net.viewSeat);
|
|
148
|
+
|
|
149
|
+
/* ------------------------------------------------------- replica view */
|
|
150
|
+
const drawn = new Map<number, { x: number; y: number; seat: number; score: number; slot: number }>();
|
|
151
|
+
const waves: { x: number; y: number; at: number; colour: string; knock?: boolean }[] = [];
|
|
152
|
+
let wavesSeen = 0;
|
|
153
|
+
let knocksSeen = 0;
|
|
154
|
+
let firstStateAt = 0;
|
|
155
|
+
let firstSnapAt = 0;
|
|
156
|
+
let frames = 0;
|
|
157
|
+
|
|
158
|
+
const rnd = (a: number, b: number): number => a + Math.random() * (b - a);
|
|
159
|
+
const spawnPoint = (i: number): { x: number; y: number } => {
|
|
160
|
+
const a = (i / MAX_SLOTS) * Math.PI * 2;
|
|
161
|
+
return { x: W / 2 + Math.cos(a) * 6.6, y: H / 2 + Math.sin(a) * 5 };
|
|
162
|
+
};
|
|
163
|
+
const newGem = (): Gem => {
|
|
164
|
+
let x = rnd(1.2, W - 1.2); let y = rnd(1.2, H - 1.2);
|
|
165
|
+
for (let i = 0; i < 12 && OBSTACLES.some((o) => Math.hypot(o.x - x, o.y - y) < o.r + 0.7); i += 1) { x = rnd(1.2, W - 1.2); y = rnd(1.2, H - 1.2); }
|
|
166
|
+
return { id: ++gemSeq, x, y };
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
function bodyFor(slot: Slot): Body {
|
|
170
|
+
const sp = spawnPoint(slot.slot);
|
|
171
|
+
return { slot: slot.slot, seat: slot.seat, name: slot.name, bot: slot.bot, x: sp.x, y: sp.y, vx: 0, vy: 0, score: 0, tx: sp.x, ty: sp.y, kvx: 0, kvy: 0, kx: sp.x, ky: sp.y, kat: 0, knockUntil: 0 };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function syncBodiesFromRoster(): void {
|
|
175
|
+
const seen = new Set<number>();
|
|
176
|
+
for (const s of roster.slots) {
|
|
177
|
+
seen.add(s.slot);
|
|
178
|
+
const b = bodies.get(s.slot);
|
|
179
|
+
if (!b) { bodies.set(s.slot, bodyFor(s)); continue; }
|
|
180
|
+
b.seat = s.seat; b.name = s.name; b.bot = s.bot;
|
|
181
|
+
}
|
|
182
|
+
for (const k of [...bodies.keys()]) if (!seen.has(k)) bodies.delete(k);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function publishRoster(): void { net.roster(roster.toJSON()); }
|
|
186
|
+
|
|
187
|
+
/** The host moved body `b` itself: tell its owner (a replica adopts it; the host's own body is `me`). */
|
|
188
|
+
function hostMoved(b: Body): void {
|
|
189
|
+
if (b.bot || b.seat === null) return;
|
|
190
|
+
if (b.seat === mySeat()) { me.x = b.x; me.y = b.y; me.vx = 0; me.vy = 0; me.has = true; return; }
|
|
191
|
+
net.reset(b.seat);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function newZone(n: number): Zone {
|
|
195
|
+
return { n, x: rnd(4.4, W - 4.4), y: rnd(4, H - 4), r: 3, until: net.now() + ZONE_MS };
|
|
196
|
+
}
|
|
197
|
+
function setZone(z: Zone): void { zone = z; net.state('zone', z); }
|
|
198
|
+
|
|
199
|
+
function startRound(n: number): void {
|
|
200
|
+
const now = net.now();
|
|
201
|
+
roster.trim();
|
|
202
|
+
syncBodiesFromRoster();
|
|
203
|
+
let i = 0;
|
|
204
|
+
for (const b of [...bodies.values()].sort((a, c) => a.slot - c.slot)) {
|
|
205
|
+
const sp = spawnPoint(i++);
|
|
206
|
+
b.x = sp.x; b.y = sp.y; b.vx = 0; b.vy = 0; b.score = 0; b.tx = sp.x; b.ty = sp.y; b.kvx = 0; b.kvy = 0; b.knockUntil = 0;
|
|
207
|
+
hostMoved(b);
|
|
208
|
+
}
|
|
209
|
+
gems = Array.from({ length: GEM_COUNT }, newGem);
|
|
210
|
+
pickups = new Map();
|
|
211
|
+
sight.clear();
|
|
212
|
+
round = { n, phase: 'live', startedAt: now, endsAt: now + ROUND_MS };
|
|
213
|
+
if (lab.stage === 'dummy') stageDummy();
|
|
214
|
+
setZone(newZone((zone?.n ?? 0) + 1));
|
|
215
|
+
net.round(round);
|
|
216
|
+
publishRoster();
|
|
217
|
+
net.snapshot(buildSnap(), tick, true);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function endRound(): void {
|
|
221
|
+
if (!round) return;
|
|
222
|
+
const now = net.now();
|
|
223
|
+
const ranked = [...bodies.values()].sort((a, b) => b.score - a.score || Number(a.bot) - Number(b.bot) || a.slot - b.slot);
|
|
224
|
+
const agentSlot = (slot: number): boolean => Boolean(roster.slots.find((x) => x.slot === slot)?.agent);
|
|
225
|
+
const results: RoundResult[] = ranked.map((b, i) => ({ slot: b.slot, seat: b.seat, name: b.name, score: b.score, bot: b.bot, place: i + 1, ...(agentSlot(b.slot) ? { agent: true as const } : {}) }));
|
|
226
|
+
round = { n: round.n, phase: 'over', startedAt: now, endsAt: now + BREAK_MS, results };
|
|
227
|
+
net.round(round);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/* ---------------------------------------------------- becoming the host */
|
|
231
|
+
function becomeHost(e: RoleChange<Snap, Ckpt>): void {
|
|
232
|
+
hosting = true;
|
|
233
|
+
if (e.promoted) restore(e);
|
|
234
|
+
else {
|
|
235
|
+
roster = new Roster({ min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
236
|
+
bodies = new Map();
|
|
237
|
+
const seat = mySeat();
|
|
238
|
+
if (seat !== null) roster.claim(seat, net.offline ? 'You' : net.name);
|
|
239
|
+
syncBodiesFromRoster();
|
|
240
|
+
startRound((e.round?.n ?? 0) + 1);
|
|
241
|
+
}
|
|
242
|
+
// Whoever is connected now is who plays: seats that left during the gap become bots.
|
|
243
|
+
const peers = net.offline ? [{ seat: 0, name: 'You' }] : [...net.peers.values()].filter((p) => p.seat !== null).map((p) => ({ seat: p.seat, name: p.name, agent: p.agent ?? null }));
|
|
244
|
+
const { claimed } = roster.reconcile(peers);
|
|
245
|
+
syncBodiesFromRoster();
|
|
246
|
+
for (const s of claimed) { const b = bodies.get(s.slot); if (b && b.seat !== mySeat()) hostMoved(b); }
|
|
247
|
+
// My own body is wherever my local avatar is: it was client-owned a moment ago and still is.
|
|
248
|
+
const mine = mySeat() !== null ? [...bodies.values()].find((b) => b.seat === mySeat()) : undefined;
|
|
249
|
+
if (mine && me.has) { mine.x = me.x; mine.y = me.y; }
|
|
250
|
+
else if (mine) { me.x = mine.x; me.y = mine.y; me.has = true; }
|
|
251
|
+
// The hot zone is keyed state: the relay handed it over with the role.
|
|
252
|
+
zone = net.stateOf<Zone>('zone') ?? zone;
|
|
253
|
+
if (!zone && round) setZone(newZone(1));
|
|
254
|
+
publishRoster();
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function restore(e: RoleChange<Snap, Ckpt>): void {
|
|
258
|
+
const ck = e.ckpt?.d ?? null;
|
|
259
|
+
if (ck) {
|
|
260
|
+
roster = Roster.from(ck.roster, { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
261
|
+
bodies = new Map(ck.bodies.map((b) => [b.slot, { ...b }]));
|
|
262
|
+
gems = ck.gems.map((g) => ({ ...g }));
|
|
263
|
+
gemSeq = ck.gemSeq;
|
|
264
|
+
round = ck.round;
|
|
265
|
+
tick = ck.tick;
|
|
266
|
+
} else {
|
|
267
|
+
roster = Roster.from(e.roster ?? [], { min: MIN_SLOTS, max: MAX_SLOTS, botName, policy });
|
|
268
|
+
bodies = new Map();
|
|
269
|
+
syncBodiesFromRoster();
|
|
270
|
+
}
|
|
271
|
+
// The snapshot is newer than the checkpoint (20 Hz vs 1 Hz): positions, scores and gems from it.
|
|
272
|
+
const s = e.snap;
|
|
273
|
+
if (s && (!e.ckpt || s.st >= e.ckpt.st)) {
|
|
274
|
+
for (const [slot, seat, x, y, score] of s.d.p) {
|
|
275
|
+
let b = bodies.get(slot);
|
|
276
|
+
if (!b) {
|
|
277
|
+
const rs0 = roster.slots.find((r) => r.slot === slot) ?? { slot, seat: seat >= 0 ? seat : null, name: seat >= 0 ? `Player ${seat + 1}` : botName(slot), bot: seat < 0 };
|
|
278
|
+
b = bodyFor(rs0);
|
|
279
|
+
bodies.set(slot, b);
|
|
280
|
+
}
|
|
281
|
+
b.x = x; b.y = y; b.score = score; b.tx = x; b.ty = y;
|
|
282
|
+
}
|
|
283
|
+
gems = s.d.g.map(([id, x, y]) => ({ id, x, y }));
|
|
284
|
+
gemSeq = Math.max(gemSeq, ...gems.map((g) => g.id));
|
|
285
|
+
const [n, ph, startedAt, endsAt] = s.d.r;
|
|
286
|
+
round = { n, phase: ph ? 'over' : 'live', startedAt, endsAt, ...(round?.n === n && round.results ? { results: round.results } : {}) };
|
|
287
|
+
tick = Math.max(tick, s.k);
|
|
288
|
+
}
|
|
289
|
+
// A round message the relay kept is at least as authoritative as the checkpoint's copy.
|
|
290
|
+
if (e.round && (!round || e.round.n > round.n || (e.round.n === round.n && e.round.phase === 'over' && round.phase === 'live'))) round = e.round;
|
|
291
|
+
if (!round) { startRound(1); return; }
|
|
292
|
+
net.round(round);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function checkpoint(): Ckpt {
|
|
296
|
+
return {
|
|
297
|
+
round: round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 },
|
|
298
|
+
bodies: [...bodies.values()].map((b) => ({ ...b })),
|
|
299
|
+
gems: gems.map((g) => ({ ...g })),
|
|
300
|
+
roster: roster.toJSON(),
|
|
301
|
+
tick,
|
|
302
|
+
gemSeq,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
net.on('role', (e) => {
|
|
307
|
+
if (e.role === 'host') becomeHost(e);
|
|
308
|
+
else { hosting = false; }
|
|
309
|
+
});
|
|
310
|
+
net.on('join', (p) => {
|
|
311
|
+
if (!hosting || p.seat === null) return;
|
|
312
|
+
// An AI takes a seat kept for AI, a person never does (revision 6: the Roster needs p.agent for that).
|
|
313
|
+
const c = roster.claim(p.seat, p.name, p.agent ? { role: p.agent.role, hands: p.agent.hands } : null);
|
|
314
|
+
if (!c) return; // full: they watch
|
|
315
|
+
syncBodiesFromRoster();
|
|
316
|
+
const b = bodies.get(c.slot.slot);
|
|
317
|
+
// The arriving human takes over the bot's body where it stands, score and all (reset = "adopt this position").
|
|
318
|
+
if (b) { b.vx = 0; b.vy = 0; b.kvx = 0; b.kvy = 0; b.knockUntil = 0; hostMoved(b); }
|
|
319
|
+
publishRoster();
|
|
320
|
+
net.snapshot(buildSnap(), tick, true);
|
|
321
|
+
});
|
|
322
|
+
net.on('leave', (p) => {
|
|
323
|
+
if (!hosting || p.seat === null) return;
|
|
324
|
+
roster.release(p.seat); // their body stays, driven by a bot
|
|
325
|
+
syncBodiesFromRoster();
|
|
326
|
+
publishRoster();
|
|
327
|
+
});
|
|
328
|
+
// The server's policy changed: its AI seats come (or go between rounds) at once.
|
|
329
|
+
net.on('policy', () => { if (!hosting) return; roster.fill(); syncBodiesFromRoster(); publishRoster(); });
|
|
330
|
+
net.on('event', (e) => {
|
|
331
|
+
if (e.k === 'wave') addWave(e.d as { slot: number });
|
|
332
|
+
if (e.k === 'knock') addWave(e.d as { slot: number }, true);
|
|
333
|
+
});
|
|
334
|
+
net.on('round', (r) => { round = hosting ? round : r; });
|
|
335
|
+
net.on('state', (e) => { if (e.k === 'zone' && !hosting) zone = (e.d as Zone | null) ?? null; if (!firstStateAt) firstStateAt = performance.now(); });
|
|
336
|
+
net.on('snapshot', () => { if (!firstSnapAt) firstSnapAt = performance.now(); });
|
|
337
|
+
/** My body's control changed: on a reset, stand where the host put me (a new round, a takeover, a knockback's end). */
|
|
338
|
+
net.on('control', (e) => {
|
|
339
|
+
if (!e.reset) return;
|
|
340
|
+
controlResets += 1;
|
|
341
|
+
const p = (e.snap as Snapshot<Snap>).d.p.find((x) => x[1] === net.seat);
|
|
342
|
+
if (p) { me.x = p[2]; me.y = p[3]; me.vx = 0; me.vy = 0; me.has = true; }
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
/* ----------------------------------------------------------------- input */
|
|
346
|
+
// The camera looks across the meadow from its near edge, square to it: screen right is +x and screen down is +y, so
|
|
347
|
+
// keys and the stick move a body exactly as in Gem Rush.
|
|
348
|
+
const keys = new Set<string>();
|
|
349
|
+
addEventListener('keydown', (e) => {
|
|
350
|
+
if (['KeyW', 'KeyA', 'KeyS', 'KeyD', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Space'].includes(e.code)) e.preventDefault();
|
|
351
|
+
if (e.code === 'Space' && !keys.has('Space')) wave();
|
|
352
|
+
keys.add(e.code);
|
|
353
|
+
});
|
|
354
|
+
addEventListener('keyup', (e) => keys.delete(e.code));
|
|
355
|
+
addEventListener('blur', () => keys.clear());
|
|
356
|
+
|
|
357
|
+
const stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0, active: false };
|
|
358
|
+
const canvas = document.getElementById('c') as HTMLCanvasElement;
|
|
359
|
+
const hudCanvas = document.getElementById('hud') as HTMLCanvasElement;
|
|
360
|
+
const ctx = hudCanvas.getContext('2d') as CanvasRenderingContext2D;
|
|
361
|
+
canvas.addEventListener('pointerdown', (e) => {
|
|
362
|
+
if (e.pointerType === 'mouse') return;
|
|
363
|
+
if (stick.active && e.pointerId !== stick.id) { wave(); return; } // a second finger waves
|
|
364
|
+
stick.id = e.pointerId; stick.ox = e.clientX; stick.oy = e.clientY; stick.x = e.clientX; stick.y = e.clientY; stick.active = true;
|
|
365
|
+
canvas.setPointerCapture(e.pointerId);
|
|
366
|
+
});
|
|
367
|
+
canvas.addEventListener('pointermove', (e) => { if (e.pointerId === stick.id) { stick.x = e.clientX; stick.y = e.clientY; } });
|
|
368
|
+
const endStick = (e: PointerEvent): void => { if (e.pointerId === stick.id) { stick.active = false; stick.id = -1; } };
|
|
369
|
+
canvas.addEventListener('pointerup', endStick);
|
|
370
|
+
canvas.addEventListener('pointercancel', endStick);
|
|
371
|
+
|
|
372
|
+
function moveVector(): { x: number; y: number } {
|
|
373
|
+
let x = 0; let y = 0;
|
|
374
|
+
if (keys.has('KeyA') || keys.has('ArrowLeft')) x -= 1;
|
|
375
|
+
if (keys.has('KeyD') || keys.has('ArrowRight')) x += 1;
|
|
376
|
+
if (keys.has('KeyW') || keys.has('ArrowUp')) y -= 1;
|
|
377
|
+
if (keys.has('KeyS') || keys.has('ArrowDown')) y += 1;
|
|
378
|
+
if (stick.active) {
|
|
379
|
+
const dx = stick.x - stick.ox; const dy = stick.y - stick.oy;
|
|
380
|
+
const len = Math.hypot(dx, dy);
|
|
381
|
+
if (len > 6) { const m = Math.min(1, len / 56); x += (dx / len) * m; y += (dy / len) * m; }
|
|
382
|
+
}
|
|
383
|
+
const len = Math.hypot(x, y);
|
|
384
|
+
return len > 1 ? { x: x / len, y: y / len } : { x, y };
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
function wave(): void {
|
|
388
|
+
const seat = mySeat();
|
|
389
|
+
if (seat === null) return;
|
|
390
|
+
if (hosting) {
|
|
391
|
+
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
392
|
+
if (b) hostWave(b);
|
|
393
|
+
} else net.press('wave');
|
|
394
|
+
}
|
|
395
|
+
/** Host: a wave rings out, and knocks back every body within reach (bots, the host itself, and replicas). */
|
|
396
|
+
function hostWave(from: Body): void {
|
|
397
|
+
addWave({ slot: from.slot }); net.send('wave', { slot: from.slot });
|
|
398
|
+
for (const b of bodies.values()) {
|
|
399
|
+
if (b === from || Math.hypot(b.x - from.x, b.y - from.y) > T.knockRange) continue;
|
|
400
|
+
knock(b, from.x, from.y, from.slot);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* Host: knock a body back. The hit LANDS first: for T.hitStopMs the body holds where it was hit (it flashes, squashes
|
|
405
|
+
* and shakes on every screen), then it SLIDES T.knockDistance along the hit, fast at first and easing into the stop
|
|
406
|
+
* (T.knockEase), so it arrives at rest, the same distance at any frame rate, and its owner steers again with no pop.
|
|
407
|
+
* A replica's body is TAKEN for the whole bump (its owner's avatar frames are ignored; the host drives it; the owner
|
|
408
|
+
* draws its body from snapshots), then the helper GIVES it back with a reset, so the owner stands where it ended.
|
|
409
|
+
*/
|
|
410
|
+
function knock(b: Body, fx: number, fy: number, by: number): void {
|
|
411
|
+
let dx = b.x - fx; let dy = b.y - fy;
|
|
412
|
+
const len = Math.hypot(dx, dy);
|
|
413
|
+
if (len < 0.02) { const a = Math.random() * Math.PI * 2; dx = Math.cos(a); dy = Math.sin(a); } else { dx /= len; dy /= len; }
|
|
414
|
+
const at = net.now() + T.hitStopMs;
|
|
415
|
+
const o: Knock & { x: number; y: number } = !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
|
|
416
|
+
o.kvx = dx; o.kvy = dy; o.kx = o.x; o.ky = o.y; o.kat = at; o.knockUntil = at + T.knockMs;
|
|
417
|
+
if (o === b && !b.bot && b.seat !== null) net.take(b.seat, T.hitStopMs + T.knockMs);
|
|
418
|
+
labKnock(b);
|
|
419
|
+
const d = { slot: b.slot, dx: q(dx, 2), dy: q(dy, 2), by };
|
|
420
|
+
addWave(d, true); net.send('knock', d);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Where a bumped body is now: held through the hit-stop, then eased out along the hit. Sets its velocity too. */
|
|
424
|
+
function slide(o: Knock & { x: number; y: number; vx: number; vy: number }, now: number, dt: number): void {
|
|
425
|
+
const u = Number.isFinite(o.kat) ? Math.max(0, Math.min(1, (now - o.kat) / Math.max(1, T.knockMs))) : 1;
|
|
426
|
+
const e = 1 - (1 - u) ** Math.max(1, T.knockEase);
|
|
427
|
+
const { x, y } = bound(o.kx + o.kvx * T.knockDistance * e, o.ky + o.kvy * T.knockDistance * e);
|
|
428
|
+
if (u >= 1) { o.vx = 0; o.vy = 0; } else if (dt > 0) { o.vx = (x - o.x) / dt; o.vy = (y - o.y) / dt; }
|
|
429
|
+
o.x = x; o.y = y;
|
|
430
|
+
}
|
|
431
|
+
/** The frame a bump is over: the body lands exactly where its slide ends, at rest (at 12 fps the last step is long). */
|
|
432
|
+
function landed(o: Knock & { x: number; y: number; vx: number; vy: number }): void {
|
|
433
|
+
if (!o.kat) return;
|
|
434
|
+
slide(o, o.kat + T.knockMs, 0);
|
|
435
|
+
o.kat = 0;
|
|
436
|
+
}
|
|
437
|
+
function addWave(d: { slot: number; dx?: number; dy?: number; by?: number }, isKnock = false): void {
|
|
438
|
+
const pos = hosting ? bodies.get(d.slot) : [...drawn.values()].find((x) => x.slot === d.slot);
|
|
439
|
+
// The action, for a watcher on Auto: whoever waved (a person, never a bot).
|
|
440
|
+
const waver = pos && 'bot' in pos ? (pos.bot ? null : pos.seat) : pos && pos.seat >= 0 ? pos.seat : null;
|
|
441
|
+
if (!isKnock) net.spotlight(waver);
|
|
442
|
+
const mineSlot = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
443
|
+
const at = mineSlot === d.slot && me.has ? me : pos;
|
|
444
|
+
if (!at) return;
|
|
445
|
+
waves.push({ x: at.x, y: at.y, at: performance.now(), colour: isKnock ? '#ffffff' : PALETTE[d.slot % PALETTE.length] as string, knock: isKnock });
|
|
446
|
+
if (isKnock) knocksSeen += 1; else wavesSeen += 1;
|
|
447
|
+
if (isKnock) bumped(d, at); else pushes.set(d.slot, performance.now());
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/* ---------------------------------------------------------------- simulate */
|
|
451
|
+
/** The one movement rule, shared by the owner, the host (host movement) and a replica's prediction. */
|
|
452
|
+
function integrate(o: { x: number; y: number; vx: number; vy: number }, mx: number, my: number, dt: number): void {
|
|
453
|
+
mx = Number(mx) || 0; my = Number(my) || 0;
|
|
454
|
+
const len = Math.hypot(mx, my);
|
|
455
|
+
if (len > 1) { mx /= len; my /= len; } // an intent is at most full stick: no speed hack through intents
|
|
456
|
+
const k = Math.min(1, dt * 14);
|
|
457
|
+
o.vx += (mx * SPEED - o.vx) * k;
|
|
458
|
+
o.vy += (my * SPEED - o.vy) * k;
|
|
459
|
+
const p = bound(o.x + o.vx * dt, o.y + o.vy * dt);
|
|
460
|
+
o.x = p.x; o.y = p.y;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function stepMe(dt: number): void {
|
|
464
|
+
const seat = mySeat();
|
|
465
|
+
if (seat === null) { me.has = false; return; }
|
|
466
|
+
// Host-driven (host movement, or knocked back on a replica): stepReplica predicts me instead.
|
|
467
|
+
if (!net.owned) return;
|
|
468
|
+
if (me.knockUntil > net.now()) { slide(me, net.now(), dt); return; }
|
|
469
|
+
landed(me);
|
|
470
|
+
const v = moveVector();
|
|
471
|
+
integrate(me, v.x, v.y, dt);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Replica, host-driven body. My stick moves me locally every frame (instant response), with the same rule the host
|
|
476
|
+
* runs. When a new snapshot lands: start from where the host has me, replay every intent it had not acknowledged,
|
|
477
|
+
* and pull toward that by 30% (or jump, if it is far). The host stays the truth; I never wait for it.
|
|
478
|
+
*/
|
|
479
|
+
let predictedK = -1;
|
|
480
|
+
function predictMe(dt: number): void {
|
|
481
|
+
const v = moveVector();
|
|
482
|
+
integrate(me, v.x, v.y, dt);
|
|
483
|
+
const snap = net.latest();
|
|
484
|
+
if (!snap || snap.k === predictedK) return;
|
|
485
|
+
predictedK = snap.k;
|
|
486
|
+
const p = snap.d.p.find((x) => x[1] === net.seat);
|
|
487
|
+
if (!p) return;
|
|
488
|
+
const pred = { x: p[2], y: p[3], vx: p[5] ?? 0, vy: p[6] ?? 0 };
|
|
489
|
+
for (const f of net.pending()) {
|
|
490
|
+
let left = Math.min(0.25, f.dt / 1000);
|
|
491
|
+
while (left > 0) { const h = Math.min(left, 1 / 60); integrate(pred, f.a[4], f.a[5], h); left -= h; }
|
|
492
|
+
}
|
|
493
|
+
const err = Math.hypot(pred.x - me.x, pred.y - me.y);
|
|
494
|
+
predictionError = err;
|
|
495
|
+
if (!me.has || err > 3) { me.x = pred.x; me.y = pred.y; me.vx = pred.vx; me.vy = pred.vy; }
|
|
496
|
+
else { me.x += (pred.x - me.x) * 0.3; me.y += (pred.y - me.y) * 0.3; }
|
|
497
|
+
me.has = true;
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function stepKnocked(b: Body, dt: number): void { slide(b, net.now(), dt); }
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* THE BOTS, AT THE ROOM'S DIAL (NETPLAY.md section 17). A bot re-reads the world only every `reactionMs` (what it
|
|
504
|
+
* last noticed is where it heads, and a miss costs it that long again), aims up to 4 m off at aimNoise 1, leaves
|
|
505
|
+
* the hot zone to the people at positioning 0 and fights for it at 1, and waves at a rival in reach about
|
|
506
|
+
* `aggression x 0.8` times a second. Rookie is beatable by anyone, Maxed by few.
|
|
507
|
+
*/
|
|
508
|
+
const sight = new Map<number, { at: number; tx: number; ty: number; arrived?: boolean }>();
|
|
509
|
+
|
|
510
|
+
function stepBots(dt: number): void {
|
|
511
|
+
const taken = new Set<number>();
|
|
512
|
+
const now = net.now();
|
|
513
|
+
for (const b of bodies.values()) {
|
|
514
|
+
if (!b.bot) continue;
|
|
515
|
+
if (b.knockUntil > now) { stepKnocked(b, dt); continue; }
|
|
516
|
+
landed(b);
|
|
517
|
+
if (lab.stage === 'dummy') { standStill(b, dt); continue; }
|
|
518
|
+
const s: Skill = net.skillOf(b.slot); // Fair when nobody set a dial
|
|
519
|
+
let eye = sight.get(b.slot);
|
|
520
|
+
if (!eye || now - eye.at >= s.reactionMs) { // REACTION TIME
|
|
521
|
+
const g = pickGem(b, taken, s); // POSITIONING
|
|
522
|
+
const miss = 4 * s.aimNoise; // AIM NOISE: up to 4 m off at 1
|
|
523
|
+
eye = { at: now, tx: g ? g.x + (Math.random() - 0.5) * miss : b.tx, ty: g ? g.y + (Math.random() - 0.5) * miss : b.ty };
|
|
524
|
+
sight.set(b.slot, eye);
|
|
525
|
+
if (g) taken.add(g.id);
|
|
526
|
+
}
|
|
527
|
+
b.tx = eye.tx; b.ty = eye.ty;
|
|
528
|
+
const rival = nearestBody(b, T.knockRange); // AGGRESSION: waves at a rival in reach
|
|
529
|
+
if (rival && round?.phase === 'live' && Math.random() < s.aggression * 0.8 * dt) hostWave(b);
|
|
530
|
+
const dx = b.tx - b.x; const dy = b.ty - b.y; const dist = Math.hypot(dx, dy);
|
|
531
|
+
const len = dist || 1;
|
|
532
|
+
// Arrived where it aimed: it stands there, and notices what it missed only a reaction later.
|
|
533
|
+
if (dist < 0.12 && !eye.arrived) { eye.arrived = true; eye.at = now; }
|
|
534
|
+
const speed = dist < 0.12 ? 0 : BOT_SPEED;
|
|
535
|
+
const k = Math.min(1, dt * 5);
|
|
536
|
+
b.vx += ((dx / len) * speed - b.vx) * k;
|
|
537
|
+
b.vy += ((dy / len) * speed - b.vy) * k;
|
|
538
|
+
const p = bound(b.x + b.vx * dt, b.y + b.vy * dt);
|
|
539
|
+
b.x = p.x; b.y = p.y;
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/** Positioning: 0 leaves the hot zone to the people, 1 fights for it. */
|
|
544
|
+
function pickGem(b: Body, taken: Set<number>, s: Skill): Gem | null {
|
|
545
|
+
let best: Gem | null = null; let bd = Infinity;
|
|
546
|
+
for (const g of gems) {
|
|
547
|
+
if (taken.has(g.id)) continue;
|
|
548
|
+
const hot = zone !== null && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r;
|
|
549
|
+
const d = Math.hypot(g.x - b.x, g.y - b.y) * (hot ? 1.5 - s.positioning : 1);
|
|
550
|
+
if (d < bd) { bd = d; best = g; }
|
|
551
|
+
}
|
|
552
|
+
return best;
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/** The nearest other body within `range` (a bot's rival), or null. */
|
|
556
|
+
function nearestBody(b: Body, range: number): Body | null {
|
|
557
|
+
let best: Body | null = null; let bd = range;
|
|
558
|
+
for (const o of bodies.values()) {
|
|
559
|
+
if (o === b) continue;
|
|
560
|
+
const d = Math.hypot(o.x - b.x, o.y - b.y);
|
|
561
|
+
if (d < bd) { bd = d; best = o; }
|
|
562
|
+
}
|
|
563
|
+
return best;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
function stepHost(dt: number): void {
|
|
567
|
+
tick += 1;
|
|
568
|
+
const seat = mySeat();
|
|
569
|
+
const now0 = net.now();
|
|
570
|
+
for (const b of bodies.values()) {
|
|
571
|
+
if (b.bot) continue;
|
|
572
|
+
if (b.seat === seat) {
|
|
573
|
+
me.has = true;
|
|
574
|
+
b.x = me.x; b.y = me.y; b.vx = me.vx; b.vy = me.vy;
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
if (b.seat === null) continue;
|
|
578
|
+
const ctl = net.control(b.seat);
|
|
579
|
+
if (ctl.taken || b.knockUntil > now0) { stepKnocked(b, dt); if (net.takePresses(b.seat)['wave']) hostWave(b); continue; } // the host drives the knockback
|
|
580
|
+
if (MOVEMENT === 'host') {
|
|
581
|
+
// Host movement: the seat sends intents; the rules move the body.
|
|
582
|
+
const f = net.inputOf(b.seat);
|
|
583
|
+
integrate(b, f ? Number(f.a?.[4]) : 0, f ? Number(f.a?.[5]) : 0, dt);
|
|
584
|
+
if (net.takePresses(b.seat)['wave']) hostWave(b);
|
|
585
|
+
continue;
|
|
586
|
+
}
|
|
587
|
+
const a = net.avatar(b.seat); // null while taken, or until the owner has adopted the last reset
|
|
588
|
+
if (a && Array.isArray(a)) {
|
|
589
|
+
// Client-owned movement, bounded: to the arena, and to 1.3x top speed per frame. A legitimate avatar catches
|
|
590
|
+
// up within a frame; a teleport crawls, and a claim more than half a second of running away is reset.
|
|
591
|
+
const claim = bound(Number(a[0]) || 0, Number(a[1]) || 0);
|
|
592
|
+
const m = capMove(b, claim, SPEED * 1.3 * dt + 0.12);
|
|
593
|
+
if (m.over > SPEED * 0.5) { net.reset(b.seat); cheatResets += 1; }
|
|
594
|
+
else { b.x = m.x; b.y = m.y; b.vx = Number(a[2]) || 0; b.vy = Number(a[3]) || 0; }
|
|
595
|
+
}
|
|
596
|
+
if (net.takePresses(b.seat)['wave']) hostWave(b);
|
|
597
|
+
}
|
|
598
|
+
stepBots(dt);
|
|
599
|
+
const now = net.now();
|
|
600
|
+
if (round && round.phase === 'live') {
|
|
601
|
+
if (!zone || now >= zone.until) setZone(newZone((zone?.n ?? 0) + 1));
|
|
602
|
+
for (const b of bodies.values()) {
|
|
603
|
+
for (let i = 0; i < gems.length; i += 1) {
|
|
604
|
+
const g = gems[i] as Gem;
|
|
605
|
+
if (Math.hypot(g.x - b.x, g.y - b.y) < R_AV + R_GEM) {
|
|
606
|
+
b.score += zone && Math.hypot(g.x - zone.x, g.y - zone.y) < zone.r ? 2 : 1;
|
|
607
|
+
pickups.set(b.slot, (pickups.get(b.slot) ?? 0) + 1);
|
|
608
|
+
gems[i] = newGem();
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
if (now >= round.endsAt) endRound();
|
|
613
|
+
} else if (round && round.phase === 'over' && now >= round.endsAt) startRound(round.n + 1);
|
|
614
|
+
if (net.snapshotDue()) net.snapshot(buildSnap(), tick);
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function buildSnap(): Snap {
|
|
618
|
+
const r = round ?? { n: 0, phase: 'live', startedAt: 0, endsAt: 0 };
|
|
619
|
+
return {
|
|
620
|
+
r: [r.n, r.phase === 'over' ? 1 : 0, r.startedAt, r.endsAt],
|
|
621
|
+
p: [...bodies.values()].map((b) => [b.slot, b.seat ?? -1, q(b.x, 2), q(b.y, 2), b.score, q(b.vx, 1), q(b.vy, 1)] as P),
|
|
622
|
+
g: gems.map((g) => [g.id, q(g.x, 2), q(g.y, 2)] as G),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function stepReplica(dt: number): void {
|
|
627
|
+
const seat = net.seat;
|
|
628
|
+
if (seat !== null) {
|
|
629
|
+
// Host-driven (host movement, or taken for a knockback): predict from the host's position and my intents.
|
|
630
|
+
if (!net.owned) predictMe(dt);
|
|
631
|
+
// Only once I have a body (the first control event adopts it): before that my position is nobody's.
|
|
632
|
+
if (me.has) {
|
|
633
|
+
const v = moveVector();
|
|
634
|
+
// Stick direction as held "buttons": a new direction is a press edge, so it is sent within 16 ms.
|
|
635
|
+
const held = [v.x > 0.2 ? 'R' : v.x < -0.2 ? 'L' : '', v.y > 0.2 ? 'D' : v.y < -0.2 ? 'U' : ''].filter(Boolean);
|
|
636
|
+
net.input([q(me.x, 2), q(me.y, 2), q(me.vx, 1), q(me.vy, 1), q(v.x, 2), q(v.y, 2)], held);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
const smp = net.sample();
|
|
640
|
+
drawn.clear();
|
|
641
|
+
if (!smp) return;
|
|
642
|
+
const bySlot = new Map<number, P>(smp.a.d.p.map((p) => [p[0], p]));
|
|
643
|
+
for (const pb of smp.b.d.p) {
|
|
644
|
+
const pa = bySlot.get(pb[0]) ?? pb;
|
|
645
|
+
drawn.set(pb[0], { slot: pb[0], seat: pb[1], x: lerp(pa[2], pb[2], smp.alpha), y: lerp(pa[3], pb[3], smp.alpha), score: pb[4] });
|
|
646
|
+
}
|
|
647
|
+
const [n, ph, startedAt, endsAt] = smp.b.d.r;
|
|
648
|
+
if (!round || round.n !== n || (round.phase === 'over') !== (ph === 1)) {
|
|
649
|
+
const kept = net.roundInfo && net.roundInfo.n === n ? net.roundInfo : null;
|
|
650
|
+
round = kept ?? { n, phase: ph ? 'over' : 'live', startedAt, endsAt };
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/* -------------------------------------------------------------- how a bump looks */
|
|
655
|
+
/*
|
|
656
|
+
* Every screen draws a bump from the knock event (the host's own, or the one it sends): the hit lands (the animal
|
|
657
|
+
* flashes white and holds, squashed against the hit, shaking), flies (stretched along the hit, less as it slows),
|
|
658
|
+
* stops (squashed, then a wobble that dies away: overlap), throws sparks, and kicks the camera of whoever was in it.
|
|
659
|
+
* The waver's own body puffs up a little when it waves. Sparks and the camera's kick roll lab.random(): their own
|
|
660
|
+
* dice, so the world's (Math.random) stay the same as a build without them, which the Game Lab needs to compare two.
|
|
661
|
+
*/
|
|
662
|
+
const bumps = new Map<number, { at: number; dx: number; dy: number }>();
|
|
663
|
+
const pushes = new Map<number, number>();
|
|
664
|
+
const sparks: { x: number; y: number; h: number; vx: number; vy: number; vh: number; at: number; life: number; gold?: boolean }[] = [];
|
|
665
|
+
let kickAt = -1e9;
|
|
666
|
+
function bumped(d: { slot: number; dx?: number; dy?: number; by?: number }, at: { x: number; y: number }): void {
|
|
667
|
+
const now = performance.now();
|
|
668
|
+
const dx = Number(d.dx) || 0; const dy = Number(d.dy) || 0;
|
|
669
|
+
bumps.set(d.slot, { at: now, dx, dy });
|
|
670
|
+
const base = Math.atan2(dy, dx);
|
|
671
|
+
for (let i = 0; i < T.sparks; i += 1) {
|
|
672
|
+
const a = base + (lab.random() - 0.5) * 1.6;
|
|
673
|
+
const v = 8.4 + lab.random() * 10.4;
|
|
674
|
+
sparks.push({ x: at.x - dx * R_AV * 0.6, y: at.y - dy * R_AV * 0.6, h: 0.45, vx: Math.cos(a) * v, vy: Math.sin(a) * v, vh: 1 + lab.random() * 3, at: now, life: 170 + lab.random() * 170 });
|
|
675
|
+
}
|
|
676
|
+
if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
|
|
677
|
+
// The camera kicks for whoever was in it: the body bumped, or the one who waved.
|
|
678
|
+
const mine = hosting ? [...bodies.values()].find((x) => x.seat !== null && x.seat === mySeat())?.slot : [...drawn.values()].find((x) => x.seat === net.seat)?.slot;
|
|
679
|
+
if (mine !== undefined && (d.slot === mine || d.by === mine)) kickAt = now;
|
|
680
|
+
}
|
|
681
|
+
/** A gem taken: a little gold burst where it was (drawn on every screen from the gem list; the rules never see it). */
|
|
682
|
+
function sparkle(x: number, y: number): void {
|
|
683
|
+
const now = performance.now();
|
|
684
|
+
for (let i = 0; i < 7; i += 1) {
|
|
685
|
+
const a = lab.random() * Math.PI * 2; const v = 1.5 + lab.random() * 2.5;
|
|
686
|
+
sparks.push({ x, y, h: 0.35, vx: Math.cos(a) * v, vy: Math.sin(a) * v, vh: 3 + lab.random() * 3, at: now, life: 260 + lab.random() * 200, gold: true });
|
|
687
|
+
}
|
|
688
|
+
if (sparks.length > 160) sparks.splice(0, sparks.length - 160);
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* A body's look this frame: offset (m), stretch along the hit (sx) and across it (sy), height (up), the hit's angle
|
|
692
|
+
* on the ground, and how white it flashes.
|
|
693
|
+
*/
|
|
694
|
+
function bodyFx(slot: number, t: number): { ox: number; oy: number; sx: number; sy: number; up: number; ang: number; flash: number } {
|
|
695
|
+
const out = { ox: 0, oy: 0, sx: 1, sy: 1, up: 1, ang: 0, flash: 0 };
|
|
696
|
+
const p = pushes.get(slot);
|
|
697
|
+
if (p !== undefined) { const u = (t - p) / 170; if (u >= 0 && u < 1) { const k = 0.14 * Math.sin(Math.PI * u); out.sx = 1 + k; out.sy = 1 + k; out.up = 1 + k; } }
|
|
698
|
+
const b = bumps.get(slot);
|
|
699
|
+
if (!b) return out;
|
|
700
|
+
const age = t - b.at;
|
|
701
|
+
const hs = T.hitStopMs; const end = hs + T.knockMs;
|
|
702
|
+
if (age > end + T.settleMs + 200) { bumps.delete(slot); return out; }
|
|
703
|
+
out.ang = Math.atan2(b.dy, b.dx);
|
|
704
|
+
out.flash = T.flashMs > 0 ? Math.max(0, 1 - age / T.flashMs) : 0;
|
|
705
|
+
let k = 0;
|
|
706
|
+
if (age < hs) { k = -T.squash * 0.45; out.ox = Math.sin(age * 0.9) * 0.06; out.oy = Math.cos(age * 1.3) * 0.06; }
|
|
707
|
+
else if (age < end) k = T.squash * (1 - (age - hs) / T.knockMs) ** Math.max(0, T.knockEase - 1);
|
|
708
|
+
else if (T.settleMs > 0 && age < end + T.settleMs) { const w = (age - end) / T.settleMs; k = -T.squash * 0.8 * Math.exp(-3 * w) * Math.sin(2.5 * Math.PI * w); }
|
|
709
|
+
out.sx *= 1 + k; out.sy *= 1 / (1 + k); out.up *= 1 / Math.sqrt(Math.max(0.2, 1 + k));
|
|
710
|
+
return out;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
/* -------------------------------------------------------------- the Game Lab */
|
|
714
|
+
/*
|
|
715
|
+
* What the lab shows of a knock (lab.json's take "knock"): the body bumped last is the subject. Every frame its phase,
|
|
716
|
+
* its speed and its distance from where it was hit go to the lab, and its pose feeds the onion skin and the spacing
|
|
717
|
+
* arc (dots far apart: fast; close together: slow), drawn as lines on the meadow. Only in the lab: outside it,
|
|
718
|
+
* labKnock and labReport never run.
|
|
719
|
+
*/
|
|
720
|
+
type Pose = { x: number; y: number; sx?: number; sy?: number; a?: number };
|
|
721
|
+
/**
|
|
722
|
+
* The take's stage "dummy" (lab.stage, only ever set by the lab): you stand left of the middle with a bot a short step
|
|
723
|
+
* to your right, and every bot stands still unless it is bumped, like a training dummy. The knock alone, every time.
|
|
724
|
+
*/
|
|
725
|
+
function stageDummy(): void {
|
|
726
|
+
const seat = mySeat();
|
|
727
|
+
const list = [...bodies.values()].sort((a, c) => a.slot - c.slot);
|
|
728
|
+
const mine = list.find((b) => !b.bot && b.seat === seat);
|
|
729
|
+
const bots = list.filter((b) => b.bot);
|
|
730
|
+
if (mine) { mine.x = W / 2 - 3.2; mine.y = H / 2; hostMoved(mine); }
|
|
731
|
+
bots.forEach((b, i) => { b.x = i === 0 ? W / 2 - 3.2 + 1.84 : W - 3.6; b.y = i === 0 ? H / 2 : 3 + (i - 1) * 14; b.tx = b.x; b.ty = b.y; });
|
|
732
|
+
}
|
|
733
|
+
/** A dummy at rest: it eases to a stop wherever the last bump left it. */
|
|
734
|
+
function standStill(b: Body, dt: number): void {
|
|
735
|
+
const k = Math.min(1, dt * 5);
|
|
736
|
+
b.vx -= b.vx * k; b.vy -= b.vy * k;
|
|
737
|
+
const p = bound(b.x + b.vx * dt, b.y + b.vy * dt); b.x = p.x; b.y = p.y;
|
|
738
|
+
}
|
|
739
|
+
const subject = { slot: -1, at: 0, x0: 0, y0: 0, px: 0, py: 0, has: false };
|
|
740
|
+
function labKnock(b: Body): void {
|
|
741
|
+
if (!lab.on) return;
|
|
742
|
+
subject.slot = b.slot; subject.at = net.now(); subject.x0 = b.x; subject.y0 = b.y; subject.px = b.x; subject.py = b.y; subject.has = true;
|
|
743
|
+
}
|
|
744
|
+
/** Where the subject is now (the host's own body is `me`), or null. */
|
|
745
|
+
function subjectAt(): Pose | null {
|
|
746
|
+
if (!subject.has) return null;
|
|
747
|
+
const b = bodies.get(subject.slot);
|
|
748
|
+
if (!b) return null;
|
|
749
|
+
return !b.bot && b.seat !== null && b.seat === mySeat() ? me : b;
|
|
750
|
+
}
|
|
751
|
+
function labReport(dt: number): void {
|
|
752
|
+
const at = subjectAt();
|
|
753
|
+
if (!at || dt <= 0) { lab.phase(null); return; }
|
|
754
|
+
const age = net.now() - subject.at;
|
|
755
|
+
lab.track('speed', Math.hypot(at.x - subject.px, at.y - subject.py) / dt, 'm/s');
|
|
756
|
+
lab.track('distance', Math.hypot(at.x - subject.x0, at.y - subject.y0), 'm');
|
|
757
|
+
subject.px = at.x; subject.py = at.y;
|
|
758
|
+
const fx = bodyFx(subject.slot, performance.now());
|
|
759
|
+
lab.track('stretch', (fx.sx - 1) * 100, '%');
|
|
760
|
+
if (age < T.hitStopMs) lab.phase('HIT-STOP', 'The hit lands: hold, flash, squash');
|
|
761
|
+
else if (age < T.hitStopMs + T.knockMs * 0.3) lab.phase('LAUNCH', 'Leaves fast, stretched along the hit');
|
|
762
|
+
else if (age < T.hitStopMs + T.knockMs) lab.phase('SLIDE', 'Eases into the stop: no creep, no pop');
|
|
763
|
+
else if (age < T.hitStopMs + T.knockMs + T.settleMs) lab.phase('SETTLE', 'Squash on the stop, overlap on the way out');
|
|
764
|
+
else lab.phase(null);
|
|
765
|
+
lab.pose('subject', { x: at.x, y: at.y, sx: fx.sx, sy: fx.sy, a: fx.ang });
|
|
766
|
+
}
|
|
767
|
+
/** The lab's views: the game's own camera, close on the knock, the whole arena. */
|
|
768
|
+
const labView = lab.camera<{ zoom?: number; whole?: boolean } | null>({ game: null, close: { zoom: 2.4 }, arena: { whole: true } });
|
|
769
|
+
|
|
770
|
+
/* ================================================================== THE LOOK */
|
|
771
|
+
|
|
772
|
+
/* ------------------------------------------------------------------ style.json */
|
|
773
|
+
interface StyleTokens {
|
|
774
|
+
palette: { bg: string; ink: string; accent: string; accent2: string; danger: string; good: string; gold: string; ramp?: string[] };
|
|
775
|
+
fonts?: { display?: string; body?: string } | null;
|
|
776
|
+
light?: { time?: string; key?: number[]; intensity?: number; hardness?: number; sky?: string; ground?: string; fog?: number } | null;
|
|
777
|
+
camera?: { pitch?: number; distance?: number; fov?: number } | null;
|
|
778
|
+
render?: string;
|
|
779
|
+
materials?: { model?: string; outline?: boolean } | null;
|
|
780
|
+
}
|
|
781
|
+
const STYLE = styleFile as unknown as StyleTokens & { materials?: { model?: string; outline?: boolean } };
|
|
782
|
+
/**
|
|
783
|
+
* How the room scores (game.json "scoring"): "rivals" (the default: a ranking, a winner) or "together" (one total the
|
|
784
|
+
* whole room fills, each player's share shown without places: a cozy game's room works as one). The rules are the
|
|
785
|
+
* same; only what the HUD and the results card say changes.
|
|
786
|
+
*/
|
|
787
|
+
const TOGETHER = (gameFile as { scoring?: string }).scoring === 'together';
|
|
788
|
+
const PAL = STYLE.palette;
|
|
789
|
+
const LIGHT = { key: [-0.5, -1, -0.35], intensity: 2.2, hardness: 0.35, sky: PAL.bg, ground: PAL.accent2, fog: 0.35, ...(STYLE.light ?? {}) };
|
|
790
|
+
const CAM = { pitch: 52, distance: 22, fov: 38, ...(STYLE.camera ?? {}) };
|
|
791
|
+
/** Two colours mixed in sRGB (k = 0: a, 1: b), as a hex string. */
|
|
792
|
+
function mixHex(a: string, b: string, k: number): string {
|
|
793
|
+
const pa = new Color(a).getHex(); const pb = new Color(b).getHex();
|
|
794
|
+
const ch = (v: number, s: number): number => (v >> s) & 255;
|
|
795
|
+
const m = (s: number): number => Math.round(ch(pa, s) + (ch(pb, s) - ch(pa, s)) * k);
|
|
796
|
+
return `#${((m(16) << 16) | (m(8) << 8) | m(0)).toString(16).padStart(6, '0')}`;
|
|
797
|
+
}
|
|
798
|
+
/** sRGB luminance, 0 to 1. */
|
|
799
|
+
function lumOf(hex: string): number { const c = new Color(hex).getHex(); return (0.2126 * ((c >> 16) & 255) + 0.7152 * ((c >> 8) & 255) + 0.0722 * (c & 255)) / 255; }
|
|
800
|
+
/**
|
|
801
|
+
* The HUD's paper and its text: the palette's light colour as paper (cream cards, like the title card) and its dark
|
|
802
|
+
* one as text, whichever way round the palette has them, so every chip and card reads and they are one UI.
|
|
803
|
+
*/
|
|
804
|
+
const PAPER = lumOf(PAL.ink) >= lumOf(PAL.bg) ? PAL.ink : PAL.bg;
|
|
805
|
+
const TEXT = lumOf(PAL.ink) >= lumOf(PAL.bg) ? mixHex(PAL.bg, '#000000', 0.25) : PAL.ink;
|
|
806
|
+
const HOT = lumOf(PAL.accent) > 0.62 ? mixHex(PAL.accent, TEXT, 0.35) : PAL.accent;
|
|
807
|
+
/** A night light (style.json light.time): the grass goes dark with it. */
|
|
808
|
+
const NIGHT = LIGHT.time === 'night';
|
|
809
|
+
/** The world's own colours, all from the palette: so a new style.json repaints everything the code draws. */
|
|
810
|
+
const COL = {
|
|
811
|
+
sky: LIGHT.sky ?? PAL.bg,
|
|
812
|
+
// The ground of the style board when the light names one (a night grove is dark grass), else the palette's greens.
|
|
813
|
+
// Grass is the palette's greens, a little shaded, tinted by the light's ground colour (a night grove is darker, a
|
|
814
|
+
// golden hour warmer): never the light's ground alone, which can be mud.
|
|
815
|
+
meadow: mixHex(mixHex(mixHex(PAL.good, PAL.accent2, 0.55), PAL.bg, NIGHT ? 0.55 : 0.08), STYLE.light?.ground ?? PAL.accent2, NIGHT ? 0.3 : 0.06),
|
|
816
|
+
meadowSpot: mixHex(mixHex(mixHex(PAL.good, PAL.accent2, 0.55), NIGHT ? PAL.bg : PAL.gold, NIGHT ? 0.45 : 0.14), STYLE.light?.ground ?? PAL.accent2, NIGHT ? 0.25 : 0.1),
|
|
817
|
+
floor: mixHex(mixHex(mixHex(PAL.good, PAL.accent2, 0.4), NIGHT ? PAL.bg : PAL.gold, NIGHT ? 0.45 : 0.12), STYLE.light?.ground ?? PAL.good, STYLE.light?.ground ? 0.2 : 0),
|
|
818
|
+
floorStripe: mixHex(mixHex(mixHex(mixHex(PAL.good, PAL.accent2, 0.4), NIGHT ? PAL.bg : PAL.gold, NIGHT ? 0.45 : 0.12), STYLE.light?.ground ?? PAL.good, STYLE.light?.ground ? 0.2 : 0), PAL.bg, 0.08),
|
|
819
|
+
// The arena's edge: a worn earth-and-moss path, a step from the grass, never a painted stripe.
|
|
820
|
+
path: mixHex(mixHex(PAL.good, PAL.accent2, 0.5), mixHex(PAL.accent, PAL.ink, 0.55), 0.38),
|
|
821
|
+
shadow: mixHex(PAL.ink, PAL.accent2, 0.25),
|
|
822
|
+
zone: PAL.accent,
|
|
823
|
+
gem: PAL.gold,
|
|
824
|
+
};
|
|
825
|
+
const fontStack = (f: string | undefined, rest: string): string => (f && /^[A-Za-z0-9 ]{1,40}$/.test(f) ? `"${f}", ${rest}` : rest);
|
|
826
|
+
const FONT_DISPLAY = fontStack(STYLE.fonts?.display, 'ui-rounded, system-ui, sans-serif');
|
|
827
|
+
const FONT_BODY = fontStack(STYLE.fonts?.body, 'ui-rounded, system-ui, sans-serif');
|
|
828
|
+
/**
|
|
829
|
+
* style.json's fonts are Google Fonts (OFL): asked for once, without waiting. Until they arrive (or if they never do,
|
|
830
|
+
* offline or blocked) the HUD draws in the system's rounded face; nothing in a round waits on a font.
|
|
831
|
+
*/
|
|
832
|
+
function loadFonts(): void {
|
|
833
|
+
const fams = [STYLE.fonts?.display, STYLE.fonts?.body].filter((f): f is string => typeof f === 'string' && /^[A-Za-z0-9 ]{1,40}$/.test(f));
|
|
834
|
+
if (!fams.length) return;
|
|
835
|
+
try {
|
|
836
|
+
const l = document.createElement('link');
|
|
837
|
+
l.rel = 'stylesheet';
|
|
838
|
+
l.href = `https://fonts.googleapis.com/css2?${[...new Set(fams)].map((f) => `family=${f.replace(/ /g, '+')}:wght@600;700`).join('&')}&display=swap`;
|
|
839
|
+
document.head.appendChild(l);
|
|
840
|
+
} catch { /* the system's face, then */ }
|
|
841
|
+
}
|
|
842
|
+
loadFonts();
|
|
843
|
+
|
|
844
|
+
/* ------------------------------------------------------------------ renderer, scene, light */
|
|
845
|
+
const renderer = new WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance' });
|
|
846
|
+
// Flat low-poly colours read true without a film curve (the style board renders the same way).
|
|
847
|
+
renderer.toneMapping = NoToneMapping;
|
|
848
|
+
const scene = new Scene();
|
|
849
|
+
scene.background = new Color(COL.sky);
|
|
850
|
+
scene.fog = new Fog(COL.sky, 40, 90);
|
|
851
|
+
const camera = new PerspectiveCamera(CAM.fov, 1, 1, 320);
|
|
852
|
+
// The light of the style board (assets/render-page.ts): a sun along style.json's key, a sky-to-ground fill, a little ambient.
|
|
853
|
+
/*
|
|
854
|
+
* Shadows: real ones on a computer (a sun's shadow map that follows the camera, as the style board draws them), soft
|
|
855
|
+
* discs under things on a phone (the phone budgets: one less pass). Decided once, by the screen the game opens on.
|
|
856
|
+
*/
|
|
857
|
+
const REAL_SHADOWS = Math.min(innerWidth, innerHeight) > 540;
|
|
858
|
+
if (REAL_SHADOWS) { renderer.shadowMap.enabled = true; renderer.shadowMap.type = PCFSoftShadowMap; }
|
|
859
|
+
const SUN_DIR = new Vector3(-(LIGHT.key[0] ?? -0.5), -(LIGHT.key[1] ?? -1), -(LIGHT.key[2] ?? -0.35)).normalize();
|
|
860
|
+
let sun: DirectionalLight;
|
|
861
|
+
{
|
|
862
|
+
const night = LIGHT.time === 'night';
|
|
863
|
+
sun = new DirectionalLight(night ? mixHex(PAL.accent2, '#ffffff', 0.55) : LIGHT.time === 'golden' ? mixHex(PAL.gold, '#ffffff', 0.62) : '#ffffff', (LIGHT.intensity ?? 2.2) * 1.05);
|
|
864
|
+
sun.position.copy(SUN_DIR).multiplyScalar(30);
|
|
865
|
+
if (REAL_SHADOWS) {
|
|
866
|
+
sun.castShadow = true;
|
|
867
|
+
sun.shadow.mapSize.set(2048, 2048);
|
|
868
|
+
Object.assign(sun.shadow.camera, { left: -22, right: 22, top: 22, bottom: -22, near: 1, far: 90 });
|
|
869
|
+
sun.shadow.bias = -0.0006; sun.shadow.normalBias = 0.03; sun.shadow.radius = 3;
|
|
870
|
+
scene.add(sun.target);
|
|
871
|
+
}
|
|
872
|
+
scene.add(sun, new HemisphereLight(new Color(night ? mixHex(LIGHT.sky ?? PAL.bg, '#9fb4ff', 0.5) : LIGHT.sky), new Color(LIGHT.ground), night ? 1.6 : 1.3), new AmbientLight(0xffffff, night ? 0.35 : 0.25));
|
|
873
|
+
}
|
|
874
|
+
const XZ = (x: number, y: number): [number, number] => [x - W / 2, y - H / 2]; // rules (x, y) to three.js (x, z)
|
|
875
|
+
const EDGE = 0.55; // the path around the arena, metres
|
|
876
|
+
/**
|
|
877
|
+
* A clearing (false: the meadow runs on, a ring of bushes and rocks marks the edge) or a fenced, mown pitch (true: the
|
|
878
|
+
* fence, the striped lawn and the path round it). A cozy game is a clearing; a sport is a pitch.
|
|
879
|
+
*/
|
|
880
|
+
const FENCED = false;
|
|
881
|
+
|
|
882
|
+
/* ------------------------------------------------------------------ the meadow and the arena (procedural) */
|
|
883
|
+
/** A small seeded dice for the dressing: never Math.random (the world's dice, which the Game Lab keeps the same in both builds). */
|
|
884
|
+
function dice(seed: number): () => number {
|
|
885
|
+
let s = seed >>> 0;
|
|
886
|
+
return () => { s = (s + 0x6d2b79f5) >>> 0; let t = s; t = Math.imul(t ^ (t >>> 15), t | 1); t ^= t + Math.imul(t ^ (t >>> 7), t | 61); return ((t ^ (t >>> 14)) >>> 0) / 4294967296; };
|
|
887
|
+
}
|
|
888
|
+
function canvasTexture(w: number, h: number, paint: (g: CanvasRenderingContext2D) => void): CanvasTexture {
|
|
889
|
+
const c = document.createElement('canvas'); c.width = w; c.height = h;
|
|
890
|
+
paint(c.getContext('2d') as CanvasRenderingContext2D);
|
|
891
|
+
const t = new CanvasTexture(c); t.colorSpace = SRGBColorSpace;
|
|
892
|
+
return t;
|
|
893
|
+
}
|
|
894
|
+
/**
|
|
895
|
+
* The ground is faceted low poly, like everything standing on it: every triangle its own shade of the palette's greens,
|
|
896
|
+
* crisp at any distance (no blurred texture). Inside the arena it is flat and mown in broad stripes, so it reads as a
|
|
897
|
+
* pitch; outside it rolls a little, more the farther it is. heightAt is the one place the ground's height comes from,
|
|
898
|
+
* so the trees, rocks and shadows stand on it.
|
|
899
|
+
*/
|
|
900
|
+
const FLAT = EDGE + 5; // metres past the arena's edge that stay flat: the path, the fence, the flowers
|
|
901
|
+
function heightAt(x: number, z: number): number {
|
|
902
|
+
const dx = Math.max(0, Math.abs(x) - (W / 2 + FLAT)); const dz = Math.max(0, Math.abs(z) - (H / 2 + FLAT));
|
|
903
|
+
const away = Math.min(1, Math.hypot(dx, dz) / 14);
|
|
904
|
+
return away * 0.55 * (0.5 + 0.5 * Math.sin(x * 0.23 + z * 0.11) * Math.cos(z * 0.19 - x * 0.08));
|
|
905
|
+
}
|
|
906
|
+
/** A non-indexed triangle list, one colour per triangle (flat faces: each its own normal). */
|
|
907
|
+
function facets(tris: number[][], colours: string[]): BufferGeometry {
|
|
908
|
+
const pos = new Float32Array(tris.length * 9); const col = new Float32Array(tris.length * 9); const c = new Color();
|
|
909
|
+
tris.forEach((t, i) => { pos.set(t, i * 9); c.set(colours[i] as string); for (let k = 0; k < 3; k += 1) col.set([c.r, c.g, c.b], i * 9 + k * 3); });
|
|
910
|
+
const g = new BufferGeometry();
|
|
911
|
+
g.setAttribute('position', new BufferAttribute(pos, 3)); g.setAttribute('color', new BufferAttribute(col, 3));
|
|
912
|
+
g.computeVertexNormals();
|
|
913
|
+
return g;
|
|
914
|
+
}
|
|
915
|
+
{
|
|
916
|
+
// The meadow: a jittered 2.2 m grid, gently rolling away from the arena.
|
|
917
|
+
const roll = dice(11);
|
|
918
|
+
const cell = 2.2; const x0 = -110; const z0 = -120; const nx = 100; const nz = 82;
|
|
919
|
+
const vx: number[] = []; const vz: number[] = [];
|
|
920
|
+
for (let j = 0; j <= nz; j += 1) for (let i = 0; i <= nx; i += 1) {
|
|
921
|
+
const edge = i === 0 || j === 0 || i === nx || j === nz;
|
|
922
|
+
vx.push(x0 + i * cell + (edge ? 0 : (roll() - 0.5) * cell * 0.6)); vz.push(z0 + j * cell + (edge ? 0 : (roll() - 0.5) * cell * 0.6));
|
|
923
|
+
}
|
|
924
|
+
const at = (i: number, j: number): [number, number, number] => { const k = j * (nx + 1) + i; const x = vx[k] as number; const z = vz[k] as number; return [x, heightAt(x, z) - 0.01, z]; };
|
|
925
|
+
// Shades a step apart, never a patchwork: the faces read as facets, not as tiles.
|
|
926
|
+
const tones = [COL.meadow, COL.meadow, mixHex(COL.meadow, mixHex(PAL.good, PAL.ink, 0.25), 0.18), mixHex(COL.meadow, COL.meadowSpot, 0.3), mixHex(COL.meadow, PAL.ink, 0.04), mixHex(COL.meadow, PAL.bg, 0.07)];
|
|
927
|
+
const tris: number[][] = []; const cols: string[] = [];
|
|
928
|
+
for (let j = 0; j < nz; j += 1) for (let i = 0; i < nx; i += 1) {
|
|
929
|
+
const a = at(i, j); const b = at(i + 1, j); const c = at(i + 1, j + 1); const d = at(i, j + 1);
|
|
930
|
+
const flip = roll() < 0.5;
|
|
931
|
+
for (const t of flip ? [[a, d, b], [b, d, c]] : [[a, d, c], [a, c, b]]) { tris.push(t.flat()); cols.push(tones[Math.floor(roll() * tones.length)] as string); }
|
|
932
|
+
}
|
|
933
|
+
const meadow = new Mesh(facets(tris, cols), new MeshLambertMaterial({ vertexColors: true }));
|
|
934
|
+
meadow.receiveShadow = REAL_SHADOWS;
|
|
935
|
+
stylize(meadow, STYLE);
|
|
936
|
+
scene.add(meadow);
|
|
937
|
+
// The arena: a mown lawn, stripes 4 m wide so it reads as a pitch at a glance, faceted in 1 m triangles whose
|
|
938
|
+
// shades differ a little (the same hand as the meadow, never a painted texture).
|
|
939
|
+
const lawn = dice(5);
|
|
940
|
+
const ltris: number[][] = []; const lcols: string[] = [];
|
|
941
|
+
for (let j = 0; j < H; j += 1) for (let i = 0; i < W; i += 1) {
|
|
942
|
+
const [ax, az] = XZ(i, j); const y = 0.004;
|
|
943
|
+
const a = [ax, y, az]; const b = [ax + 1, y, az]; const c = [ax + 1, y, az + 1]; const d = [ax, y, az + 1];
|
|
944
|
+
const base = Math.floor(i / 4) % 2 ? COL.floorStripe : COL.floor;
|
|
945
|
+
for (const t of lawn() < 0.5 ? [[a, d, b], [b, d, c]] : [[a, d, c], [a, c, b]]) {
|
|
946
|
+
ltris.push(t.flat());
|
|
947
|
+
const r = lawn();
|
|
948
|
+
lcols.push(r < 0.3 ? mixHex(base, PAL.ink, 0.035) : r < 0.6 ? mixHex(base, PAL.bg, 0.04) : base);
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
const floor = new Mesh(facets(ltris, lcols), new MeshLambertMaterial({ vertexColors: true }));
|
|
952
|
+
floor.receiveShadow = REAL_SHADOWS;
|
|
953
|
+
stylize(floor, STYLE);
|
|
954
|
+
if (FENCED) scene.add(floor);
|
|
955
|
+
// Its edge: a pale path, a hand's height above the grass (inked like the models when the style draws lines).
|
|
956
|
+
const outer = new Shape();
|
|
957
|
+
outer.moveTo(-W / 2 - EDGE, -H / 2 - EDGE); outer.lineTo(W / 2 + EDGE, -H / 2 - EDGE); outer.lineTo(W / 2 + EDGE, H / 2 + EDGE); outer.lineTo(-W / 2 - EDGE, H / 2 + EDGE); outer.closePath();
|
|
958
|
+
const hole = new Path();
|
|
959
|
+
hole.moveTo(-W / 2, -H / 2); hole.lineTo(-W / 2, H / 2); hole.lineTo(W / 2, H / 2); hole.lineTo(W / 2, -H / 2); hole.closePath();
|
|
960
|
+
outer.holes.push(hole);
|
|
961
|
+
const path = new Mesh(new ExtrudeGeometry(outer, { depth: 0.07, bevelEnabled: false }), new MeshLambertMaterial({ color: COL.path }));
|
|
962
|
+
path.rotation.x = -Math.PI / 2;
|
|
963
|
+
stylize(path, STYLE);
|
|
964
|
+
if (FENCED) scene.add(path);
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/* ------------------------------------------------------------------ models */
|
|
968
|
+
/*
|
|
969
|
+
* THE MODELS come from the Homie starter library: `game new` copied each into public/models/ (assets/manifest.json
|
|
970
|
+
* names the library item, its SHA-256 and its CC0 licence). The library keeps each at its own size and in its pack's
|
|
971
|
+
* colours, so the game decides here how tall each stands (metres) and repaints the flat-coloured ones into style.json's
|
|
972
|
+
* palette (repaint: a palette swap by colour family). A model that is not there (no library when the game was made, a
|
|
973
|
+
* refused file) is drawn as a stand-in made here in the same colours, or left out when it is only a flower or a tuft of
|
|
974
|
+
* grass: a round never waits on, or breaks for, a model.
|
|
975
|
+
*/
|
|
976
|
+
const models = createModels();
|
|
977
|
+
const ANIMALS = ['fox', 'chick', 'crab', 'elephant', 'cat', 'panda', 'caterpillar', 'pig'] as const;
|
|
978
|
+
const ANIMAL_M = 0.8;
|
|
979
|
+
/** Each seat is its own animal, and wears its seat's colour as a ring (the same key as its colour). */
|
|
980
|
+
const animalOf = (slot: number, seat: number | null): number => (seat ?? slot) % ANIMALS.length;
|
|
981
|
+
const animalUrl = (k: number): string => `./models/animal-${ANIMALS[k % ANIMALS.length]}.glb`;
|
|
982
|
+
|
|
983
|
+
/** Colour families a flat-coloured pack paints with: leaves, wood and earth, pale stone, and petals. */
|
|
984
|
+
type Family = 'leaf' | 'wood' | 'pale' | 'red' | 'yellow' | 'purple';
|
|
985
|
+
type RGB = [number, number, number];
|
|
986
|
+
function hslOf(r: number, g: number, b: number): [number, number, number] {
|
|
987
|
+
const R = r / 255; const G = g / 255; const B = b / 255;
|
|
988
|
+
const mx = Math.max(R, G, B); const mn = Math.min(R, G, B); const l = (mx + mn) / 2; const d = mx - mn;
|
|
989
|
+
if (d < 1e-6) return [0, 0, l];
|
|
990
|
+
const s = d / (1 - Math.abs(2 * l - 1));
|
|
991
|
+
const h = mx === R ? ((G - B) / d + 6) % 6 : mx === G ? (B - R) / d + 2 : (R - G) / d + 4;
|
|
992
|
+
return [h * 60, s, l];
|
|
993
|
+
}
|
|
994
|
+
function rgbOf(h: number, s: number, l: number): RGB {
|
|
995
|
+
const c = (1 - Math.abs(2 * l - 1)) * s; const x = c * (1 - Math.abs(((h / 60) % 2) - 1)); const m = l - c / 2;
|
|
996
|
+
const [r, g, b] = h < 60 ? [c, x, 0] : h < 120 ? [x, c, 0] : h < 180 ? [0, c, x] : h < 240 ? [0, x, c] : h < 300 ? [x, 0, c] : [c, 0, x];
|
|
997
|
+
return [Math.round((r + m) * 255), Math.round((g + m) * 255), Math.round((b + m) * 255)];
|
|
998
|
+
}
|
|
999
|
+
function familyOf(h: number, s: number, l: number): Family | null {
|
|
1000
|
+
if (l > 0.78 || s < 0.12) return 'pale';
|
|
1001
|
+
if (h >= 140 && h <= 205) return 'leaf';
|
|
1002
|
+
if (h >= 6 && h < 28) return 'wood';
|
|
1003
|
+
if (h >= 28 && h < 70) return 'yellow';
|
|
1004
|
+
if (h >= 225 && h < 300) return 'purple';
|
|
1005
|
+
if (h >= 320 || h < 6) return 'red';
|
|
1006
|
+
return null;
|
|
1007
|
+
}
|
|
1008
|
+
/** How light each family usually is in a pack: a pixel lighter or darker than that stays as much lighter or darker. */
|
|
1009
|
+
const FAMILY_L: Record<Family, number> = { leaf: 0.48, wood: 0.6, pale: 0.86, red: 0.6, yellow: 0.63, purple: 0.75 };
|
|
1010
|
+
/** A palette swap: each colour family to one colour of style.json's palette, keeping the pack's own shading. */
|
|
1011
|
+
function swapTo(to: Partial<Record<Family, string>>): (r: number, g: number, b: number) => RGB | null {
|
|
1012
|
+
const want = Object.fromEntries(Object.entries(to).map(([k, hex]) => { const c = new Color(hex).getHex(); return [k, hslOf((c >> 16) & 255, (c >> 8) & 255, c & 255)]; })) as Partial<Record<Family, [number, number, number]>>;
|
|
1013
|
+
return (r, g, b) => {
|
|
1014
|
+
const [h, s, l] = hslOf(r, g, b);
|
|
1015
|
+
const f = familyOf(h, s, l);
|
|
1016
|
+
const t = f ? want[f] : undefined;
|
|
1017
|
+
if (!f || !t) return null;
|
|
1018
|
+
return rgbOf(t[0], t[1], Math.max(0.04, Math.min(0.96, t[2] + (l - FAMILY_L[f]) * 0.6)));
|
|
1019
|
+
};
|
|
1020
|
+
}
|
|
1021
|
+
const nearestHue = (hue: number, fallback: string): string => (PAL.ramp ?? []).map((hex) => { const c = new Color(hex).getHex(); const [h, s] = hslOf((c >> 16) & 255, (c >> 8) & 255, c & 255); return { hex, d: s > 0.25 ? Math.abs(h - hue) : 999 }; }).sort((x, y) => x.d - y.d).find((x) => x.d < 40)?.hex ?? fallback;
|
|
1022
|
+
const PAINT = {
|
|
1023
|
+
leaf: mixHex(PAL.good, PAL.ink, 0.25), leafLight: mixHex(mixHex(PAL.good, PAL.ink, 0.25), PAL.gold, 0.22), needle: mixHex(PAL.good, PAL.ink, 0.45),
|
|
1024
|
+
wood: mixHex(PAL.accent, PAL.ink, 0.45), earth: mixHex(mixHex(PAL.accent, PAL.ink, 0.4), mixHex(PAL.bg, PAL.ink, 0.12), 0.35), blade: mixHex(PAL.good, PAL.accent2, 0.5),
|
|
1025
|
+
stone: mixHex(mixHex(PAL.bg, PAL.ink, 0.5), '#8c8f96', 0.35), cream: mixHex(PAL.gold, '#ffffff', 0.8), purple: nearestHue(270, '#b07ad9'),
|
|
1026
|
+
};
|
|
1027
|
+
const SWAPS = {
|
|
1028
|
+
tree: swapTo({ leaf: PAINT.leaf, wood: PAINT.wood }),
|
|
1029
|
+
treeLight: swapTo({ leaf: PAINT.leafLight, wood: PAINT.wood }),
|
|
1030
|
+
pine: swapTo({ leaf: PAINT.needle, wood: mixHex(PAINT.wood, PAL.ink, 0.3) }),
|
|
1031
|
+
bush: swapTo({ leaf: PAINT.leaf, wood: PAINT.wood }),
|
|
1032
|
+
blade: swapTo({ leaf: PAINT.blade }),
|
|
1033
|
+
flower: swapTo({ leaf: PAINT.leaf, red: PAL.danger, yellow: PAL.gold, purple: PAINT.purple }),
|
|
1034
|
+
mushroom: swapTo({ pale: PAINT.cream, red: PAL.danger }),
|
|
1035
|
+
rock: swapTo({ wood: PAINT.earth, leaf: mixHex(PAL.good, PAL.gold, 0.2), pale: PAINT.stone }),
|
|
1036
|
+
stone: swapTo({ pale: PAINT.stone }),
|
|
1037
|
+
gem: swapTo({ pale: PAL.gold, yellow: PAL.gold, purple: PAL.gold }),
|
|
1038
|
+
fence: swapTo({ wood: PAINT.wood }),
|
|
1039
|
+
};
|
|
1040
|
+
|
|
1041
|
+
/**
|
|
1042
|
+
* The animals keep their own painted colours (a fox stays a fox) pulled a third of the way to the palette's nearest, so
|
|
1043
|
+
* they sit in this world rather than on top of it (assets/manifest.json says so: `inGame.pull`, the lineup draws it).
|
|
1044
|
+
*/
|
|
1045
|
+
const PULL_TO: RGB[] = [...new Set([...(PAL.ramp ?? []), PAL.accent, PAL.accent2, PAL.gold, PAL.good, PAL.danger, PAL.ink])].map((hex) => { const c = new Color(hex).getHex(); return [(c >> 16) & 255, (c >> 8) & 255, c & 255] as RGB; });
|
|
1046
|
+
function pullToPalette(k: number): (r: number, g: number, b: number) => RGB {
|
|
1047
|
+
return (r, g, b) => {
|
|
1048
|
+
let best = PULL_TO[0] as RGB; let bd = Infinity;
|
|
1049
|
+
for (const p of PULL_TO) { const d = 0.3 * (p[0] - r) ** 2 + 0.59 * (p[1] - g) ** 2 + 0.11 * (p[2] - b) ** 2; if (d < bd) { bd = d; best = p; } }
|
|
1050
|
+
return [Math.round(r + (best[0] - r) * k), Math.round(g + (best[1] - g) * k), Math.round(b + (best[2] - b) * k)];
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
const ANIMAL_PULL = pullToPalette(0.35);
|
|
1054
|
+
|
|
1055
|
+
/** Stand-ins, drawn in the same colours when a model is not there: low-poly shapes, one draw call each. */
|
|
1056
|
+
type Stand = 'gem' | 'tree' | 'pine' | 'bush' | 'rock' | 'stone' | 'fence' | 'critter';
|
|
1057
|
+
function standIn(kind: Stand, colour = PAL.accent): Mesh {
|
|
1058
|
+
const parts: BufferGeometry[] = [];
|
|
1059
|
+
const add = (geo: BufferGeometry, hex: string, x: number, y: number, z: number, sx = 1, sy = 1, sz = 1): void => {
|
|
1060
|
+
const g = geo.index ? geo.toNonIndexed() : geo;
|
|
1061
|
+
g.scale(sx, sy, sz); g.translate(x, y, z);
|
|
1062
|
+
const c = new Color(hex); const n = (g.attributes.position as BufferAttribute).count; const col = new Float32Array(n * 3);
|
|
1063
|
+
for (let i = 0; i < n; i += 1) col.set([c.r, c.g, c.b], i * 3);
|
|
1064
|
+
g.setAttribute('color', new BufferAttribute(col, 3));
|
|
1065
|
+
parts.push(g);
|
|
1066
|
+
};
|
|
1067
|
+
if (kind === 'gem') add(new OctahedronGeometry(0.2, 0), PAL.gold, 0, 0.28, 0, 1, 1.4, 1);
|
|
1068
|
+
if (kind === 'tree') { add(new CylinderGeometry(0.12, 0.17, 1.5, 6), PAINT.wood, 0, 0.75, 0); add(new IcosahedronGeometry(1.05, 0), PAINT.leaf, 0, 2.45, 0); }
|
|
1069
|
+
if (kind === 'pine') { add(new CylinderGeometry(0.1, 0.14, 0.9, 6), PAINT.wood, 0, 0.45, 0); add(new ConeGeometry(1, 2.2, 7), PAINT.needle, 0, 1.9, 0); add(new ConeGeometry(0.72, 1.6, 7), PAINT.needle, 0, 2.95, 0); }
|
|
1070
|
+
if (kind === 'bush') add(new IcosahedronGeometry(0.55, 0), PAINT.leaf, 0, 0.42, 0, 1.2, 0.8, 1.1);
|
|
1071
|
+
if (kind === 'rock') add(new DodecahedronGeometry(0.5, 0), PAINT.stone, 0, 0.3, 0, 1.3, 0.75, 1.1);
|
|
1072
|
+
if (kind === 'stone') add(new CylinderGeometry(0.3, 0.45, 1, 5), PAINT.stone, 0, 0.5, 0);
|
|
1073
|
+
if (kind === 'fence') { for (const x of [-0.96, 0.96]) add(new BoxGeometry(0.1, 0.7, 0.1), PAINT.wood, x, 0.35, 0); for (const y of [0.3, 0.55]) add(new BoxGeometry(2.03, 0.08, 0.05), PAINT.wood, 0, y, 0); }
|
|
1074
|
+
if (kind === 'critter') {
|
|
1075
|
+
// A cube animal in its seat's colour: ears, eyes and feet, facing +z like the models.
|
|
1076
|
+
add(new BoxGeometry(0.62, 0.5, 0.62), colour, 0, 0.36, 0);
|
|
1077
|
+
for (const x of [-0.18, 0.18]) { add(new BoxGeometry(0.14, 0.16, 0.1), colour, x, 0.69, -0.05); add(new BoxGeometry(0.13, 0.15, 0.02), '#ffffff', x * 0.8, 0.42, 0.315); add(new BoxGeometry(0.06, 0.08, 0.02), PAL.ink, x * 0.8, 0.41, 0.33); }
|
|
1078
|
+
for (const x of [-0.18, 0.18]) for (const z of [-0.18, 0.18]) add(new BoxGeometry(0.15, 0.12, 0.15), mixHex(colour, PAL.ink, 0.35), x, 0.06, z);
|
|
1079
|
+
}
|
|
1080
|
+
const mesh = new Mesh(mergeGeometries(parts) as BufferGeometry, new MeshLambertMaterial({ vertexColors: true, flatShading: true }));
|
|
1081
|
+
mesh.name = 'stand-in';
|
|
1082
|
+
return mesh;
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/** One colour for a whole model, its picture dropped: its faces keep their shading from the light. */
|
|
1086
|
+
function paintAll(model: Object3D, hex: string): void {
|
|
1087
|
+
model.traverse((o) => {
|
|
1088
|
+
const mesh = o as Mesh;
|
|
1089
|
+
if (!mesh.isMesh) return;
|
|
1090
|
+
for (const mat of (Array.isArray(mesh.material) ? mesh.material : [mesh.material]) as MeshStandardMaterial[]) { mat.map = null; mat.color = new Color(hex); mat.needsUpdate = true; }
|
|
1091
|
+
});
|
|
1092
|
+
}
|
|
1093
|
+
/** Every model the game draws: its file, how tall it stands, its palette swap and its stand-in. */
|
|
1094
|
+
interface Spec { url: string; m: number; swap: ((r: number, g: number, b: number) => RGB | null) | null; stand: Stand | null; tint?: string; px?: number }
|
|
1095
|
+
// The gem is one colour, the palette's gold (its pack paints it from a picture atlas: the tint replaces that).
|
|
1096
|
+
const GEM: Spec = { url: './models/gem.glb', m: 0.42, swap: SWAPS.gem, stand: 'gem', tint: PAL.gold };
|
|
1097
|
+
const FENCE: Spec = { url: './models/fence.glb', m: 0.7, swap: SWAPS.fence, stand: 'fence' };
|
|
1098
|
+
// The camp at the clearing's heart: a ring of stones round its logs, two logs to sit on, and a flame made here.
|
|
1099
|
+
const CAMPFIRE: Spec = { url: './models/campfire.glb', m: 0.16, swap: swapTo({ pale: PAINT.stone, wood: PAINT.wood }), stand: 'rock' };
|
|
1100
|
+
const LOG: Spec = { url: './models/log.glb', m: 0.42, swap: swapTo({ wood: PAINT.wood, pale: mixHex(PAINT.wood, PAL.ink, 0.45) }), stand: null };
|
|
1101
|
+
const FIREWOOD: Spec = { url: './models/firewood.glb', m: 0.14, swap: swapTo({ wood: mixHex(PAINT.wood, PAL.bg, 0.25) }), stand: null };
|
|
1102
|
+
/** The dressing: how many, where, its footprint in metres (for spacing), its shadow's radius and its size range. */
|
|
1103
|
+
type Where = 'far' | 'any' | 'low' | 'inside' | 'edge';
|
|
1104
|
+
const DRESSING: (Spec & { count: number; where: Where; foot: number; shadow: number; scale: [number, number] })[] = [
|
|
1105
|
+
{ url: './models/tree-oak.glb', m: 3.6, swap: SWAPS.tree, stand: 'tree', count: 14, where: 'far', foot: 2.1, shadow: 1.3, scale: [0.85, 1.2] },
|
|
1106
|
+
{ url: './models/tree-round.glb', m: 3.2, swap: SWAPS.treeLight, stand: 'tree', count: 14, where: 'far', foot: 1.4, shadow: 1.0, scale: [0.85, 1.25] },
|
|
1107
|
+
{ url: './models/tree-pine.glb', m: 4, swap: SWAPS.pine, stand: 'pine', count: 16, where: 'far', foot: 2.1, shadow: 1.2, scale: [0.8, 1.3] },
|
|
1108
|
+
{ url: './models/stone.glb', m: 1, swap: SWAPS.stone, stand: 'stone', count: 6, where: 'far', foot: 1, shadow: 0.6, scale: [0.8, 1.3] },
|
|
1109
|
+
{ url: './models/bush.glb', m: 1.1, swap: SWAPS.bush, stand: 'bush', count: 22, where: 'any', foot: 1.4, shadow: 0.75, scale: [0.7, 1.15] },
|
|
1110
|
+
{ url: './models/rock.glb', m: 0.75, swap: SWAPS.rock, stand: 'rock', count: 12, where: 'any', foot: 1.4, shadow: 0.75, scale: [0.6, 1.1] },
|
|
1111
|
+
{ url: './models/flower-red.glb', m: 0.4, swap: SWAPS.flower, stand: null, count: 30, where: 'low', foot: 0.3, shadow: 0, scale: [0.9, 1.5] },
|
|
1112
|
+
{ url: './models/flower-yellow.glb', m: 0.4, swap: SWAPS.flower, stand: null, count: 30, where: 'low', foot: 0.4, shadow: 0, scale: [0.9, 1.5] },
|
|
1113
|
+
{ url: './models/flower-purple.glb', m: 0.4, swap: SWAPS.flower, stand: null, count: 30, where: 'low', foot: 0.3, shadow: 0, scale: [0.9, 1.5] },
|
|
1114
|
+
{ url: './models/mushrooms.glb', m: 0.45, swap: SWAPS.mushroom, stand: null, count: 12, where: 'any', foot: 0.5, shadow: 0, scale: [0.8, 1.3] },
|
|
1115
|
+
// The clearing's edge, when it has no fence: bushes and rocks just outside it (never on the camera's side).
|
|
1116
|
+
{ url: './models/bush.glb', m: 1.1, swap: SWAPS.bush, stand: 'bush', count: FENCED ? 0 : 26, where: 'edge', foot: 1.2, shadow: 0.7, scale: [0.75, 1.15] },
|
|
1117
|
+
{ url: './models/rock.glb', m: 0.75, swap: SWAPS.rock, stand: 'rock', count: FENCED ? 0 : 10, where: 'edge', foot: 1.1, shadow: 0.6, scale: [0.6, 1] },
|
|
1118
|
+
// Tufts of grass in and around the clearing: the lawn is a meadow, never a bare board (they never block a move).
|
|
1119
|
+
{ url: './models/grass.glb', m: 0.18, swap: SWAPS.blade, stand: null, count: 36, where: 'inside', foot: 0.9, shadow: 0, scale: [0.7, 1.1] },
|
|
1120
|
+
{ url: './models/grass.glb', m: 0.18, swap: SWAPS.blade, stand: null, count: 36, where: 'low', foot: 0.6, shadow: 0, scale: [0.8, 1.2] },
|
|
1121
|
+
{ url: './models/flower-red.glb', m: 0.4, swap: SWAPS.flower, stand: null, count: 22, where: 'inside', foot: 0.9, shadow: 0, scale: [0.55, 0.8] },
|
|
1122
|
+
{ url: './models/flower-purple.glb', m: 0.4, swap: SWAPS.flower, stand: null, count: 22, where: 'inside', foot: 0.9, shadow: 0, scale: [0.55, 0.8] },
|
|
1123
|
+
];
|
|
1124
|
+
|
|
1125
|
+
/** A model loaded once, repainted and measured: `fit` scales it to its height, `size` is its box at that height. */
|
|
1126
|
+
interface Ready { scene: Object3D; fit: number; size: Vector3 }
|
|
1127
|
+
const ready = new Map<string, Promise<Ready | null>>();
|
|
1128
|
+
const standIns = new Set<string>();
|
|
1129
|
+
function prepare(spec: Pick<Spec, 'url' | 'm' | 'swap' | 'tint' | 'px'>): Promise<Ready | null> {
|
|
1130
|
+
let p = ready.get(spec.url);
|
|
1131
|
+
if (!p) {
|
|
1132
|
+
p = models.load(spec.url).then((m) => {
|
|
1133
|
+
if (spec.swap) repaint(m.scene, spec.swap, spec.px ? { maxPx: spec.px } : undefined);
|
|
1134
|
+
if (spec.tint) paintAll(m.scene, spec.tint);
|
|
1135
|
+
const size = new Box3().setFromObject(m.scene).getSize(new Vector3());
|
|
1136
|
+
const fit = size.y > 1e-3 ? spec.m / size.y : 1;
|
|
1137
|
+
// The art direction's material model and ink line (style.json), as the style board drew them.
|
|
1138
|
+
stylize(m.scene, STYLE, { scale: fit });
|
|
1139
|
+
return { scene: m.scene, fit, size: size.multiplyScalar(fit) };
|
|
1140
|
+
}, () => { standIns.add(spec.url); return null; });
|
|
1141
|
+
ready.set(spec.url, p);
|
|
1142
|
+
}
|
|
1143
|
+
return p;
|
|
1144
|
+
}
|
|
1145
|
+
// The Game Lab plays a take only once the models have settled (loaded or not there), so both builds start alike.
|
|
1146
|
+
lab.hold();
|
|
1147
|
+
void Promise.all([...ANIMALS.map((_, i) => prepare({ url: animalUrl(i), m: ANIMAL_M, swap: ANIMAL_PULL, px: 512 })), prepare(GEM), ...(FENCED ? [prepare(FENCE)] : []), prepare(CAMPFIRE), prepare(FIREWOOD), prepare(LOG), ...DRESSING.map(prepare)]).then(() => lab.ready());
|
|
1148
|
+
|
|
1149
|
+
/**
|
|
1150
|
+
* A model as instanced copies (instancedCopies: one InstancedMesh per mesh, placed with placeCopy, never by baking into
|
|
1151
|
+
* the file's geometry), and the scale that makes it its height; its stand-in when it is not there, or nothing.
|
|
1152
|
+
*/
|
|
1153
|
+
async function instancedFrom(spec: Spec, count: number): Promise<{ parts: Copies[]; fit: number; size: Vector3 | null }> {
|
|
1154
|
+
const r = await prepare(spec);
|
|
1155
|
+
if (r) return { parts: instancedCopies(r.scene, count), fit: r.fit, size: r.size };
|
|
1156
|
+
return { parts: spec.stand ? instancedCopies(standIn(spec.stand), count) : [], fit: 1, size: null };
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
/* ------------------------------------------------------------------ soft discs: shadows, glows */
|
|
1160
|
+
const discTexture = canvasTexture(64, 64, (g) => {
|
|
1161
|
+
const gr = g.createRadialGradient(32, 32, 0, 32, 32, 32);
|
|
1162
|
+
gr.addColorStop(0, 'rgba(255,255,255,1)'); gr.addColorStop(0.45, 'rgba(255,255,255,0.75)'); gr.addColorStop(1, 'rgba(255,255,255,0)');
|
|
1163
|
+
g.fillStyle = gr; g.fillRect(0, 0, 64, 64);
|
|
1164
|
+
});
|
|
1165
|
+
const flatDisc = (): PlaneGeometry => { const g = new PlaneGeometry(2, 2); g.rotateX(-Math.PI / 2); return g; };
|
|
1166
|
+
const shadowMaterial = new MeshBasicMaterial({ map: discTexture, color: COL.shadow, transparent: true, opacity: 0.32, depthWrite: false });
|
|
1167
|
+
/** Shadows that move: one disc per body and per gem. */
|
|
1168
|
+
const blobs = new InstancedMesh(flatDisc(), shadowMaterial, MAX_SLOTS + 40);
|
|
1169
|
+
blobs.frustumCulled = false; blobs.count = 0; blobs.position.y = 0.012;
|
|
1170
|
+
scene.add(blobs);
|
|
1171
|
+
/** The glow under each gem: it says "take me" before the gem is near. */
|
|
1172
|
+
const glows = new InstancedMesh(flatDisc(), new MeshBasicMaterial({ map: discTexture, color: COL.gem, transparent: true, opacity: 0.32, depthWrite: false }), 40);
|
|
1173
|
+
glows.frustumCulled = false; glows.count = 0; glows.position.y = 0.016;
|
|
1174
|
+
scene.add(glows);
|
|
1175
|
+
|
|
1176
|
+
/* ------------------------------------------------------------------ the dressing around the arena */
|
|
1177
|
+
const placed: { x: number; z: number; r: number }[] = [];
|
|
1178
|
+
/**
|
|
1179
|
+
* A spot for a piece of dressing (three.js x, z): on a band round the arena, `out` metres past its path, most of them
|
|
1180
|
+
* near the fence (where the camera sees them), clear of the others. Trees and standing stones only behind the arena
|
|
1181
|
+
* and beside its far half: never between the camera and the play.
|
|
1182
|
+
*/
|
|
1183
|
+
function spotFor(roll: () => number, where: Where, r: number): { x: number; z: number } | null {
|
|
1184
|
+
if (where === 'inside') {
|
|
1185
|
+
// A few small flowers on the lawn itself, so a phone's close view is never bare grass (they never block a move).
|
|
1186
|
+
for (let tries = 0; tries < 60; tries += 1) {
|
|
1187
|
+
const x = (roll() - 0.5) * (W - 1.6); const z = (roll() - 0.5) * (H - 1.6);
|
|
1188
|
+
if (placed.some((p) => Math.hypot(p.x - x, p.z - z) < p.r + r)) continue;
|
|
1189
|
+
placed.push({ x, z, r });
|
|
1190
|
+
return { x, z };
|
|
1191
|
+
}
|
|
1192
|
+
return null;
|
|
1193
|
+
}
|
|
1194
|
+
const [lo, hi] = where === 'edge' ? [0.15, 1.4] : where === 'low' ? [1.2, 7] : where === 'any' ? [1.5, 9] : [1.8, 16];
|
|
1195
|
+
for (let tries = 0; tries < 60; tries += 1) {
|
|
1196
|
+
const out = lo + (hi - lo) * roll() ** 1.5;
|
|
1197
|
+
const hw = W / 2 + EDGE + out; const hh = H / 2 + EDGE + out;
|
|
1198
|
+
let u = roll() * (4 * hw + 4 * hh); let x: number; let z: number;
|
|
1199
|
+
if (u < 2 * hw) { x = -hw + u; z = -hh; } else if ((u -= 2 * hw) < 2 * hh) { x = hw; z = -hh + u; } else if ((u -= 2 * hh) < 2 * hw) { x = hw - u; z = hh; } else { u -= 2 * hw; x = -hw; z = hh - u; }
|
|
1200
|
+
if (where === 'far' && z > 0) continue;
|
|
1201
|
+
if (where === 'edge' && z > H / 2) continue; // the near side stays open to the camera
|
|
1202
|
+
if (where === 'any' && z > H / 2 && out > 4) continue; // little on the camera's side
|
|
1203
|
+
if (placed.some((p) => Math.hypot(p.x - x, p.z - z) < p.r + r)) continue;
|
|
1204
|
+
placed.push({ x, z, r });
|
|
1205
|
+
return { x, z };
|
|
1206
|
+
}
|
|
1207
|
+
return null;
|
|
1208
|
+
}
|
|
1209
|
+
const shadowSpots: { x: number; z: number; r: number }[] = [];
|
|
1210
|
+
/** The flame at the camp's heart (flickered in draw): two cones in the palette's gold and accent, and its glow. */
|
|
1211
|
+
let flame: Object3D | null = null;
|
|
1212
|
+
async function buildCamp(o: { x: number; y: number; r: number }): Promise<void> {
|
|
1213
|
+
const [x, z] = XZ(o.x, o.y);
|
|
1214
|
+
const camp = new Group(); camp.position.set(x, 0, z); scene.add(camp);
|
|
1215
|
+
const ring = await prepare(CAMPFIRE);
|
|
1216
|
+
if (ring) {
|
|
1217
|
+
const obj = ring.scene.clone(true);
|
|
1218
|
+
const wide = Math.max(ring.size.x, ring.size.z) || 1;
|
|
1219
|
+
obj.scale.setScalar(ring.fit * (1.5 / wide));
|
|
1220
|
+
camp.add(obj);
|
|
1221
|
+
}
|
|
1222
|
+
const wood = await prepare(FIREWOOD);
|
|
1223
|
+
if (wood) {
|
|
1224
|
+
const obj = wood.scene.clone(true);
|
|
1225
|
+
obj.scale.setScalar(wood.fit * (0.85 / (Math.max(wood.size.x, wood.size.z) || 1)));
|
|
1226
|
+
camp.add(obj);
|
|
1227
|
+
}
|
|
1228
|
+
const log = await prepare(LOG);
|
|
1229
|
+
if (log) for (const [lx, lz, rot] of [[-1.55, 0.25, 0.2], [1.5, -0.35, -0.25]] as [number, number, number][]) {
|
|
1230
|
+
const l = log.scene.clone(true); l.position.set(lx, 0, lz); l.rotation.y = Math.PI / 2 + rot; l.scale.setScalar(log.fit); camp.add(l);
|
|
1231
|
+
}
|
|
1232
|
+
const f = new Group();
|
|
1233
|
+
const outer = new Mesh(new ConeGeometry(0.26, 0.75, 6), new MeshBasicMaterial({ color: PAL.accent }));
|
|
1234
|
+
const inner = new Mesh(new ConeGeometry(0.15, 0.5, 6), new MeshBasicMaterial({ color: PAL.gold }));
|
|
1235
|
+
outer.position.y = 0.42; inner.position.y = 0.32; f.add(outer, inner);
|
|
1236
|
+
const glow = new Mesh(new CircleGeometry(1.3, 24), new MeshBasicMaterial({ map: discTexture, color: PAL.gold, transparent: true, opacity: 0.45, depthWrite: false }));
|
|
1237
|
+
glow.rotation.x = -Math.PI / 2; glow.position.y = 0.02; f.add(glow);
|
|
1238
|
+
camp.add(f); flame = f;
|
|
1239
|
+
if (REAL_SHADOWS) camp.traverse((n) => { const mm = n as Mesh; if (mm.isMesh && mm.name !== 'hull' && n !== glow) mm.castShadow = true; });
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
/** The clearing's solid features (OBSTACLES), drawn with the dressing's own models. */
|
|
1243
|
+
const OBSTACLE_SPEC = {
|
|
1244
|
+
camp: null,
|
|
1245
|
+
bush: DRESSING.find((d) => d.url.endsWith('/bush.glb')),
|
|
1246
|
+
rock: DRESSING.find((d) => d.url.endsWith('/rock.glb')),
|
|
1247
|
+
stone: DRESSING.find((d) => d.url.endsWith('/stone.glb')),
|
|
1248
|
+
tree: DRESSING.find((d) => d.url.endsWith('/tree-round.glb')),
|
|
1249
|
+
} as const;
|
|
1250
|
+
async function dressMeadow(): Promise<void> {
|
|
1251
|
+
const roll = dice(2024);
|
|
1252
|
+
const m = new Matrix4(); const qt = new Quaternion(); const e = new Euler(); const p = new Vector3(); const s = new Vector3();
|
|
1253
|
+
// The clearing's features first: the dressing keeps clear of them, and each gets its shadow.
|
|
1254
|
+
for (const o of OBSTACLES) { const [x, z] = XZ(o.x, o.y); placed.push({ x, z, r: o.r + 0.3 }); shadowSpots.push({ x, z, r: o.r * 1.4 }); }
|
|
1255
|
+
// Decided before any model arrives, so the meadow is the same on every screen whatever loads first.
|
|
1256
|
+
const plans = DRESSING.map((d) => {
|
|
1257
|
+
const spots: { x: number; z: number; rot: number; sc: number }[] = [];
|
|
1258
|
+
for (let i = 0; i < d.count; i += 1) {
|
|
1259
|
+
const sc = d.scale[0] + roll() * (d.scale[1] - d.scale[0]);
|
|
1260
|
+
const at = spotFor(roll, d.where, d.foot * 0.5 * sc + 0.15);
|
|
1261
|
+
if (at) spots.push({ ...at, rot: roll() * Math.PI * 2, sc });
|
|
1262
|
+
}
|
|
1263
|
+
return { d, spots };
|
|
1264
|
+
});
|
|
1265
|
+
for (const { d, spots } of plans) if (d.shadow) for (const sp of spots) shadowSpots.push({ x: sp.x, z: sp.z, r: d.shadow * sp.sc });
|
|
1266
|
+
// Their shadows: one draw call for the lot.
|
|
1267
|
+
const still = new InstancedMesh(flatDisc(), shadowMaterial, shadowSpots.length || 1);
|
|
1268
|
+
shadowSpots.forEach((sp, i) => still.setMatrixAt(i, m.compose(p.set(sp.x, heightAt(sp.x, sp.z) + 0.008, sp.z), qt.identity(), s.set(sp.r, 1, sp.r))));
|
|
1269
|
+
still.count = shadowSpots.length;
|
|
1270
|
+
still.computeBoundingSphere();
|
|
1271
|
+
still.visible = !REAL_SHADOWS; // a computer draws the real shadows
|
|
1272
|
+
scene.add(still);
|
|
1273
|
+
await Promise.all(plans.map(async ({ d, spots }) => {
|
|
1274
|
+
const { parts, fit } = await instancedFrom(d, Math.max(1, spots.length));
|
|
1275
|
+
for (const c of parts) {
|
|
1276
|
+
spots.forEach((sp, i) => placeCopy(c, i, m.compose(p.set(sp.x, heightAt(sp.x, sp.z) - 0.02, sp.z), qt.setFromEuler(e.set(0, sp.rot, 0)), s.setScalar(sp.sc * fit))));
|
|
1277
|
+
c.mesh.count = spots.length;
|
|
1278
|
+
c.mesh.computeBoundingSphere();
|
|
1279
|
+
c.mesh.castShadow = REAL_SHADOWS && c.mesh.name !== 'hull'; c.mesh.receiveShadow = REAL_SHADOWS;
|
|
1280
|
+
scene.add(c.mesh);
|
|
1281
|
+
}
|
|
1282
|
+
}));
|
|
1283
|
+
// The features themselves: a copy of each model (or its stand-in) where the rules put it.
|
|
1284
|
+
const turn = dice(77);
|
|
1285
|
+
await Promise.all(OBSTACLES.map(async (o) => {
|
|
1286
|
+
if (o.kind === 'camp') { await buildCamp(o); return; }
|
|
1287
|
+
const spec = OBSTACLE_SPEC[o.kind];
|
|
1288
|
+
if (!spec) return;
|
|
1289
|
+
const r = await prepare(spec);
|
|
1290
|
+
const obj = r ? r.scene.clone(true) : spec.stand ? standIn(spec.stand) : null;
|
|
1291
|
+
if (!obj) return;
|
|
1292
|
+
const [x, z] = XZ(o.x, o.y);
|
|
1293
|
+
obj.position.set(x, 0, z); obj.rotation.y = turn() * Math.PI * 2;
|
|
1294
|
+
obj.scale.setScalar((r?.fit ?? 1) * (o.kind === 'tree' ? 0.8 : 1.1));
|
|
1295
|
+
if (REAL_SHADOWS) obj.traverse((n) => { const mm = n as Mesh; if (mm.isMesh && mm.name !== 'hull') mm.castShadow = true; });
|
|
1296
|
+
scene.add(obj);
|
|
1297
|
+
}));
|
|
1298
|
+
}
|
|
1299
|
+
/** The fence just outside the path: a ring of the same piece, each stretched a little to fit its side; one draw call. */
|
|
1300
|
+
async function buildFence(): Promise<void> {
|
|
1301
|
+
const { parts, fit, size } = await instancedFrom(FENCE, 120);
|
|
1302
|
+
const piece = size?.x ?? 2.03; // metres along x at its height (the stand-in's is 2.03)
|
|
1303
|
+
const runs: { x: number; z: number; rot: number; stretch: number }[] = [];
|
|
1304
|
+
const nx = Math.ceil((W + 2 * EDGE) / piece); const nz = Math.ceil((H + 2 * EDGE) / piece);
|
|
1305
|
+
const sx = (W + 2 * EDGE) / nx; const sz = (H + 2 * EDGE) / nz;
|
|
1306
|
+
const off = 0.18;
|
|
1307
|
+
for (let i = 0; i < nx; i += 1) {
|
|
1308
|
+
const x = -W / 2 - EDGE + (i + 0.5) * sx;
|
|
1309
|
+
runs.push({ x, z: -H / 2 - EDGE - off, rot: 0, stretch: sx / piece }, { x, z: H / 2 + EDGE + off, rot: Math.PI, stretch: sx / piece });
|
|
1310
|
+
}
|
|
1311
|
+
for (let i = 0; i < nz; i += 1) {
|
|
1312
|
+
const z = -H / 2 - EDGE + (i + 0.5) * sz;
|
|
1313
|
+
runs.push({ x: -W / 2 - EDGE - off, z, rot: Math.PI / 2, stretch: sz / piece }, { x: W / 2 + EDGE + off, z, rot: -Math.PI / 2, stretch: sz / piece });
|
|
1314
|
+
}
|
|
1315
|
+
const m = new Matrix4(); const qt = new Quaternion(); const e = new Euler(); const p = new Vector3(); const s = new Vector3();
|
|
1316
|
+
for (const c of parts) {
|
|
1317
|
+
runs.forEach((r, i) => placeCopy(c, i, m.compose(p.set(r.x, 0, r.z), qt.setFromEuler(e.set(0, r.rot, 0)), s.set(r.stretch * fit, fit, fit))));
|
|
1318
|
+
c.mesh.count = runs.length;
|
|
1319
|
+
c.mesh.computeBoundingSphere();
|
|
1320
|
+
c.mesh.castShadow = REAL_SHADOWS;
|
|
1321
|
+
scene.add(c.mesh);
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
void dressMeadow();
|
|
1325
|
+
if (FENCED) void buildFence();
|
|
1326
|
+
|
|
1327
|
+
/* ------------------------------------------------------------------ gems */
|
|
1328
|
+
const GEM_CAP = 40;
|
|
1329
|
+
/** Gems are drawn a third bigger than the model's 0.42 m, so they read on a phone; picking one up is the rules' R_GEM. */
|
|
1330
|
+
const GEM_DRAWN = 1.35;
|
|
1331
|
+
let gemParts: Copies[] = [];
|
|
1332
|
+
let gemFit = 1;
|
|
1333
|
+
function setGemParts(parts: Copies[], fit = 1): void {
|
|
1334
|
+
for (const c of gemParts) scene.remove(c.mesh);
|
|
1335
|
+
gemParts = parts; gemFit = fit;
|
|
1336
|
+
for (const { mesh: im } of parts) {
|
|
1337
|
+
im.frustumCulled = false; im.count = 0;
|
|
1338
|
+
im.instanceMatrix.setUsage(DynamicDrawUsage);
|
|
1339
|
+
// A little light of their own (a copy of the file's material), so gems read as treasure in any light.
|
|
1340
|
+
const own = (im.material as MeshStandardMaterial).clone();
|
|
1341
|
+
if (own.emissive) { own.emissive = new Color(COL.gem); own.emissiveIntensity = 0.3; }
|
|
1342
|
+
im.material = own;
|
|
1343
|
+
scene.add(im);
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
{
|
|
1347
|
+
// Until the gem arrives (a few hundred ms) its stand-in, which stays if it never does: the round has already started.
|
|
1348
|
+
setGemParts(instancedCopies(standIn('gem'), GEM_CAP));
|
|
1349
|
+
void prepare(GEM).then((r) => { if (r) setGemParts(instancedCopies(r.scene, GEM_CAP), r.fit); });
|
|
1350
|
+
}
|
|
1351
|
+
const lastGems = new Map<number, { x: number; y: number }>();
|
|
1352
|
+
let lastGemRound = -1;
|
|
1353
|
+
|
|
1354
|
+
/* ------------------------------------------------------------------ the hot zone, rings, waves, sparks */
|
|
1355
|
+
const zoneFill = new Mesh(new CircleGeometry(1, 48), new MeshBasicMaterial({ color: COL.zone, transparent: true, opacity: 0.16, depthWrite: false }));
|
|
1356
|
+
zoneFill.rotation.x = -Math.PI / 2; zoneFill.position.y = 0.014; zoneFill.visible = false;
|
|
1357
|
+
const zoneRing = (() => {
|
|
1358
|
+
// A dashed ring, as Gem Rush draws it: 18 dashes in one geometry.
|
|
1359
|
+
const dashes: BufferGeometry[] = [];
|
|
1360
|
+
for (let i = 0; i < 18; i += 1) dashes.push(new RingGeometry(0.93, 1, 6, 1, (i / 18) * Math.PI * 2, (Math.PI * 2) / 18 * 0.6));
|
|
1361
|
+
const g = mergeGeometries(dashes) as BufferGeometry; g.rotateX(-Math.PI / 2);
|
|
1362
|
+
return new Mesh(g, new MeshBasicMaterial({ color: COL.zone, transparent: true, opacity: 0.9, depthWrite: false }));
|
|
1363
|
+
})();
|
|
1364
|
+
zoneRing.position.y = 0.02; zoneRing.visible = false;
|
|
1365
|
+
scene.add(zoneFill, zoneRing);
|
|
1366
|
+
|
|
1367
|
+
/** Every body's seat colour, a ring on the grass under it (people bright; bots greyed, as Gem Rush dims them). */
|
|
1368
|
+
const ringGeo = new RingGeometry(R_AV + 0.02, R_AV + 0.14, 40); ringGeo.rotateX(-Math.PI / 2);
|
|
1369
|
+
const seatRings = new InstancedMesh(ringGeo, new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.95, depthWrite: false }), MAX_SLOTS + 4);
|
|
1370
|
+
seatRings.frustumCulled = false; seatRings.count = 0; seatRings.position.y = 0.022;
|
|
1371
|
+
seatRings.setColorAt(0, new Color(0xffffff));
|
|
1372
|
+
const edgeGeo = new RingGeometry(R_AV - 0.02, R_AV + 0.18, 40); edgeGeo.rotateX(-Math.PI / 2);
|
|
1373
|
+
const seatEdges = new InstancedMesh(edgeGeo, new MeshBasicMaterial({ color: COL.shadow, transparent: true, opacity: 0.4, depthWrite: false }), MAX_SLOTS + 4);
|
|
1374
|
+
seatEdges.frustumCulled = false; seatEdges.count = 0; seatEdges.position.y = 0.018;
|
|
1375
|
+
scene.add(seatEdges, seatRings);
|
|
1376
|
+
/** The ring that says "you" (or the player a watcher follows). */
|
|
1377
|
+
const youGeo = new RingGeometry(R_AV + 0.2, R_AV + 0.3, 48); youGeo.rotateX(-Math.PI / 2);
|
|
1378
|
+
const youRing = new Mesh(youGeo, new MeshBasicMaterial({ color: 0xffffff, transparent: true, opacity: 0.95, depthWrite: false }));
|
|
1379
|
+
youRing.position.y = 0.024; youRing.visible = false;
|
|
1380
|
+
scene.add(youRing);
|
|
1381
|
+
|
|
1382
|
+
const waveGeo = new RingGeometry(0.9, 1, 48); waveGeo.rotateX(-Math.PI / 2);
|
|
1383
|
+
const wavePool: Mesh[] = [];
|
|
1384
|
+
function waveMesh(i: number): Mesh {
|
|
1385
|
+
let w = wavePool[i];
|
|
1386
|
+
if (!w) { w = new Mesh(waveGeo, new MeshBasicMaterial({ color: 0xffffff, transparent: true, depthWrite: false })); w.position.y = 0.03; scene.add(w); wavePool[i] = w; }
|
|
1387
|
+
return w;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
/** Sparks: little bars flying off a hit (white, then gold) and up off a taken gem; one draw call. */
|
|
1391
|
+
const sparkMesh = new InstancedMesh(new BoxGeometry(1, 0.07, 0.07), new MeshBasicMaterial({ color: 0xffffff }), 160);
|
|
1392
|
+
sparkMesh.frustumCulled = false; sparkMesh.count = 0; sparkMesh.instanceMatrix.setUsage(DynamicDrawUsage);
|
|
1393
|
+
sparkMesh.setColorAt(0, new Color(0xffffff));
|
|
1394
|
+
scene.add(sparkMesh);
|
|
1395
|
+
|
|
1396
|
+
/* ------------------------------------------------------------------ the animals */
|
|
1397
|
+
interface Animal {
|
|
1398
|
+
slot: number; kind: number;
|
|
1399
|
+
root: Group; tilt: Group; squash: Group; untilt: Group; turn: Group;
|
|
1400
|
+
model: Object3D | null; mixer: AnimationMixer | null; actions: Map<string, AnimationAction>; clip: string;
|
|
1401
|
+
mats: { emissive: Color; dispose(): void }[]; yaw: number; px: number; py: number; speed: number; seen: number;
|
|
1402
|
+
}
|
|
1403
|
+
const animals = new Map<number, Animal>();
|
|
1404
|
+
function animalFor(slot: number, kind: number, colour: string): Animal {
|
|
1405
|
+
let a = animals.get(slot);
|
|
1406
|
+
if (a && a.kind === kind) return a;
|
|
1407
|
+
if (!a) {
|
|
1408
|
+
// root (where) > tilt (the hit's angle) > squash (stretch along the hit) > untilt > turn (which way it faces) > model
|
|
1409
|
+
const root = new Group(); const tilt = new Group(); const squash = new Group(); const untilt = new Group(); const turn = new Group();
|
|
1410
|
+
root.add(tilt); tilt.add(squash); squash.add(untilt); untilt.add(turn);
|
|
1411
|
+
scene.add(root);
|
|
1412
|
+
a = { slot, kind, root, tilt, squash, untilt, turn, model: null, mixer: null, actions: new Map(), clip: '', mats: [], yaw: 0, px: NaN, py: NaN, speed: 0, seen: 0 };
|
|
1413
|
+
animals.set(slot, a);
|
|
1414
|
+
}
|
|
1415
|
+
a.kind = kind;
|
|
1416
|
+
dressAnimal(a, colour);
|
|
1417
|
+
return a;
|
|
1418
|
+
}
|
|
1419
|
+
function clearModel(a: Animal): void {
|
|
1420
|
+
if (a.model) a.turn.remove(a.model);
|
|
1421
|
+
if (a.model?.name === 'stand-in') (a.model as Mesh).geometry.dispose();
|
|
1422
|
+
a.mixer?.stopAllAction();
|
|
1423
|
+
for (const m of a.mats) m.dispose();
|
|
1424
|
+
a.model = null; a.mixer = null; a.actions = new Map(); a.clip = ''; a.mats = [];
|
|
1425
|
+
}
|
|
1426
|
+
/** The animal's model: a stand-in in its seat's colour at once (a round never waits), then its own copy when the file is in. */
|
|
1427
|
+
function dressAnimal(a: Animal, colour: string): void {
|
|
1428
|
+
clearModel(a);
|
|
1429
|
+
const critter = standIn('critter', colour);
|
|
1430
|
+
a.model = critter; a.turn.add(critter); a.mats = [critter.material as MeshLambertMaterial];
|
|
1431
|
+
const want = a.kind;
|
|
1432
|
+
const url = animalUrl(want);
|
|
1433
|
+
void models.instance(url).then(async (obj) => {
|
|
1434
|
+
if (a.kind !== want || animals.get(a.slot) !== a) return; // it changed while loading
|
|
1435
|
+
const clips = (await models.load(url)).animations;
|
|
1436
|
+
const fit = (await prepare({ url, m: ANIMAL_M, swap: ANIMAL_PULL, px: 512 }))?.fit ?? 1;
|
|
1437
|
+
if (a.kind !== want || animals.get(a.slot) !== a) return;
|
|
1438
|
+
clearModel(a);
|
|
1439
|
+
obj.scale.setScalar(fit);
|
|
1440
|
+
// Its own materials (the copy shares the file's), so a hit can flash this one animal.
|
|
1441
|
+
const own = new Map<Material, MeshStandardMaterial>();
|
|
1442
|
+
obj.traverse((o) => {
|
|
1443
|
+
const mesh = o as Mesh;
|
|
1444
|
+
if (!mesh.isMesh || mesh.name === 'hull') return;
|
|
1445
|
+
const m = mesh.material as MeshStandardMaterial;
|
|
1446
|
+
let c = own.get(m);
|
|
1447
|
+
if (!c) { c = m.clone(); own.set(m, c); a.mats.push(c); }
|
|
1448
|
+
mesh.material = c;
|
|
1449
|
+
});
|
|
1450
|
+
if (REAL_SHADOWS) obj.traverse((o) => { const mm = o as Mesh; if (mm.isMesh && mm.name !== 'hull') mm.castShadow = true; });
|
|
1451
|
+
a.model = obj; a.turn.add(obj);
|
|
1452
|
+
a.mixer = new AnimationMixer(obj);
|
|
1453
|
+
for (const name of ['idle', 'walk', 'run', 'dance']) { const clip = AnimationClip.findByName(clips, name); if (clip) a.actions.set(name, a.mixer.clipAction(clip)); }
|
|
1454
|
+
play(a, 'idle');
|
|
1455
|
+
}, () => { /* refused or missing: the stand-in stays (models.stats() says why) */ });
|
|
1456
|
+
}
|
|
1457
|
+
function play(a: Animal, name: string, rate = 1): void {
|
|
1458
|
+
const next = a.actions.get(name);
|
|
1459
|
+
if (!next) return;
|
|
1460
|
+
next.timeScale = rate;
|
|
1461
|
+
if (a.clip === name) return;
|
|
1462
|
+
const prev = a.actions.get(a.clip);
|
|
1463
|
+
next.reset().setEffectiveWeight(1).fadeIn(0.12).play();
|
|
1464
|
+
prev?.fadeOut(0.12);
|
|
1465
|
+
a.clip = name;
|
|
1466
|
+
}
|
|
1467
|
+
/** Place, turn, squash, flash and animate one animal this frame. */
|
|
1468
|
+
function poseAnimal(a: Animal, at: { x: number; y: number }, t: number, dt: number, fx: ReturnType<typeof bodyFx>, place: number | null): void {
|
|
1469
|
+
const [x, z] = XZ(at.x + fx.ox, at.y + fx.oy);
|
|
1470
|
+
a.root.position.set(x, 0, z);
|
|
1471
|
+
// How fast it moves, from where it is drawn (the same for its owner, the host and every replica).
|
|
1472
|
+
const dx = at.x - a.px; const dy = at.y - a.py; const step = Math.hypot(dx, dy);
|
|
1473
|
+
const sp = !Number.isFinite(step) || step > 2 || dt <= 0 ? 0 : step / dt; // a jump (a new round, a takeover) is not a run
|
|
1474
|
+
a.speed += (Math.min(sp, 12) - a.speed) * Math.min(1, dt * 10);
|
|
1475
|
+
a.px = at.x; a.py = at.y;
|
|
1476
|
+
// It faces where it goes, but not while flying from a bump (it is knocked backwards, not turned round).
|
|
1477
|
+
if (sp > 0.6 && !bumps.has(a.slot) && Number.isFinite(step)) {
|
|
1478
|
+
const want = Math.atan2(dx, dy);
|
|
1479
|
+
let d = want - a.yaw; d = Math.atan2(Math.sin(d), Math.cos(d));
|
|
1480
|
+
a.yaw += d * Math.min(1, dt * 14);
|
|
1481
|
+
}
|
|
1482
|
+
a.turn.rotation.y = a.yaw;
|
|
1483
|
+
// Squash and stretch along the hit: rotate to its angle on the ground, scale, rotate back.
|
|
1484
|
+
a.tilt.rotation.y = -fx.ang; a.untilt.rotation.y = fx.ang;
|
|
1485
|
+
a.squash.scale.set(fx.sx, fx.up, fx.sy);
|
|
1486
|
+
for (const m of a.mats) { m.emissive.setScalar(fx.flash); }
|
|
1487
|
+
if (!a.mixer) return;
|
|
1488
|
+
if (round?.phase === 'over' && place === 1) play(a, 'dance');
|
|
1489
|
+
else if (a.speed < 0.5) play(a, 'idle');
|
|
1490
|
+
else if (a.speed < 4.2) play(a, 'walk', Math.max(0.6, Math.min(1.6, a.speed / 2.2)));
|
|
1491
|
+
else play(a, 'run', Math.max(0.7, Math.min(1.4, a.speed / 6)));
|
|
1492
|
+
a.mixer.update(dt);
|
|
1493
|
+
}
|
|
1494
|
+
|
|
1495
|
+
/* ------------------------------------------------------------------ the Game Lab's overlays, as lines on the meadow */
|
|
1496
|
+
const pen = (() => {
|
|
1497
|
+
const cap = 6000;
|
|
1498
|
+
const pos = new Float32Array(cap * 3); const col = new Float32Array(cap * 3);
|
|
1499
|
+
const geo = new BufferGeometry();
|
|
1500
|
+
geo.setAttribute('position', new BufferAttribute(pos, 3).setUsage(DynamicDrawUsage));
|
|
1501
|
+
geo.setAttribute('color', new BufferAttribute(col, 3).setUsage(DynamicDrawUsage));
|
|
1502
|
+
const lines = new LineSegments(geo, new LineBasicMaterial({ vertexColors: true, depthTest: false, transparent: true }));
|
|
1503
|
+
lines.frustumCulled = false; lines.renderOrder = 10; lines.visible = false;
|
|
1504
|
+
if (lab.on) scene.add(lines);
|
|
1505
|
+
let n = 0; const c = new Color();
|
|
1506
|
+
const put = (x: number, y: number, hex: string): void => { const [px, pz] = XZ(x, y); pos.set([px, 0.06, pz], n * 3); c.set(hex); col.set([c.r, c.g, c.b], n * 3); n += 1; };
|
|
1507
|
+
return {
|
|
1508
|
+
begin(): void { n = 0; },
|
|
1509
|
+
seg(x1: number, y1: number, x2: number, y2: number, hex: string): void { if (n + 2 > cap) return; put(x1, y1, hex); put(x2, y2, hex); },
|
|
1510
|
+
ellipse(x: number, y: number, rx: number, ry: number, a: number, hex: string, dashed = false): void {
|
|
1511
|
+
const N = 32; const ca = Math.cos(a); const sa = Math.sin(a);
|
|
1512
|
+
const at = (i: number): [number, number] => { const th = (i / N) * Math.PI * 2; const ex = Math.cos(th) * rx; const ey = Math.sin(th) * ry; return [x + ex * ca - ey * sa, y + ex * sa + ey * ca]; };
|
|
1513
|
+
for (let i = 0; i < N; i += 1) { if (dashed && i % 2) continue; const [x1, y1] = at(i); const [x2, y2] = at(i + 1); this.seg(x1, y1, x2, y2, hex); }
|
|
1514
|
+
},
|
|
1515
|
+
end(): void {
|
|
1516
|
+
geo.setDrawRange(0, n);
|
|
1517
|
+
(geo.attributes.position as BufferAttribute).needsUpdate = true; (geo.attributes.color as BufferAttribute).needsUpdate = true;
|
|
1518
|
+
lines.visible = n > 0;
|
|
1519
|
+
},
|
|
1520
|
+
};
|
|
1521
|
+
})();
|
|
1522
|
+
type Pen = typeof pen;
|
|
1523
|
+
lab.overlay('onion', (c) => {
|
|
1524
|
+
const g = c as Pen;
|
|
1525
|
+
const ghosts = lab.past<Pose>('subject', 8, 3);
|
|
1526
|
+
ghosts.forEach((p) => g.ellipse(p.x, p.y, R_AV * (p.sx ?? 1), R_AV * (p.sy ?? 1), p.a ?? 0, '#ffad3b'));
|
|
1527
|
+
});
|
|
1528
|
+
lab.overlay('arcs', (c) => {
|
|
1529
|
+
const g = c as Pen;
|
|
1530
|
+
const pts = lab.past<Pose>('subject', 90, 1);
|
|
1531
|
+
for (let i = 1; i < pts.length; i += 1) { const a = pts[i - 1] as Pose; const b = pts[i] as Pose; g.seg(a.x, a.y, b.x, b.y, '#7cc4ff'); }
|
|
1532
|
+
for (const p of pts) { g.seg(p.x - 0.05, p.y, p.x + 0.05, p.y, '#7cc4ff'); g.seg(p.x, p.y - 0.05, p.x, p.y + 0.05, '#7cc4ff'); }
|
|
1533
|
+
});
|
|
1534
|
+
lab.overlay('reach', (c) => {
|
|
1535
|
+
if (!me.has) return;
|
|
1536
|
+
(c as Pen).ellipse(me.x, me.y, T.knockRange, T.knockRange, 0, '#ffffff', true);
|
|
1537
|
+
});
|
|
1538
|
+
|
|
1539
|
+
/* ------------------------------------------------------------------ the camera */
|
|
1540
|
+
/*
|
|
1541
|
+
* style.json's camera: high three-quarter (its pitch and field of view), square to the arena so the stick's up is
|
|
1542
|
+
* the screen's up. How close: the screen's short side about 13.5 m of meadow at the player on a computer and 9.5 m on
|
|
1543
|
+
* a phone (style.json's distance scales both: 22 is these), so an animal reads at about the same size in the hand.
|
|
1544
|
+
* It follows the body whose view this is and never looks past the arena's edge (beyond the path and the fence, as
|
|
1545
|
+
* far as the perspective allows); following nobody, it frames the whole arena (turned on a tall screen, so the
|
|
1546
|
+
* arena's long side runs up it).
|
|
1547
|
+
*/
|
|
1548
|
+
const PITCH0 = (CAM.pitch * Math.PI) / 180;
|
|
1549
|
+
const HALF = (CAM.fov * Math.PI) / 360;
|
|
1550
|
+
/*
|
|
1551
|
+
* A phone held upright sees the ground through a slit: its width is the short side. Followed from style.json's pitch
|
|
1552
|
+
* and distance it shows a long strip of mostly empty grass with a small animal in it. So, like the port kit's fitView
|
|
1553
|
+
* (fill a phone held upright and follow the player), it frames the action instead: closer (about 7 m across at the
|
|
1554
|
+
* player, an animal a fifth of the screen's width) at 44 to 50 degrees, the player in the lower half and what is
|
|
1555
|
+
* around and ahead of it filling the rest, the woods' edge at the top (never a wall of canopy).
|
|
1556
|
+
*/
|
|
1557
|
+
const UPRIGHT_PITCH = Math.max((44 * Math.PI) / 180, Math.min((50 * Math.PI) / 180, PITCH0 + (6 * Math.PI) / 180));
|
|
1558
|
+
let PITCH = PITCH0;
|
|
1559
|
+
const MARGIN = EDGE + 3; // how far past the arena's near side and ends the camera may look: the path, the fence, the flowers
|
|
1560
|
+
// Past its far side it may look into the woods (the trees stand 3 to 18 m out): the clearing reads as a place.
|
|
1561
|
+
const MARGIN_FAR = EDGE + 18;
|
|
1562
|
+
const WHOLE_MARGIN = EDGE + 1.2; // the whole arena: the path and the fence round it
|
|
1563
|
+
const view3 = { x: W / 2, y: H / 2, dist: 0, yaw: 0 };
|
|
1564
|
+
/** Ground reach from the point looked at, toward the camera (near, the screen's bottom) and away (far, its top), per metre of distance. */
|
|
1565
|
+
let NEAR_K = 0; let FAR_K = 0;
|
|
1566
|
+
/** The pitch this frame looks down at (style.json's, or an upright phone's steeper one), and the ground reach it gives. */
|
|
1567
|
+
function setPitch(p: number): void {
|
|
1568
|
+
if (p === PITCH && NEAR_K) return;
|
|
1569
|
+
PITCH = p;
|
|
1570
|
+
NEAR_K = Math.cos(PITCH) - Math.sin(PITCH) / Math.tan(PITCH + HALF);
|
|
1571
|
+
FAR_K = PITCH > HALF + 0.05 ? Math.sin(PITCH) / Math.tan(PITCH - HALF) - Math.cos(PITCH) : 4;
|
|
1572
|
+
}
|
|
1573
|
+
setPitch(PITCH0);
|
|
1574
|
+
function followDistance(cw: number, ch: number, phone: boolean, upright = false): number {
|
|
1575
|
+
// style.json's distance scales the view (22 is these spans); an upright phone's stays near 5.5 m whatever it says,
|
|
1576
|
+
// or a close camera would show one animal and nothing round it.
|
|
1577
|
+
const k = (CAM.distance ?? 22) / 22;
|
|
1578
|
+
const span = upright ? 7 * Math.max(0.9, Math.min(1.3, k)) : (phone ? 9.5 : 13.5) * k;
|
|
1579
|
+
return span / (2 * Math.tan(HALF) * Math.min(1, cw / ch));
|
|
1580
|
+
}
|
|
1581
|
+
/** How far away the whole arena fits; `turned`: seen from its side, its 32 m running up the screen (a tall screen). */
|
|
1582
|
+
function wholeDistance(cw: number, ch: number, turned: boolean): number {
|
|
1583
|
+
const aspect = cw / ch;
|
|
1584
|
+
const deep = turned ? W : H; const wide = turned ? H : W;
|
|
1585
|
+
const byDepth = (deep + 2 * WHOLE_MARGIN) / (NEAR_K + FAR_K);
|
|
1586
|
+
// The arena's near edge is the narrowest part of the view: it has to fit there.
|
|
1587
|
+
const byWidth = (wide / 2 + WHOLE_MARGIN) * Math.sin(PITCH + HALF) / (Math.sin(PITCH) * Math.cos(HALF) * Math.tan(HALF) * aspect);
|
|
1588
|
+
return Math.max(byDepth, byWidth);
|
|
1589
|
+
}
|
|
1590
|
+
/** How far from its far edge to look so a `deep` metres arena sits in the middle of the screen (on the screen, not in metres). */
|
|
1591
|
+
function middleOf(deep: number, d: number): number {
|
|
1592
|
+
let a = -20; let b = deep + 20;
|
|
1593
|
+
for (let i = 0; i < 30; i += 1) { const m = (a + b) / 2; if (screenY(deep - m, d) + screenY(-m, d) < 0) a = m; else b = m; }
|
|
1594
|
+
return (a + b) / 2;
|
|
1595
|
+
}
|
|
1596
|
+
/** How high on the screen (-1 bottom, 1 top) a ground point `s` metres nearer the camera than the point looked at sits. */
|
|
1597
|
+
function screenY(s: number, d: number): number {
|
|
1598
|
+
const along = d * Math.cos(PITCH) - s;
|
|
1599
|
+
return along <= 0 ? -1e3 : Math.tan(Math.atan2(d * Math.sin(PITCH), along) * -1 + PITCH) / Math.tan(HALF);
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Where to look (rules coordinates) at distance d, following `focus`, kept from looking past the arena (its path,
|
|
1603
|
+
* fence and a strip of meadow). Where the view is deeper than all that, the arena sits in the middle of the screen:
|
|
1604
|
+
* its near and far edges equally far from the screen's bottom and top (on the screen, not in metres: the near side of
|
|
1605
|
+
* a three-quarter view is the bigger).
|
|
1606
|
+
*/
|
|
1607
|
+
function aimAt(focus: { x: number; y: number } | null, d: number, cw: number, ch: number, whole: boolean, turned = false, upright = false): { x: number; y: number } {
|
|
1608
|
+
// Turned (the whole arena on a tall screen, seen from its left side): its far edge is x = W.
|
|
1609
|
+
if (turned) return { x: W - middleOf(W, d), y: H / 2 };
|
|
1610
|
+
// An upright phone follows its player everywhere (past the edge it sees the fence and the woods), looking a little
|
|
1611
|
+
// ahead so the player stands in the lower half and what is coming fills the top.
|
|
1612
|
+
if (upright && focus) return { x: focus.x, y: focus.y - d * 0.18 };
|
|
1613
|
+
const loY = FAR_K * d - MARGIN_FAR; const hiY = H + MARGIN - NEAR_K * d;
|
|
1614
|
+
let y: number;
|
|
1615
|
+
if (whole || loY > hiY) y = middleOf(H, d);
|
|
1616
|
+
// Following: a little ahead of the player, so it stands below the middle and the clearing and the woods it is
|
|
1617
|
+
// heading into fill the top.
|
|
1618
|
+
else y = !focus ? (loY + hiY) / 2 : Math.max(loY, Math.min(hiY, focus.y - d * 0.12));
|
|
1619
|
+
const hw = d * Math.tan(HALF) * (cw / ch); // half the view's width where it looks
|
|
1620
|
+
const loX = hw - MARGIN; const hiX = W - hw + MARGIN;
|
|
1621
|
+
const x = whole || !focus || loX > hiX ? W / 2 : Math.max(loX, Math.min(hiX, focus.x));
|
|
1622
|
+
return { x, y };
|
|
1623
|
+
}
|
|
1624
|
+
function placeCamera(want: { x: number; y: number; dist: number; yaw: number }, dt: number, kick: number): void {
|
|
1625
|
+
if (!view3.dist) Object.assign(view3, want);
|
|
1626
|
+
// A watcher's switch glides, never a cut; the zoom and the turn ease a little slower than the pan.
|
|
1627
|
+
const kp = 1 - Math.exp(-dt * 12); const kd = 1 - Math.exp(-dt * 7);
|
|
1628
|
+
view3.x += (want.x - view3.x) * kp; view3.y += (want.y - view3.y) * kp; view3.dist += (want.dist - view3.dist) * kd; view3.yaw += (want.yaw - view3.yaw) * kd;
|
|
1629
|
+
const [tx, tz] = XZ(view3.x, view3.y);
|
|
1630
|
+
const kx = kick ? (lab.random() - 0.5) * 2 * kick : 0; const kz = kick ? (lab.random() - 0.5) * 2 * kick : 0;
|
|
1631
|
+
const back = Math.cos(PITCH) * view3.dist;
|
|
1632
|
+
camera.position.set(tx + kx + Math.sin(view3.yaw) * back, Math.sin(PITCH) * view3.dist, tz + kz + Math.cos(view3.yaw) * back);
|
|
1633
|
+
camera.lookAt(tx + kx, 0, tz + kz);
|
|
1634
|
+
camera.updateMatrixWorld();
|
|
1635
|
+
// The sun's shadow box follows what the camera looks at (a little toward the far side, where the view widens).
|
|
1636
|
+
if (REAL_SHADOWS) { sun.target.position.set(tx, 0, tz - view3.dist * 0.25); sun.position.copy(sun.target.position).addScaledVector(SUN_DIR, 40); }
|
|
1637
|
+
const fog = LIGHT.fog ?? 0.35;
|
|
1638
|
+
const f = scene.fog as Fog;
|
|
1639
|
+
// A light haze far off only: the woods beyond the clearing stay clear.
|
|
1640
|
+
f.near = view3.dist * (2.6 - fog * 0.6); f.far = view3.dist * (6 - fog * 1.5);
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/* ------------------------------------------------------------------ size */
|
|
1644
|
+
let dpr = 1;
|
|
1645
|
+
function resize(): void {
|
|
1646
|
+
const phone = Math.min(innerWidth, innerHeight) <= 540;
|
|
1647
|
+
// Pixel budgets: 1.5 on a phone, 2 on a computer; the HUD's text stays sharp at the screen's own.
|
|
1648
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio || 1, phone ? 1.5 : 2));
|
|
1649
|
+
renderer.setSize(innerWidth, innerHeight, false);
|
|
1650
|
+
camera.aspect = innerWidth / Math.max(1, innerHeight); camera.updateProjectionMatrix();
|
|
1651
|
+
dpr = Math.min(2, devicePixelRatio || 1);
|
|
1652
|
+
hudCanvas.width = Math.round(innerWidth * dpr); hudCanvas.height = Math.round(innerHeight * dpr);
|
|
1653
|
+
}
|
|
1654
|
+
addEventListener('resize', resize);
|
|
1655
|
+
resize();
|
|
1656
|
+
|
|
1657
|
+
/* ------------------------------------------------------------------ draw */
|
|
1658
|
+
/** Where the hot zone's "×2" sits on the screen this frame: names keep off it. */
|
|
1659
|
+
let zoneMark: { left: number; top: number; right: number; bottom: number } | null = null;
|
|
1660
|
+
const labels = createLabels({ screen: () => ({ w: innerWidth, h: innerHeight }), avoid: () => (zoneMark ? [zoneMark] : []) });
|
|
1661
|
+
let shownLabels: LabelOut[] = [];
|
|
1662
|
+
let lastDraw = 0;
|
|
1663
|
+
/** Where a seat's body is drawn now (host: the real body; replica: interpolated), or null. */
|
|
1664
|
+
function seatPos(seat: number): { x: number; y: number } | null {
|
|
1665
|
+
if (seat === mySeat() && me.has && !net.watching) return me;
|
|
1666
|
+
if (hosting) { const b = [...bodies.values()].find((x) => !x.bot && x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
1667
|
+
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
1668
|
+
return d ? { x: d.x, y: d.y } : null;
|
|
1669
|
+
}
|
|
1670
|
+
const v3 = new Vector3();
|
|
1671
|
+
/** A point on the meadow (rules x, y, h metres up) on the screen, CSS px. */
|
|
1672
|
+
function onScreen(x: number, y: number, h: number, cw: number, ch: number): { x: number; y: number; ok: boolean } {
|
|
1673
|
+
const [px, pz] = XZ(x, y);
|
|
1674
|
+
v3.set(px, h, pz).project(camera);
|
|
1675
|
+
return { x: ((v3.x + 1) / 2) * cw, y: ((1 - v3.y) / 2) * ch, ok: v3.z < 1 };
|
|
1676
|
+
}
|
|
1677
|
+
const mtx = new Matrix4(); const quat = new Quaternion(); const eul = new Euler(); const vpos = new Vector3(); const vscl = new Vector3(); const tint = new Color();
|
|
1678
|
+
|
|
1679
|
+
function draw(t: number, dt: number): void {
|
|
1680
|
+
const cw = innerWidth; const ch = innerHeight;
|
|
1681
|
+
const phone = Math.min(cw, ch) <= 540;
|
|
1682
|
+
// The view: my own body, or (a watcher) the followed player's, drawn exactly as their own browser frames it.
|
|
1683
|
+
const view = viewSeat();
|
|
1684
|
+
const followed = net.watching && view !== null ? seatPos(view) : null;
|
|
1685
|
+
const overview = net.watching ? !followed : mySeat() === null;
|
|
1686
|
+
const focus = net.watching ? followed : overview || !me.has ? null : me;
|
|
1687
|
+
// The Game Lab's views (outside the lab: the game's own camera, always).
|
|
1688
|
+
const lv = labView();
|
|
1689
|
+
const whole = overview || Boolean(lv?.whole);
|
|
1690
|
+
// Only a watcher's overview on a tall screen turns (nobody steers there): the arena's long side runs up the screen.
|
|
1691
|
+
const turned = overview && ch > cw * 1.15;
|
|
1692
|
+
// A player's own phone held upright: steeper and closer, so the action fills it (the overview keeps style.json's).
|
|
1693
|
+
const upright = phone && !whole && ch > cw * 1.15;
|
|
1694
|
+
setPitch(upright ? UPRIGHT_PITCH : PITCH0);
|
|
1695
|
+
const dist = whole ? wholeDistance(cw, ch, turned) : followDistance(cw, ch, phone, upright) / (lv?.zoom ?? 1);
|
|
1696
|
+
const aim = aimAt(lv?.zoom ? subjectAt() ?? focus : focus, dist, cw, ch, whole, turned, upright);
|
|
1697
|
+
// The camera's kick (a bump you were in): a few centimetres, gone in a fifth of a second.
|
|
1698
|
+
const kick = t - kickAt < 200 ? T.shake * (1 - (t - kickAt) / 200) ** 2 : 0;
|
|
1699
|
+
placeCamera({ ...aim, dist, yaw: turned ? -Math.PI / 2 : 0 }, dt, kick);
|
|
1700
|
+
if (flame) { const k = 1 + 0.12 * Math.sin(t / 95) + 0.06 * Math.sin(t / 37); flame.children[0]?.scale.set(1, k, 1); flame.children[1]?.scale.set(1, 2 - k, 1); }
|
|
1701
|
+
|
|
1702
|
+
// hot zone (keyed state): gems inside score double
|
|
1703
|
+
zoneFill.visible = zoneRing.visible = Boolean(zone);
|
|
1704
|
+
if (zone) {
|
|
1705
|
+
const pulse = 0.5 + 0.5 * Math.sin(t / 300);
|
|
1706
|
+
const [zx, zz] = XZ(zone.x, zone.y);
|
|
1707
|
+
zoneFill.position.set(zx, 0.014, zz); zoneFill.scale.setScalar(zone.r);
|
|
1708
|
+
(zoneFill.material as MeshBasicMaterial).opacity = 0.14 + 0.1 * pulse;
|
|
1709
|
+
zoneRing.position.set(zx, 0.02, zz); zoneRing.scale.setScalar(zone.r); zoneRing.rotation.y = t / 4000;
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// gems: spinning, bobbing, glowing; a taken one bursts into gold where it was
|
|
1713
|
+
const gemList: { x: number; y: number; id: number }[] = hosting ? gems : (net.latest()?.d.g ?? []).map(([id, x, y]) => ({ id, x, y }));
|
|
1714
|
+
const rn = round?.n ?? -1;
|
|
1715
|
+
const seenNow = new Set<number>();
|
|
1716
|
+
let nb = 0;
|
|
1717
|
+
for (const g of gemList) seenNow.add(g.id);
|
|
1718
|
+
if (rn === lastGemRound) for (const [id, p] of lastGems) if (!seenNow.has(id)) sparkle(p.x, p.y);
|
|
1719
|
+
lastGems.clear(); lastGemRound = rn;
|
|
1720
|
+
for (let i = 0; i < Math.min(gemList.length, GEM_CAP); i += 1) {
|
|
1721
|
+
const g = gemList[i] as { x: number; y: number; id: number };
|
|
1722
|
+
lastGems.set(g.id, { x: g.x, y: g.y });
|
|
1723
|
+
const [gx, gz] = XZ(g.x, g.y);
|
|
1724
|
+
const bob = 0.5 + 0.5 * Math.sin(t / 300 + g.id);
|
|
1725
|
+
const sc = 1 + 0.12 * Math.sin(t / 200 + g.id);
|
|
1726
|
+
mtx.compose(vpos.set(gx, 0.1 + 0.16 * bob, gz), quat.setFromEuler(eul.set(0, t / 500 + g.id, 0)), vscl.setScalar(sc * GEM_DRAWN * gemFit));
|
|
1727
|
+
for (const c of gemParts) placeCopy(c, i, mtx);
|
|
1728
|
+
glows.setMatrixAt(i, mtx.compose(vpos.set(gx, 0, gz), quat.identity(), vscl.set(0.75 + 0.1 * bob, 1, 0.75 + 0.1 * bob)));
|
|
1729
|
+
blobs.setMatrixAt(nb++, mtx.compose(vpos.set(gx, 0, gz), quat.identity(), vscl.set(0.32 - 0.06 * bob, 1, 0.32 - 0.06 * bob)));
|
|
1730
|
+
}
|
|
1731
|
+
const ng = Math.min(gemList.length, GEM_CAP);
|
|
1732
|
+
for (const { mesh: im } of gemParts) { im.count = ng; im.instanceMatrix.needsUpdate = true; }
|
|
1733
|
+
glows.count = ng; glows.instanceMatrix.needsUpdate = true;
|
|
1734
|
+
|
|
1735
|
+
// waves
|
|
1736
|
+
let nw = 0;
|
|
1737
|
+
for (let i = waves.length - 1; i >= 0; i -= 1) {
|
|
1738
|
+
const w = waves[i] as (typeof waves)[number];
|
|
1739
|
+
const age = (t - w.at) / T.ringMs;
|
|
1740
|
+
if (age > 1) { waves.splice(i, 1); continue; }
|
|
1741
|
+
const m = waveMesh(nw++);
|
|
1742
|
+
const r = w.knock ? R_AV + 0.12 + age * 0.8 : R_AV + age * (T.knockRange + 0.2);
|
|
1743
|
+
const [wx, wz] = XZ(w.x, w.y);
|
|
1744
|
+
m.visible = true; m.position.set(wx, 0.03, wz); m.scale.set(r, 1, r);
|
|
1745
|
+
const mat = m.material as MeshBasicMaterial; mat.color.set(w.colour); mat.opacity = 1 - age;
|
|
1746
|
+
}
|
|
1747
|
+
for (let i = nw; i < wavePool.length; i += 1) (wavePool[i] as Mesh).visible = false;
|
|
1748
|
+
|
|
1749
|
+
// the animals
|
|
1750
|
+
const names = new Map<number, Slot>((net.slots ?? roster.slots).map((s) => [s.slot, s]));
|
|
1751
|
+
// `mine`: the body whose view this is: my own, or the player a watcher follows (named, never "You").
|
|
1752
|
+
const list: { slot: number; seat: number | null; x: number; y: number; bot: boolean; name: string; mine: boolean }[] = [];
|
|
1753
|
+
const isView = (seat: number | null, bot: boolean): boolean => !bot && seat !== null && seat === view;
|
|
1754
|
+
if (hosting) {
|
|
1755
|
+
for (const b of bodies.values()) list.push({ slot: b.slot, seat: b.seat, x: b.x, y: b.y, bot: b.bot, name: b.name, mine: isView(b.seat, b.bot) });
|
|
1756
|
+
} else {
|
|
1757
|
+
for (const d of drawn.values()) {
|
|
1758
|
+
const own = !net.watching && d.seat >= 0 && d.seat === net.seat;
|
|
1759
|
+
const s = names.get(d.slot);
|
|
1760
|
+
list.push({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, x: own && me.has ? me.x : d.x, y: own && me.has ? me.y : d.y, bot: d.seat < 0, name: s?.name ?? (d.seat >= 0 ? `Player ${d.seat + 1}` : botName(d.slot)), mine: isView(d.seat >= 0 ? d.seat : null, d.seat < 0) });
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
const places = new Map<number, number>((round?.phase === 'over' ? round.results ?? [] : []).map((r) => [r.slot, r.place]));
|
|
1764
|
+
let nr = 0;
|
|
1765
|
+
youRing.visible = false;
|
|
1766
|
+
for (const a of list) {
|
|
1767
|
+
const colour = colourOf(a.slot, a.bot ? null : a.seat);
|
|
1768
|
+
const fx = bodyFx(a.slot, t);
|
|
1769
|
+
const an = animalFor(a.slot, animalOf(a.slot, a.bot ? null : a.seat), colour);
|
|
1770
|
+
an.seen = frames;
|
|
1771
|
+
poseAnimal(an, a, t, dt, fx, places.get(a.slot) ?? null);
|
|
1772
|
+
const [bx, bz] = XZ(a.x + fx.ox, a.y + fx.oy);
|
|
1773
|
+
seatRings.setMatrixAt(nr, mtx.compose(vpos.set(bx, 0, bz), quat.identity(), vscl.setScalar(1)));
|
|
1774
|
+
seatEdges.setMatrixAt(nr, mtx);
|
|
1775
|
+
seatRings.setColorAt(nr, tint.set(a.bot ? mixHex(colour, '#9aa3ad', 0.55) : colour));
|
|
1776
|
+
nr += 1;
|
|
1777
|
+
blobs.setMatrixAt(nb++, mtx.compose(vpos.set(bx, 0, bz), quat.identity(), vscl.set(0.5 * fx.sx, 1, 0.5 * fx.sy)));
|
|
1778
|
+
if (a.mine) { youRing.visible = true; youRing.position.set(bx, 0.024, bz); youRing.scale.setScalar(1 + 0.05 * Math.sin(t / 220)); }
|
|
1779
|
+
}
|
|
1780
|
+
// Bodies that left: their animals go.
|
|
1781
|
+
for (const [slot, an] of animals) if (an.seen !== frames) { clearModel(an); scene.remove(an.root); animals.delete(slot); }
|
|
1782
|
+
seatRings.count = nr; seatRings.instanceMatrix.needsUpdate = true; seatEdges.count = nr; seatEdges.instanceMatrix.needsUpdate = true; if (seatRings.instanceColor) seatRings.instanceColor.needsUpdate = true;
|
|
1783
|
+
blobs.count = nb; blobs.instanceMatrix.needsUpdate = true;
|
|
1784
|
+
|
|
1785
|
+
// sparks
|
|
1786
|
+
let ns = 0;
|
|
1787
|
+
for (let i = sparks.length - 1; i >= 0; i -= 1) {
|
|
1788
|
+
const sp = sparks[i] as (typeof sparks)[number];
|
|
1789
|
+
const age = t - sp.at;
|
|
1790
|
+
if (age > sp.life) { sparks.splice(i, 1); continue; }
|
|
1791
|
+
if (!sp.gold && age < T.hitStopMs * 0.5) continue;
|
|
1792
|
+
const u = age / sp.life; const s = (age / 1000) * (1 - u * 0.5);
|
|
1793
|
+
const [sx, sz] = XZ(sp.x + sp.vx * s, sp.y + sp.vy * s);
|
|
1794
|
+
const h = sp.h + sp.vh * s - 4.9 * s * s;
|
|
1795
|
+
const len = Math.hypot(sp.vx, sp.vy) * 0.022 * (1 - u) + 0.05;
|
|
1796
|
+
mtx.compose(vpos.set(sx, Math.max(0.05, h), sz), quat.setFromEuler(eul.set(0, Math.atan2(-sp.vy, sp.vx), 0)), vscl.set(len, 1.6 * (1 - u) + 0.3, 1.6 * (1 - u) + 0.3));
|
|
1797
|
+
sparkMesh.setMatrixAt(ns, mtx);
|
|
1798
|
+
sparkMesh.setColorAt(ns, tint.set(sp.gold ? (u < 0.3 ? '#ffffff' : COL.gem) : u < 0.4 ? '#ffffff' : '#ffd166'));
|
|
1799
|
+
ns += 1;
|
|
1800
|
+
if (ns >= 160) break;
|
|
1801
|
+
}
|
|
1802
|
+
sparkMesh.count = ns; sparkMesh.instanceMatrix.needsUpdate = true; if (sparkMesh.instanceColor) sparkMesh.instanceColor.needsUpdate = true;
|
|
1803
|
+
|
|
1804
|
+
if (lab.on) { pen.begin(); lab.draw(pen); pen.end(); }
|
|
1805
|
+
renderer.render(scene, camera);
|
|
1806
|
+
|
|
1807
|
+
// ---- the HUD, over the world
|
|
1808
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
1809
|
+
ctx.clearRect(0, 0, cw, ch);
|
|
1810
|
+
// the hot zone's "×2", on the screen over its middle
|
|
1811
|
+
zoneMark = null;
|
|
1812
|
+
if (zone) {
|
|
1813
|
+
const z = onScreen(zone.x, zone.y, 0, cw, ch);
|
|
1814
|
+
const size = phone ? 20 : 24;
|
|
1815
|
+
if (z.ok) {
|
|
1816
|
+
ctx.font = `700 ${size}px ${FONT_DISPLAY}`; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round'; ctx.lineWidth = 4;
|
|
1817
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.85)'; ctx.strokeText('×2', z.x, z.y); ctx.fillStyle = PAL.accent; ctx.fillText('×2', z.x, z.y);
|
|
1818
|
+
const half = ctx.measureText('×2').width / 2 + 3;
|
|
1819
|
+
zoneMark = { left: z.x - half, top: z.y - size * 0.6, right: z.x + half, bottom: z.y + size * 0.6 };
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
// Names go on the screen, so they read at one size on any screen and never pile up.
|
|
1823
|
+
const fs = Math.round(Math.max(15, Math.min(22, (15 * Math.min(cw, ch)) / 720)));
|
|
1824
|
+
ctx.font = `700 ${fs}px ${FONT_BODY}`;
|
|
1825
|
+
const tags: LabelIn[] = [];
|
|
1826
|
+
const viewAt = list.find((a) => a.mine) ?? null;
|
|
1827
|
+
for (const a of list) {
|
|
1828
|
+
const text = a.mine && !net.watching ? 'You' : label(a.name, a.bot);
|
|
1829
|
+
const head = onScreen(a.x, a.y, 0.98, cw, ch);
|
|
1830
|
+
const mid = onScreen(a.x, a.y, 0.35, cw, ch);
|
|
1831
|
+
const side = onScreen(a.x + R_AV, a.y, 0.35, cw, ch);
|
|
1832
|
+
if (!head.ok) continue;
|
|
1833
|
+
const r = Math.max(6, Math.hypot(side.x - mid.x, side.y - mid.y));
|
|
1834
|
+
if (mid.x + r < 0 || mid.x - r > cw || head.y > ch || mid.y + r < 0) continue; // off screen: no name at the edge
|
|
1835
|
+
const foot = onScreen(a.x, a.y, 0, cw, ch);
|
|
1836
|
+
// People before bots, nearer the view's body first; each keeps off the others' bodies when it can.
|
|
1837
|
+
tags.push({ key: a.slot, text, x: head.x, y: head.y - 3, w: ctx.measureText(text).width, h: fs * 1.2, below: foot.y + r * 0.6 + 4 + fs * 1.2, body: { left: mid.x - r, top: head.y, right: mid.x + r, bottom: foot.y + r * 0.4 }, self: a.mine, rank: (a.bot ? 10_000 : 0) + (viewAt ? Math.hypot(a.x - viewAt.x, a.y - viewAt.y) * 50 : 0) });
|
|
1838
|
+
}
|
|
1839
|
+
shownLabels = labels.place(tags, Math.min(0.1, (t - (lastDraw || t)) / 1000));
|
|
1840
|
+
lastDraw = t;
|
|
1841
|
+
ctx.font = `700 ${fs}px ${FONT_BODY}`;
|
|
1842
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.lineJoin = 'round';
|
|
1843
|
+
for (const l of [...shownLabels].sort((x, y) => Number(Boolean(x.self)) - Number(Boolean(y.self)))) {
|
|
1844
|
+
if (l.alpha <= 0) continue;
|
|
1845
|
+
ctx.globalAlpha = l.alpha;
|
|
1846
|
+
if (l.moved) {
|
|
1847
|
+
// Off its own spot: a thin line to its body says whose it is.
|
|
1848
|
+
const under = l.top > l.y;
|
|
1849
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.7)'; ctx.lineWidth = 1.5;
|
|
1850
|
+
ctx.beginPath(); ctx.moveTo(l.cx, under ? l.top : l.bottom); ctx.lineTo(l.x, under ? (l.body?.bottom ?? l.top - 6) : l.y + 2); ctx.stroke();
|
|
1851
|
+
}
|
|
1852
|
+
// Every name on the HUD's paper (yours fuller, a bot's fainter): one UI with the clock and the board.
|
|
1853
|
+
ctx.fillStyle = PAPER; ctx.globalAlpha = l.alpha * (l.self ? 0.94 : 0.8); ctx.beginPath(); ctx.roundRect(l.left - 6, l.top - 1, l.right - l.left + 12, l.bottom - l.top + 2, 8); ctx.fill(); ctx.globalAlpha = l.alpha;
|
|
1854
|
+
ctx.fillStyle = l.self ? TEXT : mixHex(TEXT, PAPER, 0.25);
|
|
1855
|
+
ctx.fillText(l.text, l.cx, l.cy);
|
|
1856
|
+
}
|
|
1857
|
+
ctx.globalAlpha = 1; ctx.textBaseline = 'alphabetic';
|
|
1858
|
+
|
|
1859
|
+
hud(cw, ch, phone, list);
|
|
1860
|
+
if (stick.active) {
|
|
1861
|
+
ctx.strokeStyle = 'rgba(255,255,255,0.8)'; ctx.lineWidth = 3;
|
|
1862
|
+
ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 56, 0, Math.PI * 2); ctx.stroke();
|
|
1863
|
+
ctx.strokeStyle = 'rgba(29,43,58,0.25)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(stick.ox, stick.oy, 58, 0, Math.PI * 2); ctx.stroke();
|
|
1864
|
+
const dx = stick.x - stick.ox; const dy = stick.y - stick.oy; const len = Math.hypot(dx, dy); const m = len > 56 ? 56 / len : 1;
|
|
1865
|
+
ctx.fillStyle = 'rgba(255,255,255,0.75)'; ctx.beginPath(); ctx.arc(stick.ox + dx * m, stick.oy + dy * m, 24, 0, Math.PI * 2); ctx.fill();
|
|
1866
|
+
}
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
/** A rounded panel behind HUD text (style.json's "chips"): the palette's ink, a little see-through. */
|
|
1870
|
+
function chip(x: number, y: number, w: number, h: number, alpha = 0.88): void {
|
|
1871
|
+
ctx.fillStyle = PAPER; ctx.globalAlpha = Math.max(alpha, 0.82);
|
|
1872
|
+
ctx.beginPath(); ctx.roundRect(x, y, w, h, Math.min(12, h / 2)); ctx.fill();
|
|
1873
|
+
ctx.globalAlpha = 1;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
/**
|
|
1877
|
+
* The game's own first screen: its name and one line, on a card in the palette, for the first few seconds (it never
|
|
1878
|
+
* waits for a press and never takes one: the round is already on underneath). game.json's name and the first sentence
|
|
1879
|
+
* of its blurb.
|
|
1880
|
+
*/
|
|
1881
|
+
const BOOT_AT = performance.now();
|
|
1882
|
+
const TITLE = String((gameFile as { name?: string }).name ?? document.title).slice(0, 40);
|
|
1883
|
+
const TAGLINE = String((gameFile as { blurb?: string }).blurb ?? '').split(/(?<=[.!?])\s/)[0]?.slice(0, 90) ?? '';
|
|
1884
|
+
function titleCard(cw: number, ch: number, phone: boolean): void {
|
|
1885
|
+
const since = performance.now() - BOOT_AT;
|
|
1886
|
+
if (since > 4600) return;
|
|
1887
|
+
const a = since < 3400 ? 1 : 1 - (since - 3400) / 1200;
|
|
1888
|
+
const w = Math.min(cw - 32, phone ? 340 : 460);
|
|
1889
|
+
ctx.save();
|
|
1890
|
+
ctx.globalAlpha = Math.max(0, a);
|
|
1891
|
+
ctx.font = `700 ${phone ? 15 : 17}px ${FONT_BODY}`;
|
|
1892
|
+
const words = TAGLINE.split(' '); const lines: string[] = []; let line = '';
|
|
1893
|
+
for (const word of words) { const next = line ? `${line} ${word}` : word; if (ctx.measureText(next).width > w - 40 && line) { lines.push(line); line = word; } else line = next; }
|
|
1894
|
+
if (line) lines.push(line);
|
|
1895
|
+
const titleSize = phone ? 34 : 44; const lineH = phone ? 21 : 24;
|
|
1896
|
+
const h = 34 + titleSize + (lines.length ? 14 + lines.length * lineH : 0) + 26;
|
|
1897
|
+
const x = (cw - w) / 2; const y = phone ? ch * 0.3 - h / 2 : (ch - h) / 2;
|
|
1898
|
+
ctx.fillStyle = mixHex(PAPER, '#ffffff', 0.35);
|
|
1899
|
+
ctx.shadowColor = 'rgba(0,0,0,0.25)'; ctx.shadowBlur = 24; ctx.shadowOffsetY = 8;
|
|
1900
|
+
ctx.beginPath();
|
|
1901
|
+
const rr = (ctx as CanvasRenderingContext2D & { roundRect?: (x: number, y: number, w: number, h: number, r: number) => void }).roundRect;
|
|
1902
|
+
if (rr) rr.call(ctx, x, y, w, h, 18); else ctx.rect(x, y, w, h);
|
|
1903
|
+
ctx.fill();
|
|
1904
|
+
ctx.shadowColor = 'transparent';
|
|
1905
|
+
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
|
1906
|
+
ctx.fillStyle = HOT; ctx.font = `700 ${titleSize}px ${FONT_DISPLAY}`;
|
|
1907
|
+
ctx.fillText(TITLE, cw / 2, y + 30 + titleSize / 2);
|
|
1908
|
+
ctx.fillStyle = TEXT; ctx.font = `600 ${phone ? 15 : 17}px ${FONT_BODY}`;
|
|
1909
|
+
lines.forEach((l, i) => ctx.fillText(l, cw / 2, y + 30 + titleSize + 14 + lineH * i + lineH / 2));
|
|
1910
|
+
ctx.restore();
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
function hud(cw: number, ch: number, phone: boolean, list: { slot: number; seat: number | null; name: string; bot: boolean; mine: boolean }[]): void {
|
|
1914
|
+
const pad = phone ? 12 : 18;
|
|
1915
|
+
const debugStrip = new URLSearchParams(location.search).get('debug') === '1' || (window as unknown as { HOMIE_NET?: { debug?: boolean } }).HOMIE_NET?.debug;
|
|
1916
|
+
const top = pad + (debugStrip ? (phone ? 50 : 26) : 0);
|
|
1917
|
+
const now = net.now();
|
|
1918
|
+
const r = round;
|
|
1919
|
+
// the clock
|
|
1920
|
+
ctx.textBaseline = 'middle'; ctx.textAlign = 'left';
|
|
1921
|
+
ctx.font = `700 ${phone ? 19 : 22}px ${FONT_DISPLAY}`;
|
|
1922
|
+
const left = r ? Math.max(0, Math.ceil((r.endsAt - now) / 1000)) : 0;
|
|
1923
|
+
const clock = `${Math.floor(left / 60)}:${String(left % 60).padStart(2, '0')}`;
|
|
1924
|
+
const said = r ? (r.phase === 'live' ? `Round ${r.n} · ${clock}` : `Next round in ${left}`) : 'Joining…';
|
|
1925
|
+
const cwid = ctx.measureText(said).width;
|
|
1926
|
+
const chH = phone ? 34 : 40;
|
|
1927
|
+
chip(pad, top, cwid + 26, chH);
|
|
1928
|
+
ctx.fillStyle = r?.phase === 'live' && left <= 10 ? PAL.danger : TEXT;
|
|
1929
|
+
ctx.fillText(said, pad + 13, top + chH / 2 + 1);
|
|
1930
|
+
// scores: the play page's room button (and a server's pill beside it) sit at the top right (game.json screen.share's
|
|
1931
|
+
// default): the scores start under that band, so the buttons never cover a score.
|
|
1932
|
+
const scores = new Map<number, number>();
|
|
1933
|
+
if (hosting) for (const b of bodies.values()) scores.set(b.slot, b.score);
|
|
1934
|
+
else for (const d of drawn.values()) scores.set(d.slot, d.score);
|
|
1935
|
+
// A phone's board is compact (the top three, names without " · bot": a bot's dot is hollow), so a game's own HUD at
|
|
1936
|
+
// the top left (a basket, a timer) has room beside it.
|
|
1937
|
+
const rows = [...list].sort((a, b) => (scores.get(b.slot) ?? 0) - (scores.get(a.slot) ?? 0)).slice(0, phone ? 3 : 6);
|
|
1938
|
+
ctx.font = `700 ${phone ? 13 : 16}px ${FONT_BODY}`;
|
|
1939
|
+
const rowH = phone ? 20 : 25;
|
|
1940
|
+
const fit = (t: string, max: number): string => { let x = t; while (x.length > 3 && ctx.measureText(x).width > max) x = `${x.slice(0, -2)}…`; return x; };
|
|
1941
|
+
const texts = rows.map((a) => (a.mine && !net.watching ? 'You' : phone ? fit(a.name.replace(AI_MARK, '').trim(), 104) : label(a.name, a.bot)));
|
|
1942
|
+
const nameW = Math.max(0, ...texts.map((x) => ctx.measureText(x).width));
|
|
1943
|
+
const boardW = nameW + (phone ? 56 : 74);
|
|
1944
|
+
// Together: the room's total heads the board, which moves down by a row so it stays under the room buttons.
|
|
1945
|
+
const board = top + 18 + 44 - rowH / 2 + (TOGETHER ? rowH + 6 : 0);
|
|
1946
|
+
if (TOGETHER) {
|
|
1947
|
+
// One total for the room, then each share (no places): a cozy room works as one.
|
|
1948
|
+
const total = [...scores.values()].reduce((n, v) => n + v, 0);
|
|
1949
|
+
const head = `Together · ${total}`;
|
|
1950
|
+
ctx.font = `700 ${phone ? 15 : 17}px ${FONT_DISPLAY}`;
|
|
1951
|
+
const hw = Math.max(boardW, ctx.measureText(head).width + 28);
|
|
1952
|
+
chip(cw - pad - hw, board - 6 - (rowH + 6), hw, rowH + 6 + rows.length * rowH + 12, 0.7);
|
|
1953
|
+
ctx.textAlign = 'left'; ctx.fillStyle = HOT;
|
|
1954
|
+
ctx.fillText(head, cw - pad - hw + 14, board - rowH / 2 + 1);
|
|
1955
|
+
ctx.font = `700 ${phone ? 13 : 16}px ${FONT_BODY}`;
|
|
1956
|
+
} else if (rows.length) chip(cw - pad - boardW, board - 6, boardW, rows.length * rowH + 12, 0.7);
|
|
1957
|
+
rows.forEach((a, i) => {
|
|
1958
|
+
const y = board + i * rowH + rowH / 2;
|
|
1959
|
+
const colour = colourOf(a.slot, a.bot ? null : a.seat);
|
|
1960
|
+
ctx.fillStyle = a.bot ? mixHex(colour, '#9aa3ad', 0.55) : colour;
|
|
1961
|
+
ctx.beginPath(); ctx.arc(cw - pad - boardW + 14, y, 5, 0, Math.PI * 2);
|
|
1962
|
+
if (a.bot && phone) { ctx.strokeStyle = ctx.fillStyle; ctx.lineWidth = 2; ctx.stroke(); } else ctx.fill();
|
|
1963
|
+
ctx.textAlign = 'left';
|
|
1964
|
+
ctx.fillStyle = a.mine ? TEXT : mixHex(TEXT, PAPER, a.bot ? 0.42 : 0.15);
|
|
1965
|
+
ctx.fillText(texts[i] as string, cw - pad - boardW + 26, y + 1);
|
|
1966
|
+
ctx.textAlign = 'right';
|
|
1967
|
+
ctx.fillStyle = a.mine ? HOT : TEXT;
|
|
1968
|
+
ctx.fillText(String(scores.get(a.slot) ?? 0), cw - pad - 12, y + 1);
|
|
1969
|
+
});
|
|
1970
|
+
// The role badge (host, replica, watching): a debugging aid, only with ?debug=1 (a player never needs it).
|
|
1971
|
+
if (debugStrip) {
|
|
1972
|
+
ctx.textAlign = 'left'; ctx.font = '600 11px ui-monospace, Menlo, monospace';
|
|
1973
|
+
const badge = net.offline ? 'OFFLINE HOST' : net.watching ? 'WATCHING' : net.role.toUpperCase();
|
|
1974
|
+
chip(pad - 4, ch - pad - 16, ctx.measureText(badge).width + 12, 20, 0.55);
|
|
1975
|
+
ctx.fillStyle = mixHex(TEXT, PAPER, 0.2);
|
|
1976
|
+
ctx.fillText(badge, pad + 2, ch - pad - 5);
|
|
1977
|
+
}
|
|
1978
|
+
if (r && r.phase === 'over' && r.results) {
|
|
1979
|
+
// The results card: the palette's own paper and ink, the winner in gold.
|
|
1980
|
+
const w = Math.min(380, cw - 32); const h = 64 + Math.min(6, r.results.length) * 30;
|
|
1981
|
+
const x = (cw - w) / 2; const y = (ch - h) / 2;
|
|
1982
|
+
ctx.fillStyle = mixHex(PAPER, '#ffffff', 0.35); ctx.globalAlpha = 0.96;
|
|
1983
|
+
ctx.beginPath(); ctx.roundRect(x, y, w, h, 18); ctx.fill(); ctx.globalAlpha = 1;
|
|
1984
|
+
ctx.strokeStyle = TEXT; ctx.globalAlpha = 0.12; ctx.lineWidth = 2; ctx.stroke(); ctx.globalAlpha = 1;
|
|
1985
|
+
ctx.textAlign = 'center'; ctx.fillStyle = HOT; ctx.font = `700 ${phone ? 20 : 22}px ${FONT_DISPLAY}`;
|
|
1986
|
+
const sum = r.results.reduce((n, row) => n + row.score, 0);
|
|
1987
|
+
ctx.fillText(TOGETHER ? `Together: ${sum}` : `Round ${r.n} results`, cw / 2, y + 32);
|
|
1988
|
+
ctx.font = `700 ${phone ? 15 : 16}px ${FONT_BODY}`;
|
|
1989
|
+
const view = viewSeat();
|
|
1990
|
+
r.results.slice(0, 6).forEach((row, i) => {
|
|
1991
|
+
const mine = !row.bot && row.seat !== null && row.seat === view;
|
|
1992
|
+
const ry = y + 66 + i * 30;
|
|
1993
|
+
if (mine) { ctx.fillStyle = PAL.gold; ctx.globalAlpha = 0.55; ctx.beginPath(); ctx.roundRect(x + 14, ry - 13, w - 28, 26, 10); ctx.fill(); ctx.globalAlpha = 1; }
|
|
1994
|
+
ctx.fillStyle = row.bot ? mixHex(TEXT, PAPER, 0.4) : TEXT;
|
|
1995
|
+
ctx.fillText(`${TOGETHER ? '' : `${row.place}. `}${mine && !net.watching ? 'You' : label(row.name, row.bot)}${mine && net.watching ? ' ◂' : ''} — ${row.score}`, cw / 2, ry + 1);
|
|
1996
|
+
});
|
|
1997
|
+
}
|
|
1998
|
+
if (!net.offline && !net.connected && net.role !== 'host') {
|
|
1999
|
+
ctx.textAlign = 'center'; ctx.font = `700 15px ${FONT_BODY}`;
|
|
2000
|
+
const text = 'Reconnecting…'; const tw = ctx.measureText(text).width;
|
|
2001
|
+
chip(cw / 2 - tw / 2 - 12, ch - pad - 24, tw + 24, 26);
|
|
2002
|
+
ctx.fillStyle = HOT; ctx.fillText(text, cw / 2, ch - pad - 10);
|
|
2003
|
+
}
|
|
2004
|
+
titleCard(cw, ch, phone);
|
|
2005
|
+
if (frames < 240 && mySeat() !== null) {
|
|
2006
|
+
ctx.textAlign = 'center'; ctx.font = `700 14px ${FONT_BODY}`;
|
|
2007
|
+
const text = phone ? 'Drag to move · second finger bumps' : 'WASD / arrows to move · Space bumps';
|
|
2008
|
+
const tw = ctx.measureText(text).width;
|
|
2009
|
+
chip(cw / 2 - tw / 2 - 12, ch - pad - 56, tw + 24, 26, 0.6);
|
|
2010
|
+
ctx.fillStyle = TEXT; ctx.fillText(text, cw / 2, ch - pad - 42);
|
|
2011
|
+
}
|
|
2012
|
+
ctx.textBaseline = 'alphabetic';
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/* ------------------------------------------------------------------ loop */
|
|
2016
|
+
let lastT = -1;
|
|
2017
|
+
function frame(t: number): void {
|
|
2018
|
+
const dt = lab.time.dt(t, 0.05);
|
|
2019
|
+
// The look's own clock (the animals' clips): real seconds, at most a tenth, whatever the rules' step.
|
|
2020
|
+
const ddt = lastT < 0 ? 0 : Math.min(0.1, Math.max(0, (t - lastT) / 1000)); lastT = t;
|
|
2021
|
+
stepMe(dt);
|
|
2022
|
+
if (hosting) stepHost(dt);
|
|
2023
|
+
else stepReplica(dt);
|
|
2024
|
+
if (lab.on) labReport(dt);
|
|
2025
|
+
draw(t, lab.on ? dt : ddt);
|
|
2026
|
+
frames += 1;
|
|
2027
|
+
requestAnimationFrame(frame);
|
|
2028
|
+
}
|
|
2029
|
+
|
|
2030
|
+
net.expose({
|
|
2031
|
+
self: () => (me.has ? { x: me.x, y: me.y } : null),
|
|
2032
|
+
peer: (seat: number) => {
|
|
2033
|
+
if (hosting) { const b = [...bodies.values()].find((x) => x.seat === seat); return b ? { x: b.x, y: b.y } : null; }
|
|
2034
|
+
const d = [...drawn.values()].find((x) => x.seat === seat);
|
|
2035
|
+
return d ? { x: d.x, y: d.y } : null;
|
|
2036
|
+
},
|
|
2037
|
+
frames: () => frames,
|
|
2038
|
+
scores: () => (hosting ? [...bodies.values()].map((b) => ({ slot: b.slot, seat: b.seat, bot: b.bot, score: b.score })) : [...drawn.values()].map((d) => ({ slot: d.slot, seat: d.seat >= 0 ? d.seat : null, bot: d.seat < 0, score: d.score }))),
|
|
2039
|
+
/** Host: gems each slot picked up this round, the round's age, and the dial each bot plays at (the dial's numbers). */
|
|
2040
|
+
pickups: () => (hosting ? { round: round ? { n: round.n, phase: round.phase, ageMs: net.now() - round.startedAt } : null, slots: [...bodies.values()].map((b) => ({ slot: b.slot, bot: b.bot, gems: pickups.get(b.slot) ?? 0, level: b.bot ? net.skillOf(b.slot).level : null })) } : null),
|
|
2041
|
+
/** Host: when each bot last looked (its reaction clock). */
|
|
2042
|
+
sight: () => (hosting ? [...sight.entries()].map(([slot, e]) => ({ slot, at: e.at })) : null),
|
|
2043
|
+
waves: () => wavesSeen,
|
|
2044
|
+
knocks: () => knocksSeen,
|
|
2045
|
+
zone: () => zone,
|
|
2046
|
+
owned: () => net.owned,
|
|
2047
|
+
cheatResets: () => cheatResets,
|
|
2048
|
+
movement: () => MOVEMENT,
|
|
2049
|
+
controlResets: () => controlResets,
|
|
2050
|
+
predictionError: () => predictionError,
|
|
2051
|
+
/** Where the camera looks (rules metres), how far away it is, its turn, and whose view it is. */
|
|
2052
|
+
camera: () => ({ x: view3.x, y: view3.y, dist: view3.dist, yaw: view3.yaw, view: viewSeat() }),
|
|
2053
|
+
/** The names as drawn (boxes, never the text): the e2e probe counts overlaps and checks your own. */
|
|
2054
|
+
labels: () => shownLabels.map((l) => ({ self: Boolean(l.self), alpha: l.alpha, moved: l.moved, left: Math.round(l.left), top: Math.round(l.top), right: Math.round(l.right), bottom: Math.round(l.bottom) })),
|
|
2055
|
+
/** performance.now() of the first keyed-state value and the first live snapshot this browser received. */
|
|
2056
|
+
arrivals: () => ({ firstStateAt, firstSnapAt }),
|
|
2057
|
+
/** The last frame's draw calls and triangles (renderer.info), against the phone budgets (100 and 150k). */
|
|
2058
|
+
drawCalls: () => renderer.info.render.calls,
|
|
2059
|
+
triangles: () => renderer.info.render.triangles,
|
|
2060
|
+
/** The models: how many loaded, their triangles, texture memory and bytes, and any refused (with why). */
|
|
2061
|
+
models: () => ({ ...models.stats(), standIns: [...standIns], animals: [...animals.values()].map((a) => ({ slot: a.slot, animal: ANIMALS[a.kind], standIn: a.model?.name === 'stand-in' })) }),
|
|
2062
|
+
/** Harness hook (host only): knock the body of `seat` back, toward the middle of the arena. */
|
|
2063
|
+
debugKnock: (seat: number) => {
|
|
2064
|
+
if (!hosting) return false;
|
|
2065
|
+
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
2066
|
+
if (!b) return false;
|
|
2067
|
+
knock(b, b.x < W / 2 ? b.x - 0.05 : b.x + 0.05, b.y, -1); // push toward the middle, away from the nearer wall (bumped by nobody)
|
|
2068
|
+
return true;
|
|
2069
|
+
},
|
|
2070
|
+
});
|
|
2071
|
+
|
|
2072
|
+
exposePort(net, {
|
|
2073
|
+
view: 'top',
|
|
2074
|
+
self: () => (me.has && mySeat() !== null ? { x: me.x, y: me.y } : null),
|
|
2075
|
+
size: R_AV,
|
|
2076
|
+
score: () => { const seat = mySeat(); const b = hosting ? [...bodies.values()].find((x) => x.seat === seat) : [...drawn.values()].find((x) => x.seat === seat); return b ? b.score : null; },
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
void net.ready.then(() => {
|
|
2080
|
+
const seat = mySeat();
|
|
2081
|
+
if (seat !== null && hosting) {
|
|
2082
|
+
const b = [...bodies.values()].find((x) => x.seat === seat);
|
|
2083
|
+
if (b) { me.x = b.x; me.y = b.y; me.has = true; }
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
requestAnimationFrame(frame);
|