@homie-rocks/studio 0.38.0 → 0.40.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.
Files changed (60) hide show
  1. package/CHANGELOG.md +57 -1
  2. package/bin/homie-studio.mjs +13 -1
  3. package/lib/build.mjs +4 -0
  4. package/lib/cloudflare.mjs +2 -2
  5. package/lib/dev.mjs +12 -1
  6. package/lib/mcp-tools.mjs +10 -0
  7. package/lib/port-check.mjs +33 -7
  8. package/lib/rules-build.mjs +8 -1
  9. package/lib/rules-check-worker.mjs +3 -3
  10. package/lib/rules-check.mjs +3 -1
  11. package/lib/scaffold.mjs +19 -6
  12. package/lib/studio.mjs +1 -1
  13. package/lib/template-history.json +82 -0
  14. package/lib/tools-build.mjs +55 -0
  15. package/lib/tools-cli.mjs +55 -0
  16. package/lib/worker-build.mjs +35 -0
  17. package/netplay/NETPLAY.md +74 -2
  18. package/package.json +17 -6
  19. package/rules/collision.ts +115 -0
  20. package/rules/core.ts +65 -25
  21. package/rules/host.ts +5 -1
  22. package/rules/math.ts +15 -5
  23. package/rules/rules.ts +33 -10
  24. package/rules/types.ts +2 -2
  25. package/rules/view.ts +126 -32
  26. package/starters/ember-vale/game.json +16 -4
  27. package/starters/ember-vale/map/main.json +66 -0
  28. package/starters/ember-vale/src/move.ts +31 -0
  29. package/starters/ember-vale/src/rules.ts +244 -0
  30. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  31. package/starters/ember-vale/tunables.json +100 -11
  32. package/starters/gem-rush/src/move.ts +9 -1
  33. package/starters/gem-rush/src/view.ts +2 -1
  34. package/starters/gem-rush-3d/game.json +23 -5
  35. package/starters/gem-rush-3d/map/main.json +153 -0
  36. package/starters/gem-rush-3d/src/move.ts +37 -0
  37. package/starters/gem-rush-3d/src/rules.ts +133 -0
  38. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  39. package/starters/gem-rush-3d/tunables.json +98 -11
  40. package/starters/hero-rush-3d/game.json +23 -5
  41. package/starters/hero-rush-3d/map/main.json +153 -0
  42. package/starters/hero-rush-3d/src/move.ts +49 -0
  43. package/starters/hero-rush-3d/src/rules.ts +200 -0
  44. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  45. package/starters/hero-rush-3d/tunables.json +260 -30
  46. package/tools/TOOLS.md +113 -0
  47. package/tools/tools.ts +28 -0
  48. package/worker/app-records.mjs +8 -0
  49. package/worker/index.mjs +26 -0
  50. package/worker/mcp-room.mjs +61 -0
  51. package/worker/mcp-store.mjs +45 -0
  52. package/worker/mcp-tools.mjs +105 -0
  53. package/worker/mcp.mjs +159 -0
  54. package/worker/office-page.mjs +1 -1
  55. package/worker/office.mjs +2 -1
  56. package/worker/purchase-mcp.mjs +3 -3
  57. package/worker/tool-events.mjs +14 -0
  58. package/worker/tool-identity.mjs +4 -0
  59. package/worker/tool-webhook.mjs +28 -0
  60. package/worker/version.mjs +1 -1
@@ -1,13 +1,102 @@
1
1
  {
2
- "strikeRange": { "value": 96, "min": 50, "max": 180, "step": 1, "unit": "px", "group": "Strike", "note": "How far a strike reaches (a slime's size adds to it)" },
3
- "strikeMs": { "value": 380, "min": 150, "max": 900, "step": 10, "unit": "ms", "group": "Strike", "note": "The shortest time between two strikes" },
4
- "flashMs": { "value": 200, "min": 60, "max": 500, "step": 10, "unit": "ms", "group": "Strike", "note": "How long a strike shows on every screen" },
5
- "swingMs": { "value": 150, "min": 40, "max": 400, "step": 5, "unit": "ms", "group": "Strike", "note": "The swipe around the hero, and its smear as it carries through" },
6
- "hitStopMs": { "value": 60, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Hit", "note": "The hit lands: the slime freezes, white, before it is pushed" },
7
- "slimePush": { "value": 14, "min": 0, "max": 80, "step": 1, "unit": "px", "group": "Hit", "note": "How far a strike pushes a slime back" },
8
- "lunge": { "value": 14, "min": 0, "max": 40, "step": 1, "unit": "px", "group": "Strike", "note": "The hero steps into each strike (as far as the push, so a combo stays in reach)" },
9
- "pushMs": { "value": 220, "min": 60, "max": 600, "step": 10, "unit": "ms", "group": "Hit", "note": "How long the push takes (it eases out)" },
10
- "wobble": { "value": 0.3, "min": 0, "max": 0.7, "step": 0.01, "group": "Hit", "note": "How much a struck slime squashes and wobbles" },
11
- "wobbleMs": { "value": 420, "min": 100, "max": 1000, "step": 10, "unit": "ms", "group": "Hit", "note": "How long the wobble takes to die away (overlap)" },
12
- "shake": { "value": 4, "min": 0, "max": 16, "step": 0.5, "unit": "px", "group": "Hit", "note": "The camera's kick when your strike lands" }
2
+ "public": {
3
+ "strikeRange": {
4
+ "value": 1.92,
5
+ "min": 1.0,
6
+ "max": 3.6,
7
+ "step": 0.02,
8
+ "unit": "m",
9
+ "group": "Strike",
10
+ "note": "How far a strike reaches (a slime's size adds to it)"
11
+ },
12
+ "strikeMs": {
13
+ "value": 380,
14
+ "min": 150,
15
+ "max": 900,
16
+ "step": 10,
17
+ "unit": "ms",
18
+ "group": "Strike",
19
+ "note": "The shortest time between two strikes"
20
+ },
21
+ "flashMs": {
22
+ "value": 200,
23
+ "min": 60,
24
+ "max": 500,
25
+ "step": 10,
26
+ "unit": "ms",
27
+ "group": "Strike",
28
+ "note": "How long a strike shows on every screen"
29
+ },
30
+ "swingMs": {
31
+ "value": 150,
32
+ "min": 40,
33
+ "max": 400,
34
+ "step": 5,
35
+ "unit": "ms",
36
+ "group": "Strike",
37
+ "note": "The swipe around the hero, and its smear as it carries through"
38
+ },
39
+ "hitStopMs": {
40
+ "value": 60,
41
+ "min": 0,
42
+ "max": 200,
43
+ "step": 5,
44
+ "unit": "ms",
45
+ "group": "Hit",
46
+ "note": "The hit lands: the slime freezes, white, before it is pushed"
47
+ },
48
+ "slimePush": {
49
+ "value": 0.28,
50
+ "min": 0.0,
51
+ "max": 1.6,
52
+ "step": 0.02,
53
+ "unit": "m",
54
+ "group": "Hit",
55
+ "note": "How far a strike pushes a slime back"
56
+ },
57
+ "lunge": {
58
+ "value": 0.28,
59
+ "min": 0.0,
60
+ "max": 0.8,
61
+ "step": 0.02,
62
+ "unit": "m",
63
+ "group": "Strike",
64
+ "note": "The hero steps into each strike (as far as the push, so a combo stays in reach)"
65
+ },
66
+ "pushMs": {
67
+ "value": 220,
68
+ "min": 60,
69
+ "max": 600,
70
+ "step": 10,
71
+ "unit": "ms",
72
+ "group": "Hit",
73
+ "note": "How long the push takes (it eases out)"
74
+ },
75
+ "wobble": {
76
+ "value": 0.3,
77
+ "min": 0,
78
+ "max": 0.7,
79
+ "step": 0.01,
80
+ "group": "Hit",
81
+ "note": "How much a struck slime squashes and wobbles"
82
+ },
83
+ "wobbleMs": {
84
+ "value": 420,
85
+ "min": 100,
86
+ "max": 1000,
87
+ "step": 10,
88
+ "unit": "ms",
89
+ "group": "Hit",
90
+ "note": "How long the wobble takes to die away (overlap)"
91
+ },
92
+ "shake": {
93
+ "value": 4,
94
+ "min": 0,
95
+ "max": 16,
96
+ "step": 0.5,
97
+ "unit": "px",
98
+ "group": "Hit",
99
+ "note": "The camera's kick when your strike lands"
100
+ }
101
+ }
13
102
  }
@@ -18,6 +18,14 @@ export const move = defineMove({
18
18
  const keep = M.pow(1 - m.accel / 60, ctx.dt * 60);
19
19
  body.vel = M.add(want, M.scale(M.sub(body.vel, want), keep));
20
20
  if (M.len(body.vel) > 0.6) body.heading = M.norm(body.vel);
21
- ctx.map.sweep(body, M.scale(body.vel, ctx.dt));
21
+ // Keep the tangential travel when a thumb points slightly into an arena wall.
22
+ // The original canvas starter clamped each axis independently.
23
+ const delta = M.scale(body.vel, ctx.dt), before = body.pos;
24
+ const hit = ctx.map.sweep(body, delta);
25
+ if (hit) {
26
+ const remaining = M.sub(delta, M.sub(body.pos, before));
27
+ const into = M.dot(remaining, hit.normal);
28
+ if (into < 0) ctx.map.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
29
+ }
22
30
  },
23
31
  });
@@ -420,7 +420,8 @@ function frame(t: number): void {
420
420
  const dt = lab.time.dt(t, 0.05);
421
421
  updateView();
422
422
  if (lab.on) labReport(dt);
423
- draw(t); frames++; requestAnimationFrame(frame);
423
+ // updateView can deliver an effect newer than a delayed rAF timestamp.
424
+ draw(performance.now()); frames++; requestAnimationFrame(frame);
424
425
  }
425
426
  room.probe({ frames: () => frames, waves: () => wavesSeen, knocks: () => knocksSeen, zone: () => zone,
426
427
  camera: () => ({ ...cam, view: viewSeat() }),
@@ -2,11 +2,29 @@
2
2
  "id": "gem-rush-3d",
3
3
  "name": "Gem Rush 3D",
4
4
  "blurb": "Gem Rush in a meadow: animals grab gems and bump each other away. Sixty-second rounds that never stop, drawn in three.js with free CC0 models.",
5
- "players": { "min": 1, "max": 8 },
5
+ "players": {
6
+ "min": 1,
7
+ "max": 8
8
+ },
6
9
  "roundSeconds": 60,
7
- "entry": "src/main.ts",
8
- "netplay": { "v": 1, "public": true, "movement": "owner" },
9
- "needs": { "three": "0.185.1" },
10
+ "entry": "src/view.ts",
11
+ "netplay": {
12
+ "v": 1,
13
+ "public": true
14
+ },
15
+ "needs": {
16
+ "three": "0.185.1",
17
+ "@types/three": "0.185.1"
18
+ },
10
19
  "assets": "library",
11
- "landing": { "pitch": "Grab the gems. Bump your rivals away.", "controls": { "phone": "Drag to move; a second finger bumps.", "computer": "WASD or arrows to move, Space to bump." } }
20
+ "landing": {
21
+ "pitch": "Grab the gems. Bump your rivals away.",
22
+ "controls": {
23
+ "phone": "Drag to move; a second finger bumps.",
24
+ "computer": "WASD or arrows to move, Space to bump."
25
+ }
26
+ },
27
+ "room": {
28
+ "host": "server"
29
+ }
12
30
  }
@@ -0,0 +1,153 @@
1
+ {
2
+ "bounds": {
3
+ "min": [
4
+ 0,
5
+ 0,
6
+ 0
7
+ ],
8
+ "max": [
9
+ 26,
10
+ 16,
11
+ 8
12
+ ]
13
+ },
14
+ "circles": [
15
+ {
16
+ "at": [
17
+ 13,
18
+ 4,
19
+ 0
20
+ ],
21
+ "r": 1.05
22
+ },
23
+ {
24
+ "at": [
25
+ 4.6,
26
+ 3.8,
27
+ 0
28
+ ],
29
+ "r": 0.75
30
+ },
31
+ {
32
+ "at": [
33
+ 21.4,
34
+ 4.6,
35
+ 0
36
+ ],
37
+ "r": 0.55
38
+ },
39
+ {
40
+ "at": [
41
+ 6,
42
+ 12.6,
43
+ 0
44
+ ],
45
+ "r": 0.5
46
+ },
47
+ {
48
+ "at": [
49
+ 20.2,
50
+ 12.2,
51
+ 0
52
+ ],
53
+ "r": 0.75
54
+ },
55
+ {
56
+ "at": [
57
+ 9.6,
58
+ 13.4,
59
+ 0
60
+ ],
61
+ "r": 0.45
62
+ },
63
+ {
64
+ "at": [
65
+ 23.6,
66
+ 2,
67
+ 0
68
+ ],
69
+ "r": 0.5
70
+ }
71
+ ],
72
+ "spots": {
73
+ "start": [
74
+ [
75
+ 19.6,
76
+ 8.0,
77
+ 0
78
+ ],
79
+ [
80
+ 17.66690475583121,
81
+ 11.535533905932738,
82
+ 0
83
+ ],
84
+ [
85
+ 13.0,
86
+ 13.0,
87
+ 0
88
+ ],
89
+ [
90
+ 8.333095244168787,
91
+ 11.535533905932738,
92
+ 0
93
+ ],
94
+ [
95
+ 6.4,
96
+ 8.0,
97
+ 0
98
+ ],
99
+ [
100
+ 8.333095244168785,
101
+ 4.464466094067262,
102
+ 0
103
+ ],
104
+ [
105
+ 13,
106
+ 2.509,
107
+ 0
108
+ ],
109
+ [
110
+ 17.66690475583121,
111
+ 4.464466094067261,
112
+ 0
113
+ ]
114
+ ],
115
+ "obstacles": [
116
+ [
117
+ 13,
118
+ 4,
119
+ 1.05
120
+ ],
121
+ [
122
+ 4.6,
123
+ 3.8,
124
+ 0.75
125
+ ],
126
+ [
127
+ 21.4,
128
+ 4.6,
129
+ 0.55
130
+ ],
131
+ [
132
+ 6,
133
+ 12.6,
134
+ 0.5
135
+ ],
136
+ [
137
+ 20.2,
138
+ 12.2,
139
+ 0.75
140
+ ],
141
+ [
142
+ 9.6,
143
+ 13.4,
144
+ 0.45
145
+ ],
146
+ [
147
+ 23.6,
148
+ 2,
149
+ 0.5
150
+ ]
151
+ ]
152
+ }
153
+ }
@@ -0,0 +1,37 @@
1
+ import { defineMove, type Vec3 } from '@homie-rocks/studio/rules';
2
+
3
+ import type { MoveBody, MoveContext } from '@homie-rocks/studio/rules/types';
4
+
5
+ function slide<M>(body: MoveBody<M>, delta: Vec3, ctx: MoveContext<unknown, M>) {
6
+ const before = body.pos, hit = ctx.map.sweep(body, delta);
7
+ if (!hit) return;
8
+ const M = ctx.math, remaining = M.sub(delta, M.sub(body.pos, before));
9
+ const into = M.dot(remaining, hit.normal);
10
+ if (into < 0) ctx.map.sweep(body, M.sub(remaining, M.scale(hit.normal, into)));
11
+ }
12
+
13
+ export const move = defineMove({
14
+ runner(body, input, ctx) {
15
+ const M = ctx.math, m = body.motion, T = ctx.tune;
16
+ if (m.knockAt > 0) {
17
+ const last = ctx.tick + 1 >= m.knockUntil;
18
+ const u = M.clamp((ctx.tick - m.knockAt) / (m.knockUntil - m.knockAt), 0, 1);
19
+ const ease = last ? 1 : 1 - M.pow(1 - u, T.knockEase);
20
+ // A wall consumes blocked travel. Chasing the absolute endpoint stores up
21
+ // that travel and releases it as a burst when the body slides clear.
22
+ const previous = M.clamp((ctx.tick - 1 - m.knockAt) / (m.knockUntil - m.knockAt), 0, 1);
23
+ const before = 1 - M.pow(1 - previous, T.knockEase);
24
+ const travel = M.scale(m.knockDir, T.knockDistance * (ease - before));
25
+ const delta = travel;
26
+ slide(body, delta, ctx);
27
+ body.vel = last ? { x: 0, y: 0, z: 0 } : M.scale(delta, 1 / ctx.dt);
28
+ if (last) m.knockAt = 0;
29
+ return;
30
+ }
31
+ const want = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
32
+ const keep = M.pow(1 - m.accel / 60, ctx.dt * 60);
33
+ body.vel = M.add(want, M.scale(M.sub(body.vel, want), keep));
34
+ if (M.len(body.vel) > 0.6) body.heading = M.norm(body.vel);
35
+ slide(body, M.scale(body.vel, ctx.dt), ctx);
36
+ },
37
+ });
@@ -0,0 +1,133 @@
1
+ import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
2
+ import { move } from './move';
3
+
4
+ function gemPoint(world: Pick<GameWorld, 'random' | 'map' | 'math'>) {
5
+ let p = { x: 1.2 + world.random() * 23.6, y: 1.2 + world.random() * 13.6, z: 0 };
6
+ for (let i = 0; i < 12; i++) {
7
+ let blocked = false;
8
+ for (const o of world.map.spots('obstacles')) if (world.math.dist(p, { x: o.x, y: o.y, z: 0 }) < o.z + 0.7) blocked = true;
9
+ if (!blocked) break;
10
+ p = { x: 1.2 + world.random() * 23.6, y: 1.2 + world.random() * 13.6, z: 0 };
11
+ }
12
+ return p;
13
+ }
14
+
15
+ export default defineRules({
16
+ contract: 2, space: { dims: 3 }, move,
17
+ shapes: {
18
+ events: { hit: { from: f.vec3(), by: f.ref() }, take: { by: f.ref(), at: f.vec3() }, score: { value: f.u8() }, zone: {} },
19
+ effects: { wave: {}, knock: { dir: f.dir(), by: f.ref() } },
20
+ },
21
+ shared: { gemSeq: f.u32(), zone: f.vec3(), zoneN: f.u32(), zoneUntil: f.tick() },
22
+ entities: {
23
+ runner: {
24
+ player: { away: 'think', leave: 'bot' },
25
+ fields: { score: f.u16({ score: true }), pickups: f.u16(), target: f.vec3(), noticed: f.fix(), arrived: f.bit(), targetGem: f.ref() },
26
+ motion: { speed: f.fix({ init: 6.8 }), accel: f.fix({ init: 14 }), knockDir: f.dir(), knockFrom: f.vec3(), knockAt: f.fix(), knockUntil: f.fix() },
27
+ input: { ax: f.i8(), ay: f.i8(), wave: f.press() },
28
+ body: { shape: 'capsule', radius: 0.44, height: 1.2, maxSpeed: 6.8 },
29
+ tick(world, self) {
30
+ if (self.driver === 'person' && !self.away) { self.motion.speed = 6.8; self.motion.accel = 14; }
31
+ if (self.input.wave) {
32
+ world.emit('wave', self.id, {});
33
+ world.sendArea({ sphere: { at: self.pos, r: world.tune.knockRange } }, 'hit', { from: self.pos, by: self.id });
34
+ }
35
+ if (world.round.phase === 'live') for (const gem of world.near(self.pos, 0.7, 'gem')) world.send(gem.id, 'take', { by: self.id, at: self.pos });
36
+ },
37
+ think(world, self) {
38
+ const M = world.math;
39
+ self.motion.speed = 5; self.motion.accel = 5;
40
+ if (world.stage === 'dummy' || self.motion.knockAt > 0) return { ax: 0, ay: 0 };
41
+ const level = (world.kids ? Math.min(3, world.level) : world.level) - 1;
42
+ const reactions = [650, 420, 250, 170, 110];
43
+ const noise = [0.55, 0.3, 0.15, 0.07, 0.02];
44
+ const aggression = [0.1, 0.3, 0.5, 0.7, 0.9];
45
+ const positioning = [0.1, 0.35, 0.5, 0.75, 0.95];
46
+ if (self.noticed === 0 || world.tick - self.noticed >= reactions[level] / 1000 / world.dt) {
47
+ let best = self.pos, nearest = 10000;
48
+ self.targetGem = '';
49
+ const rivals = world.near(self.pos, 64, 'runner');
50
+ for (const gem of world.near(self.pos, 64, 'gem')) {
51
+ if (rivals.some(p => p.id !== self.id && p.noticed === world.tick && p.targetGem === gem.id)) continue;
52
+ const hot = M.dist(gem.pos, world.shared.zone) < 3;
53
+ const distance = M.dist(gem.pos, self.pos) * (hot ? 1.5 - positioning[level] : 1);
54
+ if (distance < nearest) { nearest = distance; best = gem.pos; self.targetGem = gem.id; }
55
+ }
56
+ self.target = { x: best.x + (world.random() - 0.5) * 4 * noise[level], y: best.y + (world.random() - 0.5) * 4 * noise[level], z: 0 };
57
+ self.noticed = world.tick; self.arrived = false;
58
+ }
59
+ const d = M.sub(self.target, self.pos), distance = M.len(d);
60
+ if (distance < 0.12 && !self.arrived) { self.arrived = true; self.noticed = world.tick; }
61
+ const v = distance < 0.12 ? { x: 0, y: 0, z: 0 } : M.norm(d);
62
+ let rival = false;
63
+ for (const p of world.near(self.pos, world.tune.knockRange, 'runner')) if (p.id !== self.id) rival = true;
64
+ return { ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), wave: rival && world.round.phase === 'live' && world.random() < Math.min(world.kids ? 0.3 : 1, aggression[level]) * 0.8 * world.dt };
65
+ },
66
+ on: {
67
+ score(world, self, e) { self.score += e.value; self.pickups += 1; },
68
+ takeover(world, self) { self.motion.speed = 6.8; self.motion.accel = 14; self.motion.knockAt = 0; self.motion.knockUntil = 0; self.vel = { x: 0, y: 0, z: 0 }; },
69
+ arrive(world, self) {
70
+ if (world.stage === 'dummy') {
71
+ let index = 0;
72
+ for (const p of world.near(self.pos, 64, 'runner')) if (p.driver === 'bot' && p.seat < self.seat) index++;
73
+ world.place(self, self.driver === 'person' ? { x: 9.8, y: 8, z: 0 } : index === 0 ? { x: 11.64, y: 8, z: 0 } : { x: 22.4, y: 3 + (index - 1) * 10, z: 0 });
74
+ }
75
+ },
76
+ hit(world, self, e) {
77
+ if (e.by === self.id) return;
78
+ const M = world.math, m = self.motion, delta = M.sub(self.pos, e.from);
79
+ m.knockDir = M.len(delta) < 0.02 ? M.dir(world.random() * M.TAU) : M.norm(delta);
80
+ m.knockFrom = self.pos;
81
+ m.knockAt = world.tick + world.tune.hitStopMs / 1000 / world.dt;
82
+ m.knockUntil = m.knockAt + world.tune.knockMs / 1000 / world.dt;
83
+ world.emit('knock', self.id, { dir: m.knockDir, by: e.by });
84
+ },
85
+ },
86
+ onRoom: {
87
+ roundStart(world, self) {
88
+ self.score = 0; self.pickups = 0; self.noticed = 0;
89
+ self.motion.knockAt = 0; self.motion.knockUntil = 0;
90
+ const spots = world.map.spots('start');
91
+ if (world.stage !== 'dummy') world.place(self, spots[self.seat % spots.length]);
92
+ else {
93
+ let index = 0;
94
+ for (const p of world.near(self.pos, 64, 'runner')) if (p.driver === 'bot' && p.seat < self.seat) index++;
95
+ world.place(self, self.driver === 'person' ? { x: 9.8, y: 8, z: 0 } : index === 0 ? { x: 11.64, y: 8, z: 0 } : { x: 22.4, y: 3 + (index - 1) * 10, z: 0 });
96
+ }
97
+ },
98
+ },
99
+ },
100
+ gem: {
101
+ fields: { takenAt: f.tick(), serial: f.u32() },
102
+ on: {
103
+ take(world, self, e) {
104
+ if (self.takenAt === world.tick || world.math.dist(e.at, self.pos) >= 0.7) return;
105
+ self.takenAt = world.tick;
106
+ world.send(e.by, 'score', { value: world.math.dist(self.pos, world.shared.zone) < 3 ? 2 : 1 });
107
+ world.place(self, gemPoint(world));
108
+ self.serial += 14;
109
+ },
110
+ },
111
+ onRoom: { roundOver(world, self) { world.despawn(self); } },
112
+ },
113
+ },
114
+ room: {
115
+ rounds: { seconds: 60, breakSeconds: 7 }, bots: { keep: 3 },
116
+ join(ctx, player) { const spots = ctx.map.spots('start'); return { kind: 'runner', at: spots[player.seat % spots.length] }; },
117
+ on: {
118
+ roundStart(world) {
119
+ for (let i = 0; i < 14; i++) { world.shared.gemSeq += 1; world.spawn('gem', gemPoint(world), { serial: world.shared.gemSeq }); }
120
+ world.shared.zone = { x: 4.4 + world.random() * 17.2, y: 4 + world.random() * 8, z: 0 };
121
+ world.shared.zoneN += 1; world.shared.zoneUntil = world.tick + world.ticks(12);
122
+ world.after(world.ticks(12), 'zone', {});
123
+ },
124
+ zone(world) {
125
+ if (world.round.phase !== 'live') return;
126
+ world.shared.zone = { x: 4.4 + world.random() * 17.2, y: 4 + world.random() * 8, z: 0 };
127
+ world.shared.zoneN += 1; world.shared.zoneUntil = world.tick + world.ticks(12);
128
+ world.after(world.ticks(12), 'zone', {});
129
+ },
130
+ },
131
+ },
132
+ map: './map',
133
+ });