@myvillage/cli 2.6.1 → 2.8.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/package.json +1 -1
- package/src/assets/game-toolkit/myvillage-game.js +434 -0
- package/src/assets/templates/arena/AGENTS.md +93 -0
- package/src/assets/templates/arena/README.md +36 -0
- package/src/assets/templates/arena/index.html +74 -0
- package/src/assets/templates/arena/src/arena-game.js +443 -0
- package/src/assets/templates/arena/src/arena.json +114 -0
- package/src/assets/templates/arena/src/lobby.js +94 -0
- package/src/assets/templates/arena/src/main.js +46 -0
- package/src/commands/create-game.js +6 -1
- package/src/commands/deploy.js +48 -0
- package/src/index.js +1 -1
- package/src/utils/templates.js +252 -115
- package/vendor/realtime/myvillage-realtime-client.js +634 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
// The arena: rendering, input and HUD for a live match.
|
|
2
|
+
//
|
|
3
|
+
// Everything multiplayer (prediction, interpolation, lag compensation) lives in
|
|
4
|
+
// the ArenaClient that `mp.connect()` returns; this file only reads controls,
|
|
5
|
+
// hands them to the client, and draws what it reports. Restyle freely.
|
|
6
|
+
|
|
7
|
+
import * as THREE from 'three';
|
|
8
|
+
import { forwardOf } from './myvillage-realtime.js';
|
|
9
|
+
|
|
10
|
+
const EYE_HEIGHT = 1.6;
|
|
11
|
+
const LOOK_SENSITIVITY_MOUSE = 0.0025;
|
|
12
|
+
const LOOK_SENSITIVITY_TOUCH = 0.006;
|
|
13
|
+
const TURN_SPEED_KEYS = 2.5; // rad/s for Q/E
|
|
14
|
+
const STICK_RADIUS = 50;
|
|
15
|
+
|
|
16
|
+
export class ArenaGame {
|
|
17
|
+
/**
|
|
18
|
+
* @param {HTMLElement} container element the canvas and HUD go into
|
|
19
|
+
* @param {object} arena this game's arena config (src/arena.json)
|
|
20
|
+
*/
|
|
21
|
+
constructor(container, arena) {
|
|
22
|
+
this.container = container;
|
|
23
|
+
this.arena = arena;
|
|
24
|
+
this.net = null;
|
|
25
|
+
this.handlers = {};
|
|
26
|
+
this.yaw = 0;
|
|
27
|
+
this.pitch = 0;
|
|
28
|
+
this.keys = new Set();
|
|
29
|
+
this.mouseFire = false;
|
|
30
|
+
this.touchFire = false;
|
|
31
|
+
this.stick = { id: -1, ox: 0, oy: 0, x: 0, y: 0 };
|
|
32
|
+
this.look = { id: -1, x: 0, y: 0 };
|
|
33
|
+
this.avatars = new Map();
|
|
34
|
+
this.tracers = [];
|
|
35
|
+
this.bannerUntil = 0;
|
|
36
|
+
this.hitUntil = 0;
|
|
37
|
+
|
|
38
|
+
this.renderer = new THREE.WebGLRenderer({ antialias: window.devicePixelRatio < 2 });
|
|
39
|
+
this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
40
|
+
container.prepend(this.renderer.domElement);
|
|
41
|
+
|
|
42
|
+
this.scene = new THREE.Scene();
|
|
43
|
+
this.scene.background = new THREE.Color(0x9fc4e8);
|
|
44
|
+
this.scene.fog = new THREE.Fog(0x9fc4e8, 30, 70);
|
|
45
|
+
this.camera = new THREE.PerspectiveCamera(75, 1, 0.05, 200);
|
|
46
|
+
this.camera.rotation.order = 'YXZ';
|
|
47
|
+
this.scene.add(new THREE.HemisphereLight(0xffffff, 0x5a6b45, 1.6));
|
|
48
|
+
const sun = new THREE.DirectionalLight(0xffffff, 1.2);
|
|
49
|
+
sun.position.set(10, 20, 6);
|
|
50
|
+
this.scene.add(sun);
|
|
51
|
+
this.buildMap(arena.map);
|
|
52
|
+
|
|
53
|
+
this.hud = this.buildHud();
|
|
54
|
+
this.bindInput();
|
|
55
|
+
this.resize();
|
|
56
|
+
window.addEventListener('resize', () => this.resize());
|
|
57
|
+
|
|
58
|
+
this.last = performance.now();
|
|
59
|
+
requestAnimationFrame((t) => this.frame(t));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Start playing a connected match. handlers: { code, onLeave, onEnd, onExitResults } */
|
|
63
|
+
play(net, handlers) {
|
|
64
|
+
this.net = net;
|
|
65
|
+
this.handlers = handlers;
|
|
66
|
+
this.hud.root.hidden = false;
|
|
67
|
+
this.hud.results.hidden = true;
|
|
68
|
+
this.hud.fire.hidden = !matchMedia('(pointer: coarse)').matches;
|
|
69
|
+
|
|
70
|
+
net.on('event', (e) => this.onEvent(e));
|
|
71
|
+
net.on('end', (e) => this.onEnd(e));
|
|
72
|
+
net.on('close', (e) => {
|
|
73
|
+
if (net.phase !== 'ended' && this.net === net) this.banner('Disconnected (' + (e.reason || e.code) + ')', 60000);
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Back to the idle fly-over (lobby showing). */
|
|
78
|
+
stop() {
|
|
79
|
+
if (this.net) this.net.close();
|
|
80
|
+
this.net = null;
|
|
81
|
+
for (const a of this.avatars.values()) this.scene.remove(a.root);
|
|
82
|
+
this.avatars.clear();
|
|
83
|
+
this.hud.root.hidden = true;
|
|
84
|
+
this.hud.fire.hidden = true;
|
|
85
|
+
document.exitPointerLock?.();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── scene ────────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
buildMap(map) {
|
|
91
|
+
const outside = new THREE.Mesh(new THREE.PlaneGeometry(400, 400), new THREE.MeshLambertMaterial({ color: 0x5f7d50 }));
|
|
92
|
+
outside.rotation.x = -Math.PI / 2;
|
|
93
|
+
outside.position.y = -0.02;
|
|
94
|
+
this.scene.add(outside);
|
|
95
|
+
const floor = new THREE.Mesh(new THREE.PlaneGeometry(map.width, map.depth), new THREE.MeshLambertMaterial({ color: 0x7fa36b }));
|
|
96
|
+
floor.rotation.x = -Math.PI / 2;
|
|
97
|
+
this.scene.add(floor);
|
|
98
|
+
const size = Math.max(map.width, map.depth);
|
|
99
|
+
const grid = new THREE.GridHelper(size, size / 2, 0x5d7f4d, 0x6c9159);
|
|
100
|
+
grid.position.y = 0.01;
|
|
101
|
+
this.scene.add(grid);
|
|
102
|
+
|
|
103
|
+
const wallMat = new THREE.MeshLambertMaterial({ color: 0xc9b48f });
|
|
104
|
+
for (const w of map.walls) {
|
|
105
|
+
const h = w.height ?? 2;
|
|
106
|
+
const m = new THREE.Mesh(new THREE.BoxGeometry(w.maxX - w.minX, h, w.maxZ - w.minZ), wallMat);
|
|
107
|
+
m.position.set((w.minX + w.maxX) / 2, h / 2, (w.minZ + w.maxZ) / 2);
|
|
108
|
+
this.scene.add(m);
|
|
109
|
+
}
|
|
110
|
+
const edgeMat = new THREE.MeshLambertMaterial({ color: 0x8a7a5e });
|
|
111
|
+
for (const [x, z, sx, sz] of [
|
|
112
|
+
[0, -map.depth / 2, map.width, 0.4],
|
|
113
|
+
[0, map.depth / 2, map.width, 0.4],
|
|
114
|
+
[-map.width / 2, 0, 0.4, map.depth],
|
|
115
|
+
[map.width / 2, 0, 0.4, map.depth],
|
|
116
|
+
]) {
|
|
117
|
+
const m = new THREE.Mesh(new THREE.BoxGeometry(sx, 1, sz), edgeMat);
|
|
118
|
+
m.position.set(x, 0.5, z);
|
|
119
|
+
this.scene.add(m);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
avatarFor(p) {
|
|
124
|
+
let a = this.avatars.get(p.id);
|
|
125
|
+
if (a && a.name !== p.n) {
|
|
126
|
+
a.root.remove(a.label);
|
|
127
|
+
a.label = makeLabel(p.n);
|
|
128
|
+
a.root.add(a.label);
|
|
129
|
+
a.name = p.n;
|
|
130
|
+
}
|
|
131
|
+
if (a) return a;
|
|
132
|
+
const r = this.net.cfg.playerRadius;
|
|
133
|
+
const root = new THREE.Group();
|
|
134
|
+
const body = new THREE.Mesh(new THREE.CapsuleGeometry(r, 1.0, 4, 10), new THREE.MeshLambertMaterial({ color: colorFor(p.id) }));
|
|
135
|
+
body.position.y = 0.9;
|
|
136
|
+
const visor = new THREE.Mesh(new THREE.BoxGeometry(r * 1.2, 0.18, 0.2), new THREE.MeshLambertMaterial({ color: 0x1b1f27 }));
|
|
137
|
+
visor.position.set(0, 1.45, -r);
|
|
138
|
+
const itMarker = new THREE.Mesh(new THREE.ConeGeometry(0.25, 0.5, 12), new THREE.MeshBasicMaterial({ color: 0xe5484d }));
|
|
139
|
+
itMarker.rotation.x = Math.PI;
|
|
140
|
+
itMarker.position.y = 2.75;
|
|
141
|
+
const label = makeLabel(p.n);
|
|
142
|
+
root.add(body, visor, itMarker, label);
|
|
143
|
+
this.scene.add(root);
|
|
144
|
+
a = { root, itMarker, label, name: p.n };
|
|
145
|
+
this.avatars.set(p.id, a);
|
|
146
|
+
return a;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
addTracer(e) {
|
|
150
|
+
const f = forwardOf(e.y);
|
|
151
|
+
const from = new THREE.Vector3(e.x, 1.35, e.z);
|
|
152
|
+
const to = new THREE.Vector3(e.x + f.x * e.d, 1.35, e.z + f.z * e.d);
|
|
153
|
+
const color = this.net.cfg.mode === 'tag' ? 0xffffff : e.hit ? 0xff5a5f : 0xffe08a;
|
|
154
|
+
const line = new THREE.Line(new THREE.BufferGeometry().setFromPoints([from, to]), new THREE.LineBasicMaterial({ color }));
|
|
155
|
+
this.scene.add(line);
|
|
156
|
+
this.tracers.push({ line, until: performance.now() + 120 });
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ── match events ─────────────────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
nameOf(id) {
|
|
162
|
+
return this.net.roster.find((p) => p.id === id)?.n ?? 'someone';
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
onEvent(e) {
|
|
166
|
+
const you = this.net.you;
|
|
167
|
+
if (e.k === 'shot') {
|
|
168
|
+
this.addTracer(e);
|
|
169
|
+
if (e.a === you && e.hit) this.hitUntil = performance.now() + 150;
|
|
170
|
+
} else if (e.k === 'elim') {
|
|
171
|
+
if (e.v === you) this.banner('Blasted by ' + this.nameOf(e.a), this.net.cfg.respawnSec * 1000);
|
|
172
|
+
else if (e.a === you) this.banner('You got ' + this.nameOf(e.v) + '!');
|
|
173
|
+
} else if (e.k === 'tag') {
|
|
174
|
+
if (e.v === you) this.banner("You're it!");
|
|
175
|
+
else if (e.a === you) this.banner('Tagged ' + this.nameOf(e.v) + '!');
|
|
176
|
+
} else if (e.k === 'spawn' && e.v === you) {
|
|
177
|
+
// Face the way the spawn point faces.
|
|
178
|
+
this.yaw = e.y;
|
|
179
|
+
this.pitch = 0;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
onEnd(end) {
|
|
184
|
+
document.exitPointerLock?.();
|
|
185
|
+
const tag = this.net.cfg.mode === 'tag';
|
|
186
|
+
const list = this.hud.resultsList;
|
|
187
|
+
list.innerHTML = '';
|
|
188
|
+
for (const r of end.results) {
|
|
189
|
+
const li = document.createElement('li');
|
|
190
|
+
li.textContent = r.name + ' — ' + r.score + (tag ? 's' : '') + (r.id === this.net.you ? ' (you)' : '');
|
|
191
|
+
list.appendChild(li);
|
|
192
|
+
}
|
|
193
|
+
this.hud.resultsTitle.textContent = end.status === 'completed' ? 'Match over' : 'Match abandoned';
|
|
194
|
+
this.hud.results.hidden = false;
|
|
195
|
+
this.handlers.onEnd?.(end);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
banner(text, ms = 1500) {
|
|
199
|
+
this.hud.banner.textContent = text;
|
|
200
|
+
this.bannerUntil = performance.now() + ms;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ── HUD ──────────────────────────────────────────────────────────────────
|
|
204
|
+
|
|
205
|
+
buildHud() {
|
|
206
|
+
const root = el('div', 'hud');
|
|
207
|
+
root.hidden = true;
|
|
208
|
+
const status = el('div', 'hud-status');
|
|
209
|
+
const board = el('div', 'hud-board');
|
|
210
|
+
const leave = el('button', 'hud-leave', 'Leave');
|
|
211
|
+
const crosshair = el('div', 'hud-crosshair');
|
|
212
|
+
const banner = el('div', 'hud-banner');
|
|
213
|
+
const hp = el('div', 'hud-hp');
|
|
214
|
+
const ping = el('div', 'hud-ping');
|
|
215
|
+
const results = el('div', 'hud-results');
|
|
216
|
+
results.hidden = true;
|
|
217
|
+
const card = el('div', 'card');
|
|
218
|
+
const resultsTitle = el('h2', '', 'Match over');
|
|
219
|
+
const resultsList = el('ol');
|
|
220
|
+
const again = el('button', 'primary', 'Back to lobby');
|
|
221
|
+
card.append(resultsTitle, resultsList, again);
|
|
222
|
+
results.append(card);
|
|
223
|
+
root.append(status, board, leave, crosshair, banner, hp, ping, results);
|
|
224
|
+
const fire = el('div', 'hud-fire', 'FIRE');
|
|
225
|
+
fire.hidden = true;
|
|
226
|
+
const stickEl = el('div', 'hud-stick');
|
|
227
|
+
stickEl.append(el('i'));
|
|
228
|
+
stickEl.hidden = true;
|
|
229
|
+
this.container.append(root, fire, stickEl);
|
|
230
|
+
|
|
231
|
+
leave.addEventListener('click', () => this.handlers.onLeave?.());
|
|
232
|
+
again.addEventListener('click', () => this.handlers.onExitResults?.());
|
|
233
|
+
return { root, status, board, leave, crosshair, banner, hp, ping, results, resultsTitle, resultsList, fire, stickEl };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
updateHud(now, me) {
|
|
237
|
+
const net = this.net;
|
|
238
|
+
const { hud } = this;
|
|
239
|
+
const cfg = net.cfg;
|
|
240
|
+
const code = this.handlers.code ? 'Code ' + this.handlers.code + ' · ' : '';
|
|
241
|
+
hud.status.textContent =
|
|
242
|
+
net.phase === 'waiting'
|
|
243
|
+
? code + 'Waiting for players (' + net.roster.length + '/' + cfg.minPlayers + ') — warm up!'
|
|
244
|
+
: net.phase === 'countdown'
|
|
245
|
+
? 'Starting in ' + Math.ceil(net.timeLeft)
|
|
246
|
+
: net.phase === 'playing'
|
|
247
|
+
? Math.floor(net.timeLeft / 60) + ':' + String(Math.floor(net.timeLeft % 60)).padStart(2, '0')
|
|
248
|
+
: 'Match over';
|
|
249
|
+
hud.board.innerHTML = [...net.roster]
|
|
250
|
+
.sort((a, b) => b.s - a.s)
|
|
251
|
+
.map((p) => '<div class="' + (p.id === net.you ? 'me' : '') + '"><span>' + (p.it ? '🔴 ' : '') + escapeHtml(p.n) + '</span><b>' + p.s + '</b></div>')
|
|
252
|
+
.join('');
|
|
253
|
+
hud.hp.textContent = cfg.mode === 'tag' ? (me.it ? "You're IT — tag someone!" : 'Run!') : me.alive ? '♥ ' + me.hp : 'Respawning…';
|
|
254
|
+
hud.ping.textContent = Math.round(net.rttMs) + ' ms';
|
|
255
|
+
hud.crosshair.classList.toggle('hit', now < this.hitUntil);
|
|
256
|
+
if (now > this.bannerUntil) hud.banner.textContent = '';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ── input ────────────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
bindInput() {
|
|
262
|
+
const canvas = this.renderer.domElement;
|
|
263
|
+
addEventListener('keydown', (e) => this.keys.add(e.code));
|
|
264
|
+
addEventListener('keyup', (e) => this.keys.delete(e.code));
|
|
265
|
+
canvas.addEventListener('mousedown', () => {
|
|
266
|
+
if (!this.net) return;
|
|
267
|
+
if (document.pointerLockElement !== canvas) canvas.requestPointerLock();
|
|
268
|
+
else this.mouseFire = true;
|
|
269
|
+
});
|
|
270
|
+
addEventListener('mouseup', () => (this.mouseFire = false));
|
|
271
|
+
addEventListener('mousemove', (e) => {
|
|
272
|
+
if (document.pointerLockElement !== canvas) return;
|
|
273
|
+
this.turn(e.movementX * LOOK_SENSITIVITY_MOUSE, e.movementY * LOOK_SENSITIVITY_MOUSE);
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Phones: left half = move stick, right half = drag to look, FIRE button.
|
|
277
|
+
canvas.addEventListener('touchstart', (e) => {
|
|
278
|
+
for (const t of Array.from(e.changedTouches)) {
|
|
279
|
+
if (t.clientX < innerWidth / 2 && this.stick.id < 0) {
|
|
280
|
+
Object.assign(this.stick, { id: t.identifier, ox: t.clientX, oy: t.clientY, x: 0, y: 0 });
|
|
281
|
+
const s = this.hud.stickEl;
|
|
282
|
+
s.style.left = t.clientX + 'px';
|
|
283
|
+
s.style.top = t.clientY + 'px';
|
|
284
|
+
s.hidden = !this.net;
|
|
285
|
+
} else if (this.look.id < 0) {
|
|
286
|
+
Object.assign(this.look, { id: t.identifier, x: t.clientX, y: t.clientY });
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
}, { passive: false });
|
|
291
|
+
canvas.addEventListener('touchmove', (e) => {
|
|
292
|
+
for (const t of Array.from(e.changedTouches)) {
|
|
293
|
+
if (t.identifier === this.stick.id) {
|
|
294
|
+
let dx = t.clientX - this.stick.ox;
|
|
295
|
+
let dy = t.clientY - this.stick.oy;
|
|
296
|
+
const len = Math.hypot(dx, dy);
|
|
297
|
+
if (len > STICK_RADIUS) {
|
|
298
|
+
dx = (dx / len) * STICK_RADIUS;
|
|
299
|
+
dy = (dy / len) * STICK_RADIUS;
|
|
300
|
+
}
|
|
301
|
+
this.stick.x = dx / STICK_RADIUS;
|
|
302
|
+
this.stick.y = dy / STICK_RADIUS;
|
|
303
|
+
this.hud.stickEl.firstElementChild.style.transform = 'translate(' + dx + 'px, ' + dy + 'px)';
|
|
304
|
+
} else if (t.identifier === this.look.id) {
|
|
305
|
+
this.turn((t.clientX - this.look.x) * LOOK_SENSITIVITY_TOUCH, (t.clientY - this.look.y) * LOOK_SENSITIVITY_TOUCH);
|
|
306
|
+
this.look.x = t.clientX;
|
|
307
|
+
this.look.y = t.clientY;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
}, { passive: false });
|
|
312
|
+
const endTouch = (e) => {
|
|
313
|
+
for (const t of Array.from(e.changedTouches)) {
|
|
314
|
+
if (t.identifier === this.stick.id) {
|
|
315
|
+
Object.assign(this.stick, { id: -1, x: 0, y: 0 });
|
|
316
|
+
this.hud.stickEl.hidden = true;
|
|
317
|
+
this.hud.stickEl.firstElementChild.style.transform = '';
|
|
318
|
+
} else if (t.identifier === this.look.id) this.look.id = -1;
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
canvas.addEventListener('touchend', endTouch);
|
|
322
|
+
canvas.addEventListener('touchcancel', endTouch);
|
|
323
|
+
const fire = this.hud.fire;
|
|
324
|
+
fire.addEventListener('touchstart', (e) => { this.touchFire = true; e.preventDefault(); }, { passive: false });
|
|
325
|
+
fire.addEventListener('touchend', () => (this.touchFire = false));
|
|
326
|
+
fire.addEventListener('touchcancel', () => (this.touchFire = false));
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
turn(dx, dy) {
|
|
330
|
+
this.yaw -= dx;
|
|
331
|
+
this.pitch = Math.max(-1.4, Math.min(1.4, this.pitch - dy));
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
readControls(dt) {
|
|
335
|
+
const k = this.keys;
|
|
336
|
+
this.yaw += ((k.has('KeyQ') ? 1 : 0) - (k.has('KeyE') ? 1 : 0)) * TURN_SPEED_KEYS * dt;
|
|
337
|
+
const kx = (k.has('KeyD') || k.has('ArrowRight') ? 1 : 0) - (k.has('KeyA') || k.has('ArrowLeft') ? 1 : 0);
|
|
338
|
+
const kz = (k.has('KeyW') || k.has('ArrowUp') ? 1 : 0) - (k.has('KeyS') || k.has('ArrowDown') ? 1 : 0);
|
|
339
|
+
return {
|
|
340
|
+
mx: kx || this.stick.x,
|
|
341
|
+
mz: kz || -this.stick.y,
|
|
342
|
+
yaw: this.yaw,
|
|
343
|
+
pitch: this.pitch,
|
|
344
|
+
fire: this.mouseFire || this.touchFire || k.has('Space'),
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// ── loop ─────────────────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
resize() {
|
|
351
|
+
const w = this.container.clientWidth || innerWidth;
|
|
352
|
+
const h = this.container.clientHeight || innerHeight;
|
|
353
|
+
this.renderer.setSize(w, h);
|
|
354
|
+
this.camera.aspect = w / h;
|
|
355
|
+
this.camera.updateProjectionMatrix();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
frame(now) {
|
|
359
|
+
requestAnimationFrame((t) => this.frame(t));
|
|
360
|
+
const dt = Math.min(0.1, (now - this.last) / 1000);
|
|
361
|
+
this.last = now;
|
|
362
|
+
const net = this.net;
|
|
363
|
+
|
|
364
|
+
if (!net || !net.cfg) {
|
|
365
|
+
// Lobby showing: slow fly-over of the arena.
|
|
366
|
+
const t = now / 12000;
|
|
367
|
+
this.camera.position.set(Math.sin(t) * 26, 14, Math.cos(t) * 26);
|
|
368
|
+
this.camera.lookAt(0, 0, 0);
|
|
369
|
+
this.renderer.render(this.scene, this.camera);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
net.controls = this.readControls(dt);
|
|
374
|
+
net.update(dt);
|
|
375
|
+
|
|
376
|
+
const me = net.view;
|
|
377
|
+
this.camera.position.set(me.x, me.alive ? EYE_HEIGHT : 4, me.z);
|
|
378
|
+
this.camera.rotation.set(this.pitch, this.yaw, 0);
|
|
379
|
+
|
|
380
|
+
const seen = new Set();
|
|
381
|
+
for (const p of net.others(now)) {
|
|
382
|
+
seen.add(p.id);
|
|
383
|
+
const a = this.avatarFor(p);
|
|
384
|
+
a.root.visible = p.a;
|
|
385
|
+
a.root.position.set(p.x, 0, p.z);
|
|
386
|
+
a.root.rotation.y = p.y;
|
|
387
|
+
a.itMarker.visible = !!p.it;
|
|
388
|
+
a.itMarker.position.y = 2.75 + Math.sin(now / 200) * 0.1;
|
|
389
|
+
}
|
|
390
|
+
for (const [id, a] of this.avatars) {
|
|
391
|
+
if (!seen.has(id)) {
|
|
392
|
+
this.scene.remove(a.root);
|
|
393
|
+
this.avatars.delete(id);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
for (let i = this.tracers.length - 1; i >= 0; i--) {
|
|
397
|
+
if (now > this.tracers[i].until) {
|
|
398
|
+
this.scene.remove(this.tracers[i].line);
|
|
399
|
+
this.tracers[i].line.geometry.dispose();
|
|
400
|
+
this.tracers.splice(i, 1);
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
this.updateHud(now, me);
|
|
405
|
+
this.renderer.render(this.scene, this.camera);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// ── helpers ──────────────────────────────────────────────────────────────
|
|
410
|
+
|
|
411
|
+
function el(tag, className = '', text = '') {
|
|
412
|
+
const e = document.createElement(tag);
|
|
413
|
+
if (className) e.className = className;
|
|
414
|
+
if (text) e.textContent = text;
|
|
415
|
+
return e;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function colorFor(id) {
|
|
419
|
+
return new THREE.Color().setHSL((Number(id) * 0.618) % 1, 0.65, 0.55);
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function makeLabel(text) {
|
|
423
|
+
const c = document.createElement('canvas');
|
|
424
|
+
c.width = 256;
|
|
425
|
+
c.height = 64;
|
|
426
|
+
const g = c.getContext('2d');
|
|
427
|
+
g.font = 'bold 34px system-ui, sans-serif';
|
|
428
|
+
g.textAlign = 'center';
|
|
429
|
+
g.textBaseline = 'middle';
|
|
430
|
+
g.lineWidth = 6;
|
|
431
|
+
g.strokeStyle = 'rgba(0,0,0,0.7)';
|
|
432
|
+
g.strokeText(text, 128, 32);
|
|
433
|
+
g.fillStyle = '#fff';
|
|
434
|
+
g.fillText(text, 128, 32);
|
|
435
|
+
const s = new THREE.Sprite(new THREE.SpriteMaterial({ map: new THREE.CanvasTexture(c), depthWrite: false }));
|
|
436
|
+
s.scale.set(1.6, 0.4, 1);
|
|
437
|
+
s.position.y = 2.25;
|
|
438
|
+
return s;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function escapeHtml(s) {
|
|
442
|
+
return String(s).replace(/[&<>"']/g, (c) => '&#' + c.charCodeAt(0) + ';');
|
|
443
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"mode": "elimination",
|
|
3
|
+
"minPlayers": 2,
|
|
4
|
+
"maxPlayers": 8,
|
|
5
|
+
"durationSec": 180,
|
|
6
|
+
"countdownSec": 3,
|
|
7
|
+
"moveSpeed": 6,
|
|
8
|
+
"playerRadius": 0.4,
|
|
9
|
+
"maxHp": 100,
|
|
10
|
+
"damage": 25,
|
|
11
|
+
"fireCooldownMs": 300,
|
|
12
|
+
"weaponRange": 40,
|
|
13
|
+
"respawnSec": 3,
|
|
14
|
+
"tagRange": 2,
|
|
15
|
+
"tagCooldownSec": 2,
|
|
16
|
+
"itSpeedMultiplier": 1.1,
|
|
17
|
+
"map": {
|
|
18
|
+
"width": 40,
|
|
19
|
+
"depth": 40,
|
|
20
|
+
"walls": [
|
|
21
|
+
{
|
|
22
|
+
"minX": -2,
|
|
23
|
+
"minZ": -2,
|
|
24
|
+
"maxX": 2,
|
|
25
|
+
"maxZ": 2,
|
|
26
|
+
"height": 3
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"minX": -12,
|
|
30
|
+
"minZ": -12,
|
|
31
|
+
"maxX": -8,
|
|
32
|
+
"maxZ": -10,
|
|
33
|
+
"height": 2
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"minX": 8,
|
|
37
|
+
"minZ": 10,
|
|
38
|
+
"maxX": 12,
|
|
39
|
+
"maxZ": 12,
|
|
40
|
+
"height": 2
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
"minX": -12,
|
|
44
|
+
"minZ": 8,
|
|
45
|
+
"maxX": -10,
|
|
46
|
+
"maxZ": 14,
|
|
47
|
+
"height": 2
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
"minX": 10,
|
|
51
|
+
"minZ": -14,
|
|
52
|
+
"maxX": 12,
|
|
53
|
+
"maxZ": -8,
|
|
54
|
+
"height": 2
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"minX": -1,
|
|
58
|
+
"minZ": -16,
|
|
59
|
+
"maxX": 1,
|
|
60
|
+
"maxZ": -9,
|
|
61
|
+
"height": 2.5
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"minX": -1,
|
|
65
|
+
"minZ": 9,
|
|
66
|
+
"maxX": 1,
|
|
67
|
+
"maxZ": 16,
|
|
68
|
+
"height": 2.5
|
|
69
|
+
}
|
|
70
|
+
],
|
|
71
|
+
"spawns": [
|
|
72
|
+
{
|
|
73
|
+
"x": -16,
|
|
74
|
+
"z": -16,
|
|
75
|
+
"yaw": -2.3562
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"x": 16,
|
|
79
|
+
"z": 16,
|
|
80
|
+
"yaw": 0.7854
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
"x": -16,
|
|
84
|
+
"z": 16,
|
|
85
|
+
"yaw": -0.7854
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
"x": 16,
|
|
89
|
+
"z": -16,
|
|
90
|
+
"yaw": 2.3562
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
"x": 0,
|
|
94
|
+
"z": -18,
|
|
95
|
+
"yaw": 3.1416
|
|
96
|
+
},
|
|
97
|
+
{
|
|
98
|
+
"x": 0,
|
|
99
|
+
"z": 18,
|
|
100
|
+
"yaw": 0
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
"x": -18,
|
|
104
|
+
"z": 0,
|
|
105
|
+
"yaw": -1.5708
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"x": 18,
|
|
109
|
+
"z": 0,
|
|
110
|
+
"yaw": 1.5708
|
|
111
|
+
}
|
|
112
|
+
]
|
|
113
|
+
}
|
|
114
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// The lobby — YOUR game's screen for getting players into a match.
|
|
2
|
+
//
|
|
3
|
+
// This is a starting point, not platform UI: restyle it, add teams or a ready
|
|
4
|
+
// check, show open matches with mp.list(), or replace it entirely. It only
|
|
5
|
+
// calls the platform plumbing on `mp` (see src/myvillage-game.js):
|
|
6
|
+
// mp.quickMatch() join the fullest open match in your village, or host one
|
|
7
|
+
// mp.host({ visibility }) host a match; share match.code with friends
|
|
8
|
+
// mp.join(code) join a friend's match by code
|
|
9
|
+
// and hands the match to `onEnter`, which connects and starts playing.
|
|
10
|
+
|
|
11
|
+
export class Lobby {
|
|
12
|
+
/**
|
|
13
|
+
* @param {HTMLElement} root element to render into
|
|
14
|
+
* @param {object} mp the Multiplayer object from MyVillageGame.multiplayer()
|
|
15
|
+
*/
|
|
16
|
+
constructor(root, mp) {
|
|
17
|
+
this.root = root;
|
|
18
|
+
this.mp = mp;
|
|
19
|
+
/** Called with the match once the player is in one. */
|
|
20
|
+
this.onEnter = null;
|
|
21
|
+
this.render();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
render() {
|
|
25
|
+
const dev = this.mp.mode === 'dev';
|
|
26
|
+
this.root.innerHTML = `
|
|
27
|
+
<div class="card">
|
|
28
|
+
<h1>Arena</h1>
|
|
29
|
+
<p class="who">Playing as <b></b></p>
|
|
30
|
+
<button class="primary" data-action="quick">Quick match</button>
|
|
31
|
+
<button data-action="host">Host a game</button>
|
|
32
|
+
<form class="join">
|
|
33
|
+
<input name="code" placeholder="Friend's code" maxlength="9" autocomplete="off" autocapitalize="characters" />
|
|
34
|
+
<button type="submit">Join</button>
|
|
35
|
+
</form>
|
|
36
|
+
<p class="error" role="alert"></p>
|
|
37
|
+
${dev ? '<p class="hint">Dev mode: open this page in a second tab and press Quick match in both (or host in one, join with its code in the other) to play yourself.</p>' : ''}
|
|
38
|
+
</div>`;
|
|
39
|
+
this.root.querySelector('.who b').textContent = this.mp.me.displayName;
|
|
40
|
+
this.root.querySelector('[data-action="quick"]').addEventListener('click', () => this.go(() => this.mp.quickMatch()));
|
|
41
|
+
this.root.querySelector('[data-action="host"]').addEventListener('click', () => this.go(() => this.mp.host({ visibility: 'private' })));
|
|
42
|
+
this.root.querySelector('form.join').addEventListener('submit', (e) => {
|
|
43
|
+
e.preventDefault();
|
|
44
|
+
const code = new FormData(e.target).get('code');
|
|
45
|
+
if (String(code).trim()) this.go(() => this.mp.join(code));
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async go(action) {
|
|
50
|
+
this.setBusy(true);
|
|
51
|
+
this.showError('');
|
|
52
|
+
try {
|
|
53
|
+
const match = await action();
|
|
54
|
+
await this.onEnter?.(match);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
this.showError(friendlyError(err));
|
|
57
|
+
} finally {
|
|
58
|
+
this.setBusy(false);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
setBusy(busy) {
|
|
63
|
+
for (const b of this.root.querySelectorAll('button, input')) b.disabled = busy;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
showError(message) {
|
|
67
|
+
this.root.querySelector('.error').textContent = message;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
show() {
|
|
71
|
+
this.root.hidden = false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
hide() {
|
|
75
|
+
this.root.hidden = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function friendlyError(err) {
|
|
80
|
+
switch (err && err.code) {
|
|
81
|
+
case 'match_not_found':
|
|
82
|
+
return 'No match with that code.';
|
|
83
|
+
case 'match_full':
|
|
84
|
+
return 'That match is full.';
|
|
85
|
+
case 'match_closed':
|
|
86
|
+
return 'That match has already started.';
|
|
87
|
+
case 'not_in_village':
|
|
88
|
+
return 'You can only play with people from your village.';
|
|
89
|
+
case 'no_village':
|
|
90
|
+
return 'Join a village to play multiplayer — you play with your village.';
|
|
91
|
+
default:
|
|
92
|
+
return (err && err.message) || 'Something went wrong. Try again.';
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Boot: lobby → live match → results → lobby.
|
|
2
|
+
|
|
3
|
+
import { MyVillageGame } from './myvillage-game.js';
|
|
4
|
+
import arena from './arena.json';
|
|
5
|
+
import { ArenaGame } from './arena-game.js';
|
|
6
|
+
import { Lobby } from './lobby.js';
|
|
7
|
+
|
|
8
|
+
// Names this game's dev-mode match rooms (standalone `npm run dev` / Studio).
|
|
9
|
+
const GAME_ID = '{{SLUG}}';
|
|
10
|
+
|
|
11
|
+
async function boot() {
|
|
12
|
+
const game = new ArenaGame(document.getElementById('game-container'), arena);
|
|
13
|
+
const mp = await MyVillageGame.multiplayer({ arena, game: GAME_ID });
|
|
14
|
+
const lobby = new Lobby(document.getElementById('lobby'), mp);
|
|
15
|
+
MyVillageGame.ready();
|
|
16
|
+
|
|
17
|
+
const backToLobby = async () => {
|
|
18
|
+
game.stop();
|
|
19
|
+
await mp.leave().catch(() => {});
|
|
20
|
+
lobby.show();
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
lobby.onEnter = async (match) => {
|
|
24
|
+
let net;
|
|
25
|
+
try {
|
|
26
|
+
net = await mp.connect();
|
|
27
|
+
} catch (err) {
|
|
28
|
+
await mp.leave().catch(() => {});
|
|
29
|
+
throw err;
|
|
30
|
+
}
|
|
31
|
+
lobby.hide();
|
|
32
|
+
// No MyVillageGame.complete() here: in multiplayer the platform records
|
|
33
|
+
// each player's result and MVT from the match server itself, and hosts
|
|
34
|
+
// treat complete() as "the game is over" and close it — players should be
|
|
35
|
+
// able to play again from the results screen.
|
|
36
|
+
game.play(net, {
|
|
37
|
+
code: match.code,
|
|
38
|
+
onLeave: backToLobby,
|
|
39
|
+
onExitResults: backToLobby,
|
|
40
|
+
});
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
lobby.show();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
boot();
|