Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions src/MascotGallery.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<!--
Every companion family in a few moods side by side, for captures and for
looking at them move: `index.html?view=mascots`, in the dev bundle only
(`main.ts`). `size=<px>` sets their size (64 by default, the desktop's);
`family=<family>` shows every shape of that family instead.
-->
<script lang="ts">
import { onMount } from 'svelte';
import { startTheme } from '@boite/ui/lib/theme';
import CompanionCharacter from './components/companion/CompanionCharacter.svelte';
import type { Mood } from './lib/companion/mood';
import { ROBOT_FAMILIES, ROBOT_PARTS, type Robot, type RobotFamily } from './lib/robots';
import { strings } from './lib/strings';

/** One robot per family, each wearing something on top so the antenna, the cap and the lid move too. */
const FAMILIES: Robot[] = [
{ family: 'box', shape: 0, color: 0, eyes: 0, top: 0 },
{ family: 'jelly', shape: 0, color: 3, eyes: 0, top: 3 },
{ family: 'bubble', shape: 0, color: 5, eyes: 2, top: 1 },
{ family: 'capsule', shape: 0, color: 1, eyes: 0, top: 1 },
{ family: 'retro', shape: 0, color: 7, eyes: 1, top: 0 },
];
const MOODS: { mood: Mood; asleep?: boolean; caption: string }[] = [
{ mood: 'idle', caption: strings.companion.moods.idle },
{ mood: 'working', caption: strings.companion.moods.working },
{ mood: 'calling', caption: strings.companion.moods.calling },
{ mood: 'happy', caption: strings.companion.moods.happy },
{ mood: 'idle', asleep: true, caption: 'z' },
];
const params = new URLSearchParams(location.search);
const size = Number(params.get('size')) || 64;
const family = params.get('family') as RobotFamily | null;
const own = family && ROBOT_FAMILIES.includes(family) ? FAMILIES.find((robot) => robot.family === family)! : null;
const ROBOTS: Robot[] = own ? Array.from({ length: ROBOT_PARTS[own.family].shape }, (_, shape) => ({ ...own, shape, top: (own.top + shape) % ROBOT_PARTS[own.family].top })) : FAMILIES;
onMount(() => startTheme());
</script>

<main class="gallery" style:--cell="{size + 40}px">
<span></span>
{#each MOODS as column, index (index)}<span class="caption">{column.caption}</span>{/each}
{#each ROBOTS as robot, row (row)}
<span class="caption">{strings.agents.robot.families[robot.family]}{own ? ` ${robot.shape + 1}` : ''}</span>
{#each MOODS as column, index (index)}
<span class="cell" data-family={robot.family} data-mood={column.mood}>
<CompanionCharacter mood={column.mood} asleep={column.asleep} skin={robot} lively={false} {size} gaze={{ x: 0.6, y: -0.2 }} />
</span>
{/each}
{/each}
</main>

<style>
:global(html), :global(body) { margin: 0; min-height: 100%; background: var(--color-background); }
.gallery { display: grid; grid-template-columns: 90px repeat(5, var(--cell)); gap: 10px 18px; align-items: center; justify-items: center; padding: 24px; width: max-content; }
.caption { font: 600 var(--text-sm) / 1.2 var(--font-sans); color: var(--color-foreground); background: var(--color-surface); padding: 3px 8px; border-radius: var(--radius-md); }
.cell { display: grid; place-items: center; width: var(--cell); height: var(--cell); }
</style>
77 changes: 51 additions & 26 deletions src/components/agents/RobotFace.svelte
Original file line number Diff line number Diff line change
@@ -1,3 +1,19 @@
<script module lang="ts">
/** The retro head's box per shape, which the screen, the ears and the antenna follow (64 units). */
const RETRO_HEADS = [
{ x: 11, y: 16, w: 42, h: 36, r: 11 },
{ x: 10, y: 13, w: 44, h: 42, r: 21 },
{ x: 14, y: 12, w: 36, h: 42, r: 11 },
{ x: 7, y: 19, w: 50, h: 32, r: 11 },
];

/** A retro robot's head and the dark screen on it, for anything drawn over them (`RetroCharacter`). */
export function retroHead(shape: number) {
const head = RETRO_HEADS[shape] ?? RETRO_HEADS[0]!;
return { head, screen: { x: head.x + 5, y: head.y + 6, w: head.w - 10, h: Math.min(head.h - 15, 22) } };
}
</script>

<script lang="ts">
import { boxPaint } from '../../lib/companion/skin';
import { robotColor, type Robot } from '../../lib/robots';
Expand All @@ -9,8 +25,10 @@
* that color washed out, the trim that color darkened. `state` moves the
* face: a blink at rest, a glance from side to side while it works, a hop
* while it waits on the user. `still` draws it without motion, for pickers.
* `gaze` turns a jelly's eyes toward a point (each axis -1 to 1), and `bare`
* `gaze` turns the eyes toward a point (each axis -1 to 1), and `bare`
* leaves out the disc behind it, for the companion standing on its own.
* The parts a companion moves on their own have a layer each: the eyes, a
* capsule's top (`cap`) and a retro robot's antenna (`antenna`).
*
* It is drawn in stacked layers over the same square: what is behind the
* eyes, the eyes, what is over them, and the light that pulses. What moves
Expand All @@ -21,14 +39,9 @@
let { robot, state = 'idle', gaze = null, bare = false }: { robot: Robot; state?: 'idle' | 'working' | 'waiting' | 'still'; gaze?: { x: number; y: number } | null; bare?: boolean } = $props();

const INK = 'var(--robot-ink)';
/** The retro head's box, which the screen, the ears and the antenna follow. */
const head = $derived([
{ x: 11, y: 16, w: 42, h: 36, r: 11 },
{ x: 10, y: 13, w: 44, h: 42, r: 21 },
{ x: 14, y: 12, w: 36, h: 42, r: 11 },
{ x: 7, y: 19, w: 50, h: 32, r: 11 },
][robot.shape] ?? { x: 11, y: 16, w: 42, h: 36, r: 11 });
const screen = $derived({ x: head.x + 5, y: head.y + 6, w: head.w - 10, h: Math.min(head.h - 15, 22) });
const retro = $derived(retroHead(robot.shape));
const head = $derived(retro.head);
const screen = $derived(retro.screen);
const sy = $derived(screen.y + screen.h / 2);
const lx = $derived(32 - screen.w / 4.2);
const rx = $derived(32 + screen.w / 4.2);
Expand Down Expand Up @@ -83,8 +96,8 @@
}
});
const pc = (units: number) => `${+(units * 100 / 64).toFixed(4)}%`;
/** A jelly's gaze, in hundredths of the square. */
const look = $derived(gaze && robot.family === 'jelly' ? `translate(${(gaze.x * 3.75).toFixed(3)}%, ${(gaze.y * 2.8125).toFixed(3)}%)` : undefined);
/** The gaze, in hundredths of the square. */
const look = $derived(gaze ? `translate(${(gaze.x * 3.75).toFixed(3)}%, ${(gaze.y * 2.8125).toFixed(3)}%)` : undefined);
</script>

<div
Expand Down Expand Up @@ -185,7 +198,7 @@
<ellipse class="blush" cx="17" cy="48" rx="3.6" ry="2.2" /><ellipse class="blush" cx="47" cy="48" rx="3.6" ry="2.2" />
</svg>
{#if robot.top === 1}<svg class="layer led" viewBox="0 0 64 64"><circle class="deep" cx="32" cy="7" r="3.6" /></svg>{/if}
<div class="layer eyes">
<div class="layer" style:transform={look}><div class="layer eyes">
<svg class="layer" viewBox="0 0 64 64">
{#if robot.eyes === 0}<circle cx="24.5" cy="41" r="3.3" fill={INK} /><circle cx="39.5" cy="41" r="3.3" fill={INK} />
{:else if robot.eyes === 1}<rect x="21.8" y="36" width="5.4" height="10" rx="2.7" fill={INK} /><rect x="36.8" y="36" width="5.4" height="10" rx="2.7" fill={INK} />
Expand All @@ -196,7 +209,7 @@
{:else if robot.eyes === 3}<path d="M20.5 42.5 q4 -6 8 0 M35.5 42.5 q4 -6 8 0" fill="none" class="stroke-ink" stroke-width="2.6" stroke-linecap="round" />
{:else}<path d="M20.5 41 q4 3.5 8 0 M35.5 41 q4 3.5 8 0" fill="none" class="stroke-ink" stroke-width="2.4" stroke-linecap="round" />{/if}
</svg>
</div>
</div></div>
{#if robot.top >= 3 && robot.top <= 6}
<!-- Over the body: what it wears. -->
<svg class="layer" viewBox="0 0 64 64">
Expand All @@ -215,7 +228,9 @@
{:else if robot.family === 'capsule'}
<!-- Leaning to one side; the eyes lean with it. -->
<div class="layer tilt">
<svg class="layer" viewBox="0 0 64 64">
<!-- Behind the face: what it wears on top, with the antenna's light. -->
<div class="layer cap">
<svg class="layer" viewBox="0 0 64 64">
{#if robot.top === 1}
<line x1="32" y1="11" x2="35" y2="2" class="stroke-ink" stroke-width="2.4" stroke-linecap="round" />
{:else if robot.top === 2}
Expand All @@ -228,6 +243,10 @@
<path d="M7 36 C7 6 57 6 57 36" fill="none" class="stroke-ink" stroke-width="3" />
<rect x="1" y="30" width="9" height="15" rx="4.5" class="deep" /><rect x="54" y="30" width="9" height="15" rx="4.5" class="deep" />
{/if}
</svg>
{#if robot.top === 1}<svg class="layer led" viewBox="0 0 64 64"><circle class="deep" cx="35.5" cy="2.5" r="3.6" /></svg>{/if}
</div>
<svg class="layer" viewBox="0 0 64 64">
<g class="face">
{#if robot.shape === 0}<circle cx="32" cy="36" r="26" />
{:else if robot.shape === 1}<ellipse cx="32" cy="37" rx="23" ry="27" />
Expand All @@ -236,8 +255,7 @@
</g>
<ellipse class="blush" cx="16" cy="43" rx="4.4" ry="2.6" /><ellipse class="blush" cx="48" cy="43" rx="4.4" ry="2.6" />
</svg>
{#if robot.top === 1}<svg class="layer led" viewBox="0 0 64 64"><circle class="deep" cx="35.5" cy="2.5" r="3.6" /></svg>{/if}
<div class="layer eyes">
<div class="layer" style:transform={look}><div class="layer eyes">
<svg class="layer" viewBox="0 0 64 64">
{#if robot.eyes === 0}<rect x="21" y="26.5" width="6" height="13" rx="3" fill={INK} /><rect x="37" y="26.5" width="6" height="13" rx="3" fill={INK} />
{:else if robot.eyes === 1}<circle cx="24" cy="33" r="3.6" fill={INK} /><circle cx="40" cy="33" r="3.6" fill={INK} />
Expand All @@ -248,7 +266,7 @@
<path d="M40 27 l1.9 4.1 4.4 .5 -3.3 3 .9 4.4 -3.9 -2.2 -3.9 2.2 .9 -4.4 -3.3 -3 4.4 -.5Z" fill={INK} />
{/if}
</svg>
</div>
</div></div>
</div>

{:else if robot.family === 'box'}
Expand All @@ -268,8 +286,9 @@
<svg class="layer" viewBox="0 0 64 64"><g transform={BOX}><BoxTop top={paint.top} x={50} y={26} scale={1.5} /></g></svg>

{:else}
<!-- Retro: a head with a dark screen, the eyes lit on it. -->
<svg class="layer" viewBox="0 0 64 64">
<!-- Retro: a head with a dark screen, the eyes lit on it. The antenna and its lights are a layer that turns round its foot. -->
<div class="layer antenna" style:--antenna-at="50% {pc(head.y)}">
<svg class="layer" viewBox="0 0 64 64">
{#if robot.top === 0}
<line x1="32" y1={head.y} x2="32" y2={head.y - 8} class="stroke-deep" stroke-width="2.4" />
{:else if robot.top === 1}
Expand All @@ -278,6 +297,14 @@
<line x1="32" y1={head.y} x2="32" y2={head.y - 6} class="stroke-deep" stroke-width="2.4" />
<ellipse cx="24.5" cy={head.y - 7} rx="7.5" ry="2.2" class="deep" /><ellipse cx="39.5" cy={head.y - 7} rx="7.5" ry="2.2" class="deep" /><circle cx="32" cy={head.y - 7} r="2.2" fill={INK} />
{/if}
</svg>
{#if robot.top === 0}
<svg class="layer led" viewBox="0 0 64 64"><circle class="bulb" cx="32" cy={head.y - 9} r="3.4" /></svg>
{:else if robot.top === 1}
<svg class="layer led" viewBox="0 0 64 64"><circle class="halo" cx="19.5" cy={head.y - 9} r="2.8" /><circle class="halo" cx="44.5" cy={head.y - 9} r="2.8" /></svg>
{/if}
</div>
<svg class="layer" viewBox="0 0 64 64">
<rect x="20" y={head.y + head.h - 2} width="24" height="30" rx="7" class="deep" />
<rect x={head.x - 5} y={head.y + head.h / 2 - 6} width="7" height="12" rx="3" class="deep" />
<rect x={head.x + head.w - 2} y={head.y + head.h / 2 - 6} width="7" height="12" rx="3" class="deep" />
Expand All @@ -286,12 +313,7 @@
{#if head.h - (screen.y - head.y) - screen.h > 8}<path d="M27 {screen.y + screen.h + 4.5} h10" class="stroke-deep" stroke-width="2" stroke-linecap="round" />{/if}
<ellipse cx={head.x + 9} cy={head.y + 4} rx="5" ry="2" class="shine" />
</svg>
{#if robot.top === 0}
<svg class="layer led" viewBox="0 0 64 64"><circle class="bulb" cx="32" cy={head.y - 9} r="3.4" /></svg>
{:else if robot.top === 1}
<svg class="layer led" viewBox="0 0 64 64"><circle class="halo" cx="19.5" cy={head.y - 9} r="2.8" /><circle class="halo" cx="44.5" cy={head.y - 9} r="2.8" /></svg>
{/if}
<div class="layer eyes">
<div class="layer" style:transform={look}><div class="layer eyes">
<svg class="layer" viewBox="0 0 64 64"><g class="lit">
{#if robot.eyes === 0}<circle cx={lx} cy={sy} r="3.2" /><circle cx={rx} cy={sy} r="3.2" />
{:else if robot.eyes === 1}<rect x={lx - 2.5} y={sy - 5} width="5" height="10" rx="2.5" /><rect x={rx - 2.5} y={sy - 5} width="5" height="10" rx="2.5" />
Expand All @@ -302,7 +324,7 @@
<path d="M{rx} {sy + 3.5} l-4 -4 a2.3 2.3 0 0 1 4 -2.6 a2.3 2.3 0 0 1 4 2.6Z" class="heart" />
{/if}
</g></svg>
</div>
</div></div>
{/if}
</div>
</div>
Expand Down Expand Up @@ -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); }
Expand Down
2 changes: 1 addition & 1 deletion src/components/agents/RobotPicker.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading