@homie-rocks/studio 0.58.6 → 0.58.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/guides/game/RULES.md +3 -0
- package/guides/game/SKILL.md +4 -4
- package/lib/mcp-tools.mjs +1 -1
- package/lib/port-check.mjs +2 -2
- package/lib/template-history.json +84 -0
- package/package.json +1 -1
- package/starters/block-drop/README.md +14 -0
- package/starters/block-drop/game.json +7 -0
- package/starters/block-drop/index.html +4 -0
- package/starters/block-drop/map/main.json +1 -0
- package/starters/block-drop/src/move.ts +3 -0
- package/starters/block-drop/src/pieces.ts +19 -0
- package/starters/block-drop/src/rules.ts +114 -0
- package/starters/block-drop/src/view.ts +153 -0
- package/starters/block-drop/tunables.json +1 -0
- package/worker/build-id.mjs +1 -1
- package/worker/index.mjs +3 -1
- package/worker/version.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -17,6 +17,41 @@ To bring a studio up to date, tell Claude: "Upgrade my studio to the newest Homi
|
|
|
17
17
|
`npx -y @homie-rocks/studio@latest upgrade`, which shows what's new since the version your studio pins (from this
|
|
18
18
|
file) and what the upgrade would change, and changes nothing until you agree.
|
|
19
19
|
|
|
20
|
+
## [0.58.8] - 2026-10-19
|
|
21
|
+
|
|
22
|
+
**Plugin 0.58.3**
|
|
23
|
+
|
|
24
|
+
Play falling-block games with visible standings, round winners and controls that follow a sliding thumb.
|
|
25
|
+
|
|
26
|
+
### Fixed
|
|
27
|
+
|
|
28
|
+
- Block Drop shows a live leaderboard on phones and ranked round results, keeps its controls above the room chip, enlarges the landscape well, and follows a thumb sliding between directional buttons. Its synthesized audio is more audible, and watching a falling piece no longer pauses the round after 60 seconds without input.
|
|
29
|
+
- Concept previews label the outer page once, without injecting a second banner over embedded game and app controls. No-index headers remain on every response.
|
|
30
|
+
|
|
31
|
+
## [0.58.7] - 2026-10-19
|
|
32
|
+
|
|
33
|
+
**Plugin 0.58.3**
|
|
34
|
+
|
|
35
|
+
A falling-block starter gives board-game requests a working mechanic to build on.
|
|
36
|
+
|
|
37
|
+
### Added
|
|
38
|
+
|
|
39
|
+
- `block-drop`: separate 10×20 wells, seven-piece bags, rotation and wall kicks, ghost pieces, line clears, keyboard and touch controls, procedural audio, bots and two-minute server rounds. A newcomer taking a bot seat gets a fresh well.
|
|
40
|
+
- Regressions for independent player input, hard drops, line clearing, save/restore and bot-seat takeover.
|
|
41
|
+
|
|
42
|
+
### Changed
|
|
43
|
+
|
|
44
|
+
- Game guides and starter discovery point falling-block requests to Block Drop. They explain stationary anchors, player `seat`, room events and exact-location compiler repairs.
|
|
45
|
+
- Editing guidance distinguishes a complete-file write from a targeted repair and explains recovery after an interrupted write.
|
|
46
|
+
|
|
47
|
+
### Fixed
|
|
48
|
+
|
|
49
|
+
- The late-join owner test retains its seat, round, roster, replication and clock checks for stationary games, but marks body steering N/A instead of falsely failing it. Actual board controls still need a directed play trial.
|
|
50
|
+
|
|
51
|
+
### Upgrade notes
|
|
52
|
+
|
|
53
|
+
- Existing games are unchanged. New falling-block games can use `game new <id> --from block-drop`.
|
|
54
|
+
|
|
20
55
|
## [0.58.6] - 2026-10-18
|
|
21
56
|
|
|
22
57
|
**Plugin 0.58.2**
|
package/guides/game/RULES.md
CHANGED
|
@@ -120,6 +120,7 @@ length before taking an entry or using modulo.
|
|
|
120
120
|
A player's kind currently requires a body and a named move handler even when the
|
|
121
121
|
interface is a board, quiz or poll. Use `body: { shape: 'circle', radius: 0.1,
|
|
122
122
|
maxSpeed: 0 }` and `defineMove({ participant() {} })` for a `participant` kind.
|
|
123
|
+
The `block-drop` starter is a complete falling-block example: one stationary player anchor owns one 10×20 well, bots use their own input, and the view identifies players by `seat` (not `slot`). Its `pieces.ts` shares immutable geometry between rules and view. Read it before inventing a board-game API.
|
|
123
124
|
The anchor need not be drawn. Use commands for choices; validate whose turn it is,
|
|
124
125
|
then send an event to a room handler to update shared state. Do not fabricate movement
|
|
125
126
|
or a score to satisfy a test.
|
|
@@ -277,6 +278,8 @@ Do not add a role, policy, owner or guide field to client input as a source of a
|
|
|
277
278
|
|
|
278
279
|
## Handler slots
|
|
279
280
|
|
|
281
|
+
There is no `room.tick` hook. For periodic room work, schedule a declared room event with `world.after`; an entity can ask the room to act with `world.sendRoom`. Only the room handler calls `world.round.end()`.
|
|
282
|
+
|
|
280
283
|
| Slot | Arguments and purpose |
|
|
281
284
|
| --- | --- |
|
|
282
285
|
| `room.start` | `(world)` once when a match starts; initialize shared state or spawn objects |
|
package/guides/game/SKILL.md
CHANGED
|
@@ -65,7 +65,7 @@ Cloudflare or physical-phone capacity guarantees.
|
|
|
65
65
|
- **New game:** call the Homie MCP tool `game_make` (id, name) for the exact command
|
|
66
66
|
and rules (without the connector, go on: the command is here and the rules are this skill's), then run
|
|
67
67
|
`npx --no-install homie-studio game new <id> --from gem-rush --name "<Name>"`
|
|
68
|
-
(`--from gem-rush-3d` for a 3D game: three.js with free library models; `--from hero-rush-3d` for a 3D game with
|
|
68
|
+
(`--from block-drop` for a falling-block puzzle; `--from gem-rush-3d` for a 3D game: three.js with free library models; `--from hero-rush-3d` for a 3D game with
|
|
69
69
|
animated characters that run, jump and swing). The id becomes the game's
|
|
70
70
|
address (`/<id>/`); lowercase, digits, hyphens.
|
|
71
71
|
- **Make an existing single-player web game multiplayer:** use the `port` skill (it
|
|
@@ -89,16 +89,16 @@ Prefer one indexed map revision for a generated maze; keep independently moving
|
|
|
89
89
|
doors and platforms as live colliders. Generation and navigation baking spend the
|
|
90
90
|
room handler's budget.
|
|
91
91
|
|
|
92
|
-
**Read [RULES.md](RULES.md) before writing code.** All
|
|
92
|
+
**Read [RULES.md](RULES.md) before writing code.** All starters use this contract.
|
|
93
93
|
Work in few, large edits: read each source file once, decide its changes, then
|
|
94
94
|
make every change it needs in ONE file_edit with an edits list. Build after each
|
|
95
|
-
coherent change across rules, movement and view
|
|
95
|
+
coherent change across rules, movement and view. When a diagnostic names a file and line, read that exact location and repair the expression it names before unrelated cleanup. Rebuild to verify that error is gone. `room.me.seat` identifies a player; there is no `room.me.slot`. Prefer targeted edits for repairs. A write replaces the whole file: never send only its tail or resume a truncated file with a replacement containing the remaining lines.
|
|
96
96
|
|
|
97
97
|
New games use `game.json` `"entry": "src/view.ts"`, `"room": { "host": "server" }`,
|
|
98
98
|
and a studio-chosen `players.max`. HTML loads `<script type="module"
|
|
99
99
|
src="./assets/main.js"></script>`, not the source-file URL. Choose seats, tick rate (20 by default), dimensions and
|
|
100
100
|
starter yourself from the request; never ask the person an engineering question.
|
|
101
|
-
`coin-dash` is the smallest reference; `gem-rush` adds bumps; `ember-vale` adds
|
|
101
|
+
`block-drop` is the falling-block/board-game reference: separate stationary wells, seven-piece bags, rotation, line clears, real keyboard and touch controls. Use it for Tetris-like requests and retain its working mechanic while changing the requested look or features. `coin-dash` is the smallest movement reference; `gem-rush` adds bumps; `ember-vale` adds
|
|
102
102
|
companions, decisions and character saves; the two 3D starters add models and jumps.
|
|
103
103
|
A starter is a starting point: implement the requested mechanic, not a renamed gem collector.
|
|
104
104
|
The build refuses a game with neither rules plus view nor a browser-hosted netplay contract;
|
package/lib/mcp-tools.mjs
CHANGED
|
@@ -936,7 +936,7 @@ export function toolDefs(ctx, avail = {}) {
|
|
|
936
936
|
},
|
|
937
937
|
{
|
|
938
938
|
name: 'game_make', title: 'Make a multiplayer game',
|
|
939
|
-
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: coin-dash (the smallest rules-plus-view example), gem-rush (an arena: the server runs rules, browsers predict movement and render, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
|
|
939
|
+
description: 'Make a game in the studio from a multiplayer starter, under the id you choose, to change into the person\'s game: block-drop (falling-block puzzles such as Tetris: separate stationary wells, seven-piece bags, line clears, keyboard and touch controls), coin-dash (the smallest rules-plus-view example), gem-rush (an arena: the server runs rules, browsers predict movement and render, bots fill empty seats, rounds restart), gem-rush-3d (the same arena in three.js, dressed with free CC0 models through @homie-rocks/studio/assets: the start for a 3D game), hero-rush-3d (the arena with animated CC0 heroes that run, jump and swing through @homie-rocks/studio/animate, a shared clip library per skeleton, a jump and a swing tuned in the Game Lab: the start for a 3D game with characters) or ember-vale (a hero who lasts for days, with cloud saves: for persistent games). In a new studio only once their game is planned (game_plan), or when they ask for a copy of a working starter; never as a first step. A planned game\'s codex stays. Before writing one of its systems from scratch, look for a piece of an existing game that does it (parts_find; part_add brings it in), and for the general mechanism in the @homie-rocks/* packages.',
|
|
940
940
|
inputSchema: { type: 'object', properties: { id: str('The game\'s id (lowercase, digits, hyphens): its address'), name: str('The game\'s display name'), from: str('Starter id (default gem-rush)'), ...STUDIO_ARG }, required: ['id', 'name'] },
|
|
941
941
|
annotations: { title: 'Make a game', ...RW },
|
|
942
942
|
run: async (a) => {
|
package/lib/port-check.mjs
CHANGED
|
@@ -717,7 +717,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
717
717
|
const clockSkew = ic?.round && ib3?.round ? Math.abs(ic.round.leftMs - ib3.round.leftMs) : null;
|
|
718
718
|
// The joiner must steer its body at once, not only hold a seat: a short press moves it.
|
|
719
719
|
let steers = null;
|
|
720
|
-
if (ic?.v !== 'board' && oc.ok) {
|
|
720
|
+
if (ic?.v !== 'board' && ic?.movement !== 'stationary' && oc.ok) {
|
|
721
721
|
await focusGame(C);
|
|
722
722
|
const keysC = ic?.keys ?? { right: 'ArrowRight', left: 'ArrowLeft' };
|
|
723
723
|
const r0 = (await rowsSince(C, 0)).filter(finite).pop();
|
|
@@ -729,7 +729,7 @@ export async function portCheck({ url, game, root, only = null, shots = null, lo
|
|
|
729
729
|
}
|
|
730
730
|
await shot(C, 'late-joiner');
|
|
731
731
|
const ok = oc.ok && ic?.seat !== null && ic?.round && ib3?.round && ic.round.n === ib3.round.n && humans >= 2 && tookBot && snapIn > 5 && (clockSkew === null || clockSkew < 2500) && steers !== false;
|
|
732
|
-
row('late-join', Boolean(ok), { seatedMs: oc.ms, sinceJoinMs: Date.now() - joinAt, role: ic?.role, seat: ic?.seat, round: ic?.round, hostRound: ib3?.round, humans, bodies: slots.length, bodiesBefore: before.length, tookBot, steers, snapHzIn: snapIn, clockSkewMs: clockSkew,
|
|
732
|
+
row('late-join', Boolean(ok), { seatedMs: oc.ms, sinceJoinMs: Date.now() - joinAt, role: ic?.role, seat: ic?.seat, round: ic?.round, hostRound: ib3?.round, humans, bodies: slots.length, bodiesBefore: before.length, tookBot, steers, ...(ic?.movement === 'stationary' ? { steeringApplicable: false, steeringNote: 'Stationary player anchors: movement is N/A. Late-join admission and replication are checked here; exercise the actual board/turn controls separately.' } : {}), snapHzIn: snapIn, clockSkewMs: clockSkew,
|
|
733
733
|
why: !oc.ok ? oc.why : ic?.round?.n !== ib3?.round?.n ? 'the late joiner is in a different round' : humans < 2 ? 'the late joiner is not one of the people in the roster' : !tookBot ? 'the late joiner added a body instead of taking a bot\'s place' : steers === false ? 'the late joiner has a seat but its keys do not move its body' : snapIn <= 5 ? 'no world is flowing to the late joiner' : clockSkew !== null && clockSkew >= 2500 ? `the late joiner's clock is ${clockSkew} ms off the host's` : undefined });
|
|
734
734
|
collect(C); await close(C);
|
|
735
735
|
}
|
|
@@ -4428,6 +4428,90 @@
|
|
|
4428
4428
|
}
|
|
4429
4429
|
}
|
|
4430
4430
|
}
|
|
4431
|
+
},
|
|
4432
|
+
"0.58.7": {
|
|
4433
|
+
"files": {
|
|
4434
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4435
|
+
"README.md": "684b3cca8001673c",
|
|
4436
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4437
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4438
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4439
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4440
|
+
"posts/README.md": "37841aae476bd540",
|
|
4441
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4442
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4443
|
+
},
|
|
4444
|
+
"sections": {
|
|
4445
|
+
"AGENTS.md": {
|
|
4446
|
+
"(intro)": "af7f2f732505d379",
|
|
4447
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4448
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4449
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4450
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4451
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4452
|
+
"## Making games": "bb715272fd1a615b",
|
|
4453
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4454
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4455
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4456
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4457
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4458
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4459
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4460
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4461
|
+
"## Beta": "9d02638207a26e73"
|
|
4462
|
+
}
|
|
4463
|
+
},
|
|
4464
|
+
"named": {
|
|
4465
|
+
"Homie Arcade": {
|
|
4466
|
+
"sections": {
|
|
4467
|
+
"AGENTS.md": {
|
|
4468
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4469
|
+
}
|
|
4470
|
+
}
|
|
4471
|
+
}
|
|
4472
|
+
}
|
|
4473
|
+
},
|
|
4474
|
+
"0.58.8": {
|
|
4475
|
+
"files": {
|
|
4476
|
+
"CLAUDE.md": "ddf2b2a0376202f6",
|
|
4477
|
+
"README.md": "684b3cca8001673c",
|
|
4478
|
+
"HANDOFF.md": "29337e2ace95b2e7",
|
|
4479
|
+
"games/README.md": "5ad32fe80c61f117",
|
|
4480
|
+
"music/README.md": "a3dc042dadd39df8",
|
|
4481
|
+
"videos/README.md": "16babc3437d5545b",
|
|
4482
|
+
"posts/README.md": "37841aae476bd540",
|
|
4483
|
+
"site/README.md": "1dbdf22c03a1e505",
|
|
4484
|
+
"site/src/worker.mjs": "6cc5c6131eaf6fd0"
|
|
4485
|
+
},
|
|
4486
|
+
"sections": {
|
|
4487
|
+
"AGENTS.md": {
|
|
4488
|
+
"(intro)": "af7f2f732505d379",
|
|
4489
|
+
"## Version-matched authoring guides": "f6261a0e5801b96b",
|
|
4490
|
+
"## Layout": "f3a280b8c96cd068",
|
|
4491
|
+
"## Apps: one screen, roles and parts": "c10aa0441ea89702",
|
|
4492
|
+
"## Commands (all through the pinned CLI in node_modules)": "c20d90de21cde0c7",
|
|
4493
|
+
"## The site": "0cb8dd9a6f8a0096",
|
|
4494
|
+
"## Making games": "bb715272fd1a615b",
|
|
4495
|
+
"## The Game Codex and progress": "0738ef719cef3a4c",
|
|
4496
|
+
"## Art direction and models": "a48fff7bf0a5f2fc",
|
|
4497
|
+
"## Running live games (the back office)": "51ebf2ab756f6e4d",
|
|
4498
|
+
"## Servers and AI seats": "1ccfd549d2369a60",
|
|
4499
|
+
"## Selling things (the shop)": "0df5a5fe1833c624",
|
|
4500
|
+
"## Continuing a build from the Claude app": "0d68081e924ca419",
|
|
4501
|
+
"## Rules": "b2234d80f6cbe71d",
|
|
4502
|
+
"## Telling Homie": "80b46fdd9e578db8",
|
|
4503
|
+
"## Beta": "9d02638207a26e73"
|
|
4504
|
+
}
|
|
4505
|
+
},
|
|
4506
|
+
"named": {
|
|
4507
|
+
"Homie Arcade": {
|
|
4508
|
+
"sections": {
|
|
4509
|
+
"AGENTS.md": {
|
|
4510
|
+
"## Commands (all through the pinned CLI in node_modules)": "8364e05cebefcba4"
|
|
4511
|
+
}
|
|
4512
|
+
}
|
|
4513
|
+
}
|
|
4514
|
+
}
|
|
4431
4515
|
}
|
|
4432
4516
|
}
|
|
4433
4517
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@homie-rocks/studio",
|
|
3
|
-
"version": "0.58.
|
|
3
|
+
"version": "0.58.8",
|
|
4
4
|
"description": "A studio for games, music and video 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 and D1, all on the free Workers plan, no payment method), a site with the hub's sections (Home, Games, Music, Videos, Rooms, Posts with feeds) in the studio's own look, an epic landing page for every game, big songs and videos in the studio's own R2 once it has storage (uploaded, checked by SHA-256, served at the same addresses), deploy, and the homie.rocks directory.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# Block Drop
|
|
2
|
+
|
|
3
|
+
Start here for a falling-block puzzle. It already plays: preserve the working mechanic while adding the requested theme or features.
|
|
4
|
+
|
|
5
|
+
- `rules.ts`: each stationary player owns a 10×20 well. Immutable lists are copied, changed and assigned. Seven-piece bags use the room's saved random source. People and bots share one legal input path, but never one well. Room events alone end rounds.
|
|
6
|
+
- `pieces.ts`: shared immutable piece geometry and collision checks. The view's ghost uses the same cells as the rules.
|
|
7
|
+
- `move.ts`: an intentionally empty movement handler with maxSpeed 0. Movement probes are N/A; real keyboard/touch tests prove board controls.
|
|
8
|
+
- `view.ts`: player identity comes from `room.me.seat` and `room.roster`. The full well fits above the native touch buttons. Effects unlock audio on real input. No score is decided in the view.
|
|
9
|
+
|
|
10
|
+
Build and repair the exact diagnostic before adding more. A file write replaces its entire contents; use targeted edits for small repairs. After an interrupted edit, read the file before continuing.
|
|
11
|
+
|
|
12
|
+
Exercise left/right, rotation, soft and hard drop on keyboard and with real touchscreen taps. Play two independent wells, clear a row, top out, finish/restart a round and join a bot's seat. Inspect computer, phone and sideways-phone frames. A green movement probe or compiler alone does not prove a board game plays.
|
|
13
|
+
|
|
14
|
+
The room uses `rest: "quiet"`: gravity and the round clock keep running while a connected person watches. Do not use the movement starters' away-from-keyboard `idle` pause for this continuous falling-block game. Empty rooms still stop normally.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"id":"block-drop","name":"Block Drop","blurb":"Seven shapes. One well. Stack, rotate and clear lines before the clock runs out.",
|
|
3
|
+
"players":{"min":1,"max":4},"entry":"src/view.ts","room":{"host":"server","rest":"quiet"},
|
|
4
|
+
"playtest":{"primary":{"label":"hard drop","computer":{"key":"Space"},"phone":{"selector":"[data-action=drop]"}}},
|
|
5
|
+
"screen":{"share":{"desk":"top-right","phone":"top-right"},"chat":{"lines":"sheet-only"}},
|
|
6
|
+
"landing":{"pitch":"Find the gap. Make the line.","about":"A falling-block puzzle with a separate well for every player. Seven-piece bags, ghost pieces, wall kicks and line-clear scoring. Play with a friend or a bot in two-minute rounds.","howToPlay":["Move and rotate each falling shape into a gap.","Fill a horizontal row to clear it. Clear four at once for the biggest score.","Use the ghost outline to aim a hard drop. Keep your stack below the top."],"controls":{"phone":"Tap the arrows to move, ↻ to rotate and DROP to place the piece.","computer":"Left/right to move, up to rotate, down for soft drop and space for hard drop.","tv":"Watch every well; each player uses their own phone."},"credits":[{"role":"Starter","name":"Homie · Block Drop"}]}
|
|
7
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover"><title>Block Drop</title><link rel="icon" href="data:,"><style>
|
|
3
|
+
*{box-sizing:border-box}html,body{margin:0;height:100%;background:#060b16;overflow:hidden;overscroll-behavior:none;font-family:system-ui;color:#edf5ff}canvas{position:fixed;inset:0;width:100%;height:100%;touch-action:none}#controls{position:fixed;bottom:max(44px,env(safe-area-inset-bottom));left:50%;transform:translateX(-50%);display:flex;gap:7px}button{width:55px;height:49px;border:1px solid #92b6e34d;border-radius:12px;background:#172741;color:#eaf4ff;font:600 19px system-ui;touch-action:none;user-select:none;-webkit-user-select:none;box-shadow:0 4px 0 #030713}button[data-action=drop]{width:70px;font-size:12px;background:#185950;border-color:#68e9cc66}button.pressed,button:active{transform:translateY(3px);background:#398b89;box-shadow:none}button[data-action=audio]{width:38px;font-size:16px}button:focus-visible{outline:2px solid #fff;outline-offset:3px}#hint{position:fixed;bottom:102px;left:0;right:0;text-align:center;font:10px system-ui;color:#a3b5ce;pointer-events:none}@media(max-width:600px){#hint{display:none}}@media(max-height:460px) and (min-width:600px){button{width:48px;height:44px}button[data-action=drop]{width:58px}#controls{left:14px;top:100px;bottom:auto;transform:none;display:grid;grid-template-columns:58px 58px;gap:9px}#hint{display:none}}
|
|
4
|
+
</style></head><body><canvas></canvas><div id="hint">← → move · ↑ rotate · ↓ soft drop · SPACE hard drop</div><div id="controls" aria-label="Game controls"><button data-action="left" aria-label="Move left">←</button><button data-action="rotate" aria-label="Rotate">↻</button><button data-action="soft" aria-label="Soft drop">↓</button><button data-action="right" aria-label="Move right">→</button><button data-action="drop" aria-label="Hard drop">DROP</button><button data-action="audio" aria-label="Sound" aria-pressed="true">♪</button></div><script type="module" crossorigin src="./assets/main.js"></script></body></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"bounds":{"min":[-2,-2],"max":[10,2]},"boxes":[],"circles":[],"spots":{}}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Four occupied cells in a 4×4 box. Shared, immutable geometry for rules and view.
|
|
2
|
+
const SHAPES = [[4,5,6,7],[1,2,5,6],[1,4,5,6],[1,2,4,5],[0,1,5,6],[0,4,5,6],[2,4,5,6]];
|
|
3
|
+
export function cells(kind: number, rotation: number): number[] {
|
|
4
|
+
const source = SHAPES[Math.max(0, Math.min(6, kind - 1))];
|
|
5
|
+
const out: number[] = [];
|
|
6
|
+
for (const cell of source) {
|
|
7
|
+
let x = cell % 4, y = Math.floor(cell / 4);
|
|
8
|
+
for (let r = 0; r < (kind === 2 ? 0 : rotation % 4); r++) { const old = x; x = 3 - y; y = old; }
|
|
9
|
+
out.push(y * 4 + x);
|
|
10
|
+
}
|
|
11
|
+
return out;
|
|
12
|
+
}
|
|
13
|
+
export function fits(board: readonly number[], kind: number, rotation: number, x: number, y: number): boolean {
|
|
14
|
+
for (const cell of cells(kind, rotation)) {
|
|
15
|
+
const cx = x + cell % 4, cy = y + Math.floor(cell / 4);
|
|
16
|
+
if (cx < 0 || cx >= 10 || cy >= 20 || (cy >= 0 && board[cy * 10 + cx] !== 0)) return false;
|
|
17
|
+
}
|
|
18
|
+
return true;
|
|
19
|
+
}
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// Separate wells: people and bots can only change their own falling piece.
|
|
2
|
+
import { defineRules, f } from '@homie-rocks/studio/rules';
|
|
3
|
+
import { move } from './move';
|
|
4
|
+
import { cells, fits } from './pieces';
|
|
5
|
+
|
|
6
|
+
type Well = {
|
|
7
|
+
grid: readonly number[]; bag: readonly number[]; piece: number; next: number;
|
|
8
|
+
x: number; y: number; rotation: number; score: number; lines: number;
|
|
9
|
+
dead: boolean; fallAt: number; repeatAt: number; botAt: number; target: number;
|
|
10
|
+
};
|
|
11
|
+
function take(s: Well, random: number): number {
|
|
12
|
+
const bag = s.bag.length ? s.bag.slice() : [1,2,3,4,5,6,7];
|
|
13
|
+
const at = Math.min(bag.length - 1, Math.floor(random * bag.length));
|
|
14
|
+
const kind = bag[at]; bag.splice(at, 1); s.bag = bag; return kind;
|
|
15
|
+
}
|
|
16
|
+
function spawn(s: Well, random: number): void {
|
|
17
|
+
s.piece = s.next; s.next = take(s, random); s.x = 3; s.y = -1; s.rotation = 0;
|
|
18
|
+
s.dead = !fits(s.grid, s.piece, s.rotation, s.x, s.y);
|
|
19
|
+
// A modest bot: aim for a low column; it uses the same input and lock path.
|
|
20
|
+
let best = 21, target = 3;
|
|
21
|
+
for (let x = 0; x < 10; x++) {
|
|
22
|
+
let height = 0;
|
|
23
|
+
for (let y = 0; y < 20; y++) if (s.grid[y * 10 + x]) { height = 20 - y; break; }
|
|
24
|
+
if (height < best) { best = height; target = Math.max(0, Math.min(6, x - 1)); }
|
|
25
|
+
}
|
|
26
|
+
s.target = target;
|
|
27
|
+
}
|
|
28
|
+
function reset(s: Well, a: number, b: number): void {
|
|
29
|
+
const grid: number[] = []; for (let i = 0; i < 200; i++) grid.push(0);
|
|
30
|
+
s.grid = grid; s.bag = []; s.score = 0; s.lines = 0; s.dead = false;
|
|
31
|
+
s.fallAt = 0; s.repeatAt = 0; s.botAt = 0; s.next = take(s, a); spawn(s, b);
|
|
32
|
+
}
|
|
33
|
+
function lock(s: Well): number {
|
|
34
|
+
const grid = s.grid.slice();
|
|
35
|
+
for (const cell of cells(s.piece, s.rotation)) {
|
|
36
|
+
const x = s.x + cell % 4, y = s.y + Math.floor(cell / 4);
|
|
37
|
+
if (y < 0) { s.dead = true; return 0; }
|
|
38
|
+
grid[y * 10 + x] = s.piece;
|
|
39
|
+
}
|
|
40
|
+
const kept: number[] = []; let cleared = 0;
|
|
41
|
+
for (let y = 0; y < 20; y++) {
|
|
42
|
+
let full = true; for (let x = 0; x < 10; x++) if (!grid[y * 10 + x]) full = false;
|
|
43
|
+
if (full) cleared++; else for (let x = 0; x < 10; x++) kept.push(grid[y * 10 + x]);
|
|
44
|
+
}
|
|
45
|
+
const next: number[] = []; for (let i = 0; i < cleared * 10; i++) next.push(0);
|
|
46
|
+
s.grid = next.concat(kept); s.lines += cleared;
|
|
47
|
+
s.score += [0,100,300,500,800][cleared] * (1 + Math.floor(s.lines / 10));
|
|
48
|
+
return cleared;
|
|
49
|
+
}
|
|
50
|
+
export default defineRules({
|
|
51
|
+
contract: 2, space: { dims: 2 }, move, map: './map', shared: {},
|
|
52
|
+
shapes: { events: { topped: {} }, effects: { placed: { rows: f.u8() }, moved: {}, turned: {} } },
|
|
53
|
+
entities: {
|
|
54
|
+
board: {
|
|
55
|
+
player: { away: 'think', leave: 'bot' },
|
|
56
|
+
body: { shape: 'circle', radius: 0.1, maxSpeed: 0 },
|
|
57
|
+
input: { dx: f.i8(), soft: f.bit(), rotate: f.press(), drop: f.press() },
|
|
58
|
+
fields: {
|
|
59
|
+
grid: f.list(f.u8(), 200), bag: f.list(f.u8(), 7), piece: f.u8(), next: f.u8(),
|
|
60
|
+
x: f.i8(), y: f.i8(), rotation: f.u8(), score: f.u32({ score: true }), lines: f.u16(),
|
|
61
|
+
dead: f.bit(), fallAt: f.tick(), repeatAt: f.tick(), botAt: f.tick(), target: f.i8(),
|
|
62
|
+
},
|
|
63
|
+
tick(world, self) {
|
|
64
|
+
if (world.round.phase !== 'live' || self.dead || !self.grid.length) return;
|
|
65
|
+
const dx = Math.sign(self.input.dx);
|
|
66
|
+
if (dx && world.tick >= self.repeatAt) {
|
|
67
|
+
if (fits(self.grid, self.piece, self.rotation, self.x + dx, self.y)) { self.x += dx; world.emit('moved', self.id, {}); }
|
|
68
|
+
self.repeatAt = world.tick + world.ticks(0.12);
|
|
69
|
+
} else if (!dx) self.repeatAt = 0;
|
|
70
|
+
if (self.input.rotate) {
|
|
71
|
+
const rotation = (self.rotation + 1) % 4;
|
|
72
|
+
for (const kick of [0,-1,1,-2,2]) if (fits(self.grid, self.piece, rotation, self.x + kick, self.y)) {
|
|
73
|
+
self.x += kick; self.rotation = rotation; world.emit('turned', self.id, {}); break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
let landed = false;
|
|
77
|
+
if (self.input.drop) {
|
|
78
|
+
for (let i = 0; i < 22; i++) { if (!fits(self.grid, self.piece, self.rotation, self.x, self.y + 1)) break; self.y++; self.score += 2; }
|
|
79
|
+
landed = true; self.botAt = world.tick + world.ticks(1.5);
|
|
80
|
+
} else if (world.tick >= self.fallAt || self.input.soft) {
|
|
81
|
+
if (fits(self.grid, self.piece, self.rotation, self.x, self.y + 1)) { self.y++; if (self.input.soft) self.score++; }
|
|
82
|
+
else landed = true;
|
|
83
|
+
self.fallAt = world.tick + world.ticks(Math.max(0.12, 0.7 - Math.floor(self.lines / 10) * 0.05));
|
|
84
|
+
}
|
|
85
|
+
if (landed) {
|
|
86
|
+
const rows = lock(self); world.emit('placed', self.id, { rows });
|
|
87
|
+
if (!self.dead) spawn(self, world.random());
|
|
88
|
+
self.fallAt = world.tick + world.ticks(0.7);
|
|
89
|
+
if (self.dead) world.sendRoom('topped', {});
|
|
90
|
+
}
|
|
91
|
+
},
|
|
92
|
+
think(world, self) {
|
|
93
|
+
return { dx: Math.sign(self.target - self.x), soft: false, rotate: false,
|
|
94
|
+
drop: !self.dead && self.x === self.target && world.tick >= self.botAt };
|
|
95
|
+
},
|
|
96
|
+
on: {
|
|
97
|
+
arrive(world, self) { reset(self, world.random(), world.random()); },
|
|
98
|
+
takeover(world, self) { if (self.driver === 'person') reset(self, world.random(), world.random()); },
|
|
99
|
+
},
|
|
100
|
+
onRoom: { roundStart(world, self) { reset(self, world.random(), world.random()); } },
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
room: {
|
|
104
|
+
rounds: { seconds: 120, breakSeconds: 6 }, bots: { keep: 2 },
|
|
105
|
+
join(_ctx, player) { return { kind: 'board', at: { x: player.seat, y: 0 } }; },
|
|
106
|
+
on: {
|
|
107
|
+
topped(world) {
|
|
108
|
+
// Only room handlers can end rounds. No invented room.tick callback.
|
|
109
|
+
const boards = world.near({ x: 0, y: 0, z: 0 }, 32, 'board');
|
|
110
|
+
if (boards.length && boards.every(b => b.dead)) world.round.end();
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { openRoom } from '@homie-rocks/studio/rules/view';
|
|
2
|
+
import { guardGestures } from '@homie-rocks/studio/netplay';
|
|
3
|
+
import type rules from './rules';
|
|
4
|
+
import { cells, fits } from './pieces';
|
|
5
|
+
|
|
6
|
+
const room = openRoom<typeof rules>();
|
|
7
|
+
room.probe({
|
|
8
|
+
alive: () => !!room.me && !room.me.dead,
|
|
9
|
+
busy: () => !room.me || room.me.dead || room.round?.phase !== 'live',
|
|
10
|
+
pieceX: () => room.me?.x, pieceY: () => room.me?.y, piece: () => room.me?.piece,
|
|
11
|
+
rotation: () => room.me?.rotation, lines: () => room.me?.lines,
|
|
12
|
+
lockedCells: () => room.me?.grid.filter(Boolean).length,
|
|
13
|
+
});
|
|
14
|
+
const canvas = document.querySelector('canvas')!;
|
|
15
|
+
const g = canvas.getContext('2d')!;
|
|
16
|
+
guardGestures({ touch: 'canvas' });
|
|
17
|
+
const COLORS = ['#152038','#40dcf0','#ffd75b','#b78aff','#65e6ab','#ff6f91','#6d9eff','#ffa465'];
|
|
18
|
+
const keys = new Set<string>();
|
|
19
|
+
let rotate = false, drop = false, touchDx = 0, soft = false;
|
|
20
|
+
let audio: AudioContext | null = null;
|
|
21
|
+
let muted = false, note = 0, nextNote = 0;
|
|
22
|
+
const melody = [659,494,523,587,523,494,440,440,523,659,587,523,494,523,587,659,523,440,440,0,587,698,880,784,698,659,523,659,587,523,494,494];
|
|
23
|
+
function unlock(): void { audio ??= new AudioContext(); void audio.resume(); }
|
|
24
|
+
function tone(hz: number, seconds = 0.055, volume = 0.055): void {
|
|
25
|
+
if (!audio || audio.state !== 'running' || muted) return;
|
|
26
|
+
const o = audio.createOscillator(), gain = audio.createGain();
|
|
27
|
+
o.type = 'triangle'; o.frequency.value = hz;
|
|
28
|
+
gain.gain.setValueAtTime(volume * 3, audio.currentTime);
|
|
29
|
+
gain.gain.exponentialRampToValueAtTime(0.001, audio.currentTime + seconds);
|
|
30
|
+
o.connect(gain).connect(audio.destination); o.start(); o.stop(audio.currentTime + seconds);
|
|
31
|
+
}
|
|
32
|
+
room.on('moved', e => { if (e.id === room.me?.id) tone(180, 0.04, 0.035); });
|
|
33
|
+
room.on('turned', e => { if (e.id === room.me?.id) tone(330); });
|
|
34
|
+
room.on('placed', e => { if (e.id === room.me?.id) { tone(e.rows ? 660 : 120, e.rows ? 0.23 : 0.09, 0.08); if (e.rows) navigator.vibrate?.(25); } });
|
|
35
|
+
addEventListener('keydown', e => {
|
|
36
|
+
if ((e.target as HTMLElement)?.closest('input,textarea,[contenteditable]')) return;
|
|
37
|
+
if (!['ArrowLeft','ArrowRight','ArrowDown','ArrowUp','Space','KeyA','KeyD','KeyS','KeyW','KeyX'].includes(e.code)) return;
|
|
38
|
+
e.preventDefault(); unlock(); keys.add(e.code);
|
|
39
|
+
if (!e.repeat && ['ArrowUp','KeyW','KeyX'].includes(e.code)) rotate = true;
|
|
40
|
+
if (!e.repeat && e.code === 'Space') drop = true;
|
|
41
|
+
});
|
|
42
|
+
addEventListener('keyup', e => keys.delete(e.code));
|
|
43
|
+
addEventListener('blur', () => { keys.clear(); touchDx = 0; soft = false; });
|
|
44
|
+
for (const button of document.querySelectorAll<HTMLButtonElement>('[data-action]')) {
|
|
45
|
+
const action = button.dataset.action;
|
|
46
|
+
button.addEventListener('pointerdown', e => {
|
|
47
|
+
e.preventDefault(); unlock(); button.setPointerCapture(e.pointerId); button.classList.add('pressed');
|
|
48
|
+
if (action === 'left') touchDx = -1;
|
|
49
|
+
if (action === 'right') touchDx = 1;
|
|
50
|
+
if (action === 'soft') soft = true;
|
|
51
|
+
if (action === 'rotate') rotate = true;
|
|
52
|
+
if (action === 'drop') drop = true;
|
|
53
|
+
if (action === 'audio') { muted = !muted; button.textContent = muted ? '×♪' : '♪'; button.setAttribute('aria-pressed', String(!muted)); }
|
|
54
|
+
});
|
|
55
|
+
button.addEventListener('pointermove', e => {
|
|
56
|
+
if (!button.hasPointerCapture(e.pointerId) || !['left','right','soft'].includes(action ?? '')) return;
|
|
57
|
+
const target = document.elementFromPoint(e.clientX, e.clientY)?.closest<HTMLButtonElement>('[data-action]');
|
|
58
|
+
const next = target?.dataset.action;
|
|
59
|
+
touchDx = next === 'left' ? -1 : next === 'right' ? 1 : 0; soft = next === 'soft';
|
|
60
|
+
for (const control of document.querySelectorAll<HTMLButtonElement>('[data-action]'))
|
|
61
|
+
control.classList.toggle('pressed', control === target && ['left','right','soft'].includes(next ?? ''));
|
|
62
|
+
});
|
|
63
|
+
const release = (): void => { for (const control of document.querySelectorAll('[data-action]')) control.classList.remove('pressed'); if (['left','right','soft'].includes(action ?? '')) { touchDx = 0; soft = false; } };
|
|
64
|
+
button.addEventListener('pointerup', release); button.addEventListener('pointercancel', release);
|
|
65
|
+
// Keyboard activation for focused native buttons; pointer input is handled above.
|
|
66
|
+
button.addEventListener('click', e => { if (e.detail === 0) { unlock(); if (action === 'rotate') rotate = true; if (action === 'drop') drop = true; } });
|
|
67
|
+
}
|
|
68
|
+
let w = innerWidth, h = innerHeight;
|
|
69
|
+
function size(): void { w = innerWidth; h = innerHeight; const dpr = Math.min(2, devicePixelRatio || 1); canvas.width = Math.round(w * dpr); canvas.height = Math.round(h * dpr); g.setTransform(dpr, 0, 0, dpr, 0, 0); }
|
|
70
|
+
addEventListener('resize', size); size();
|
|
71
|
+
type Board = NonNullable<typeof room.me>;
|
|
72
|
+
function block(x: number, y: number, s: number, kind: number, ghost = false): void {
|
|
73
|
+
const gap = Math.max(1, s * 0.055);
|
|
74
|
+
g.fillStyle = ghost ? '#c6dcff12' : COLORS[kind];
|
|
75
|
+
g.fillRect(x + gap, y + gap, s - gap * 2, s - gap * 2);
|
|
76
|
+
g.strokeStyle = ghost ? '#b8d5ff72' : '#ffffff55'; g.lineWidth = 1;
|
|
77
|
+
g.strokeRect(x + gap + 0.5, y + gap + 0.5, s - gap * 2 - 1, s - gap * 2 - 1);
|
|
78
|
+
if (!ghost && s > 12) { g.fillStyle = '#ffffff30'; g.fillRect(x + gap + 1, y + gap + 1, s - gap * 2 - 2, s * 0.13); g.fillStyle = '#09101c25'; g.fillRect(x + gap + 1, y + s * 0.77, s - gap * 2 - 2, s * 0.15); }
|
|
79
|
+
}
|
|
80
|
+
function piece(b: Board, ox: number, oy: number, s: number, py: number, ghost = false): void {
|
|
81
|
+
for (const cell of cells(b.piece, b.rotation)) { const x = b.x + cell % 4, y = py + Math.floor(cell / 4); if (y >= 0 && y < 20) block(ox + x * s, oy + y * s, s, b.piece, ghost); }
|
|
82
|
+
}
|
|
83
|
+
function well(b: Board, x: number, y: number, s: number, own: boolean): void {
|
|
84
|
+
g.fillStyle = '#060d1a'; g.fillRect(x, y, 10 * s, 20 * s);
|
|
85
|
+
g.strokeStyle = own ? '#71e9e099' : '#7990b64d'; g.lineWidth = own ? 2 : 1; g.strokeRect(x - 1, y - 1, 10 * s + 2, 20 * s + 2);
|
|
86
|
+
g.strokeStyle = '#91b6ff0c'; g.lineWidth = 1;
|
|
87
|
+
for (let i = 1; i < 10; i++) { g.beginPath(); g.moveTo(x + i * s, y); g.lineTo(x + i * s, y + 20 * s); g.stroke(); }
|
|
88
|
+
for (let i = 1; i < 20; i++) { g.beginPath(); g.moveTo(x, y + i * s); g.lineTo(x + 10 * s, y + i * s); g.stroke(); }
|
|
89
|
+
for (let i = 0; i < b.grid.length; i++) if (b.grid[i]) block(x + i % 10 * s, y + Math.floor(i / 10) * s, s, b.grid[i]);
|
|
90
|
+
if (!b.dead && b.piece) {
|
|
91
|
+
let ghostY = b.y; for (let i = 0; i < 22; i++) { if (!fits(b.grid, b.piece, b.rotation, b.x, ghostY + 1)) break; ghostY++; }
|
|
92
|
+
piece(b, x, y, s, ghostY, true); piece(b, x, y, s, b.y);
|
|
93
|
+
}
|
|
94
|
+
if (b.dead) { g.fillStyle = '#060d1acb'; g.fillRect(x, y + 8 * s, 10 * s, 4 * s); g.fillStyle = '#ffabc0'; g.font = `700 ${Math.max(10, s * 0.65)}px system-ui`; g.textAlign = 'center'; g.fillText('TOP OUT', x + 5 * s, y + 10 * s); g.textAlign = 'left'; }
|
|
95
|
+
}
|
|
96
|
+
function text(value: string, x: number, y: number, size = 13, colour = '#a7b8d5'): void { g.fillStyle = colour; g.font = `600 ${size}px system-ui`; g.fillText(value, x, y); }
|
|
97
|
+
function frame(): void {
|
|
98
|
+
const now = performance.now();
|
|
99
|
+
if (audio?.state === 'running' && now >= nextNote) { const hz = melody[note++ % melody.length]; if (hz) tone(hz, 0.21, 0.075); nextNote = now + 250; }
|
|
100
|
+
room.input({ dx: touchDx || (keys.has('ArrowRight') || keys.has('KeyD') ? 1 : 0) - (keys.has('ArrowLeft') || keys.has('KeyA') ? 1 : 0), soft: soft || keys.has('ArrowDown') || keys.has('KeyS'), rotate, drop }); rotate = false; drop = false;
|
|
101
|
+
const bg = g.createRadialGradient(w * 0.5, h * 0.3, 10, w * 0.5, h * 0.5, Math.max(w, h)); bg.addColorStop(0, '#172747'); bg.addColorStop(1, '#060b16'); g.fillStyle = bg; g.fillRect(0, 0, w, h);
|
|
102
|
+
text(document.title.toUpperCase(), 20, 61, 18, '#eef6ff');
|
|
103
|
+
const round = room.round;
|
|
104
|
+
text(round?.phase === 'over' ? `Next round in ${Math.ceil(round.secondsLeft)}` : round ? `${Math.ceil(round.secondsLeft)}s` : 'Joining…', 20, 80, 12);
|
|
105
|
+
const boards: Board[] = []; room.each('board', b => boards.push(b));
|
|
106
|
+
const me = room.me; const focus = me ?? boards[0];
|
|
107
|
+
if (focus) {
|
|
108
|
+
const compact = w < 640, landscape = w >= 640 && h <= 460, top = landscape ? 58 : 108, bottom = compact ? 174 : landscape ? 18 : 110;
|
|
109
|
+
const s = Math.max(5, Math.min(32, (h - top - bottom) / 20, (w - (compact ? 116 : 330)) / 10));
|
|
110
|
+
const total = 10 * s + 98 + (!compact && !landscape && boards.length > 1 ? 150 : 0);
|
|
111
|
+
const x = Math.max(12, (w - total) / 2), y = top;
|
|
112
|
+
const roster = room.roster.find(r => r.seat === focus.seat);
|
|
113
|
+
text(me ? 'YOUR WELL' : (roster?.name ?? 'WATCHING'), x, y - 13, 11, '#7aeee1');
|
|
114
|
+
well(focus, x, y, s, !!me);
|
|
115
|
+
const side = x + 10 * s + 18;
|
|
116
|
+
text('NEXT', side, y + 12, 10);
|
|
117
|
+
const preview = Math.min(17, s);
|
|
118
|
+
for (const cell of cells(focus.next || 1, 0)) block(side + cell % 4 * preview, y + 26 + Math.floor(cell / 4) * preview, preview, focus.next || 1);
|
|
119
|
+
text('SCORE', side, y + 110, 10); text(String(focus.score), side, y + 136, 21, '#f5f8ff');
|
|
120
|
+
if (h >= 350) { text('LINES', side, y + 170, 10); text(String(focus.lines), side, y + 194, 19, '#f5f8ff'); }
|
|
121
|
+
if (20 * s > 260) { text('LEVEL', side, y + 230, 10); text(String(1 + Math.floor(focus.lines / 10)), side, y + 254, 19, '#f5f8ff'); }
|
|
122
|
+
if (!compact && !landscape) {
|
|
123
|
+
let ry = y;
|
|
124
|
+
for (const rival of boards.filter(b => b.id !== focus.id).slice(0, 3)) {
|
|
125
|
+
const mini = Math.min(6, s * 0.25), rx = side + 112;
|
|
126
|
+
const r = room.roster.find(r => r.seat === rival.seat);
|
|
127
|
+
text(`${r?.driver === 'bot' ? 'BOT · ' : ''}${r?.name ?? 'Player'}`, rx, ry - 10, 10);
|
|
128
|
+
well(rival, rx, ry, mini, false); text(String(rival.score), rx + 10 * mini + 9, ry + 14, 12, '#eef6ff'); ry += 20 * mini + 38;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
const ranked = boards.slice().sort((a, b) => b.score - a.score || a.seat - b.seat);
|
|
132
|
+
const nameOf = (b: Board): string => { const r = room.roster.find(r => r.seat === b.seat); return b.id === me?.id ? 'YOU' : `${r?.driver === 'bot' ? 'BOT ' : ''}${r?.name ?? 'Player'}`; };
|
|
133
|
+
const rank = ranked.findIndex(b => b.id === focus.id) + 1;
|
|
134
|
+
if (compact) {
|
|
135
|
+
text(`RANK ${rank}/${ranked.length} · LEADER ${ranked[0]?.score ?? 0}`, 16, h - 155, 12, '#7aeee1');
|
|
136
|
+
ranked.slice(0, 4).forEach((b, i) => text(`${i + 1}. ${nameOf(b).slice(0, 12)} ${b.score}`, 16 + (i % 2) * (w / 2), h - 132 + Math.floor(i / 2) * 19, 11, b.id === me?.id ? '#f5f8ff' : '#a7b8d5'));
|
|
137
|
+
} else if (landscape) {
|
|
138
|
+
text(`RANK ${rank}/${ranked.length}`, side + 112, y + 142, 12, '#7aeee1');
|
|
139
|
+
ranked.slice(0, 4).forEach((b, i) => text(`${i + 1}. ${nameOf(b).slice(0, 10)} ${b.score}`, side + 112, y + 15 + i * 24, 12, b.id === me?.id ? '#f5f8ff' : '#a7b8d5'));
|
|
140
|
+
}
|
|
141
|
+
if (round?.phase === 'over') {
|
|
142
|
+
const results = round.results?.length ? round.results : ranked.map((b, i) => ({ name: nameOf(b), score: b.score, seat: b.seat, place: i + 1 }));
|
|
143
|
+
const pw = Math.min(340, w - 32), ph = 110 + results.length * 27, px = (w - pw) / 2, py = Math.max(42, (h - ph) / 2);
|
|
144
|
+
g.fillStyle = '#081329f5'; g.fillRect(px, py, pw, ph); g.strokeStyle = '#71e9e0'; g.strokeRect(px, py, pw, ph);
|
|
145
|
+
text(`${results[0]?.name ?? 'Player'} WINS`, px + 16, py + 30, 18, '#ffd75b');
|
|
146
|
+
text('FINAL SCORES', px + 16, py + 55, 11);
|
|
147
|
+
results.forEach((r, i) => text(`${r.place}. ${r.name.slice(0, 18)}${r.seat === me?.seat ? ' (YOU)' : ''} ${r.score}`, px + 16, py + 82 + i * 27, 13, '#f5f8ff'));
|
|
148
|
+
text(`Next round in ${Math.ceil(round.secondsLeft)}s`, px + 16, py + ph - 15, 11);
|
|
149
|
+
}
|
|
150
|
+
} else text('Finding your seat…', Math.max(20, w / 2 - 70), h / 2, 15);
|
|
151
|
+
requestAnimationFrame(frame);
|
|
152
|
+
}
|
|
153
|
+
requestAnimationFrame(frame);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"public":{}}
|
package/worker/build-id.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// Generated by lib/build-identity.mjs; identifies the bundled runtime sources.
|
|
2
|
-
export const STUDIO_BUILD = Object.freeze({"version":"0.58.
|
|
2
|
+
export const STUDIO_BUILD = Object.freeze({"version":"0.58.8","sha256":"dd01f171a16a619c527dc71c2beaaf0c9a9b70c395cb28156214d3ac988f81c5"});
|
package/worker/index.mjs
CHANGED
|
@@ -99,7 +99,9 @@ export default {
|
|
|
99
99
|
if(env.HOMIE_CONCEPT==='1' && res.status!==101) {
|
|
100
100
|
const headers=new Headers(res.headers);headers.set('x-robots-tag','noindex, nofollow');
|
|
101
101
|
let body=res.body;
|
|
102
|
-
|
|
102
|
+
// The outer page labels the whole preview. Repeating that overlay in an
|
|
103
|
+
// embedded game/app obscures its HUD and counts as the game's own UI.
|
|
104
|
+
if(request.headers.get('sec-fetch-dest')!=='iframe' && /text\/html/i.test(headers.get('content-type')??'')) {
|
|
103
105
|
const html=await res.text(),banner='<aside role="status" style="position:fixed;z-index:2147483647;top:0;left:0;right:0;text-align:center;background:#ffda44;color:#171717;padding:6px;font:bold 14px system-ui;pointer-events:none">TEST / CONCEPT</aside>';
|
|
104
106
|
body=/<body[^>]*>/i.test(html)?html.replace(/<body[^>]*>/i,m=>m+banner):banner+html;
|
|
105
107
|
headers.delete('content-length');
|
package/worker/version.mjs
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The @homie-rocks/studio version this Worker code is (package.json's; a test keeps them equal). Cache-busts /_homie/site.js. */
|
|
2
|
-
export const STUDIO_VERSION_TAG = '0.58.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.58.8';
|