@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.
- package/CHANGELOG.md +55 -1
- package/README.md +50 -3
- package/assets/animate.ts +521 -0
- package/assets/render-page.ts +77 -4
- package/bin/homie-studio.mjs +13 -4
- package/chat/CHAT.md +36 -5
- package/lib/art-cli.mjs +105 -5
- package/lib/art-tools.mjs +168 -1
- package/lib/asset-check.mjs +72 -5
- package/lib/build.mjs +3 -0
- package/lib/characters.mjs +380 -0
- package/lib/clips.mjs +417 -0
- package/lib/decisions.mjs +38 -8
- package/lib/mcp-tools.mjs +1 -1
- package/lib/mcp.mjs +2 -2
- package/lib/optimise.mjs +79 -3
- package/lib/render3d.mjs +2 -0
- package/lib/rig.mjs +431 -0
- package/lib/scaffold.mjs +17 -4
- package/lib/studio.mjs +18 -3
- package/lib/style-board.mjs +3 -1
- package/lib/template-history.json +78 -0
- package/mcp/ui/animation.js +56 -0
- package/mcp/ui/card.css +4 -0
- package/mcp/ui/cast.js +36 -2
- package/mcp/ui/lineup.js +7 -7
- package/netplay/NETPLAY.md +9 -5
- package/package.json +5 -1
- package/site/SITE.md +4 -0
- package/starters/hero-rush-3d/assets/RIGHTS.md +163 -0
- package/starters/hero-rush-3d/assets/manifest.json +1471 -0
- package/starters/hero-rush-3d/credits.json +40 -0
- package/starters/hero-rush-3d/game.json +11 -0
- package/starters/hero-rush-3d/index.html +20 -0
- package/starters/hero-rush-3d/lab.json +32 -0
- package/starters/hero-rush-3d/src/main.ts +2361 -0
- package/starters/hero-rush-3d/style.json +68 -0
- package/starters/hero-rush-3d/tunables.json +32 -0
- package/worker/chat-page.mjs +173 -28
- package/worker/chat.mjs +4 -1
- 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
|
+
}
|
package/worker/chat-page.mjs
CHANGED
|
@@ -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
|
|
8
|
-
* Report on a line, and for the studio's owner Remove, Mute and
|
|
9
|
-
* status chip sits
|
|
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
|
|
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":
|
|
34
|
-
|
|
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
|
-
|
|
92
|
-
.ctick
|
|
93
|
-
.ctick.at-bottom-
|
|
94
|
-
.ctick.at-bottom-
|
|
95
|
-
.ctick.at-
|
|
96
|
-
.ctick.at-top-
|
|
97
|
-
.ctick.at-top-
|
|
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
|
-
/* ------------------------------------------------------------
|
|
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
|
-
|
|
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' ||
|
|
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
|
-
|
|
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
|
-
|
|
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-' + (
|
|
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
|
-
|
|
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 {
|
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.
|
|
2
|
+
export const STUDIO_VERSION_TAG = '0.25.0';
|