@homie-rocks/studio 0.24.4 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/CHANGELOG.md +55 -1
  2. package/README.md +50 -3
  3. package/assets/animate.ts +521 -0
  4. package/assets/render-page.ts +77 -4
  5. package/bin/homie-studio.mjs +13 -4
  6. package/chat/CHAT.md +36 -5
  7. package/lib/art-cli.mjs +105 -5
  8. package/lib/art-tools.mjs +168 -1
  9. package/lib/asset-check.mjs +72 -5
  10. package/lib/build.mjs +3 -0
  11. package/lib/characters.mjs +380 -0
  12. package/lib/clips.mjs +417 -0
  13. package/lib/decisions.mjs +38 -8
  14. package/lib/mcp-tools.mjs +1 -1
  15. package/lib/mcp.mjs +2 -2
  16. package/lib/optimise.mjs +79 -3
  17. package/lib/render3d.mjs +2 -0
  18. package/lib/rig.mjs +431 -0
  19. package/lib/scaffold.mjs +17 -4
  20. package/lib/studio.mjs +18 -3
  21. package/lib/style-board.mjs +3 -1
  22. package/lib/template-history.json +78 -0
  23. package/mcp/ui/animation.js +56 -0
  24. package/mcp/ui/card.css +4 -0
  25. package/mcp/ui/cast.js +36 -2
  26. package/mcp/ui/lineup.js +7 -7
  27. package/netplay/NETPLAY.md +9 -5
  28. package/package.json +5 -1
  29. package/site/SITE.md +4 -0
  30. package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
  31. package/starters/hero-rush-3d/assets/manifest.json +1471 -0
  32. package/starters/hero-rush-3d/credits.json +40 -0
  33. package/starters/hero-rush-3d/game.json +11 -0
  34. package/starters/hero-rush-3d/index.html +20 -0
  35. package/starters/hero-rush-3d/lab.json +32 -0
  36. package/starters/hero-rush-3d/src/main.ts +2361 -0
  37. package/starters/hero-rush-3d/style.json +68 -0
  38. package/starters/hero-rush-3d/tunables.json +32 -0
  39. package/worker/chat-page.mjs +173 -28
  40. package/worker/chat.mjs +4 -1
  41. package/worker/version.mjs +1 -1
@@ -0,0 +1,68 @@
1
+ {
2
+ "v": 1,
3
+ "palette": {
4
+ "bg": "#cfe6f2",
5
+ "ink": "#1e2a38",
6
+ "accent": "#e8833a",
7
+ "accent2": "#4fa36a",
8
+ "danger": "#d9483b",
9
+ "good": "#5bb36a",
10
+ "gold": "#fdbf41",
11
+ "ramp": [
12
+ "#9fcfe6",
13
+ "#cfe6f2",
14
+ "#f6f1e4",
15
+ "#7cc46a",
16
+ "#4fa36a",
17
+ "#2f7a52",
18
+ "#007650",
19
+ "#fdbf41",
20
+ "#f68537",
21
+ "#e8833a",
22
+ "#9f5e48",
23
+ "#6b4a3a",
24
+ "#99a6ab",
25
+ "#a63c49",
26
+ "#3d395c",
27
+ "#ffffff"
28
+ ]
29
+ },
30
+ "fonts": {
31
+ "display": "Lilita One",
32
+ "body": "Nunito"
33
+ },
34
+ "light": {
35
+ "time": "morning",
36
+ "key": [
37
+ -0.5,
38
+ -1,
39
+ -0.35
40
+ ],
41
+ "intensity": 2.3,
42
+ "hardness": 0.4,
43
+ "sky": "#dcedf5",
44
+ "ground": "#86c47c",
45
+ "fog": 0.3,
46
+ "bloom": 0.1
47
+ },
48
+ "camera": {
49
+ "angle": "low-3/4",
50
+ "projection": "perspective",
51
+ "pitch": 38,
52
+ "distance": 13,
53
+ "fov": 42
54
+ },
55
+ "render": "painted",
56
+ "materials": {
57
+ "model": "hand-painted",
58
+ "outline": false,
59
+ "texelDensity": 256,
60
+ "atlas": false
61
+ },
62
+ "rev": {
63
+ "style.palette": 1,
64
+ "style.ui": 1,
65
+ "style.light": 1,
66
+ "style.camera": 1
67
+ }
68
+ }
@@ -0,0 +1,32 @@
1
+ {
2
+ "windupMs": { "value": 240, "min": 0, "max": 500, "step": 10, "unit": "ms", "group": "Swing", "note": "From the press to the moment the swing lands: the anticipation everyone sees coming" },
3
+ "swingSkip": { "value": 0.15, "min": 0, "max": 0.5, "step": 0.01, "group": "Swing", "note": "How much of the swing clip's own wind-up the hero skips (the windup above is what players wait for)" },
4
+ "swingRestMs": { "value": 260, "min": 0, "max": 900, "step": 10, "unit": "ms", "group": "Swing", "note": "How long after a swing lands before the next can start" },
5
+ "knockRange": { "value": 2.1, "min": 1.2, "max": 4, "step": 0.02, "unit": "m", "group": "Swing", "note": "How far a swing reaches" },
6
+ "swingArc": { "value": 200, "min": 60, "max": 360, "step": 5, "unit": "deg", "group": "Swing", "note": "How wide the swing sweeps in front of the hero" },
7
+ "hitStopMs": { "value": 70, "min": 0, "max": 200, "step": 5, "unit": "ms", "group": "Swing", "note": "The hit lands: the struck hero holds, flashes and flinches before it flies" },
8
+ "knockDistance": { "value": 3.4, "min": 0.8, "max": 8, "step": 0.1, "unit": "m", "group": "Swing", "note": "How far a hit sends a hero, the same at any frame rate" },
9
+ "knockMs": { "value": 420, "min": 120, "max": 1000, "step": 10, "unit": "ms", "group": "Swing", "note": "How long the slide takes" },
10
+ "knockEase": { "value": 4, "min": 1, "max": 6, "step": 0.1, "group": "Swing", "note": "1 slides at one speed; higher leaves faster and eases into the stop" },
11
+ "squash": { "value": 0.3, "min": 0, "max": 0.8, "step": 0.01, "group": "Swing", "note": "How much a flying hero stretches along the hit, and squashes when it stops" },
12
+ "settleMs": { "value": 260, "min": 0, "max": 700, "step": 10, "unit": "ms", "group": "Swing", "note": "The wobble after the stop (overlap)" },
13
+ "flashMs": { "value": 110, "min": 0, "max": 300, "step": 5, "unit": "ms", "group": "Swing", "note": "How long the struck hero flashes white" },
14
+ "shake": { "value": 0.16, "min": 0, "max": 0.6, "step": 0.01, "unit": "m", "group": "Swing", "note": "The camera's kick when you hit or are hit" },
15
+ "sparks": { "value": 9, "min": 0, "max": 24, "step": 1, "group": "Swing", "note": "Sparks thrown off the hit" },
16
+ "ringMs": { "value": 520, "min": 150, "max": 2000, "step": 10, "unit": "ms", "group": "Swing", "note": "How long a slash and a hit's ring last" },
17
+ "jumpHeight": { "value": 1.05, "min": 0.3, "max": 2.5, "step": 0.05, "unit": "m", "group": "Jump", "note": "How high a jump goes (above 0.3 m a swing passes under)" },
18
+ "jumpRise": { "value": 0.3, "min": 0.12, "max": 0.8, "step": 0.01, "unit": "s", "group": "Jump", "note": "How long it takes to reach the top" },
19
+ "fallFaster": { "value": 2.1, "min": 1, "max": 3.5, "step": 0.05, "group": "Jump", "note": "Down this much harder than up: higher feels heavier, snappier" },
20
+ "fade": { "value": 0.14, "min": 0, "max": 0.6, "step": 0.01, "unit": "s", "group": "Motion", "note": "How long one clip takes to blend into the next" },
21
+ "walkSpeed": { "value": 1.7, "min": 0.3, "max": 6, "step": 0.05, "unit": "m/s", "group": "Motion", "note": "The ground speed the walk clip's feet move at (feet slide when this is wrong)" },
22
+ "runSpeed": { "value": 4.6, "min": 1, "max": 12, "step": 0.05, "unit": "m/s", "group": "Motion", "note": "The ground speed the run clip's feet move at" },
23
+ "runFrom": { "value": 2.6, "min": 0.5, "max": 8, "step": 0.05, "unit": "m/s", "group": "Motion", "note": "From this speed up, the walk becomes a run" },
24
+ "actSpeed": { "value": 1.9, "min": 0.5, "max": 3, "step": 0.05, "group": "Motion", "note": "How fast the swing plays: higher is snappier" },
25
+ "jumpStretch": { "value": 0.22, "min": 0, "max": 0.5, "step": 0.01, "group": "Motion", "note": "How tall and thin a hero goes as it leaves the ground" },
26
+ "landSquash": { "value": 0.28, "min": 0, "max": 0.6, "step": 0.01, "group": "Motion", "note": "How flat it goes when it lands" },
27
+ "squashHz": { "value": 5.5, "min": 1, "max": 14, "step": 0.1, "unit": "Hz", "group": "Motion", "note": "How fast the squash springs back (lower wobbles longer)" },
28
+ "lean": { "value": 0.22, "min": 0, "max": 0.8, "step": 0.01, "group": "Motion", "note": "How far a hero banks into a turn and tips into a sprint" },
29
+ "flinch": { "value": 0.6, "min": 0, "max": 1.5, "step": 0.05, "group": "Motion", "note": "How strongly a hit jolts the upper body" },
30
+ "lookAt": { "value": 0.7, "min": 0, "max": 1, "step": 0.05, "group": "Motion", "note": "How much the head turns toward what it looks at" },
31
+ "spring": { "value": 0.5, "min": 0, "max": 1, "step": 0.05, "group": "Motion", "note": "How loose tails, ears and capes are (0 stiff, 1 floppy)" }
32
+ }
@@ -3,10 +3,11 @@
3
3
  * screen (the TV view) and the watch page. It speaks on the page's own watch socket, so every game has it, old builds
4
4
  * too, with no game code:
5
5
  *
6
- * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD),
7
- * its sheet (the room's last minutes, the room's emoji, its quick lines, typing where the rules allow,
8
- * Report on a line, and for the studio's owner Remove, Mute and Kick), a short ticker of new lines where the
9
- * status chip sits, and reactions floating up the screen
6
+ * play a Chat pill beside the room button (the corner game.json screen.share keeps clear of the game's HUD; the
7
+ * round icon on a phone, as the room button is), its sheet (the room's last minutes, the room's emoji, its
8
+ * quick lines, typing where the rules allow, Report on a line, and for the studio's owner Remove, Mute and
9
+ * Kick), a short strip of new lines where the status chip sits or where game.json screen.chat puts it
10
+ * (chatPlaces; "sheet-only" keeps them in the sheet), and reactions floating up the screen
10
11
  * tv the room's lines in a corner (no typing: a television is read across a room) and the float
11
12
  * watch a Chat button in the watch page's band, its panel over the stage, and the float
12
13
  *
@@ -22,17 +23,86 @@ import { REACTIONS } from './chat.mjs';
22
23
  import { REPORT_REASONS, REPORT_WORDS } from './chat-store.mjs';
23
24
  import { NET_PALETTE } from './room.mjs';
24
25
 
26
+ /** Where room chat's strip of new lines may sit: a corner, or the middle of the top or bottom edge. */
27
+ export const CHAT_PLACES = ['top-left', 'top-center', 'top-right', 'bottom-left', 'bottom-center', 'bottom-right'];
28
+ const CHAT_CORNERS = ['top-left', 'top-right', 'bottom-left', 'bottom-right'];
29
+ const CHAT_DEVICES = ['desk', 'phone', 'sideways', 'tv'];
30
+ const CHAT_FIELDS = ['at', 'x', 'y', 'lines'];
31
+
32
+ /** One entry of game.json `screen.chat` as written (a place, false, or an object), keeping only the fields that are right. */
33
+ function chatEntry(v) {
34
+ if (v === false) return { lines: 'sheet-only' };
35
+ if (typeof v === 'string') return CHAT_PLACES.includes(v) ? { at: v } : {};
36
+ if (!v || typeof v !== 'object' || Array.isArray(v)) return {};
37
+ const o = {};
38
+ if (CHAT_PLACES.includes(v.at)) o.at = v.at;
39
+ for (const k of ['x', 'y']) if (v[k] !== undefined && v[k] !== null && v[k] !== '' && Number.isFinite(Number(v[k]))) o[k] = Math.round(Number(v[k]));
40
+ if (v.lines === 'strip' || v.lines === 'sheet-only') o.lines = v.lines;
41
+ return o;
42
+ }
43
+
44
+ /**
45
+ * Where room chat sits on each screen, from game.json `screen.chat` (0.24.5), so a game with a busy HUD keeps its chat
46
+ * off its clock, its title and its controls. The Chat pill itself sits in the room button's band (`screen.share`
47
+ * places both); this places the strip of new lines that shows for a moment, and the big screen's corner of lines:
48
+ *
49
+ * "chat": "bottom-right" every device's strip there (and the big screen's corner)
50
+ * "chat": false no strip: new lines wait in the sheet, the pill counts them
51
+ * "chat": { "at": "top-left", "y": 120 } moved 120 px in from its edge (x: from its side)
52
+ * "chat": { "lines": "strip", "phone": { "lines": "sheet-only" }, "tv": { "at": "top-right" } }
53
+ *
54
+ * `at` is a place from CHAT_PLACES (default: the bottom left, or the bottom right when the room button is there); `x`
55
+ * and `y` move it in, 0 to 600 px (in the middle of an edge, `x` moves it either way); `lines` is "strip" (the default)
56
+ * or "sheet-only". `desk`, `phone` (held upright), `sideways` (else as `phone`) and `tv` (the big screen) each change
57
+ * only what they name. The big screen has no sheet: it takes a corner only, its place follows the top level's when that
58
+ * is a corner, and its lines stay unless its own entry says "sheet-only" (then it shows only the float).
59
+ */
60
+ export function chatPlaces(value) {
61
+ const top = chatEntry(value);
62
+ const per = value && typeof value === 'object' && !Array.isArray(value) ? value : {};
63
+ const done = (o) => {
64
+ const at = o.at ?? null;
65
+ const clamp = (n, lo) => Math.max(lo, Math.min(600, n ?? 0));
66
+ return { at, x: clamp(o.x, at && at.endsWith('center') ? -600 : 0), y: clamp(o.y, 0), lines: o.lines ?? 'strip' };
67
+ };
68
+ const phone = { ...top, ...chatEntry(per.phone) };
69
+ const { lines: _l, ...where } = top;
70
+ const own = chatEntry(per.tv);
71
+ if (own.at && !CHAT_CORNERS.includes(own.at)) delete own.at;
72
+ const tv = { ...(CHAT_CORNERS.includes(where.at) ? where : {}), ...own };
73
+ return { desk: done({ ...top, ...chatEntry(per.desk) }), phone: done(phone), sideways: done({ ...phone, ...chatEntry(per.sideways) }), tv: done(tv) };
74
+ }
75
+
76
+ /** game.json `screen.chat` as the build reads it: the problems in words (the page uses the default for anything wrong). */
77
+ export function screenChatProblems(value) {
78
+ if (value === undefined || value === null || value === false) return [];
79
+ const out = [];
80
+ const one = (v, name, top) => {
81
+ if (v === false) return;
82
+ if (typeof v === 'string') { if (!CHAT_PLACES.includes(v)) out.push(`${name} "${v}" is not a place (${CHAT_PLACES.join(', ')})`); return; }
83
+ if (!v || typeof v !== 'object' || Array.isArray(v)) { out.push(`${name} is a place, false, or { "at", "x", "y", "lines" }`); return; }
84
+ for (const k of Object.keys(v)) {
85
+ if (top && CHAT_DEVICES.includes(k)) continue;
86
+ if (!CHAT_FIELDS.includes(k)) out.push(`${name}.${k} is not a field (${k === 'corner' || k === 'place' ? 'the place is "at"' : `${CHAT_FIELDS.join(', ')}${top ? `, or ${CHAT_DEVICES.join(', ')}` : ''}`})`);
87
+ }
88
+ if (v.at !== undefined && !CHAT_PLACES.includes(v.at)) out.push(`${name}.at "${v.at}" is not a place (${CHAT_PLACES.join(', ')})`);
89
+ if (name.endsWith('.tv') && v.at !== undefined && !CHAT_CORNERS.includes(v.at)) out.push(`${name}.at: the big screen takes a corner`);
90
+ for (const k of ['x', 'y']) if (v[k] !== undefined && !Number.isFinite(Number(v[k]))) out.push(`${name}.${k} is a number of pixels`);
91
+ if (v.lines !== undefined && v.lines !== 'strip' && v.lines !== 'sheet-only') out.push(`${name}.lines is "strip" or "sheet-only"`);
92
+ };
93
+ one(value, 'screen.chat', true);
94
+ if (value && typeof value === 'object' && !Array.isArray(value)) for (const d of CHAT_DEVICES) if (value[d] !== undefined) one(value[d], `screen.chat.${d}`, false);
95
+ return out.slice(0, 8);
96
+ }
97
+
25
98
  /** What a page's chat starts from (`window.__HOMIE_CHAT`): its surface, the game, and who this browser is to the studio. */
26
- export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null, place = null } = {}) {
27
- const screen = g?.screen?.chat;
28
- const ticker = screen === false ? false : typeof screen === 'string' ? screen : screen && typeof screen === 'object' && typeof screen.at === 'string' ? screen.at : null;
99
+ export function chatBoot(g, { surface = 'play', owner = false, acct = false, member = false, room = null } = {}) {
29
100
  return {
30
101
  surface, game: g.id, name: g.name, owner: Boolean(owner), acct: Boolean(acct), member: Boolean(member), palette: NET_PALETTE,
31
102
  reactions: REACTIONS, reasons: REPORT_REASONS.map((k) => ({ k, text: REPORT_WORDS[k] })),
32
103
  ...(room ? { room } : {}),
33
- // game.json "screen": { "chat": "bottom-right" | false }: where new lines show for a moment (false: only the pill's count).
34
- ...(ticker !== null ? { ticker } : {}),
35
- ...(place ? { place } : {}),
104
+ // game.json "screen": { "chat": … }: where the strip of new lines sits on each device, or "sheet-only" (chatPlaces).
105
+ places: chatPlaces(g?.screen?.chat),
36
106
  account: '/account/',
37
107
  };
38
108
  }
@@ -42,7 +112,11 @@ export const CHAT_CSS = `
42
112
  .cpill .cico { width: 15px; height: 15px; flex: none; }
43
113
  .cpill .cbadge { min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 8px; background: var(--hot); color: #0b0b10; font: 800 10px/16px ui-sans-serif, system-ui, sans-serif; text-align: center; }
44
114
  .cpill.dim .cbadge { display: inline-block; }
115
+ /* The pill as the room button's round icon (on a phone, beside a room button kept an icon, or where its word would reach
116
+ the game's clock or title): its word goes, and the count rides its corner. */
117
+ .cpill.icon-only, .cpill.icon-only:hover, .cpill.icon-only:focus-visible, .cpill.icon-only[aria-expanded="true"] { position: relative; width: 34px; padding: 0; justify-content: center; }
45
118
  .cpill.icon-only span.cword { display: none; }
119
+ .cpill.icon-only .cbadge { position: absolute; top: -5px; right: -5px; min-width: 16px; box-shadow: 0 0 0 2px rgba(6,9,16,.85); }
46
120
  .csheet { display: flex; flex-direction: column; gap: 8px; width: min(340px, calc(100vw - 16px)); }
47
121
  .chead { display: flex; align-items: center; gap: 8px; margin: 0 2px; }
48
122
  .chead b { font: 800 15px/1.2 ui-sans-serif, system-ui, sans-serif; letter-spacing: -.01em; }
@@ -88,24 +162,25 @@ export const CHAT_CSS = `
88
162
  .creport { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 6px 0 2px; }
89
163
  .creport p { grid-column: 1 / -1; margin: 0; color: #c3cad9; font: 600 12.5px/1.35 ui-sans-serif, system-ui, sans-serif; }
90
164
  .creport button { min-height: 32px; padding: 0 8px; border-radius: 9px; border: 1px solid rgba(255,255,255,.16); background: transparent; color: inherit; font: 700 12px/1.2 ui-sans-serif, system-ui, sans-serif; cursor: pointer; text-align: left; }
91
- .ctick { position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
92
- .ctick.at-bottom-left { left: max(10px, env(safe-area-inset-left)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-start; }
93
- .ctick.at-bottom-right { right: max(10px, env(safe-area-inset-right)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: flex-end; }
94
- .ctick.at-bottom-center { left: 50%; transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px); align-items: center; }
95
- .ctick.at-top-left { left: max(10px, env(safe-area-inset-left)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-start; flex-direction: column-reverse; }
96
- .ctick.at-top-right { right: max(10px, env(safe-area-inset-right)); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: flex-end; flex-direction: column-reverse; }
97
- .ctick.at-top-center { left: 50%; transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px); align-items: center; flex-direction: column-reverse; }
165
+ /* The strip of new lines (game.json screen.chat: its place, moved in by --chat-x / --chat-y). */
166
+ .ctick { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 5; display: flex; flex-direction: column; gap: 4px; max-width: min(320px, calc(100vw - 24px)); pointer-events: none; }
167
+ .ctick.at-bottom-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-start; }
168
+ .ctick.at-bottom-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: flex-end; }
169
+ .ctick.at-bottom-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); bottom: calc(max(10px, env(safe-area-inset-bottom)) + 36px + var(--chat-y)); align-items: center; }
170
+ .ctick.at-top-left { left: calc(max(10px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-start; flex-direction: column-reverse; }
171
+ .ctick.at-top-right { right: calc(max(10px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: flex-end; flex-direction: column-reverse; }
172
+ .ctick.at-top-center { left: calc(50% + var(--chat-x)); transform: translateX(-50%); top: calc(max(10px, env(safe-area-inset-top)) + 48px + var(--chat-y)); align-items: center; flex-direction: column-reverse; }
98
173
  .ctick div { padding: 5px 10px; border-radius: 12px; background: rgba(6,9,16,.66); color: #eef1f8; font: 600 13px/1.3 ui-sans-serif, system-ui, -apple-system, sans-serif; overflow-wrap: anywhere; backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); animation: ctin .2s ease-out; transition: opacity .5s; }
99
174
  .ctick div b { font-weight: 800; margin-right: 6px; }
100
175
  .ctick div.cout { opacity: 0; }
101
176
  @keyframes ctin { from { opacity: 0; transform: translateY(6px); } }
102
177
  .cfloat { position: fixed; left: 0; top: 0; width: 100%; height: 100%; pointer-events: none; z-index: 4; }
103
- .ctv { position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
178
+ .ctv { --chat-x: 0px; --chat-y: 0px; position: fixed; z-index: 6; box-sizing: border-box; width: min(26rem, 30vw); max-height: 42vh; display: flex; flex-direction: column; justify-content: flex-end; gap: .3em; padding: .55em .8em; border-radius: 16px; background: rgba(8,12,22,.7); border: 1px solid rgba(255,255,255,.12); color: #eef1f8; font: 600 clamp(14px, 1.6vmin, 24px)/1.35 ui-sans-serif, system-ui, -apple-system, sans-serif; pointer-events: none; overflow: hidden; }
104
179
  .ctv:empty { display: none; }
105
- .ctv.at-bottom-left { left: max(16px, env(safe-area-inset-left)); bottom: max(16px, env(safe-area-inset-bottom)); }
106
- .ctv.at-bottom-right { right: max(16px, env(safe-area-inset-right)); bottom: max(16px, env(safe-area-inset-bottom)); }
107
- .ctv.at-top-left { left: max(16px, env(safe-area-inset-left)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
108
- .ctv.at-top-right { right: max(16px, env(safe-area-inset-right)); top: max(16px, env(safe-area-inset-top)); justify-content: flex-start; }
180
+ .ctv.at-bottom-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
181
+ .ctv.at-bottom-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); bottom: calc(max(16px, env(safe-area-inset-bottom)) + var(--chat-y)); }
182
+ .ctv.at-top-left { left: calc(max(16px, env(safe-area-inset-left)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
183
+ .ctv.at-top-right { right: calc(max(16px, env(safe-area-inset-right)) + var(--chat-x)); top: calc(max(16px, env(safe-area-inset-top)) + var(--chat-y)); justify-content: flex-start; }
109
184
  .ctv div { overflow-wrap: anywhere; transition: opacity 1s; }
110
185
  .ctv div.cout { opacity: 0; }
111
186
  .ctv b { font-weight: 800; margin-right: .4em; }
@@ -221,21 +296,41 @@ export const CHAT_JS = String.raw`(function () {
221
296
  if (parts.length) raf = requestAnimationFrame(paint);
222
297
  }
223
298
 
224
- /* ------------------------------------------------------------ the ticker: new lines, for a moment, where the chip sits */
299
+ /* ------------------------------------------------------------ where chat sits on this screen (game.json screen.chat) */
300
+ // This screen as the room button knows it (desk, phone held upright, phone turned sideways), else by its size.
301
+ function device() {
302
+ if (surface === 'tv') return 'tv';
303
+ var s = window.__shell && window.__shell.share;
304
+ if (s && s.device) return s.device;
305
+ var w = window.innerWidth || 0; var h = window.innerHeight || 0;
306
+ return w && h && Math.min(w, h) <= 540 ? (w > h ? 'sideways' : 'phone') : 'desk';
307
+ }
308
+ function placeHere() { var p = cfg.places || {}; return p[device()] || p.desk || { at: null, x: 0, y: 0, lines: 'strip' }; }
309
+
310
+ /* ------------------------------------------------------------ the strip: new lines, for a moment, where the chip sits */
225
311
  var tickEl = null;
226
312
  function tickPlace() {
227
- if (cfg.ticker) return cfg.ticker;
313
+ var p = placeHere();
314
+ if (p.at) return p.at;
228
315
  var s = window.__shell && window.__shell.share;
229
316
  return s && s.at === 'bottom-left' ? 'bottom-right' : 'bottom-left';
230
317
  }
231
318
  function tick(m) {
232
- if (surface === 'tv' || cfg.ticker === false || !st.show || st.open) return;
319
+ if (surface === 'tv' || !st.show || st.open) return;
320
+ // "sheet-only": a busy HUD keeps new lines in the sheet; the pill's count says they are there.
321
+ var p = placeHere();
322
+ if (p.lines === 'sheet-only') return;
233
323
  if (!tickEl) {
234
324
  var host = (surface === 'watch' ? document.querySelector('.stage') : null) || document.body;
235
325
  if (!host) return;
236
326
  tickEl = el('div', 'ctick'); tickEl.setAttribute('aria-hidden', 'true'); host.appendChild(tickEl); if (surface === 'watch') tickEl.style.position = 'absolute';
237
327
  }
238
- tickEl.className = 'ctick at-' + tickPlace();
328
+ var at = tickPlace();
329
+ tickEl.className = 'ctick at-' + at;
330
+ // Moved in by the game's x / y, and past the room button when the strip shares its place.
331
+ var s = window.__shell && window.__shell.share; var same = surface === 'play' && s && s.at === at;
332
+ tickEl.style.setProperty('--chat-x', ((p.x || 0) + (same ? s.x || 0 : 0)) + 'px');
333
+ tickEl.style.setProperty('--chat-y', ((p.y || 0) + (same ? s.y || 0 : 0)) + 'px');
239
334
  var row = el('div');
240
335
  if (m.name) { var b = el('b', '', m.name); b.style.color = colourOf(m); row.appendChild(b); }
241
336
  row.appendChild(document.createTextNode(m.glyph && m.react ? m.glyph : (m.text || '')));
@@ -247,13 +342,16 @@ export const CHAT_JS = String.raw`(function () {
247
342
  /* ------------------------------------------------------------ the TV's corner */
248
343
  var tvEl = null;
249
344
  function tvLine(m) {
250
- if (!st.show) return;
345
+ var p = placeHere();
346
+ if (!st.show || p.lines === 'sheet-only') return;
251
347
  if (!tvEl) {
252
348
  if (!document.body) return;
253
349
  tvEl = el('div', 'ctv');
254
350
  var join = document.querySelector('[data-join]');
255
351
  var at = join && /join-bottom-left/.test(join.className) ? 'bottom-right' : join && /join-top-left|join-top-right/.test(join.className) ? 'bottom-left' : 'bottom-left';
256
- tvEl.className = 'ctv at-' + (cfg.ticker && cfg.ticker !== 'bottom-center' && cfg.ticker !== 'top-center' ? cfg.ticker : at);
352
+ tvEl.className = 'ctv at-' + (p.at || at);
353
+ tvEl.style.setProperty('--chat-x', (p.x || 0) + 'px');
354
+ tvEl.style.setProperty('--chat-y', (p.y || 0) + 'px');
257
355
  document.body.appendChild(tvEl);
258
356
  }
259
357
  var row = el('div', m.t === 'react' ? 'creact' : '');
@@ -277,6 +375,7 @@ export const CHAT_JS = String.raw`(function () {
277
375
  pill = el('button', 'pill cpill'); pill.type = 'button'; pill.setAttribute('data-chat-toggle', ''); pill.setAttribute('aria-expanded', 'false'); pill.setAttribute('aria-controls', 'chat-sheet'); pill.setAttribute('aria-label', 'Room chat');
278
376
  pill.innerHTML = ICON; pill.appendChild(el('span', 'cword', 'Chat')); badge = el('b', 'cbadge'); badge.hidden = true; pill.appendChild(badge);
279
377
  band.appendChild(pill);
378
+ watchBand(band);
280
379
  sheet = el('div', 'sheet csheet'); sheet.id = 'chat-sheet'; sheet.setAttribute('role', 'dialog'); sheet.setAttribute('aria-label', 'Room chat'); sheet.hidden = true;
281
380
  ui.appendChild(sheet);
282
381
  // One sheet at a time: the room's and the server's close this one, and this one closes theirs.
@@ -318,6 +417,52 @@ export const CHAT_JS = String.raw`(function () {
318
417
  renderFeed();
319
418
  paintRules();
320
419
  }
420
+ /*
421
+ * The pill's word, as the room button's: on a phone (either way up), or beside a room button that game.json keeps an
422
+ * icon ("label": false), the pill is the round icon from the start. Elsewhere its word shows unless, with it, the band
423
+ * would leave the screen or reach from its corner into the middle third of the screen's width (where games keep a
424
+ * clock or a title); once it has, it stays the icon until the screen changes size, so it never flickers as the room
425
+ * button's own label comes and goes.
426
+ */
427
+ var narrow = false;
428
+ function shareHere() { var s = window.__shell && window.__shell.share; return s || {}; }
429
+ function wordCollides() {
430
+ var band = pill && pill.parentNode;
431
+ var w = window.innerWidth || 0;
432
+ if (!band || !band.getBoundingClientRect || !w) return false;
433
+ var had = pill.classList.contains('icon-only');
434
+ if (had) pill.classList.remove('icon-only');
435
+ var r = band.getBoundingClientRect();
436
+ if (had) pill.classList.add('icon-only');
437
+ if (!r.width) return false;
438
+ if (r.left < 4 || r.right > w - 4) return true;
439
+ var at = shareHere().at || 'top-right';
440
+ if (/center/.test(at)) return false;
441
+ return /right/.test(at) ? r.left < w * 2 / 3 : r.right > w / 3;
442
+ }
443
+ function fitPill() {
444
+ if (!pill || surface !== 'play') return;
445
+ var s = shareHere();
446
+ if (!narrow && s.device !== 'phone' && s.device !== 'sideways' && s.label !== false) narrow = wordCollides();
447
+ var icon = s.device === 'phone' || s.device === 'sideways' || s.label === false || narrow;
448
+ if (icon) { pill.classList.add('icon-only'); pill.title = 'Room chat'; } else { pill.classList.remove('icon-only'); pill.title = ''; }
449
+ st.compact = icon;
450
+ }
451
+ var fitQueued = false;
452
+ function fitSoon() {
453
+ if (fitQueued) return;
454
+ fitQueued = true;
455
+ var go = function () { fitQueued = false; fitPill(); };
456
+ if (window.requestAnimationFrame) window.requestAnimationFrame(go); else setTimeout(go, 16);
457
+ }
458
+ function watchBand(band) {
459
+ fitPill();
460
+ // The band changes as the room code arrives, the server's pill shows and the labels fade to dots: look again.
461
+ try { if (window.ResizeObserver) new ResizeObserver(fitSoon).observe(band); } catch (e) {}
462
+ // A new size (a turned phone, a resized window): the room button moves first, then the word is measured afresh.
463
+ window.addEventListener('resize', function () { setTimeout(function () { narrow = false; fitPill(); }, 60); });
464
+ }
465
+ st.fit = fitPill;
321
466
  function openChat(on, quiet) {
322
467
  if (!sheet) return;
323
468
  st.open = Boolean(on);
package/worker/chat.mjs CHANGED
@@ -209,7 +209,10 @@ export function chatProblems(raw) {
209
209
  const out = [];
210
210
  if (raw.mode !== undefined && !CHAT_MODES.includes(raw.mode)) out.push('chat.mode is off, emoji, lines or text');
211
211
  for (const k of ['who', 'react']) if (raw[k] !== undefined && !CHAT_WHO.includes(raw[k])) out.push(`chat.${k} is anyone, signed-in or members`);
212
- if (raw.lines !== undefined && raw.lines !== null) {
212
+ // Where chat sits on the screen is the page's (game.json "screen": { "chat" }), never the room's rules.
213
+ for (const k of ['corner', 'place', 'at']) if (raw[k] !== undefined && (k !== 'at' || typeof raw.at === 'string')) out.push(`chat.${k}: where chat sits on the screen goes in "screen": { "chat": { "at": … } }`);
214
+ if (raw.lines === 'strip' || raw.lines === 'sheet-only') out.push(`chat.lines is the quick lines ({ "id": "text" }); "${raw.lines}" goes in "screen": { "chat": { "lines": "${raw.lines}" } }`);
215
+ else if (raw.lines !== undefined && raw.lines !== null) {
213
216
  const pairs = Array.isArray(raw.lines) ? raw.lines.map((x) => [x?.id, x?.text]) : typeof raw.lines === 'object' ? Object.entries(raw.lines) : null;
214
217
  if (!pairs) out.push('chat.lines is { "id": "text" }');
215
218
  else {
@@ -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.24.4';
2
+ export const STUDIO_VERSION_TAG = '0.25.0';