From 312f4900db552620d5f5f88617a687c0eea96ae0 Mon Sep 17 00:00:00 2001 From: meetsu <96637888+klNuno@users.noreply.github.com> Date: Wed, 14 Oct 2026 10:05:13 +0000 Subject: [PATCH] feat(companion): every robot family as an animated companion --- src/MascotGallery.svelte | 56 +++++++ src/components/agents/RobotFace.svelte | 77 ++++++---- src/components/agents/RobotPicker.svelte | 2 +- .../companion/BubbleCharacter.svelte | 125 ++++++++++++++++ .../companion/CapsuleCharacter.svelte | 106 +++++++++++++ .../companion/CharacterMarks.svelte | 37 +++++ .../companion/CompanionCharacter.svelte | 25 +++- .../companion/JellyCharacter.svelte | 45 +----- .../companion/RetroCharacter.svelte | 139 ++++++++++++++++++ .../companion-character.svelte.test.ts | 50 +++++++ src/lib/companion/character.test.ts | 28 ++++ src/lib/companion/character.ts | 56 +++++++ src/lib/companion/companion-crew.test.ts | 53 ++++--- src/lib/companion/companion.svelte.ts | 2 +- src/lib/companion/crew.ts | 42 ++++-- src/lib/companion/prefs.ts | 14 +- src/lib/companion/skin.ts | 11 +- src/lib/island/pose.ts | 14 ++ src/main.ts | 16 +- 19 files changed, 773 insertions(+), 125 deletions(-) create mode 100644 src/MascotGallery.svelte create mode 100644 src/components/companion/BubbleCharacter.svelte create mode 100644 src/components/companion/CapsuleCharacter.svelte create mode 100644 src/components/companion/CharacterMarks.svelte create mode 100644 src/components/companion/RetroCharacter.svelte create mode 100644 src/components/companion/companion-character.svelte.test.ts create mode 100644 src/lib/companion/character.test.ts create mode 100644 src/lib/companion/character.ts diff --git a/src/MascotGallery.svelte b/src/MascotGallery.svelte new file mode 100644 index 0000000..a174202 --- /dev/null +++ b/src/MascotGallery.svelte @@ -0,0 +1,56 @@ + + + +
+ + {#each MOODS as column, index (index)}{column.caption}{/each} + {#each ROBOTS as robot, row (row)} + {strings.agents.robot.families[robot.family]}{own ? ` ${robot.shape + 1}` : ''} + {#each MOODS as column, index (index)} + + + + {/each} + {/each} +
+ + diff --git a/src/components/agents/RobotFace.svelte b/src/components/agents/RobotFace.svelte index 457f38a..ba1f39a 100644 --- a/src/components/agents/RobotFace.svelte +++ b/src/components/agents/RobotFace.svelte @@ -1,3 +1,19 @@ + +
{#if robot.top === 1}{/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -196,7 +209,7 @@ {:else if robot.eyes === 3} {:else}{/if} -
+
{#if robot.top >= 3 && robot.top <= 6} @@ -215,7 +228,9 @@ {:else if robot.family === 'capsule'}
- + +
+ {#if robot.top === 1} {:else if robot.top === 2} @@ -228,6 +243,10 @@ {/if} + + {#if robot.top === 1}{/if} +
+ {#if robot.shape === 0} {:else if robot.shape === 1} @@ -236,8 +255,7 @@ - {#if robot.top === 1}{/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -248,7 +266,7 @@ {/if} -
+
{:else if robot.family === 'box'} @@ -268,8 +286,9 @@ {:else} - - + +
+ {#if robot.top === 0} {:else if robot.top === 1} @@ -278,6 +297,14 @@ {/if} + + {#if robot.top === 0} + + {:else if robot.top === 1} + + {/if} +
+ @@ -286,12 +313,7 @@ {#if head.h - (screen.y - head.y) - screen.h > 8}{/if} - {#if robot.top === 0} - - {:else if robot.top === 1} - - {/if} -
+
{#if robot.eyes === 0} {:else if robot.eyes === 1} @@ -302,7 +324,7 @@ {/if} -
+
{/if}
@@ -346,6 +368,9 @@ .box-led { fill: var(--robot-led); stroke: var(--box-line); stroke-width: 1.5; } /* The capsule leans round the middle of its face. */ .tilt { transform: rotate(-11deg) translate(4.6875%, 9.375%); transform-origin: 50% 56.25%; } + /* A capsule's top turns round the middle of its head, a retro antenna round its foot. */ + .cap { transform-origin: 52% 50%; } + .antenna { transform-origin: var(--antenna-at); } /* A blink at rest, a glance while working, a hop while it waits on the user; moves in hundredths of the square. */ .eyes { transform-origin: var(--eyes-at); } diff --git a/src/components/agents/RobotPicker.svelte b/src/components/agents/RobotPicker.svelte index ea34859..c98ab76 100644 --- a/src/components/agents/RobotPicker.svelte +++ b/src/components/agents/RobotPicker.svelte @@ -11,7 +11,7 @@ * Shuffle for a new one, and one row per part where every choice is drawn * on the robot itself, so nothing has to be read. A box's lid, body and * eyes are colours, so those rows are swatches. `families` narrows the styles - * offered: the island's mascot is a box or a jelly. + * offered. */ let { robot, onpick, compact = false, families = ROBOT_FAMILIES }: { robot: Robot; onpick: (robot: Robot) => void; compact?: boolean; families?: readonly RobotFamily[] } = $props(); const labels = $derived(strings.agents.robot); diff --git a/src/components/companion/BubbleCharacter.svelte b/src/components/companion/BubbleCharacter.svelte new file mode 100644 index 0000000..ef2c10b --- /dev/null +++ b/src/components/companion/BubbleCharacter.svelte @@ -0,0 +1,125 @@ + + + + + + diff --git a/src/components/companion/CapsuleCharacter.svelte b/src/components/companion/CapsuleCharacter.svelte new file mode 100644 index 0000000..06050b8 --- /dev/null +++ b/src/components/companion/CapsuleCharacter.svelte @@ -0,0 +1,106 @@ + + + + + + diff --git a/src/components/companion/CharacterMarks.svelte b/src/components/companion/CharacterMarks.svelte new file mode 100644 index 0000000..2dbb4d2 --- /dev/null +++ b/src/components/companion/CharacterMarks.svelte @@ -0,0 +1,37 @@ + + + +{#if mark === 'z'} + +{:else if mark === 'shout'} + +{:else if mark === 'spark'} + +{/if} + + diff --git a/src/components/companion/CompanionCharacter.svelte b/src/components/companion/CompanionCharacter.svelte index 6e029d6..021741e 100644 --- a/src/components/companion/CompanionCharacter.svelte +++ b/src/components/companion/CompanionCharacter.svelte @@ -10,6 +10,10 @@ it. What keeps moving (a breath, a blink, the gaze, the lid, the light) is a whole layer moved by transform or opacity only, which the compositor plays on its own: the page paints nothing while the robot lives. + + A skin of another family is drawn by that family's character + (`characterOf`): a Jelly, a Bubble, a Capsule or a Retro robot, each moved + its own way from the same face. --> -{#if skin?.family === 'jelly'} - +{#if skin && family !== 'box'} + {@const Character = CHARACTERS[family]} + {:else}
- +
- {#if face === 'sleep'} - - {:else if face === 'calling' || face === 'alarm'} - - {:else if face === 'happy' || face === 'cheer'} - - {/if} +
diff --git a/src/components/companion/companion-character.svelte.test.ts b/src/components/companion/companion-character.svelte.test.ts new file mode 100644 index 0000000..e43fd98 --- /dev/null +++ b/src/components/companion/companion-character.svelte.test.ts @@ -0,0 +1,50 @@ +/* + * The companion draws a skin with its family's character: the box for a box + * or no skin, a Jelly, Bubble, Capsule or Retro robot for the others. + */ +import { afterEach, expect, test } from 'vitest'; +import { flushSync, mount, unmount } from 'svelte'; +import type { Robot } from '../../lib/robots'; +import CompanionCharacter from './CompanionCharacter.svelte'; + +let shown: { character: Record; target: HTMLElement } | null = null; + +function show(skin: Robot | null, mood: 'idle' | 'working' | 'calling' = 'idle'): HTMLElement { + const target = document.body.appendChild(document.createElement('div')); + shown = { character: mount(CompanionCharacter, { target, props: { mood, skin, lively: false } }), target }; + flushSync(); + return target.firstElementChild as HTMLElement; +} + +afterEach(() => { + if (shown) { + unmount(shown.character); + shown.target.remove(); + shown = null; + } +}); + +const robot = (family: Robot['family']): Robot => ({ family, shape: 1, color: 2, eyes: 1, top: 1 }); + +test('no skin and a box skin draw the box', () => { + expect(show(null).classList.contains('char')).toBe(true); + unmount(shown!.character); + shown!.target.remove(); + expect(show(robot('box')).classList.contains('char')).toBe(true); +}); + +for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) { + test(`a ${family} skin draws the ${family} character, with the mood's face`, () => { + const root = show(robot(family), 'working'); + expect(root.classList.contains(family)).toBe(true); + expect(root.classList.contains('f-working')).toBe(true); + expect(root.querySelector(`[data-family='${family}']`)).not.toBeNull(); + expect(root.getAttribute('role')).toBe('img'); + }); +} + +test('a calling robot shows its mark', () => { + const root = show(robot('retro'), 'calling'); + expect(root.classList.contains('f-calling')).toBe(true); + expect(root.querySelector('.shout')?.textContent).toBe('!'); +}); diff --git a/src/lib/companion/character.test.ts b/src/lib/companion/character.test.ts new file mode 100644 index 0000000..0913087 --- /dev/null +++ b/src/lib/companion/character.test.ts @@ -0,0 +1,28 @@ +import { expect, test } from 'vitest'; +import { characterOf, faceState, markOf } from './character'; + +test('each skin is drawn by its family, and no skin by the classic box', () => { + for (const family of ['box', 'jelly', 'bubble', 'capsule', 'retro'] as const) { + expect(characterOf({ family, shape: 0, color: 0, eyes: 0, top: 0 })).toBe(family); + } + expect(characterOf(null)).toBe('box'); + expect(characterOf(undefined)).toBe('box'); +}); + +test('the drawing blinks at rest, glances at work, hops when it calls, keeps still with its eyes shut', () => { + expect(faceState('awake')).toBe('idle'); + expect(faceState('happy')).toBe('idle'); + expect(faceState('working')).toBe('working'); + expect(faceState('calling')).toBe('waiting'); + expect(faceState('alarm')).toBe('waiting'); + for (const face of ['sleep', 'calm', 'rest', 'startled', 'yawn'] as const) expect(faceState(face)).toBe('still'); +}); + +test('a z asleep, a ! when it calls, sparks for joy, nothing otherwise', () => { + expect(markOf('sleep')).toBe('z'); + expect(markOf('calling')).toBe('shout'); + expect(markOf('alarm')).toBe('shout'); + expect(markOf('cheer')).toBe('spark'); + expect(markOf('happy')).toBe('spark'); + expect(markOf('working')).toBeNull(); +}); diff --git a/src/lib/companion/character.ts b/src/lib/companion/character.ts new file mode 100644 index 0000000..4400139 --- /dev/null +++ b/src/lib/companion/character.ts @@ -0,0 +1,56 @@ +/* + * What every animated companion shares, whatever its family: the face + * `CompanionCharacter` works out from the mood and its flags, the props it + * hands a family's character (`JellyCharacter`, `BubbleCharacter`, + * `CapsuleCharacter`, `RetroCharacter`), the state its `RobotFace` drawing + * takes, and which character draws a skin. The box is `CompanionCharacter`'s + * own drawing. + */ +import type { Robot, RobotFamily } from '../robots'; + +/** What the companion shows it feels now. */ +export type Face = 'awake' | 'sleep' | 'yawn' | 'groove' | 'typing' | 'working' | 'calling' | 'happy' | 'worried' | 'alarm' | 'calm' | 'rest' | 'cheer' | 'game' | 'startled'; +/** An idle gesture now and then. */ +export type Act = 'look' | 'blink' | 'peek' | 'stretch' | 'yawn'; +/** The light's colour: the mood's, or a calm one. */ +export type Tone = 'idle' | 'working' | 'calling' | 'happy' | 'worried'; +/** How `RobotFace` moves on its own: a blink, a glance, a hop, or nothing. */ +export type FaceState = 'idle' | 'working' | 'waiting' | 'still'; + +/** What `CompanionCharacter` hands the character of a family other than the box. */ +export interface CharacterProps { + robot: Robot; + face: Face; + tone: Tone; + /** Where the eyes look, each axis from -1 to 1. */ + look: { x: number; y: number }; + /** Poked: a hop. */ + bouncing: boolean; + /** Poked too often: a wobble, eyes spinning. */ + dizzy: boolean; + act: Act | null; + size: number; + label: string; +} + +/** The faces with the eyes shut or nearly: the drawing keeps still, so the character can close them. */ +const SHUT: ReadonlySet = new Set(['sleep', 'calm', 'rest', 'startled', 'yawn']); + +/** The state `RobotFace` takes for a face: it blinks at rest, glances while working, hops while it calls. */ +export function faceState(face: Face): FaceState { + if (SHUT.has(face)) return 'still'; + if (face === 'working') return 'working'; + return face === 'calling' || face === 'alarm' ? 'waiting' : 'idle'; +} + +/** The character that draws a skin: its family's, or the classic box when there is none. */ +export function characterOf(skin: Robot | null | undefined): RobotFamily { + return skin?.family ?? 'box'; +} + +/** The mark beside the character: a z asleep, a ! when it calls, sparks for joy. */ +export function markOf(face: Face): 'z' | 'shout' | 'spark' | null { + if (face === 'sleep') return 'z'; + if (face === 'calling' || face === 'alarm') return 'shout'; + return face === 'happy' || face === 'cheer' ? 'spark' : null; +} diff --git a/src/lib/companion/companion-crew.test.ts b/src/lib/companion/companion-crew.test.ts index e4487ad..ff15dfd 100644 --- a/src/lib/companion/companion-crew.test.ts +++ b/src/lib/companion/companion-crew.test.ts @@ -6,7 +6,7 @@ import { afterEach, expect, test, vi } from 'vitest'; import type { Account, AgentConversationMessage, AgentMemory, AgentProfile, AgentScope, AgentsSnapshot, ProviderSummary } from '@boite/contracts'; import type { Client } from '@boite/ui/lib/client'; -import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot } from '../robots'; +import { BOX_COLORS, boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots'; import { strings } from '../strings'; import type { Brain } from './brain'; import { ROLE_START } from './brain'; @@ -15,7 +15,6 @@ import { Crew } from './crew.svelte'; import { addReminder, readMemory, readReminders, remember } from './memory'; import { Notes } from './notes.svelte'; import { readCompanionPrefs, writeCompanionPrefs } from './prefs'; -import { toBox } from './skin'; afterEach(() => { vi.useRealTimers(); @@ -65,33 +64,49 @@ const snapshotOf = (part: Partial) => ({ profiles: [], messages: // Looks // --------------------------------------------------------------------------- -test('an agent stands as a box: its own box, any other robot made one in its colour, or the box its id draws', () => { +test('an agent stands as its own robot, of any family, or the box its id draws when its picture is text', () => { expect(skinOf(profile('bots', { avatar: CLASSIC_SKIN }))).toEqual({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 }); const box = { family: 'box', shape: 4, color: 7, eyes: 2, top: 5 } as const; expect(skinOf(profile('a', { avatar: encodeRobot(box) }))).toEqual(box); - // A jelly robot keeps its colour (one up, past the classic body) and its top as the accessory. - expect(skinOf(profile('a', { avatar: encodeRobot({ family: 'jelly', shape: 1, color: 2, eyes: 1, top: 3 }) }))).toMatchObject({ family: 'box', color: 3, top: 3 }); - // No avatar: the robot the Agents page draws for its id, as a box. - expect(skinOf(profile('seeded'))).toEqual(toBox(seededRobot('seeded'))); + for (const family of ['jelly', 'bubble', 'capsule', 'retro'] as const) { + const robot = { family, shape: 1, color: 2, eyes: 1, top: 3 }; + expect(skinOf(profile('a', { avatar: encodeRobot(robot) }))).toEqual(robot); + } + // No avatar: the robot the Agents page draws for its id. + expect(skinOf(profile('seeded'))).toEqual(seededRobot('seeded')); expect(skinOf(profile('emoji', { avatar: '🦊' }))).toEqual(seededRobot('emoji', 'box')); }); -test('a fresh look takes a body colour nobody wears, never the classic one, and always an accessory', () => { - const worn = (color: number) => ({ family: 'box' as const, shape: 0, color, eyes: 0, top: 0 }); - for (const seed of ['a', 'b', 'c', 'd', 'e', 'f']) { - const others = [worn(0), worn(1), worn(2), worn(3)]; +test('a fresh look takes a family and a colour nobody wears, never the classic body, a box always with an accessory', () => { + const box = (color: number): Robot => ({ family: 'box', shape: 0, color, eyes: 0, top: 0 }); + const seeds = Array.from({ length: 40 }, (_, index) => `seed-${index}`); + for (const seed of seeds) { + const others = [box(0), box(1), box(2), box(3)]; const skin = robotOf('x', freshSkin(seed, others))!; - expect(skin.family).toBe('box'); - expect(skin.color).toBeGreaterThan(3); - expect(skin.color).toBeLessThan(BOX_COLORS); + expect(skin.family).not.toBe('box'); + expect(boxColorOf(skin)).toBeGreaterThan(3); + expect(freshSkin(seed, others)).toBe(freshSkin(seed, others)); + } + // Over many seeds, every family but the one worn comes up. + const drawn = new Set(seeds.map((seed) => robotOf('x', freshSkin(seed, [box(0)]))!.family)); + expect([...drawn].sort()).toEqual(['bubble', 'capsule', 'jelly', 'retro']); + // Every family worn: any family again, a box with an accessory on its lid. + const crowd: Robot[] = ROBOT_FAMILIES.map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 })); + const boxes = seeds.map((seed) => robotOf('x', freshSkin(seed, crowd))!).filter((skin) => skin.family === 'box'); + expect(boxes.length).toBeGreaterThan(0); + for (const skin of boxes) { + expect(skin.color).toBeGreaterThan(0); expect(skin.top).toBeGreaterThan(0); expect(skin.top).toBeLessThan(ROBOT_PARTS.box.top); - expect(freshSkin(seed, others)).toBe(freshSkin(seed, others)); } - const all = Array.from({ length: BOX_COLORS }, (_, color) => worn(color)); - expect(robotOf('x', freshSkin('crowded', all))!.color).toBeGreaterThan(0); - const lastFree = all.filter((robot) => robot.color !== 6); - expect(robotOf('x', freshSkin('one-left', lastFree))!.color).toBe(6); + // Every colour worn: still never the classic body. + const all = [...crowd, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color))]; + for (const seed of seeds) expect(boxColorOf(robotOf('x', freshSkin(seed, all))!)).toBeGreaterThan(0); + // One box colour left: a jelly takes it, as its own colour one below. + const jellies: Robot[] = ['box', 'bubble', 'capsule', 'retro'].map((family) => ({ family, shape: 0, color: 0, eyes: 0, top: 0 }) as Robot); + const lastFree = [...jellies, ...Array.from({ length: BOX_COLORS }, (_, color) => box(color)).filter((robot) => robot.color !== 6)]; + const last = robotOf('x', freshSkin('one-left', lastFree))!; + expect(last).toMatchObject({ family: 'jelly', color: 5 }); }); // --------------------------------------------------------------------------- diff --git a/src/lib/companion/companion.svelte.ts b/src/lib/companion/companion.svelte.ts index ba046d6..a260aba 100644 --- a/src/lib/companion/companion.svelte.ts +++ b/src/lib/companion/companion.svelte.ts @@ -202,7 +202,7 @@ export class Companion { readonly senses: Senses; // With no agent yet, a lone character stands, and says so when asked. The - // first one wears the mascot chosen in Settings, else its own box. + // first one wears the mascot chosen in Settings, else its own robot. readonly members = $derived.by(() => this.crew.members.length > 0 ? this.crew.members.map((agent, index) => ({ id: agent.id, name: agent.name, skin: (index === 0 && this.prefs.mascot) || skinOf(agent), talk: this.talkOf(agent.id) })) diff --git a/src/lib/companion/crew.ts b/src/lib/companion/crew.ts index 2ee78bc..d9b5a71 100644 --- a/src/lib/companion/crew.ts +++ b/src/lib/companion/crew.ts @@ -5,34 +5,46 @@ * to it. Pure, so it is tested without a core. */ import type { AgentConversationMessage, AgentMemory, AgentProfile, AgentSave, AgentScope, AgentsSnapshot, AgentWork } from '@boite/contracts'; -import { BOX_COLORS, encodeRobot, ROBOT_PARTS, robotOf, seededRobot, type Robot } from '../robots'; +import { boxColorOf, encodeRobot, ROBOT_FAMILIES, ROBOT_PARTS, robotColors, robotOf, seededRobot, type Robot } from '../robots'; import type { Brain } from './brain'; import { namesFact, normalized } from './memory'; -import { toBox, type BoxRobot } from './skin'; /** The name of the agent the companion makes for itself the first time. */ export const FIRST_AGENT_NAME = 'B.O.T.S'; /** The classic companion, as an avatar: the first agent keeps the look the companion always had. */ export const CLASSIC_SKIN = encodeRobot({ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 }); -/** The box an agent stands as: its own robot made a box, keeping its colour, or the box its id draws. */ -export function skinOf(profile: Pick): BoxRobot { - return toBox(robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box')); +/** The robot an agent stands as: the one the Agents page draws for it, of any family, or the box its id draws when its picture is text. */ +export function skinOf(profile: Pick): Robot { + return robotOf(profile.id, profile.avatar) ?? seededRobot(profile.id, 'box'); +} + +/** A number from 0 to n - 1, the same for a seed on every client. */ +function pickFor(seed: string, n: number): number { + let hash = 2166136261; + for (const char of seed) hash = Math.imul(hash ^ char.codePointAt(0)!, 16777619); + return (hash >>> 0) % n; } /** - * A look to tell an agent apart from `others`, as an avatar: the box `seed` - * draws, on a body colour none of them wears while one is left (never the - * classic body, which the first agent keeps), with an accessory on its lid. + * A look to tell an agent apart from `others`, as an avatar: a robot of a + * family none of them is while one is left, any of the five otherwise, drawn + * from `seed`. Its colour, seen as a box's (`boxColorOf`), is one none of + * them wears while one is left, and never the classic body, which the first + * agent keeps; a box also gets an accessory on its lid. */ export function freshSkin(seed: string, others: Robot[]): string { - const drawn = seededRobot(seed, 'box'); - const worn = new Set(others.map((robot) => robot.color)); - const colors = Array.from({ length: BOX_COLORS - 1 }, (_, index) => index + 1); - const free = colors.filter((color) => !worn.has(color)); - const pool = free.length > 0 ? free : colors; - const top = drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top; - return encodeRobot({ ...drawn, color: pool[drawn.color % pool.length]!, top }); + const taken = new Set(others.map((robot) => robot.family)); + const families = ROBOT_FAMILIES.filter((family) => !taken.has(family)); + const pool = families.length > 0 ? families : ROBOT_FAMILIES; + const family = pool[pickFor(seed, pool.length)]!; + const drawn = seededRobot(seed, family); + const worn = new Set(others.map(boxColorOf)); + const colors = Array.from({ length: robotColors(family) }, (_, color) => color).filter((color) => boxColorOf({ family, color }) !== 0); + const free = colors.filter((color) => !worn.has(boxColorOf({ family, color }))); + const choice = free.length > 0 ? free : colors; + const top = family === 'box' && drawn.top === 0 ? 1 + (drawn.eyes % (ROBOT_PARTS.box.top - 1)) : drawn.top; + return encodeRobot({ ...drawn, color: choice[drawn.color % choice.length]!, top }); } /** The collaboration tools a companion agent gets, as the Agents page gives a new agent. */ diff --git a/src/lib/companion/prefs.ts b/src/lib/companion/prefs.ts index 8f2807c..00a290a 100644 --- a/src/lib/companion/prefs.ts +++ b/src/lib/companion/prefs.ts @@ -10,7 +10,7 @@ * first one leading (`crew.svelte.ts`). The brain fields are what a companion * agent made from here runs on; each agent then keeps its own. */ -import { encodeRobot, parseRobot, type Robot, type RobotFamily } from '../robots'; +import { encodeRobot, parseRobot, ROBOT_FAMILIES, type Robot, type RobotFamily } from '../robots'; /** Along the top of the screen, or `free`: where the user dropped the character. */ export type CompanionAnchor = 'left' | 'center' | 'right' | 'free'; @@ -25,9 +25,9 @@ export type PillRest = 'live' | 'quota' | 'music' | 'timer' | 'robot'; export const PILL_RESTS = ['live', 'quota', 'music', 'timer', 'robot'] as const satisfies readonly PillRest[]; /** The character's centre, in fractions of the screen's work area. */ export interface CompanionSpot { x: number; y: number } -/** The families the island's mascot may come from: Boîte, the box, or a Jelly. */ -export const MASCOT_FAMILIES = ['box', 'jelly'] as const satisfies readonly RobotFamily[]; -export type Mascot = Robot & { family: (typeof MASCOT_FAMILIES)[number] }; +/** The families the island's mascot may come from: every robot Boite draws, and the box. */ +export const MASCOT_FAMILIES: readonly RobotFamily[] = ROBOT_FAMILIES; +export type Mascot = Robot; /** The shortcuts Settings offers, in the shell's notation (`platform/desktop.rs`). */ export const COMPANION_HOTKEYS = ['Alt+Shift+Space', 'Ctrl+Alt+Space', 'Ctrl+Shift+Space', 'Ctrl+Alt+B'] as const; @@ -41,7 +41,7 @@ export interface CompanionPrefs { openOnHover: boolean; /** What the pill shows at rest (`IslandBar.svelte`). */ pillRest: PillRest; - /** The island's character, the first agent's on the desktop; null is that agent's own box (Boîte by default). */ + /** The island's character, the first agent's on the desktop; null is that agent's own robot (Boîte, the classic box, by default). */ mascot: Mascot | null; anchor: CompanionAnchor; /** Where the character was dropped, with `anchor: 'free'`. */ @@ -138,11 +138,11 @@ function spotOf(value: unknown): CompanionSpot | null { return left === null || top === null ? null : { x: left, y: top }; } -/** A box or a jelly, each part in its range; anything else is no mascot. */ +/** A robot of a known family, each part in its range; anything else is no mascot. */ function mascotOf(value: unknown): Mascot | null { if (!value || typeof value !== 'object') return null; const { family, shape, color, eyes, top } = value as Record; - if (family !== 'box' && family !== 'jelly') return null; + if (!(MASCOT_FAMILIES as readonly unknown[]).includes(family)) return null; const parts = [shape, color, eyes, top]; if (!parts.every((part) => typeof part === 'number' && Number.isInteger(part) && part >= 0)) return null; const robot = parseRobot(encodeRobot({ family, shape, color, eyes, top } as Robot)); diff --git a/src/lib/companion/skin.ts b/src/lib/companion/skin.ts index 9b58439..f64161a 100644 --- a/src/lib/companion/skin.ts +++ b/src/lib/companion/skin.ts @@ -1,13 +1,14 @@ /* - * The companion's skin: the colours and the accessory a box robot gives the + * The box's skin: the colours and the accessory a box robot gives the * companion box, so several agents standing side by side as companions can be - * told apart. The skin is the agent's robot (`lib/robots.ts`, family `box`), - * kept in its profile's `avatar`, so the Agents page draws the same look. + * told apart. The skin is the agent's robot (`lib/robots.ts`), kept in its + * profile's `avatar`, so the Agents page draws the same look; a robot of + * another family is drawn by its own character (`lib/companion/character.ts`). * * What a caller needs: * - `seededRobot(seed, 'box')` (robots.ts): the box an id draws. - * - `isBox(robot)`: whether a robot is a box; `CompanionCharacter` draws the - * classic companion for anything else, or for no skin at all. + * - `isBox(robot)`: whether a robot is a box, which `CompanionCharacter` + * paints; no skin at all draws the classic companion. * - `toBox(robot)`: any robot as a box that keeps its colour and accessory slot. * - `boxPaint(robot)`: the CSS colours a box wears, for anything drawing it. * diff --git a/src/lib/island/pose.ts b/src/lib/island/pose.ts index ea21c20..b9d5abf 100644 --- a/src/lib/island/pose.ts +++ b/src/lib/island/pose.ts @@ -49,6 +49,8 @@ * (one paused, one whose project is gone) for Settings; * - `bar=custom`: the bar arranged by hand: two tabs left out, one renamed, * two apps and a link added, the pin taken away (`bar.ts`); + * - `skin=box|jelly|bubble|capsule|retro`: the lead robot wears a robot of + * that family as its mascot (`SKIN_POSES`); * - `edit=1`: with `pose=open` or `pose=deck`, the bar in its edit mode, what * it can take under it (`deck/BarPalette.svelte`); * - `pose=decktab&tab=apps|clipboard|wallpaper|system`: one of the deck's @@ -63,6 +65,7 @@ import { activeLocale } from '@boite/ui/lib/i18n.svelte'; import type { Companion } from '../companion/companion.svelte'; import { nextAt, type Repeat } from '../companion/missions'; import { PILL_RESTS, writeCompanionPrefs, type PillRest } from '../companion/prefs'; +import type { Robot, RobotFamily } from '../robots'; import { DEMO_COMMAND, DEMO_THREAD } from '../fake-client/allow-demo'; import { addApp, addLink, DEFAULT_BAR, removeItem, removeTool, renameItem, writeBar, writeBarEdit, type BarLayout } from './bar'; import { FAKE_APPS } from '../windows/fake'; @@ -194,6 +197,15 @@ function poseAway(): void { }; } +/** The mascot a `skin=` pose puts on the lead robot: one per family, each wearing something on top. */ +const SKIN_POSES: Record = { + box: { family: 'box', shape: 6, color: 4, eyes: 2, top: 3 }, + jelly: { family: 'jelly', shape: 0, color: 3, eyes: 0, top: 3 }, + bubble: { family: 'bubble', shape: 0, color: 5, eyes: 2, top: 1 }, + capsule: { family: 'capsule', shape: 0, color: 1, eyes: 0, top: 1 }, + retro: { family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 }, +}; + const WALLPAPER = 'html[data-companion] body { background: linear-gradient(160deg, #34466e, #7d6797 55%, #d49c78) !important; }'; async function until(ready: () => boolean, tries = 60): Promise { @@ -244,6 +256,8 @@ export async function strikePose(companion: Companion, frame: IslandFrame, param if (params.get('todo') === '1') poseTodo(companion); const mission = params.get('mission'); if (mission === 'ask' || mission === 'list') poseMissions(companion, mission); + const skin = params.get('skin'); + if (skin && Object.hasOwn(SKIN_POSES, skin)) companion.prefs = { ...companion.prefs, mascot: SKIN_POSES[skin as RobotFamily] }; const rest = params.get('rest') as PillRest | null; if (rest && PILL_RESTS.includes(rest)) companion.prefs = { ...companion.prefs, pillRest: rest }; if (params.get('rule') === '1') { diff --git a/src/main.ts b/src/main.ts index 20ef2d4..4bab1f6 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,6 +1,7 @@ /* * The page's entry: the companion's transparent window, or Bots's settings - * window on `?view=settings`. The language comes first, so nothing is drawn + * window on `?view=settings` (the dev bundle adds `?view=mascots`, every + * companion family side by side). The language comes first, so nothing is drawn * in English and swapped a tick later. */ import '@boite/ui/app.css'; @@ -10,11 +11,18 @@ import { startLocale } from '@boite/ui/lib/i18n.svelte'; import { followTrayLocale } from './lib/tray.svelte'; const target = document.getElementById('app')!; -const settings = new URLSearchParams(location.search).get('view') === 'settings'; +const view = new URLSearchParams(location.search).get('view'); +const settings = view === 'settings'; +// Every companion family side by side, for captures: the dev bundle only. +const mascots = import.meta.env.DEV && view === 'mascots'; document.documentElement.dataset.view = settings ? 'settings' : 'bots'; void startLocale().then(async () => { - const page = settings ? (await import('./SettingsApp.svelte')).default : (await import('./App.svelte')).default; + const page = mascots + ? (await import('./MascotGallery.svelte')).default + : settings + ? (await import('./SettingsApp.svelte')).default + : (await import('./App.svelte')).default; mount(page, { target }); - if (!settings) followTrayLocale(); + if (!settings && !mascots) followTrayLocale(); });