@homie-rocks/studio 0.39.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 (37) hide show
  1. package/CHANGELOG.md +28 -1
  2. package/lib/build.mjs +1 -0
  3. package/lib/port-check.mjs +6 -3
  4. package/lib/rules-build.mjs +8 -1
  5. package/lib/rules-check-worker.mjs +3 -3
  6. package/lib/rules-check.mjs +3 -1
  7. package/lib/template-history.json +41 -0
  8. package/netplay/NETPLAY.md +74 -2
  9. package/package.json +1 -1
  10. package/rules/collision.ts +115 -0
  11. package/rules/core.ts +65 -25
  12. package/rules/host.ts +5 -1
  13. package/rules/math.ts +15 -5
  14. package/rules/rules.ts +33 -10
  15. package/rules/types.ts +2 -2
  16. package/rules/view.ts +126 -32
  17. package/starters/ember-vale/game.json +16 -4
  18. package/starters/ember-vale/map/main.json +66 -0
  19. package/starters/ember-vale/src/move.ts +31 -0
  20. package/starters/ember-vale/src/rules.ts +244 -0
  21. package/starters/ember-vale/src/{main.ts → view.ts} +89 -574
  22. package/starters/ember-vale/tunables.json +100 -11
  23. package/starters/gem-rush/src/move.ts +9 -1
  24. package/starters/gem-rush/src/view.ts +2 -1
  25. package/starters/gem-rush-3d/game.json +23 -5
  26. package/starters/gem-rush-3d/map/main.json +153 -0
  27. package/starters/gem-rush-3d/src/move.ts +37 -0
  28. package/starters/gem-rush-3d/src/rules.ts +133 -0
  29. package/starters/gem-rush-3d/src/{main.ts → view.ts} +63 -671
  30. package/starters/gem-rush-3d/tunables.json +98 -11
  31. package/starters/hero-rush-3d/game.json +23 -5
  32. package/starters/hero-rush-3d/map/main.json +153 -0
  33. package/starters/hero-rush-3d/src/move.ts +49 -0
  34. package/starters/hero-rush-3d/src/rules.ts +200 -0
  35. package/starters/hero-rush-3d/src/{main.ts → view.ts} +83 -976
  36. package/starters/hero-rush-3d/tunables.json +260 -30
  37. package/worker/version.mjs +1 -1
