@homie-rocks/studio 0.2.0 → 0.3.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/README.md +14 -2
- package/bin/homie-studio.mjs +34 -3
- package/lib/build.mjs +83 -21
- package/lib/port-check.mjs +651 -0
- package/lib/port.mjs +277 -0
- package/lib/scaffold.mjs +6 -0
- package/netplay/netplay.ts +93 -8
- package/package.json +11 -2
- package/port/audio.ts +96 -0
- package/port/bots.ts +97 -0
- package/port/camera.ts +75 -0
- package/port/controls.ts +48 -0
- package/port/early.ts +14 -0
- package/port/global.ts +9 -0
- package/port/hud.ts +96 -0
- package/port/index.ts +29 -0
- package/port/keys.ts +102 -0
- package/port/probe.ts +126 -0
- package/port/room.ts +356 -0
- package/port/sandbox.ts +112 -0
- package/port/touch.ts +378 -0
- package/starters/gem-rush/src/main.ts +9 -0
- package/worker/index.mjs +19 -0
- package/worker/pages.mjs +30 -10
- package/worker/qr.mjs +501 -0
package/lib/port.mjs
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `homie-studio port plan <folder>` and `homie-studio port import <folder> --id <id>`:
|
|
3
|
+
* the first two steps of making an existing single-player web game multiplayer.
|
|
4
|
+
*
|
|
5
|
+
* plan reads the game without running it (loop, input, state, camera, physics,
|
|
6
|
+
* storage, audio, network, size) and drafts a port plan: a difficulty
|
|
7
|
+
* grade with its reasons, the netplay movement mode, the build mode, the
|
|
8
|
+
* check view, and every risk the sandboxed play frame will hit. The agent
|
|
9
|
+
* then READS the code and writes the final PORT.md; the draft is a start,
|
|
10
|
+
* never the verdict.
|
|
11
|
+
* import copies the game into games/<id>/ untouched except for two lines (the
|
|
12
|
+
* port toolkit first in <head>, a phone-safe viewport), writes game.json,
|
|
13
|
+
* and keeps the licence beside it.
|
|
14
|
+
*/
|
|
15
|
+
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
16
|
+
import { basename, extname, join, relative, resolve } from 'node:path';
|
|
17
|
+
import { GAME_ID } from './studio.mjs';
|
|
18
|
+
|
|
19
|
+
const SKIP = new Set(['node_modules', '.git', '.wrangler', '.port', '.DS_Store', '.cache', '.parcel-cache', '.vite', 'coverage']);
|
|
20
|
+
const CODE = new Set(['.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.vue', '.svelte']);
|
|
21
|
+
const TEXT = new Set([...CODE, '.html', '.htm', '.css', '.json', '.md', '.txt', '.glsl', '.frag', '.vert', '.tmx', '.xml', '.svg']);
|
|
22
|
+
const MAX_READ = 2 * 1024 * 1024;
|
|
23
|
+
|
|
24
|
+
function walk(dir, rel = '', out = []) {
|
|
25
|
+
for (const e of readdirSync(join(dir, rel), { withFileTypes: true })) {
|
|
26
|
+
if (SKIP.has(e.name)) continue;
|
|
27
|
+
const p = rel ? `${rel}/${e.name}` : e.name;
|
|
28
|
+
if (e.isDirectory()) walk(dir, p, out);
|
|
29
|
+
else if (e.isFile()) out.push({ path: p, bytes: statSync(join(dir, p)).size, ext: extname(e.name).toLowerCase() });
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const count = (text, re) => (text.match(re) ?? []).length;
|
|
35
|
+
|
|
36
|
+
/** A licence file's SPDX-ish kind, from its words. */
|
|
37
|
+
export function licenceOf(dir) {
|
|
38
|
+
const names = readdirSync(dir).filter((n) => /^(licen[cs]e|copying)(\.|$)/i.test(n));
|
|
39
|
+
if (!names.length) {
|
|
40
|
+
const pkg = existsSync(join(dir, 'package.json')) ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) : null;
|
|
41
|
+
return { file: null, kind: pkg?.license ? `${pkg.license} (package.json only)` : 'none', permissive: false };
|
|
42
|
+
}
|
|
43
|
+
const text = readFileSync(join(dir, names[0]), 'utf8');
|
|
44
|
+
let kind = 'unknown';
|
|
45
|
+
if (/Permission is hereby granted, free of charge/i.test(text)) kind = 'MIT';
|
|
46
|
+
else if (/Apache License[\s\S]{0,40}Version 2\.0/i.test(text)) kind = 'Apache-2.0';
|
|
47
|
+
else if (/Redistribution and use in source and binary forms/i.test(text)) kind = /Neither the name/i.test(text) ? 'BSD-3-Clause' : 'BSD-2-Clause';
|
|
48
|
+
else if (/Permission to use, copy, modify, and\/or distribute/i.test(text)) kind = 'ISC';
|
|
49
|
+
else if (/GNU AFFERO GENERAL PUBLIC LICENSE/i.test(text)) kind = 'AGPL';
|
|
50
|
+
else if (/GNU LESSER GENERAL PUBLIC LICENSE/i.test(text)) kind = 'LGPL';
|
|
51
|
+
else if (/GNU GENERAL PUBLIC LICENSE/i.test(text)) kind = 'GPL';
|
|
52
|
+
else if (/Mozilla Public License/i.test(text)) kind = 'MPL-2.0';
|
|
53
|
+
else if (/This is free and unencumbered software released into the public domain/i.test(text)) kind = 'Unlicense';
|
|
54
|
+
else if (/Creative Commons/i.test(text)) kind = 'Creative Commons';
|
|
55
|
+
return { file: names[0], kind, permissive: ['MIT', 'Apache-2.0', 'BSD-3-Clause', 'BSD-2-Clause', 'ISC', 'Unlicense'].includes(kind) };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Read a game folder and draft its port plan. */
|
|
59
|
+
export function planPort(folder) {
|
|
60
|
+
const dir = resolve(folder ?? '.');
|
|
61
|
+
if (!existsSync(dir) || !statSync(dir).isDirectory()) throw new Error(`${dir} is not a folder`);
|
|
62
|
+
const files = walk(dir);
|
|
63
|
+
const texts = [];
|
|
64
|
+
let read = 0;
|
|
65
|
+
for (const f of files) {
|
|
66
|
+
if (!TEXT.has(f.ext) || f.bytes > 1024 * 1024 || read > MAX_READ) continue;
|
|
67
|
+
// Vendored libraries (three.module.js, jquery…) are not the game: counted, not analysed.
|
|
68
|
+
const vendored = /(^|\/)(vendor|libs?|third[-_]?party|build|jsm|addons|node_modules)\//i.test(f.path) || /(^|\/)(GLTFLoader|OBJLoader|FBXLoader|OrbitControls|PointerLockControls|BufferGeometryUtils|Octree|Capsule|OctreeHelper|EffectComposer|RenderPass|UnrealBloomPass)\.js$/.test(f.path) || /(\.min\.js|three(\.module)?\.js|three\.core\.js|jquery[-.\d]*\.js|phaser[.\w-]*\.js|pixi[.\w-]*\.js|matter[.\w-]*\.js|cannon[.\w-]*\.js|howler[.\w-]*\.js|lil-gui[.\w-]*\.js|stats[.\w-]*\.js|typeface\.js)$/i.test(f.path);
|
|
69
|
+
const text = readFileSync(join(dir, f.path), 'utf8');
|
|
70
|
+
read += text.length;
|
|
71
|
+
texts.push({ ...f, text, vendored });
|
|
72
|
+
}
|
|
73
|
+
const game = texts.filter((t) => !t.vendored && (CODE.has(t.ext) || t.ext === '.html' || t.ext === '.htm'));
|
|
74
|
+
const all = game.map((t) => t.text).join('\n');
|
|
75
|
+
const everything = texts.map((t) => t.text).join('\n');
|
|
76
|
+
const loc = game.filter((t) => CODE.has(t.ext)).reduce((n, t) => n + t.text.split('\n').length, 0)
|
|
77
|
+
+ game.filter((t) => t.ext === '.html').reduce((n, t) => n + (t.text.match(/<script(?![^>]*src)[^>]*>[\s\S]*?<\/script>/gi) ?? []).join('\n').split('\n').length, 0);
|
|
78
|
+
const totalBytes = files.reduce((n, f) => n + f.bytes, 0);
|
|
79
|
+
const pkg = existsSync(join(dir, 'package.json')) ? JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) : null;
|
|
80
|
+
const deps = { ...(pkg?.dependencies ?? {}), ...(pkg?.devDependencies ?? {}) };
|
|
81
|
+
const html = files.filter((f) => f.ext === '.html' || f.ext === '.htm').map((f) => f.path);
|
|
82
|
+
const index = html.find((p) => /(^|\/)index\.html?$/i.test(p) && !p.includes('/')) ?? html.find((p) => /index\.html?$/i.test(p)) ?? html[0] ?? null;
|
|
83
|
+
const indexText = index ? readFileSync(join(dir, index), 'utf8') : '';
|
|
84
|
+
const scripts = [...indexText.matchAll(/<script\b([^>]*)>/gi)].map((m) => ({ src: /src=["']([^"']+)["']/i.exec(m[1])?.[1] ?? null, module: /type=["']module["']/i.test(m[1]) }));
|
|
85
|
+
const has = (re) => re.test(all) || re.test(everything);
|
|
86
|
+
|
|
87
|
+
const engine = [];
|
|
88
|
+
if (has(/from ['"]three['"]|THREE\.|three\.module\.js|three\.core\.js/)) engine.push('three.js');
|
|
89
|
+
if (has(/BABYLON\.|@babylonjs/)) engine.push('babylon.js');
|
|
90
|
+
if (has(/new Phaser\.Game|from ['"]phaser['"]/)) engine.push('phaser');
|
|
91
|
+
if (has(/PIXI\.|from ['"]pixi\.js['"]/)) engine.push('pixi.js');
|
|
92
|
+
if (has(/\bkaboom\(|from ['"]kaboom['"]|from ['"]kaplay['"]/)) engine.push('kaboom');
|
|
93
|
+
if (has(/\bengineInit\(|LittleJS/)) engine.push('littlejs');
|
|
94
|
+
if (has(/\bme\.game\b|melonJS/)) engine.push('melonjs');
|
|
95
|
+
if (/function setup\s*\(|function draw\s*\(/.test(all) && /createCanvas\(/.test(all)) engine.push('p5');
|
|
96
|
+
if (!engine.length && /getContext\(\s*['"]2d['"]/.test(all)) engine.push('canvas-2d');
|
|
97
|
+
if (!engine.length && /getContext\(\s*['"](webgl2?|experimental-webgl)['"]/.test(all)) engine.push('webgl');
|
|
98
|
+
if (!engine.length) engine.push('dom');
|
|
99
|
+
const threeD = engine.some((e) => ['three.js', 'babylon.js', 'webgl'].includes(e));
|
|
100
|
+
|
|
101
|
+
const physics = [];
|
|
102
|
+
for (const [re, name] of [[/matter-js|Matter\.Engine/, 'matter.js'], [/cannon(-es)?|CANNON\./, 'cannon'], [/@dimforge\/rapier|RAPIER\./, 'rapier'], [/planck|Box2D|box2d/, 'box2d/planck'], [/ammo\.js|Ammo\(/, 'ammo.js'], [/oimo/i, 'oimo'], [/\bOctree\b.*capsule|Capsule\b/i, 'three octree collisions']]) {
|
|
103
|
+
if (has(re)) physics.push(name);
|
|
104
|
+
}
|
|
105
|
+
const customPhysics = /gravity/i.test(all) && /(dy|vy|velocity|\.vel)\b/i.test(all);
|
|
106
|
+
|
|
107
|
+
const loop = {
|
|
108
|
+
raf: count(all, /requestAnim(ation)?Frame|setAnimationLoop|\bticker\.add|scene\.registerBeforeRender|engine\.runRenderLoop/g),
|
|
109
|
+
interval: count(all, /setInterval\s*\(/g),
|
|
110
|
+
timeoutLoop: count(all, /setTimeout\s*\(/g),
|
|
111
|
+
};
|
|
112
|
+
const input = {
|
|
113
|
+
keys: count(all, /['"]key(down|up|press)['"]|onkey(down|up|press)|\.key(down|up)\s*\(/gi),
|
|
114
|
+
keyCode: count(all, /keyCode|\.which\b/g),
|
|
115
|
+
mouse: count(all, /['"](mouse(down|up|move)|click|dblclick|wheel|contextmenu)['"]|onclick|\.click\s*\(/gi),
|
|
116
|
+
pointer: count(all, /['"]pointer(down|up|move|cancel)['"]/g),
|
|
117
|
+
touch: count(all, /['"]touch(start|move|end|cancel)['"]|ontouch/g),
|
|
118
|
+
pointerLock: count(all, /requestPointerLock/g),
|
|
119
|
+
gamepad: count(all, /getGamepads/g),
|
|
120
|
+
};
|
|
121
|
+
const camera = {
|
|
122
|
+
perspective: count(all, /PerspectiveCamera/g),
|
|
123
|
+
orthographic: count(all, /OrthographicCamera/g),
|
|
124
|
+
followsHeading: /camera\.(rotation\.y|quaternion)\s*[=.]|camera\.lookAt\([^)]*(dir|heading|forward)/i.test(all),
|
|
125
|
+
firstPerson: input.pointerLock > 0 || /first[-\s]?person|PointerLockControls|FirstPersonControls/i.test(all),
|
|
126
|
+
twoDScroll: /(camera|viewport|cam)\.(x|y)\s*[+\-]?=|ctx\.translate\(/.test(all),
|
|
127
|
+
};
|
|
128
|
+
const storage = { localStorage: count(all, /localStorage/g), sessionStorage: count(all, /sessionStorage/g), indexedDB: count(all, /indexedDB/g), cookie: count(all, /document\.cookie/g) };
|
|
129
|
+
const audio = { webAudio: count(all, /AudioContext/g), media: count(all, /new Audio\(|<audio|\.play\(\)/g), howler: count(everything, /Howl\b|howler/g), tone: count(all, /Tone\./g) };
|
|
130
|
+
const network = {
|
|
131
|
+
fetch: count(all, /\bfetch\s*\(/g), xhr: count(all, /XMLHttpRequest|\$\.(get|getJSON|ajax)\(/g), websocket: count(all, /new WebSocket\(/g),
|
|
132
|
+
absolutePaths: [...new Set([...(indexText + all).matchAll(/(?:src|href)=["'](\/[^/"'][^"']*)["']|fetch\(\s*["'](\/[^/"'][^"']*)["']/g)].map((m) => m[1] ?? m[2]))].slice(0, 12),
|
|
133
|
+
cdn: [...new Set([...indexText.matchAll(/(?:src|href)=["'](https?:\/\/[^"']+)["']/g)].map((m) => new URL(m[1]).host))],
|
|
134
|
+
};
|
|
135
|
+
const workers = count(all, /new (Shared)?Worker\(/g);
|
|
136
|
+
const wasm = files.filter((f) => f.ext === '.wasm').length + count(all, /WebAssembly\./g);
|
|
137
|
+
const dialogs = count(all, /\b(alert|confirm|prompt)\s*\(/g);
|
|
138
|
+
const random = count(all, /Math\.random\(/g);
|
|
139
|
+
const multiplayer = /socket\.io|peerjs|new RTCPeerConnection|colyseus|firebase/i.test(everything);
|
|
140
|
+
const homiePackage = existsSync(join(dir, 'homie.json'));
|
|
141
|
+
const houseApi = /@homie\/(tabletop|arcade)|\bhomie\.(ready|report|showPrivate|on\(|seats)|__homie\//.test(all);
|
|
142
|
+
// Real time: a body moved by time (dt, velocity, speed × delta, a game loop); turn-based: moves on input only.
|
|
143
|
+
const realtime = count(all, /\bdt\b|deltaTime|\bdelta\b|velocity|\.vel\b|\.vx\b|\.vy\b|\bvx\b|\bvy\b|speed\s*\*|mainLoop|gameLoop|getDelta\(|update\(\s*(dt|delta|step)/g);
|
|
144
|
+
const boardWords = count(all, /\b(turn|board|grid|tile|cell|move\(\s*(direction|dir)|swipe)s?\b/gi);
|
|
145
|
+
const turnBased = (loop.raf === 0 && loop.interval === 0) || (realtime <= 2 && boardWords >= 3);
|
|
146
|
+
const timing = /\b(rhythm|beat|bpm|frame[-\s]?perfect|metronome)\b/i.test(all);
|
|
147
|
+
const sideView = !threeD && /gravity/i.test(all) && /jump/i.test(all);
|
|
148
|
+
const ammo = /bullet|projectile|missile|shoot|fire/i.test(all);
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------- grade
|
|
151
|
+
const reasons = [];
|
|
152
|
+
let grade = 'easy';
|
|
153
|
+
const up = (to, why) => { const order = ['easy', 'medium', 'hard', 'not a fit']; if (order.indexOf(to) > order.indexOf(grade)) grade = to; reasons.push(`${to}: ${why}`); };
|
|
154
|
+
if (!index) up('not a fit', 'no index.html: a studio game is a web page');
|
|
155
|
+
if (multiplayer) up('not a fit', 'it already has its own networking (socket.io/peer/firebase): port its rules instead, or keep it on its own server');
|
|
156
|
+
if (network.websocket) up('hard', 'it opens its own WebSocket: a server it depends on will not be there');
|
|
157
|
+
if (wasm && /SharedArrayBuffer/.test(all)) up('not a fit', 'WebAssembly threads need cross-origin isolation, which the sandboxed play frame cannot give');
|
|
158
|
+
if (totalBytes > 60 * 1024 * 1024) up('hard', `${Math.round(totalBytes / 1048576)} MB of files: every visitor downloads all of it (keep a game well under 30 MB)`);
|
|
159
|
+
if (timing) up('hard', 'precise timing (rhythm/beat): every screen needs the same clock; run the beat from the room clock (net.now())');
|
|
160
|
+
if (physics.length && !turnBased) up('hard', `fast physics (${physics.join(', ')}): the host simulates the world; each player owns only their own body`);
|
|
161
|
+
if (threeD && !turnBased) up(physics.length ? 'hard' : 'medium', `real-time 3D (${engine.join(', ')}): camera rules, screen-relative input and a stable yaw are needed`);
|
|
162
|
+
if (camera.followsHeading) up('medium', 'the camera turns with the character: it must stop (input on screen axes, yaw only on look input)');
|
|
163
|
+
if (camera.firstPerson) up('medium', 'first person with pointer lock: phones need a look-drag and a stick, and hits need the host');
|
|
164
|
+
if (!turnBased && !threeD) up(customPhysics ? 'medium' : 'easy', customPhysics ? 'real-time 2D with its own gravity/velocity: owner movement keeps jumps local, the host runs everything else' : 'real-time 2D movement: owner movement');
|
|
165
|
+
if (turnBased) reasons.push('easy: turn-based or grid rules: the host runs the rules from intents (host movement); or a race where everyone plays the same seed');
|
|
166
|
+
if (loc > 6000) up('hard', `${loc} lines of game code: huge state; find the few things other players must see`);
|
|
167
|
+
else if (loc > 2500) up('medium', `${loc} lines of game code`);
|
|
168
|
+
if (ammo && !turnBased) up('medium', 'projectiles and hits: the host decides every hit and carries projectiles in the snapshot');
|
|
169
|
+
if (workers) up('medium', 'it starts Web Workers: a sandboxed frame (opaque origin) cannot load a worker script by URL; inline it or drop it');
|
|
170
|
+
if (network.absolutePaths.length) up('medium', `absolute paths (${network.absolutePaths.slice(0, 3).join(', ')}): the game is served under /<id>/__game/, so every path must be relative`);
|
|
171
|
+
if (grade === 'easy' && !reasons.length) reasons.push('easy: a small game with simple state');
|
|
172
|
+
|
|
173
|
+
const movement = turnBased ? 'host' : 'owner';
|
|
174
|
+
const view = turnBased ? 'board' : camera.firstPerson ? 'first-person' : sideView ? 'side' : 'top';
|
|
175
|
+
const buildMode = pkg?.scripts?.build && Object.keys(deps).some((d) => /^(vite|webpack|parcel|rollup|esbuild|@sveltejs|next)/.test(d)) ? 'command'
|
|
176
|
+
: scripts.some((s) => s.module) || indexText.includes('importmap') ? 'static'
|
|
177
|
+
: 'static';
|
|
178
|
+
|
|
179
|
+
const risks = [];
|
|
180
|
+
if (storage.localStorage || storage.sessionStorage || storage.cookie) risks.push('storage: localStorage/cookies throw in the sandboxed frame; the port toolkit (homie-port.js / port/early) puts in-memory stand-ins first. High scores last for the visit.');
|
|
181
|
+
if (storage.indexedDB) risks.push('indexedDB is blocked in the sandboxed frame: guard every open() or drop it.');
|
|
182
|
+
if (dialogs) risks.push(`${dialogs} alert/confirm/prompt call(s): they block every browser in the room and the host's clock; replace with in-game text.`);
|
|
183
|
+
if (audio.webAudio || audio.media || audio.howler) risks.push('audio: starts suspended; the toolkit resumes it on the first touch/key. Remove any "click to start sound" gate that blocks play.');
|
|
184
|
+
if (input.keys && !input.touch && !input.pointer) risks.push('keyboard only: phones need the touch kit (a floating stick that presses the same keys, small buttons for actions).');
|
|
185
|
+
if (input.mouse && !input.touch) risks.push('mouse input: map it to touch (tap/drag) explicitly; do not rely on emulated mouse events on phones.');
|
|
186
|
+
if (input.pointer && /pointerId\s*[<>]=?\s*0|pointerId\s*===?\s*-1|=\s*-1\s*;?\s*\/\/.*(touch|pointer)/.test(all)) risks.push('a negative pointer id is used as "no finger": iOS Safari ids are negative half the time. Use null.');
|
|
187
|
+
if (loop.raf === 0 && loop.interval > 0) risks.push('the loop runs on setInterval: keep the rules on the host, and make rendering follow snapshots.');
|
|
188
|
+
if (random > 0 && !turnBased) risks.push(`${random} Math.random() call(s): randomness that changes the world must happen on the host only (replicas get the result in snapshots or keyed state).`);
|
|
189
|
+
if (network.cdn.length) risks.push(`loads from other hosts (${network.cdn.join(', ')}): copy those files into the game so it cannot break when a CDN does.`);
|
|
190
|
+
if (camera.perspective && !camera.firstPerson) risks.push('3D camera: verify the owner tests (hold 5 s: straight line, yaw < 10°; alternate 10 s: every press goes the pressed way).');
|
|
191
|
+
if (/antialias\s*:\s*true/.test(all) && threeD) risks.push('MSAA (antialias: true) drew a black world on iPhone WebKit in a past port; turn it off on WebKit or everywhere.');
|
|
192
|
+
if (ammo && !turnBased) risks.push('projectiles/hits: the host decides every hit (a shooter\'s claim may be checked against recent positions); projectiles ride the snapshot.');
|
|
193
|
+
|
|
194
|
+
if (houseApi) risks.push('it is written for a shared TV screen with phone pads (a Homie package): on the web every browser is its own screen and there is no pad. Keep its rules, drive them from netplay seats, keys and the touch kit, and give it a page of its own (index.html that boots the rules and draws them).');
|
|
195
|
+
if (houseApi) up('medium', 'written for a shared TV screen and phone pads: the rules are reusable, the seats and the screen are not');
|
|
196
|
+
const licence = licenceOf(dir);
|
|
197
|
+
if (!licence.file && homiePackage) { licence.kind = 'a Homie package (the house\'s own game; no licence file)'; licence.permissive = true; }
|
|
198
|
+
if (!licence.permissive) risks.push(`licence: ${licence.kind}${licence.file ? ` (${licence.file})` : ''} — publish only what its licence allows; with no licence, do not publish it.`);
|
|
199
|
+
|
|
200
|
+
return {
|
|
201
|
+
ok: true, command: 'port plan', folder: dir, grade, reasons,
|
|
202
|
+
facts: {
|
|
203
|
+
files: files.length, bytes: totalBytes, loc, index, scripts: scripts.slice(0, 12), engine, physics, customPhysics, loop, input, camera, storage, audio,
|
|
204
|
+
network, workers, wasm, dialogs, random, realtime, boardWords, turnBased, sideView, houseApi, package: pkg ? { name: pkg.name, scripts: Object.keys(pkg.scripts ?? {}), deps: Object.keys(deps).slice(0, 30) } : null,
|
|
205
|
+
},
|
|
206
|
+
recommend: { movement, view, build: buildMode },
|
|
207
|
+
risks, licence,
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Copy a game into games/<id>/ as this studio's port of it. */
|
|
212
|
+
export function importPort(root, folder, id, { name, mode } = {}) {
|
|
213
|
+
if (!GAME_ID.test(String(id ?? ''))) throw new Error('give the port an id: --id <lowercase-id> (letters, digits, hyphens)');
|
|
214
|
+
const src = resolve(folder ?? '');
|
|
215
|
+
if (!existsSync(src) || !statSync(src).isDirectory()) throw new Error(`${src} is not a folder`);
|
|
216
|
+
const dest = join(root, 'games', id);
|
|
217
|
+
if (existsSync(dest)) throw new Error(`games/${id} already exists; pick another id`);
|
|
218
|
+
const rel = relative(src, dest);
|
|
219
|
+
if (!rel.startsWith('..') && rel !== '') throw new Error('the game folder cannot contain the studio');
|
|
220
|
+
const plan = planPort(src);
|
|
221
|
+
if (!plan.facts.index) throw new Error(`${src} has no index.html; nothing to port`);
|
|
222
|
+
const buildMode = mode ?? plan.recommend.build;
|
|
223
|
+
mkdirSync(dest, { recursive: true });
|
|
224
|
+
const copied = [];
|
|
225
|
+
const copy = (rel2 = '') => {
|
|
226
|
+
for (const e of readdirSync(join(src, rel2), { withFileTypes: true })) {
|
|
227
|
+
if (SKIP.has(e.name) || (buildMode !== 'command' && e.name === 'dist')) continue;
|
|
228
|
+
const p = rel2 ? `${rel2}/${e.name}` : e.name;
|
|
229
|
+
if (e.isDirectory()) { mkdirSync(join(dest, p), { recursive: true }); copy(p); }
|
|
230
|
+
else if (e.isFile()) { cpSync(join(src, p), join(dest, p)); copied.push(p); }
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
copy();
|
|
234
|
+
// A static game needs index.html at its root (the Worker serves games/<id>/index.html).
|
|
235
|
+
if (buildMode === 'static' && plan.facts.index !== 'index.html') throw new Error(`the game's page is ${plan.facts.index}; move it to the folder's root as index.html first`);
|
|
236
|
+
const title = /<title>([^<]{1,80})<\/title>/i.exec(readFileSync(join(src, plan.facts.index), 'utf8'))?.[1]?.trim();
|
|
237
|
+
const meta = {
|
|
238
|
+
id,
|
|
239
|
+
name: String(name ?? title ?? id).slice(0, 60),
|
|
240
|
+
blurb: '',
|
|
241
|
+
players: { min: 1, max: 8 },
|
|
242
|
+
roundSeconds: 120,
|
|
243
|
+
build: buildMode === 'command' ? { mode: 'command', command: 'npm run build', out: 'dist' } : { mode: buildMode },
|
|
244
|
+
netplay: { v: 1, public: true, movement: plan.recommend.movement },
|
|
245
|
+
port: { from: basename(src), licence: plan.licence.kind, licenceFile: plan.licence.file, grade: plan.grade, view: plan.recommend.view, at: new Date().toISOString() },
|
|
246
|
+
};
|
|
247
|
+
writeFileSync(join(dest, 'game.json'), `${JSON.stringify(meta, null, 2)}\n`);
|
|
248
|
+
const edits = [];
|
|
249
|
+
if (buildMode === 'static') {
|
|
250
|
+
const indexPath = join(dest, 'index.html');
|
|
251
|
+
let html = readFileSync(indexPath, 'utf8');
|
|
252
|
+
if (!/homie-port\.js/.test(html)) {
|
|
253
|
+
const tag = '<script src="./homie-port.js"></script>';
|
|
254
|
+
// First script in <head>, after a charset declaration if there is one.
|
|
255
|
+
if (/<meta[^>]+charset[^>]*>/i.test(html)) html = html.replace(/<meta[^>]+charset[^>]*>/i, (m) => `${m}\n${tag}`);
|
|
256
|
+
else html = /<head[^>]*>/i.test(html) ? html.replace(/<head([^>]*)>/i, (m) => `${m}\n${tag}`) : `${tag}\n${html}`;
|
|
257
|
+
edits.push('index.html: loads ./homie-port.js first (sandbox shims, first-touch audio, window.HomiePort)');
|
|
258
|
+
}
|
|
259
|
+
const vp = 'width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover';
|
|
260
|
+
if (/<meta[^>]+name=["']viewport["'][^>]*>/i.test(html)) {
|
|
261
|
+
html = html.replace(/<meta[^>]+name=["']viewport["'][^>]*>/i, `<meta name="viewport" content="${vp}">`);
|
|
262
|
+
edits.push('index.html: phone-safe viewport (no pinch-zoom under a thumb)');
|
|
263
|
+
} else {
|
|
264
|
+
html = /<meta[^>]+charset[^>]*>/i.test(html) ? html.replace(/<meta[^>]+charset[^>]*>/i, (m) => `${m}\n<meta name="viewport" content="${vp}">`) : html.replace(/<head([^>]*)>/i, (m) => `${m}\n<meta name="viewport" content="${vp}">`);
|
|
265
|
+
edits.push('index.html: added a phone-safe viewport');
|
|
266
|
+
}
|
|
267
|
+
writeFileSync(indexPath, html);
|
|
268
|
+
}
|
|
269
|
+
return {
|
|
270
|
+
ok: true, command: 'port import', id, dir: dest, from: src, mode: buildMode, files: copied.length, edits, plan: { grade: plan.grade, movement: plan.recommend.movement, view: plan.recommend.view },
|
|
271
|
+
next: [
|
|
272
|
+
`Write games/${id}/PORT.md: the grade and why, the movement mode, what the host owns, what each browser owns, bots, touch, camera.`,
|
|
273
|
+
buildMode === 'static' ? 'Add the netplay layer as a script after the game\'s own (HomiePort.createRoom …), or an "entry" in game.json bundled to assets/main.js.' : 'Import \'@homie-rocks/studio/port/early\' first, then build the netplay layer with createRoom.',
|
|
274
|
+
`npm run dev (in the background), then: npx --no-install homie-studio port check ${id} --url http://127.0.0.1:8787`,
|
|
275
|
+
],
|
|
276
|
+
};
|
|
277
|
+
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -80,6 +80,10 @@ studio's pinned copy, never a registry lookup of the bare name.
|
|
|
80
80
|
|
|
81
81
|
- \`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"\` — a new game from a
|
|
82
82
|
multiplayer starter (one live public room from its first build, bots fill seats).
|
|
83
|
+
- \`npx --no-install homie-studio port plan <folder>\` — read an existing single-player web game and grade
|
|
84
|
+
how hard making it multiplayer will be; \`port import\` brings it into \`games/\`, \`port check\` runs the
|
|
85
|
+
owner tests (real touch, a late joiner, a killed host, two browsers finishing a round). The Homie
|
|
86
|
+
plugin's \`port\` skill does the whole job.
|
|
83
87
|
- \`npm run build\` — bundle every game into \`site/dist\`.
|
|
84
88
|
- \`npm run dev\` — the whole site locally (pages, the netplay relay in a local
|
|
85
89
|
Durable Object, D1): open the printed address in two browsers and they share a room.
|
|
@@ -153,6 +157,8 @@ site/.wrangler/
|
|
|
153
157
|
.dev.vars
|
|
154
158
|
.env
|
|
155
159
|
*.log
|
|
160
|
+
# Port checks: receipts and screenshots of each run (games/<id>/.port/check-*/).
|
|
161
|
+
games/*/.port/
|
|
156
162
|
# Large media lives in this studio's R2; the manifests beside it are committed.
|
|
157
163
|
music/**/*.wav
|
|
158
164
|
music/**/*.mp3
|
package/netplay/netplay.ts
CHANGED
|
@@ -254,6 +254,17 @@ export interface Netplay<S = unknown, A = unknown, C = unknown> {
|
|
|
254
254
|
readonly spectator: boolean;
|
|
255
255
|
readonly offline: boolean;
|
|
256
256
|
readonly connected: boolean;
|
|
257
|
+
/** Why the client stopped for good (a refusal reconnecting would repeat: 'replaced', 'room-full', ...), else null. */
|
|
258
|
+
readonly closedWhy: string | null;
|
|
259
|
+
/** How long the socket has been down while it reconnects (ms; 0 while connected). */
|
|
260
|
+
readonly downMs: number;
|
|
261
|
+
/**
|
|
262
|
+
* Host: hand the room to another browser — a checkpoint, then the protocol's `yield` (the same road a hidden
|
|
263
|
+
* tab takes). The relay elects the best other candidate, or keeps me host when nobody else can host.
|
|
264
|
+
* Use it when a big screen or a struggling phone should give the room to a seated computer (never in a
|
|
265
|
+
* host's first 30 s, at most once a minute); false when not sent.
|
|
266
|
+
*/
|
|
267
|
+
handOff(): boolean;
|
|
257
268
|
/** Seated replica: true while my browser moves my body; false while the host does (host movement, or taken). */
|
|
258
269
|
readonly owned: boolean;
|
|
259
270
|
readonly id: string | null;
|
|
@@ -476,8 +487,26 @@ export class Roster {
|
|
|
476
487
|
/* --------------------------------------------------------------- the client */
|
|
477
488
|
|
|
478
489
|
const LADDER = [250, 500, 1000, 2000, 4000];
|
|
479
|
-
/**
|
|
480
|
-
|
|
490
|
+
/**
|
|
491
|
+
* Relay errors after which reconnecting would only repeat the refusal. A 'flood' kick is NOT one: a replica's
|
|
492
|
+
* inputs bunch up behind a stall it did not cause (a host migration, a relay or network hiccup that delivers a
|
|
493
|
+
* second of frames at once), and a kick that ended the page's play for good left a phone frozen with no word.
|
|
494
|
+
* After one it comes back (its seat token keeps its body), sending at the slow rate below.
|
|
495
|
+
*/
|
|
496
|
+
const FINAL_ERRORS = new Set(['replaced', 'version', 'room-full', 'too-many']);
|
|
497
|
+
/**
|
|
498
|
+
* Input pacing (NETPLAY.md §5): at most this many `in` frames in any rolling second from this browser, whatever
|
|
499
|
+
* the frame rate or the press rate: two thirds of the relay's cap of 60, so frames that reach it bunched (a
|
|
500
|
+
* stall anywhere between) still fit under the cap.
|
|
501
|
+
*/
|
|
502
|
+
const IN_CAP_PER_S = 32;
|
|
503
|
+
/** Longest a page with a live socket listens for the relay's welcome before it plays alone (ms of time it could listen). */
|
|
504
|
+
const CONNECT_OPEN_MAX_MS = 10_000;
|
|
505
|
+
/** After the relay reports dropped inputs (or kicked this browser for them): the cap for the next while. */
|
|
506
|
+
const IN_SLOW_CAP_PER_S = 15;
|
|
507
|
+
const IN_SLOW_MS = 8000;
|
|
508
|
+
/** A socket with this much still unsent does not queue another input frame (the newest one waits instead). */
|
|
509
|
+
const IN_BACKLOG_BYTES = 2048;
|
|
481
510
|
|
|
482
511
|
function heldList(held: Iterable<string> | Record<string, boolean> | undefined): string[] {
|
|
483
512
|
if (!held) return [];
|
|
@@ -606,6 +635,13 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
606
635
|
let prevHeld = new Set<string>();
|
|
607
636
|
let pendingPresses: Record<string, number> = {};
|
|
608
637
|
let inputTimer: ReturnType<typeof setTimeout> | null = null;
|
|
638
|
+
/** Wall times of the input frames sent in the last second, and until when the slow cap holds. */
|
|
639
|
+
const inSent: number[] = [];
|
|
640
|
+
let inSlowUntil = 0;
|
|
641
|
+
/** Why this client stopped for good (a FINAL_ERRORS refusal), or null while it plays or reconnects. */
|
|
642
|
+
let closedWhy: string | null = null;
|
|
643
|
+
/** Wall time the socket went down (0 while connected): how long a reconnect has taken. */
|
|
644
|
+
let downSince = 0;
|
|
609
645
|
const sentHist: { q: number; a: A; h: string[]; at: number; r: number }[] = [];
|
|
610
646
|
const inputs = new Map<number, InputFrame<A>>();
|
|
611
647
|
const presses = new Map<number, Record<string, number>>();
|
|
@@ -798,12 +834,15 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
798
834
|
}
|
|
799
835
|
case 'error': {
|
|
800
836
|
const code = String(m['code'] ?? '');
|
|
837
|
+
// Inputs over the relay's cap were dropped: send fewer for a while (the newest frame still goes out).
|
|
838
|
+
if ((code === 'rate' && m['of'] === 'in') || code === 'flood') inSlowUntil = wall() + IN_SLOW_MS;
|
|
801
839
|
if (code === 'rate' || code === 'state-full') { warnOnce(`${code}:${String(m['of'] ?? '')}`, m['message']); return; }
|
|
802
840
|
console.warn('[netplay] relay refused:', code, m['message']);
|
|
803
841
|
// The same seat opened in another tab, a full room, a version the relay does not speak: reconnecting
|
|
804
842
|
// would repeat the refusal (or, for 'replaced', make the two tabs evict each other for ever).
|
|
805
843
|
if (FINAL_ERRORS.has(code)) {
|
|
806
844
|
closed = true;
|
|
845
|
+
closedWhy = code;
|
|
807
846
|
post?.({ what: 'closed', why: code });
|
|
808
847
|
}
|
|
809
848
|
return;
|
|
@@ -846,6 +885,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
846
885
|
if (next === 'host' && !snap && !continuing) snap = localSnap();
|
|
847
886
|
offline = false;
|
|
848
887
|
connected = true;
|
|
888
|
+
downSince = 0;
|
|
849
889
|
attempt = 0;
|
|
850
890
|
emit('status', true);
|
|
851
891
|
post?.({ what: 'token', token, seat, room: cfg?.room ?? null });
|
|
@@ -897,10 +937,14 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
897
937
|
if (ages.length > 40) ages.shift();
|
|
898
938
|
const sorted = [...ages].sort((x, y) => x - y);
|
|
899
939
|
ageP90 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.9))] as number;
|
|
940
|
+
// The 95th percentile and 1.4 intervals, not the 90th and 1.2. A phone host's frame
|
|
941
|
+
// quantisation (snapshots at 50/67 ms) plus 30-50 ms relay jitter held the picture on 5-8 % of a desktop
|
|
942
|
+
// replica's frames in the contract e2e (interpolation-not-starved); this buys ~20 ms of delay for it.
|
|
943
|
+
const ageP95 = sorted[Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95))] as number;
|
|
900
944
|
// Render far enough back that the next snapshot is normally here before we need it: its age, plus one
|
|
901
945
|
// interval. Rise up to 6 ms a snapshot (a hold is visible); fall 6% of the excess a snapshot, at least 3 ms
|
|
902
946
|
// (boot-time jank clears in about a second, as a brief time-warp nobody sees).
|
|
903
|
-
const target = Math.max(50, Math.min(400,
|
|
947
|
+
const target = Math.max(50, Math.min(400, ageP95 + iv * 1.4 + 6));
|
|
904
948
|
if (!delaySmooth || ages.length <= 3) delaySmooth = target;
|
|
905
949
|
else if (target > delaySmooth) delaySmooth += Math.min(6, target - delaySmooth);
|
|
906
950
|
else delaySmooth -= Math.min(delaySmooth - target, Math.max(3, (delaySmooth - target) * 0.06));
|
|
@@ -1010,9 +1054,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1010
1054
|
raw({ t: 'ckpt', k: c.k, st: c.st, d: c.d, c: c.c });
|
|
1011
1055
|
}
|
|
1012
1056
|
|
|
1057
|
+
/** How long until one more input frame fits the rolling-second cap (0: now). */
|
|
1058
|
+
function inputBudgetWait(): number {
|
|
1059
|
+
const t = wall();
|
|
1060
|
+
while (inSent.length && (inSent[0] as number) <= t - 1000) inSent.shift();
|
|
1061
|
+
const cap = t < inSlowUntil ? IN_SLOW_CAP_PER_S : IN_CAP_PER_S;
|
|
1062
|
+
if (inSent.length < cap) return 0;
|
|
1063
|
+
return Math.max(1, (inSent[inSent.length - cap] as number) + 1000 - t);
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1013
1066
|
function flushInput(): void {
|
|
1014
1067
|
inputTimer = null;
|
|
1015
1068
|
if (role === 'host' || seat === null || offline || !connected || pendingA === undefined) return;
|
|
1069
|
+
// Never a burst: over the cap, or with the last frames still unsent on the socket, the newest frame waits
|
|
1070
|
+
// (it replaces whatever was pending, so nothing stale is ever flushed after a stall).
|
|
1071
|
+
const budget = inputBudgetWait();
|
|
1072
|
+
const backlog = (ws?.bufferedAmount ?? 0) > IN_BACKLOG_BYTES;
|
|
1073
|
+
if (budget > 0 || backlog) { inputTimer = setTimeout(flushInput, backlog ? Math.max(budget, 30) : budget); return; }
|
|
1074
|
+
inSent.push(wall());
|
|
1016
1075
|
inSeq += 1;
|
|
1017
1076
|
const msg: Record<string, unknown> = { t: 'in', q: inSeq, a: pendingA, h: pendingHeld, r: mine.rs };
|
|
1018
1077
|
if (Object.keys(pendingPresses).length) msg['p'] = pendingPresses;
|
|
@@ -1026,7 +1085,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1026
1085
|
|
|
1027
1086
|
function scheduleInput(urgent: boolean): void {
|
|
1028
1087
|
const since = wall() - lastInputSentAt;
|
|
1029
|
-
const wait = urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since);
|
|
1088
|
+
const wait = Math.max(inputBudgetWait(), urgent ? Math.max(0, 16 - since) : Math.max(0, inputMs - since));
|
|
1030
1089
|
if (wait <= 0) { if (inputTimer) { clearTimeout(inputTimer); inputTimer = null; } flushInput(); return; }
|
|
1031
1090
|
if (inputTimer) return;
|
|
1032
1091
|
inputTimer = setTimeout(flushInput, wait);
|
|
@@ -1071,6 +1130,7 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1071
1130
|
if (why !== 'closed') { try { sock.close(4000, why); } catch { /* gone */ } }
|
|
1072
1131
|
const was = connected;
|
|
1073
1132
|
connected = false;
|
|
1133
|
+
if (was) downSince = wall();
|
|
1074
1134
|
if (was) emit('status', false);
|
|
1075
1135
|
notifyStats(true);
|
|
1076
1136
|
retry();
|
|
@@ -1106,8 +1166,24 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1106
1166
|
} else {
|
|
1107
1167
|
post?.({ what: 'attached', v: NETPLAY_VERSION });
|
|
1108
1168
|
open();
|
|
1109
|
-
// A relay that never answers must not leave a game on a black screen.
|
|
1110
|
-
|
|
1169
|
+
// A relay that never answers must not leave a game on a black screen. But the wait is counted only while
|
|
1170
|
+
// this page is able to listen: a phone compiling shaders under load is blocked for seconds at a time, and
|
|
1171
|
+
// neither its socket's open nor the relay's welcome can be handled meanwhile. Giving up on the wall clock
|
|
1172
|
+
// started a private fight 1 at every loaded boot and swapped it for the room's a moment later. A socket
|
|
1173
|
+
// that is connecting or open gets CONNECT_OPEN_MAX_MS of that listening time; one that failed outright
|
|
1174
|
+
// (ws is null between retries) gets connectTimeoutMs.
|
|
1175
|
+
let listenedMs = 0;
|
|
1176
|
+
let lastCheckAt = wall();
|
|
1177
|
+
const connectCheck = (): void => {
|
|
1178
|
+
if (roleKnown) return;
|
|
1179
|
+
const t = wall();
|
|
1180
|
+
listenedMs += Math.min(t - lastCheckAt, 600);
|
|
1181
|
+
lastCheckAt = t;
|
|
1182
|
+
const live = Boolean(ws && (ws.readyState === 0 || ws.readyState === 1));
|
|
1183
|
+
if (listenedMs < (live ? CONNECT_OPEN_MAX_MS : connectTimeoutMs)) { connectTimer = setTimeout(connectCheck, 250); return; }
|
|
1184
|
+
goOfflineHost('relay-timeout');
|
|
1185
|
+
};
|
|
1186
|
+
connectTimer = setTimeout(connectCheck, 250);
|
|
1111
1187
|
pingTimer = setInterval(ping, 2000);
|
|
1112
1188
|
ckptTimer = setInterval(sendCheckpoint, checkpointMs);
|
|
1113
1189
|
statsTimer = setInterval(() => notifyStats(true), 500);
|
|
@@ -1133,6 +1209,8 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1133
1209
|
get spectator() { return seat === null && !offline; },
|
|
1134
1210
|
get offline() { return offline; },
|
|
1135
1211
|
get connected() { return connected; },
|
|
1212
|
+
get closedWhy() { return closedWhy; },
|
|
1213
|
+
get downMs() { return connected || !downSince ? 0 : wall() - downSince; },
|
|
1136
1214
|
get owned() { return role === 'host' || offline || seat === null ? true : mine.own; },
|
|
1137
1215
|
get id() { return id; },
|
|
1138
1216
|
get name() { return name; },
|
|
@@ -1161,8 +1239,10 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1161
1239
|
const c = ctlWire();
|
|
1162
1240
|
const text = JSON.stringify(c.length ? { t: 'snap', k: tick, st, d, c } : { t: 'snap', k: tick, st, d });
|
|
1163
1241
|
if (!ws || ws.readyState !== 1) return false;
|
|
1164
|
-
// A congested socket drops a snapshot rather than queueing a stale one.
|
|
1165
|
-
|
|
1242
|
+
// A congested socket drops a snapshot rather than queueing a stale one. At 3 KB (about six
|
|
1243
|
+
// snapshots), not 256 KB. A starved network thread on a loaded phone held a second of snapshots and then
|
|
1244
|
+
// flushed them at once; the relay counted 30+ in a second, dropped them, and called the host stalled.
|
|
1245
|
+
if ((ws.bufferedAmount ?? 0) > 3 * 1024) return false;
|
|
1166
1246
|
try { ws.send(text); } catch { return false; }
|
|
1167
1247
|
countBytes(bytesOut, text.length);
|
|
1168
1248
|
lastSnapSentAt = wall();
|
|
@@ -1173,6 +1253,11 @@ export function createNetplay<S = unknown, A = unknown, C = unknown>(opts: Netpl
|
|
|
1173
1253
|
return true;
|
|
1174
1254
|
},
|
|
1175
1255
|
checkpointNow: sendCheckpoint,
|
|
1256
|
+
handOff() {
|
|
1257
|
+
if (role !== 'host' || offline || !connected || peers.size < 2) return false;
|
|
1258
|
+
sendCheckpoint();
|
|
1259
|
+
return raw({ t: 'yield' });
|
|
1260
|
+
},
|
|
1176
1261
|
round(r) {
|
|
1177
1262
|
roundInfo = r;
|
|
1178
1263
|
post?.({ what: 'round', round: r });
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A game studio on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, the site Worker with public rooms (Table/Lobby Durable Objects, D1, R2), deploy, and the homie.rocks directory.",
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "A game studio on its own Cloudflare: scaffold, multiplayer starters on the netplay contract, a port toolkit that makes existing single-player web games multiplayer, the site Worker with public rooms (Table/Lobby Durable Objects, D1, R2), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
7
7
|
"homepage": "https://homie.rocks/studio/",
|
|
@@ -18,6 +18,14 @@
|
|
|
18
18
|
"types": "./netplay/netplay.ts",
|
|
19
19
|
"default": "./netplay/netplay.ts"
|
|
20
20
|
},
|
|
21
|
+
"./port": {
|
|
22
|
+
"types": "./port/index.ts",
|
|
23
|
+
"default": "./port/index.ts"
|
|
24
|
+
},
|
|
25
|
+
"./port/early": {
|
|
26
|
+
"types": "./port/early.ts",
|
|
27
|
+
"default": "./port/early.ts"
|
|
28
|
+
},
|
|
21
29
|
"./worker": "./worker/index.mjs",
|
|
22
30
|
"./package.json": "./package.json"
|
|
23
31
|
},
|
|
@@ -25,6 +33,7 @@
|
|
|
25
33
|
"bin/",
|
|
26
34
|
"lib/",
|
|
27
35
|
"netplay/",
|
|
36
|
+
"port/",
|
|
28
37
|
"worker/",
|
|
29
38
|
"starters/",
|
|
30
39
|
"README.md",
|
package/port/audio.ts
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sound that starts on the first touch, with no "tap for sound" screen.
|
|
3
|
+
*
|
|
4
|
+
* Browsers start every AudioContext suspended and refuse media.play() until a
|
|
5
|
+
* person has touched the page. A single-player game usually handled that with
|
|
6
|
+
* a title screen; a multiplayer port drops the player straight into a live
|
|
7
|
+
* round, so the unlock has to ride the first real input instead:
|
|
8
|
+
*
|
|
9
|
+
* - every AudioContext the game makes is remembered (the constructor is
|
|
10
|
+
* wrapped, so install this BEFORE the game's code runs);
|
|
11
|
+
* - on pointerdown / pointerup / touchstart / touchend / keydown / click
|
|
12
|
+
* (capture phase, so a game that stops propagation still unlocks), every
|
|
13
|
+
* suspended context is resumed inside the gesture and plays one silent
|
|
14
|
+
* sample (iOS needs a sound started inside the gesture);
|
|
15
|
+
* - a media element whose play() was refused is played again on that gesture;
|
|
16
|
+
* - iOS's silent switch mutes Web Audio unless the audio session says
|
|
17
|
+
* "playback".
|
|
18
|
+
* A held stick only counts as a gesture when the finger lifts (browsers grant
|
|
19
|
+
* activation on touchend), so the first tap or the first release is when sound
|
|
20
|
+
* begins. Say so nowhere: it just works.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
type Ctx = AudioContext;
|
|
24
|
+
const contexts = new Set<Ctx>();
|
|
25
|
+
const refused = new Set<HTMLMediaElement>();
|
|
26
|
+
let installed = false;
|
|
27
|
+
let unlockedAt = 0;
|
|
28
|
+
let gestures = 0;
|
|
29
|
+
|
|
30
|
+
function silentTick(ctx: Ctx): void {
|
|
31
|
+
try {
|
|
32
|
+
const b = ctx.createBuffer(1, 1, 22050);
|
|
33
|
+
const s = ctx.createBufferSource();
|
|
34
|
+
s.buffer = b;
|
|
35
|
+
s.connect(ctx.destination);
|
|
36
|
+
s.start(0);
|
|
37
|
+
} catch { /* a closed context */ }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function onGesture(): void {
|
|
41
|
+
gestures += 1;
|
|
42
|
+
for (const ctx of contexts) {
|
|
43
|
+
if (ctx.state === 'closed') { contexts.delete(ctx); continue; }
|
|
44
|
+
if (ctx.state !== 'running') { void ctx.resume().catch(() => {}); silentTick(ctx); }
|
|
45
|
+
}
|
|
46
|
+
for (const el of [...refused]) {
|
|
47
|
+
refused.delete(el);
|
|
48
|
+
const p = realPlay?.call(el);
|
|
49
|
+
if (p && typeof p.catch === 'function') p.catch(() => { refused.add(el); });
|
|
50
|
+
}
|
|
51
|
+
if (!unlockedAt && [...contexts].some((c) => c.state === 'running')) unlockedAt = performance.now();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let realPlay: (() => Promise<void>) | null = null;
|
|
55
|
+
|
|
56
|
+
/** Wrap AudioContext and media play() so the first gesture unlocks everything. Idempotent. */
|
|
57
|
+
export function installAudioUnlock(): void {
|
|
58
|
+
if (installed || typeof window === 'undefined') return;
|
|
59
|
+
installed = true;
|
|
60
|
+
const w = window as unknown as Record<string, unknown>;
|
|
61
|
+
for (const name of ['AudioContext', 'webkitAudioContext']) {
|
|
62
|
+
const Real = w[name] as (new (...a: unknown[]) => Ctx) | undefined;
|
|
63
|
+
if (typeof Real !== 'function') continue;
|
|
64
|
+
// A subclass keeps instanceof, static members and every method; it only remembers the instance.
|
|
65
|
+
const Tracked = class extends (Real as new (...a: unknown[]) => Ctx) {
|
|
66
|
+
constructor(...args: unknown[]) { super(...args); contexts.add(this as unknown as Ctx); }
|
|
67
|
+
};
|
|
68
|
+
try { Object.defineProperty(window, name, { value: Tracked, configurable: true, writable: true }); } catch { /* keep the real one */ }
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const session = (navigator as unknown as { audioSession?: { type: string } }).audioSession;
|
|
72
|
+
if (session) session.type = 'playback';
|
|
73
|
+
} catch { /* not iOS */ }
|
|
74
|
+
if (typeof HTMLMediaElement !== 'undefined') {
|
|
75
|
+
realPlay = HTMLMediaElement.prototype.play;
|
|
76
|
+
const play = realPlay;
|
|
77
|
+
HTMLMediaElement.prototype.play = function patchedPlay(this: HTMLMediaElement): Promise<void> {
|
|
78
|
+
const p = play.call(this);
|
|
79
|
+
if (!p || typeof p.catch !== 'function') return p;
|
|
80
|
+
// Refused for want of a gesture: it plays on the first one. Any other failure is the game's to see.
|
|
81
|
+
return p.catch((e: unknown) => {
|
|
82
|
+
if (e && (e as { name?: string }).name === 'NotAllowedError') { refused.add(this); return undefined; }
|
|
83
|
+
throw e;
|
|
84
|
+
}) as Promise<void>;
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
for (const ev of ['pointerdown', 'pointerup', 'touchstart', 'touchend', 'mousedown', 'keydown', 'click']) {
|
|
88
|
+
window.addEventListener(ev, onGesture, { capture: true, passive: true });
|
|
89
|
+
}
|
|
90
|
+
document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible') for (const c of contexts) if (c.state === 'suspended' && gestures) void c.resume().catch(() => {}); });
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** What the check harness reads: each context's state, and when the first one started running. */
|
|
94
|
+
export function audioReport(): { contexts: string[]; refusedMedia: number; gestures: number; unlockedAtMs: number | null } {
|
|
95
|
+
return { contexts: [...contexts].map((c) => c.state), refusedMedia: refused.size, gestures, unlockedAtMs: unlockedAt || null };
|
|
96
|
+
}
|