@@ -1,13 +1,100 @@
1
1
  {
2
- "knockRange": { "value": 2.2, "min": 1.2, "max": 4, "step": 0.02, "unit": "m", "group": "Knock", "note": "How near a body must be for a wave to bump it" },
3
- "hitStopMs": { "value": 70, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Knock", "note": "The hit lands: the bumped body holds, squashed and shaking, before it flies" },
4
- "knockDistance": { "value": 3.7, "min": 0.8, "max": 8, "step": 0.1, "unit": "m", "group": "Knock", "note": "How far a bump sends a body, the same at any frame rate" },
5
- "knockMs": { "value": 420, "min": 120, "max": 1000, "step": 10, "unit": "ms", "group": "Knock", "note": "How long the slide takes" },
6
- "knockEase": { "value": 4, "min": 1, "max": 6, "step": 0.1, "group": "Knock", "note": "1 slides at one speed; higher leaves faster and eases into the stop" },
7
- "squash": { "value": 0.35, "min": 0, "max": 0.8, "step": 0.01, "group": "Body", "note": "How much a flying body stretches along the hit, and squashes when it stops" },
8
- "settleMs": { "value": 260, "min": 0, "max": 700, "step": 10, "unit": "ms", "group": "Body", "note": "The wobble after the stop (overlap)" },
9
- "flashMs": { "value": 110, "min": 0, "max": 300, "step": 5, "unit": "ms", "group": "Hit", "note": "How long the bumped body flashes white" },
10
- "shake": { "value": 0.16, "min": 0, "max": 0.6, "step": 0.01, "unit": "m", "group": "Hit", "note": "The camera's kick when you bump or are bumped" },
11
- "sparks": { "value": 9, "min": 0, "max": 24, "step": 1, "group": "Hit", "note": "Sparks thrown off the hit" },
12
- "ringMs": { "value": 900, "min": 200, "max": 2000, "step": 10, "unit": "ms", "group": "Wave", "note": "How long a wave's ring lasts" }
2
+ "public": {
3
+ "knockRange": {
4
+ "value": 2.2,
5
+ "min": 1.2,
6
+ "max": 4,
7
+ "step": 0.02,
8
+ "unit": "m",
9
+ "group": "Knock",
10
+ "note": "How near a body must be for a wave to bump it"
11
+ },
12
+ "hitStopMs": {
13
+ "value": 70,
14
+ "min": 0,
15
+ "max": 200,
16
+ "step": 5,
17
+ "unit": "ms",
18
+ "group": "Knock",
19
+ "note": "The hit lands: the bumped body holds, squashed and shaking, before it flies"
20
+ },
21
+ "knockDistance": {
22
+ "value": 3.7,
23
+ "min": 0.8,
24
+ "max": 8,
25
+ "step": 0.1,
26
+ "unit": "m",
27
+ "group": "Knock",
28
+ "note": "How far a bump sends a body, the same at any frame rate"
29
+ },
30
+ "knockMs": {
31
+ "value": 420,
32
+ "min": 120,
33
+ "max": 1000,
34
+ "step": 10,
35
+ "unit": "ms",
36
+ "group": "Knock",
37
+ "note": "How long the slide takes"
38
+ },
39
+ "knockEase": {
40
+ "value": 4,
41
+ "min": 1,
42
+ "max": 6,
43
+ "step": 0.1,
44
+ "group": "Knock",
45
+ "note": "1 slides at one speed; higher leaves faster and eases into the stop"
46
+ },
47
+ "squash": {
48
+ "value": 0.35,
49
+ "min": 0,
50
+ "max": 0.8,
51
+ "step": 0.01,
52
+ "group": "Body",
53
+ "note": "How much a flying body stretches along the hit, and squashes when it stops"
54
+ },
55
+ "settleMs": {
56
+ "value": 260,
57
+ "min": 0,
58
+ "max": 700,
59
+ "step": 10,
60
+ "unit": "ms",
61
+ "group": "Body",
62
+ "note": "The wobble after the stop (overlap)"
63
+ },
64
+ "flashMs": {
65
+ "value": 110,
66
+ "min": 0,
67
+ "max": 300,
68
+ "step": 5,
69
+ "unit": "ms",
70
+ "group": "Hit",
71
+ "note": "How long the bumped body flashes white"
72
+ },
73
+ "shake": {
74
+ "value": 0.16,
75
+ "min": 0,
76
+ "max": 0.6,
77
+ "step": 0.01,
78
+ "unit": "m",
79
+ "group": "Hit",
80
+ "note": "The camera's kick when you bump or are bumped"
81
+ },
82
+ "sparks": {
83
+ "value": 9,
84
+ "min": 0,
85
+ "max": 24,
86
+ "step": 1,
87
+ "group": "Hit",
88
+ "note": "Sparks thrown off the hit"
89
+ },
90
+ "ringMs": {
91
+ "value": 900,
92
+ "min": 200,
93
+ "max": 2000,
94
+ "step": 10,
95
+ "unit": "ms",
96
+ "group": "Wave",
97
+ "note": "How long a wave's ring lasts"
98
+ }
99
+ }
13
100
  }
@@ -2,11 +2,29 @@
2
2
  "id": "hero-rush-3d",
3
3
  "name": "Hero Rush 3D",
4
4
  "blurb": "Knights, mages and rogues race for the coins, jump each other's swings and knock the loot loose. Sixty-second rounds that never stop, in three.js with free CC0 animated characters.",
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 coins. Jump their swings. Knock the loot loose.", "controls": { "phone": "Drag anywhere to run; Jump and Swing at the bottom right.", "computer": "WASD or arrows to run, Space to jump, F or a click to swing." } }
20
+ "landing": {
21
+ "pitch": "Grab the coins. Jump their swings. Knock the loot loose.",
22
+ "controls": {
23
+ "phone": "Drag anywhere to run; Jump and Swing at the bottom right.",
24
+ "computer": "WASD or arrows to run, Space to jump, F or a click to swing."
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,49 @@
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
+ const dt = ctx.dt, knocked = m.knockAt > 0;
17
+ const g0 = 2 * T.jumpHeight / (T.jumpRise * T.jumpRise);
18
+ let vz = body.vel.z, dz = 0;
19
+ if (input.jump && body.grounded && !knocked) { vz = g0 * T.jumpRise; m.jumpAt = ctx.tick; }
20
+ if (vz > 0 || !body.grounded) {
21
+ const gravity = g0 * (vz < 0 ? T.fallFaster : 1);
22
+ dz = vz * dt - gravity * dt * dt / 2;
23
+ vz -= gravity * dt;
24
+ if (Math.abs(vz) < 0.001) vz = 0;
25
+ }
26
+ let delta;
27
+ if (knocked) {
28
+ const last = ctx.tick + 1 >= m.knockUntil;
29
+ const u = M.clamp((ctx.tick - m.knockAt) / (m.knockUntil - m.knockAt), 0, 1);
30
+ const ease = last ? 1 : 1 - M.pow(1 - u, T.knockEase);
31
+ // A wall consumes blocked travel. Chasing the absolute endpoint stores up
32
+ // that travel and releases it as a burst when the body slides clear.
33
+ const previous = M.clamp((ctx.tick - 1 - m.knockAt) / (m.knockUntil - m.knockAt), 0, 1);
34
+ const before = 1 - M.pow(1 - previous, T.knockEase);
35
+ const travel = M.scale(m.knockDir, T.knockDistance * (ease - before));
36
+ delta = { x: travel.x, y: travel.y, z: dz };
37
+ if (last) m.knockAt = 0;
38
+ } else {
39
+ const want = M.scale(M.clampLen({ x: input.ax / 127, y: input.ay / 127, z: 0 }, 1), m.speed);
40
+ const keep = M.pow(1 - m.accel / 60, dt * 60);
41
+ const velocity = M.add(want, M.scale(M.sub({ x: body.vel.x, y: body.vel.y, z: 0 }, want), keep));
42
+ if (M.len(velocity) > 0.6 && !input.swing) body.heading = M.norm(velocity);
43
+ delta = { x: velocity.x * dt, y: velocity.y * dt, z: dz };
44
+ }
45
+ slide(body, delta, ctx);
46
+ body.vel = { x: knocked && m.knockAt === 0 ? 0 : delta.x / dt, y: knocked && m.knockAt === 0 ? 0 : delta.y / dt, z: body.grounded ? 0 : vz };
47
+
48
+ },
49
+ });
@@ -0,0 +1,200 @@
1
+ import { defineRules, f, type GameWorld } from '@homie-rocks/studio/rules';
2
+ import { move } from './move';
3
+
4
+ function flatDist(world: Pick<GameWorld, 'math'>, a: { x: number; y: number; z?: number }, b: { x: number; y: number; z?: number }) {
5
+ return world.math.len({ x: a.x - b.x, y: a.y - b.y, z: 0 });
6
+ }
7
+
8
+ function gemPoint(world: Pick<GameWorld, 'random' | 'map' | 'math'>) {
9
+ let p = { x: 1.2 + world.random() * 23.6, y: 1.2 + world.random() * 13.6, z: 0 };
10
+ for (let i = 0; i < 12; i++) {
11
+ let blocked = false;
12
+ 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;
13
+ if (!blocked) break;
14
+ p = { x: 1.2 + world.random() * 23.6, y: 1.2 + world.random() * 13.6, z: 0 };
15
+ }
16
+ return p;
17
+ }
18
+
19
+ export default defineRules({
20
+ contract: 2, space: { dims: 3 }, move,
21
+ shapes: {
22
+ events: { hit: { from: f.vec3(), by: f.ref(), dir: f.dir() }, land: {}, notice: { by: f.ref() }, take: { by: f.ref(), at: f.vec3() }, score: { value: f.u8() }, zone: {} },
23
+ effects: { swing: { dir: f.dir() }, jump: {}, dodge: {}, knock: { dir: f.dir(), by: f.ref() } },
24
+ },
25
+ shared: { gemSeq: f.u32(), zone: f.vec3(), zoneN: f.u32(), zoneUntil: f.tick() },
26
+ entities: {
27
+ runner: {
28
+ player: { away: 'think', leave: 'bot' },
29
+ fields: { score: f.u16({ score: true }), pickups: f.u16(), target: f.vec3(), noticed: f.fix(), arrived: f.bit(), swingReady: f.tick(), dodgeAt: f.tick(), firstRound: f.u32(), soft: f.fix(), dial: f.u8({ init: 3 }), targetGem: f.ref() },
30
+ motion: { jumpAt: f.tick(), speed: f.fix({ init: 6.2 }), accel: f.fix({ init: 14 }), knockDir: f.dir(), knockFrom: f.vec3(), knockAt: f.fix(), knockUntil: f.fix() },
31
+ input: { ax: f.i8(), ay: f.i8(), jump: f.press(), swing: f.press() },
32
+ body: { shape: 'capsule', radius: 0.44, height: 1.7, maxSpeed: 6.2 },
33
+ tick(world, self) {
34
+ if (self.driver === 'person' && !self.away) { self.motion.speed = 6.2; self.motion.accel = 14; }
35
+ if (self.motion.jumpAt === world.tick) world.emit('jump', self.id, {});
36
+ if (self.input.swing && world.tick >= self.swingReady && self.motion.knockAt === 0) {
37
+ self.swingReady = world.tick + world.ticks((world.tune.windupMs + world.tune.swingRestMs) / 1000);
38
+ world.emit('swing', self.id, { dir: self.heading });
39
+ world.after(Math.max(1, world.ticks(world.tune.windupMs / 1000) - 1), 'land', {});
40
+ world.sendArea({ sphere: { at: self.pos, r: world.tune.knockRange + 0.6 } }, 'notice', { by: self.id });
41
+ }
42
+ if (world.round.phase === 'live') for (const gem of world.near(self.pos, 4, 'gem'))
43
+ if (flatDist(world, self.pos, gem.pos) < 0.74) world.send(gem.id, 'take', { by: self.id, at: self.pos });
44
+ },
45
+ think(world, self) {
46
+ const M = world.math, knocked = self.motion.knockAt > 0;
47
+ const jump = self.dodgeAt > 0 && world.tick >= self.dodgeAt && self.grounded && !knocked;
48
+ if (self.dodgeAt > 0 && world.tick >= self.dodgeAt) self.dodgeAt = 0;
49
+ self.motion.accel = 5;
50
+ if ((world.stage === 'dummy' || world.stage === 'jump') || knocked) { self.motion.speed = 0; return { ax: 0, ay: 0 }; }
51
+ const folk = world.near(self.pos, 64, 'runner').filter(p => p.driver === 'person');
52
+ const easy = folk.some(p => p.firstRound >= world.round.n);
53
+ const level = Math.max(1, (world.kids ? Math.min(3, world.level) : world.level) - (easy && !world.levelSet ? 1 : 0));
54
+ self.dial = level;
55
+ const reactions = [650, 420, 250, 170, 110], noise = [0.55, 0.3, 0.15, 0.07, 0.02];
56
+ const positioning = [0.1, 0.35, 0.5, 0.75, 0.95], aggression = [0.1, 0.3, 0.5, 0.7, 0.9];
57
+ const ix = level - 1;
58
+ let best = -1, near = folk[0], nearDist = 10000;
59
+ for (const person of folk) {
60
+ best = Math.max(best, person.score);
61
+ const distance = flatDist(world, person.pos, self.pos);
62
+ if (distance < nearDist) { near = person; nearDist = distance; }
63
+ }
64
+ const soft = level === 5 || best < 0 ? 0 : M.clamp((self.score - best - [-1, 1, 2, 5, 0][ix]) / 2, 0, 1);
65
+ self.soft = soft;
66
+ const gems = world.near(self.pos, 64, 'gem');
67
+ if (self.noticed === 0 || world.tick - self.noticed >= reactions[ix] / 1000 / world.dt * (soft >= 1 ? 1 : 1 + 2.5 * soft)) {
68
+ let target = self.target;
69
+ self.targetGem = '';
70
+ if (near && soft >= 1) {
71
+ for (let i = 0; i < 6; i++) {
72
+ const a = M.clamp(M.atan2(Math.min(self.pos.y - near.pos.y, -0.6), self.pos.x - near.pos.x) + (world.random() - 0.5) * (1.2 + i * 0.5), -M.PI + 0.7, -0.7);
73
+ const distance = 2.2 + world.random() * 0.8;
74
+ target = { x: M.clamp(near.pos.x + M.cos(a) * distance, 0.44, 25.56), y: M.clamp(near.pos.y + M.sin(a) * distance, 0.44, 15.56), z: 0 };
75
+ const dx = target.x - self.pos.x, dy = target.y - self.pos.y, len2 = dx * dx + dy * dy || 1;
76
+ let clear = true;
77
+ for (const gem of gems) {
78
+ const u = M.clamp(((gem.pos.x - self.pos.x) * dx + (gem.pos.y - self.pos.y) * dy) / len2, 0, 1);
79
+ if (flatDist(world, { x: self.pos.x + dx * u, y: self.pos.y + dy * u, z: 0 }, gem.pos) < 0.89) clear = false;
80
+ }
81
+ if (clear) break;
82
+ }
83
+ } else {
84
+ let distance = 10000;
85
+ const rivals = world.near(self.pos, 64, 'runner');
86
+ for (const gem of gems) {
87
+ if (rivals.some(p => p.id !== self.id && p.noticed === world.tick && p.targetGem === gem.id)) continue;
88
+ const hot = flatDist(world, gem.pos, world.shared.zone) < 3;
89
+ const d = flatDist(world, gem.pos, self.pos) * (hot ? 1.5 - positioning[ix] : 1) + (near ? 2 * soft * flatDist(world, gem.pos, near.pos) : 0);
90
+ if (d < distance) { distance = d; target = gem.pos; self.targetGem = gem.id; }
91
+ }
92
+ if (self.targetGem) target = { x: target.x + (world.random() - 0.5) * 4 * noise[ix], y: target.y + (world.random() - 0.5) * 4 * noise[ix], z: 0 };
93
+ }
94
+ self.target = target; self.noticed = world.tick; self.arrived = false;
95
+ }
96
+ let v = M.sub(self.target, { x: self.pos.x, y: self.pos.y, z: 0 });
97
+ const distance = M.len(v);
98
+ if (distance < 0.12 && !self.arrived) { self.arrived = true; self.noticed = world.tick; }
99
+ self.motion.speed = distance < 0.12 ? 0 : 4.2 * (soft >= 1 && nearDist > 3.5 ? 1 : 1 - 0.5 * soft);
100
+ v = distance < 0.12 ? { x: 0, y: 0, z: 0 } : M.norm(v);
101
+ if (soft >= 1 && near) {
102
+ const look = Math.max(0.25, self.motion.speed * world.dt * 4);
103
+ let chosen = false;
104
+ for (const angle of [0, 1.05, -1.05, 2.1, -2.1]) {
105
+ const step = { x: v.x * M.cos(angle) - v.y * M.sin(angle), y: v.x * M.sin(angle) + v.y * M.cos(angle), z: 0 };
106
+ const p = M.add(self.pos, M.scale(step, look));
107
+ if (!gems.some(g => flatDist(world, p, g.pos) < 0.8 && flatDist(world, p, g.pos) < flatDist(world, self.pos, g.pos))) { v = step; chosen = true; break; }
108
+ }
109
+ if (!chosen) { v = { x: 0, y: 0, z: 0 }; self.vel = { x: 0, y: 0, z: self.vel.z }; }
110
+ }
111
+ let rival = null, rivalDist = world.tune.knockRange * 0.9;
112
+ for (const p of world.near(self.pos, 4, 'runner')) {
113
+ const d = flatDist(world, self.pos, p.pos);
114
+ if (p.id !== self.id && d < rivalDist) { rival = p; rivalDist = d; }
115
+ }
116
+ const attack = rival !== null && world.round.phase === 'live' && world.random() < (world.kids ? Math.min(0.3, aggression[ix]) : aggression[ix]) * (1 - 0.75 * soft) * 0.8 * world.dt;
117
+ if (attack && rival) self.heading = M.norm({ x: rival.pos.x - self.pos.x, y: rival.pos.y - self.pos.y, z: 0 });
118
+ return { ax: Math.round(v.x * 127), ay: Math.round(v.y * 127), jump, swing: attack };
119
+ },
120
+ on: {
121
+ land(world, self) {
122
+ if (self.motion.knockAt > 0) return;
123
+ world.sendArea({ box: { min: { x: self.pos.x - world.tune.knockRange, y: self.pos.y - world.tune.knockRange, z: 0 }, max: { x: self.pos.x + world.tune.knockRange, y: self.pos.y + world.tune.knockRange, z: 8 } } }, 'hit', { from: self.pos, by: self.id, dir: self.heading });
124
+ },
125
+ notice(world, self, e) {
126
+ if (e.by === self.id || self.driver !== 'bot' || (world.stage === 'dummy' || world.stage === 'jump')) return;
127
+ const ix = self.dial - 1, noise = [0.55, 0.3, 0.15, 0.07, 0.02], reaction = [650, 420, 250, 170, 110];
128
+ if (world.random() < (0.15 + 0.6 * (1 - noise[ix])) * (1 - 0.5 * self.soft)) self.dodgeAt = world.tick + world.ticks(Math.max(40, reaction[ix] * 0.5 - 60) / 1000);
129
+ },
130
+ score(world, self, e) { self.score += e.value; self.pickups += 1; },
131
+ takeover(world, self) { self.firstRound = world.round.n + (world.round.endsAt - world.tick < world.ticks(30) ? 1 : 0); self.motion.speed = 6.2; self.motion.accel = 14; self.motion.knockAt = 0; self.motion.knockUntil = 0; self.vel = { x: 0, y: 0, z: 0 }; },
132
+ arrive(world, self) {
133
+ self.firstRound = world.round.n + (world.round.endsAt - world.tick < world.ticks(30) ? 1 : 0);
134
+ if ((world.stage === 'dummy' || world.stage === 'jump')) {
135
+ let index = 0;
136
+ for (const p of world.near(self.pos, 64, 'runner')) if (p.driver === 'bot' && p.seat < self.seat) index++;
137
+ world.place(self, self.driver === 'person' ? { x: world.stage === 'jump' ? 13 : 9.8, y: 8, z: 0 } : index === 0 && world.stage !== 'jump' ? { x: 11.3, y: 8, z: 0 } : { x: 22.4, y: 3 + index * 5, z: 0 }, { heading: { x: self.driver === 'person' ? 1 : -1, y: 0, z: 0 } });
138
+ }
139
+ },
140
+ hit(world, self, e) {
141
+ if (e.by === self.id) return;
142
+ const M = world.math, m = self.motion, delta = { x: self.pos.x - e.from.x, y: self.pos.y - e.from.y, z: 0 };
143
+ const distance = M.len(delta);
144
+ if (distance > world.tune.knockRange || distance > 0.66 && M.dot(M.norm(delta), e.dir) < M.cos(world.tune.swingArc * M.PI / 360)) return;
145
+ if (self.pos.z >= 0.3) { world.emit('dodge', self.id, {}); return; }
146
+ m.knockDir = M.len(delta) < 0.02 ? M.dir(world.random() * M.TAU) : M.norm(delta);
147
+ m.knockFrom = self.pos;
148
+ m.knockAt = world.tick + world.tune.hitStopMs / 1000 / world.dt;
149
+ m.knockUntil = m.knockAt + world.tune.knockMs / 1000 / world.dt;
150
+ world.emit('knock', self.id, { dir: m.knockDir, by: e.by });
151
+ },
152
+ },
153
+ onRoom: {
154
+ roundStart(world, self) {
155
+ self.score = 0; self.pickups = 0; self.noticed = 0; self.swingReady = 0; self.dodgeAt = 0;
156
+ self.motion.knockAt = 0; self.motion.knockUntil = 0;
157
+ const spots = world.map.spots('start');
158
+ if (world.stage !== 'dummy' && world.stage !== 'jump') world.place(self, spots[self.seat % spots.length]);
159
+ else {
160
+ let index = 0;
161
+ for (const p of world.near(self.pos, 64, 'runner')) if (p.driver === 'bot' && p.seat < self.seat) index++;
162
+ world.place(self, self.driver === 'person' ? { x: world.stage === 'jump' ? 13 : 9.8, y: 8, z: 0 } : index === 0 && world.stage !== 'jump' ? { x: 11.3, y: 8, z: 0 } : { x: 22.4, y: 3 + index * 5, z: 0 }, { heading: { x: self.driver === 'person' ? 1 : -1, y: 0, z: 0 } });
163
+ }
164
+ },
165
+ },
166
+ },
167
+ gem: {
168
+ fields: { takenAt: f.tick(), serial: f.u32() },
169
+ on: {
170
+ take(world, self, e) {
171
+ if (self.takenAt === world.tick || flatDist(world, e.at, self.pos) >= 0.74) return;
172
+ self.takenAt = world.tick;
173
+ world.send(e.by, 'score', { value: flatDist(world, self.pos, world.shared.zone) < 3 ? 2 : 1 });
174
+ world.place(self, gemPoint(world));
175
+ self.serial += 14;
176
+ },
177
+ },
178
+ onRoom: { roundOver(world, self) { world.despawn(self); } },
179
+ },
180
+ },
181
+ room: {
182
+ rounds: { seconds: 60, breakSeconds: 7 }, bots: { keep: 3 },
183
+ join(ctx, player) { const spots = ctx.map.spots('start'); return { kind: 'runner', at: spots[player.seat % spots.length] }; },
184
+ on: {
185
+ roundStart(world) {
186
+ for (let i = 0; i < 14; i++) { world.shared.gemSeq += 1; world.spawn('gem', gemPoint(world), { serial: world.shared.gemSeq }); }
187
+ world.shared.zone = { x: 4.4 + world.random() * 17.2, y: 4 + world.random() * 8, z: 0 };
188
+ world.shared.zoneN += 1; world.shared.zoneUntil = world.tick + world.ticks(12);
189
+ world.after(world.ticks(12), 'zone', {});
190
+ },
191
+ zone(world) {
192
+ if (world.round.phase !== 'live') return;
193
+ world.shared.zone = { x: 4.4 + world.random() * 17.2, y: 4 + world.random() * 8, z: 0 };
194
+ world.shared.zoneN += 1; world.shared.zoneUntil = world.tick + world.ticks(12);
195
+ world.after(world.ticks(12), 'zone', {});
196
+ },
197
+ },
198
+ },
199
+ map: './map',
200
+ });