Add desktop games, robot pet and TCG app

This commit is contained in:
SirBlob committed 2026-11-01 18:42:28 -05:00
1 parent 812503251b
commit c664f573b8
55 files changed
+5090 -15

No files matched your search

+485
View File
@@ -0,0 +1,485 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
commandPet,
createPet,
dropPet,
holdPet,
hopPet,
petExpression,
placePet,
stepPet,
type Pet,
type PetAction
} from '#lib/pet/behavior';
import { petState } from '#lib/pet/petState.svelte';
import { randomVoiceLine, type VoiceMoment } from '#lib/pet/voiceLines';
import type { Rect } from '#lib/pet/world';
import RobotSprite from './RobotSprite.svelte';
import MoveMarker from './MoveMarker.svelte';
import PixelIcon from './PixelIcon.svelte';
let { areaWidth, areaHeight }: { areaWidth: number; areaHeight: number } = $props();
interface SpeechBubble {
id: number;
text: string;
}
interface Marker {
id: number;
x: number;
y: number;
}
const dragThreshold = 4;
const longestFrameSeconds = 0.05;
const bubbleMilliseconds = 1800;
const markerMilliseconds = 600;
const commandReplyChance = 0.4;
const arriveReplyChance = 0.5;
const activityReplyChance = 0.5;
const chatterChancePerSecond = 0.03;
let pet = $state<Pet | null>(null);
let bubble = $state<SpeechBubble | null>(null);
let markers = $state<Marker[]>([]);
let obstacles = $state<Rect[]>([]);
let layerElement = $state<HTMLDivElement>();
let petElement = $state<HTMLButtonElement>();
let nextId = 0;
let bubbleTimer: ReturnType<typeof setTimeout> | undefined;
let dragStart: { x: number; y: number } | null = null;
let grabOffset = { x: 0, y: 0 };
const area = $derived({ width: areaWidth, height: areaHeight, obstacles });
const expression = $derived(pet ? petExpression(pet) : 'awake');
const noteRows = ['..XX', '..X.', '..X.', 'XXX.', 'XX..'];
const boltRows = ['..XX', '.XX.', 'XXXX', '.XX.', 'XX..'];
function say(moment: VoiceMoment) {
clearTimeout(bubbleTimer);
bubble = { id: nextId++, text: randomVoiceLine(moment) };
bubbleTimer = setTimeout(() => (bubble = null), bubbleMilliseconds);
}
function reactToActionChange(previousAction: PetAction, currentPet: Pet) {
if (previousAction === currentPet.action) return;
if (currentPet.activity && Math.random() < activityReplyChance) say(currentPet.activity);
else if (currentPet.action === 'sleep') say('sleep');
else if (previousAction === 'sleep') say('wake');
else if (previousAction === 'walk' && currentPet.commanded && Math.random() < arriveReplyChance) say('arrive');
}
function maybeChatter(currentPet: Pet, seconds: number) {
if (bubble || currentPet.action !== 'idle') return;
if (Math.random() < chatterChancePerSecond * seconds) say('chatter');
}
function measureObstacles() {
const desktopElement = layerElement?.closest('.desktop');
if (!layerElement || !desktopElement) return;
const layerRect = layerElement.getBoundingClientRect();
obstacles = [...desktopElement.querySelectorAll('.desktop-icon')].map((iconElement) => {
const iconRect = iconElement.getBoundingClientRect();
return {
left: iconRect.left - layerRect.left,
top: iconRect.top - layerRect.top,
right: iconRect.right - layerRect.left,
bottom: iconRect.bottom - layerRect.top
};
});
}
$effect(() => {
areaWidth;
areaHeight;
measureObstacles();
});
function showMarker(x: number, y: number) {
const marker = { id: nextId++, x, y };
markers.push(marker);
setTimeout(() => (markers = markers.filter((item) => item.id !== marker.id)), markerMilliseconds);
}
function isDesktopBackground(target: EventTarget | null): boolean {
if (!(target instanceof Element) || !layerElement) return false;
const desktopElement = layerElement.closest('.desktop');
return !!desktopElement?.contains(target) && !target.closest('.window');
}
function handleContextMenu(event: MouseEvent) {
if (!pet || !petState.enabled || !layerElement || !isDesktopBackground(event.target)) return;
event.preventDefault();
const layerRect = layerElement.getBoundingClientRect();
const pointX = event.clientX - layerRect.left;
const pointY = event.clientY - layerRect.top;
commandPet(pet, pointX, pointY, area);
showMarker(pointX, pointY);
if (Math.random() < commandReplyChance) say('command');
}
function handlePointerDown(event: PointerEvent) {
if (event.button !== 0 || !petElement) return;
event.preventDefault();
petElement.setPointerCapture(event.pointerId);
const petRect = petElement.getBoundingClientRect();
dragStart = { x: event.clientX, y: event.clientY };
grabOffset = { x: event.clientX - petRect.left, y: event.clientY - petRect.top };
}
function handlePointerMove(event: PointerEvent) {
if (!pet || !dragStart || !layerElement) return;
const movedDistance = Math.hypot(event.clientX - dragStart.x, event.clientY - dragStart.y);
if (pet.action !== 'held' && movedDistance < dragThreshold) return;
if (pet.action !== 'held') {
holdPet(pet);
say('pickup');
}
const layerRect = layerElement.getBoundingClientRect();
placePet(pet, event.clientX - layerRect.left - grabOffset.x, event.clientY - layerRect.top - grabOffset.y, area);
}
function handlePointerUp() {
if (!pet || !dragStart) return;
dragStart = null;
if (pet.action === 'held') {
dropPet(pet);
say('drop');
return;
}
hopPet(pet);
say('click');
}
onMount(() => {
petState.load();
let frameId = 0;
let lastTime = performance.now();
const frame = (time: number) => {
const seconds = Math.min(longestFrameSeconds, (time - lastTime) / 1000);
lastTime = time;
if (!pet && areaWidth > 0) pet = createPet(area);
if (pet && petState.enabled) {
const previousAction = pet.action;
stepPet(pet, seconds, area);
reactToActionChange(previousAction, pet);
maybeChatter(pet, seconds);
}
frameId = requestAnimationFrame(frame);
};
frameId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameId);
});
</script>
<svelte:window oncontextmenu={handleContextMenu} />
<div class="pet-layer" bind:this={layerElement}>
{#if pet && petState.enabled}
{#each markers as marker (marker.id)}
<MoveMarker x={marker.x} y={marker.y} />
{/each}
<button
class="pet {pet.action} {pet.activity ?? ''}"
style="left: {pet.x}px; top: {pet.y}px"
bind:this={petElement}
aria-label="Desktop robot, click to say hi, drag to move or right click the desktop to send it somewhere"
onmousedown={(event) => event.preventDefault()}
onpointerdown={handlePointerDown}
onpointermove={handlePointerMove}
onpointerup={handlePointerUp}
onpointercancel={handlePointerUp}
>
<span class="shadow"></span>
<span class="facing" style="transform: scaleX({pet.facing})">
<span class="motion">
<RobotSprite {expression} activity={pet.activity} />
</span>
</span>
{#if pet.action === 'sleep'}
<span class="snore">zZ</span>
{:else if pet.activity === 'dance'}
<span class="floating-icon note"><PixelIcon rows={noteRows} pixelSize={4} /></span>
{:else if pet.activity === 'charge'}
<span class="floating-icon bolt"><PixelIcon rows={boltRows} pixelSize={4} /></span>
{/if}
{#if bubble}
{#key bubble.id}
<span class="bubble">{bubble.text}</span>
{/key}
{/if}
</button>
{/if}
</div>
<style>
.pet-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
}
.pet {
position: absolute;
padding: 0;
background: none;
border: none;
cursor: grab;
pointer-events: auto;
touch-action: none;
user-select: none;
}
.pet.held {
cursor: grabbing;
}
.shadow {
position: absolute;
bottom: -3px;
left: 50%;
width: 52px;
height: 12px;
background: rgb(0 0 0 / 0.35);
border-radius: 50%;
transform: translateX(-50%);
}
.held .shadow {
bottom: -18px;
width: 36px;
opacity: 0.6;
}
.facing,
.motion {
position: relative;
display: block;
}
.motion {
transform-origin: bottom center;
}
.walk .motion {
animation: robot-step 0.3s steps(2) infinite;
}
.idle .motion {
animation: robot-hum 2.4s ease-in-out infinite;
}
.sleep .motion {
transform: translateY(2px) scaleY(0.94);
}
.hop .motion {
animation: robot-hop 0.4s ease-out;
}
.held .motion {
animation: robot-wiggle 0.3s ease-in-out infinite;
}
.wave .motion,
.scan .motion,
.compute .motion {
animation: robot-hum 2.4s ease-in-out infinite;
}
.dance .motion {
animation: robot-dance 0.5s infinite;
}
.spin .motion {
animation: robot-spin 0.8s infinite;
}
.charge .motion {
animation: robot-buzz 0.15s steps(2) infinite;
}
.floating-icon {
position: absolute;
bottom: calc(100% - 6px);
left: 70%;
pointer-events: none;
animation: icon-float 1.2s ease-out infinite;
}
.note {
color: var(--magenta);
}
.bolt {
color: var(--yellow);
}
@keyframes robot-dance {
0% {
transform: translateY(0) rotate(-8deg);
animation-timing-function: steps(1);
}
50% {
transform: translateY(-4px) rotate(8deg);
animation-timing-function: steps(1);
}
}
@keyframes robot-spin {
0% {
transform: scaleX(1);
animation-timing-function: steps(1);
}
25% {
transform: scaleX(0.3);
animation-timing-function: steps(1);
}
50% {
transform: scaleX(-1);
animation-timing-function: steps(1);
}
75% {
transform: scaleX(-0.3);
animation-timing-function: steps(1);
}
}
@keyframes robot-buzz {
from {
transform: translateX(-1px);
}
to {
transform: translateX(1px);
}
}
@keyframes icon-float {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(8px, -18px);
}
}
.snore,
.bubble {
position: absolute;
bottom: calc(100% + 2px);
left: 50%;
white-space: nowrap;
pointer-events: none;
}
.snore {
color: var(--muted);
font-size: 12px;
animation: snore-float 2s ease-out infinite;
}
.bubble {
padding: 1px 6px;
background: var(--background);
border: 1px solid var(--accent);
color: var(--foreground);
font-size: 12px;
transform: translateX(-50%);
animation: bubble-rise 1.8s ease-out forwards;
}
@keyframes robot-step {
from {
transform: translateY(0);
}
to {
transform: translateY(-2px);
}
}
@keyframes robot-hum {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-1px);
}
}
@keyframes robot-hop {
0%,
100% {
transform: translateY(0);
}
40% {
transform: translateY(-14px);
}
}
@keyframes robot-wiggle {
0%,
100% {
transform: rotate(-6deg);
}
50% {
transform: rotate(6deg);
}
}
@keyframes snore-float {
from {
opacity: 1;
transform: translate(0, 0);
}
to {
opacity: 0;
transform: translate(10px, -14px);
}
}
@keyframes bubble-rise {
0% {
opacity: 0;
transform: translate(-50%, 4px);
}
15% {
opacity: 1;
transform: translate(-50%, 0);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
transform: translate(-50%, -10px);
}
}
</style>
+77
View File
@@ -0,0 +1,77 @@
<script lang="ts">
import { pixelRuns } from '#lib/pet/pixelArt';
let { x, y }: { x: number; y: number } = $props();
const pixelSize = 3;
const frameSeconds = 0.2;
const frameRows = [
[
'...XXXXX...',
'.XX.....XX.',
'X.........X',
'X.........X',
'X.........X',
'.XX.....XX.',
'...XXXXX...'
],
['..XXX..', '.X...X.', 'X.....X', '.X...X.', '..XXX..'],
['.X.', 'X.X', '.X.']
];
const frames = frameRows.map((rows, index) => ({
index,
width: rows[0].length,
height: rows.length,
runs: pixelRuns(rows)
}));
</script>
<span class="move-marker" style="left: {x}px; top: {y}px">
{#each frames as frame (frame.index)}
<svg
class="frame"
viewBox="0 0 {frame.width} {frame.height}"
width={frame.width * pixelSize}
height={frame.height * pixelSize}
shape-rendering="crispEdges"
style="animation-delay: {frame.index * frameSeconds}s; animation-duration: {frameSeconds}s"
aria-hidden="true"
>
{#each frame.runs as run (`${run.x}-${run.y}`)}
<rect x={run.x} y={run.y} width={run.width} height="1" />
{/each}
</svg>
{/each}
</span>
<style>
.move-marker {
position: absolute;
width: 0;
height: 0;
pointer-events: none;
}
.frame {
position: absolute;
top: 0;
left: 0;
fill: var(--green);
opacity: 0;
transform: translate(-50%, -50%);
animation-name: show-frame;
animation-timing-function: steps(1, end);
}
@keyframes show-frame {
from {
opacity: 1;
}
to {
opacity: 1;
}
}
</style>
+29
View File
@@ -0,0 +1,29 @@
<script lang="ts">
import { pixelRuns } from '#lib/pet/pixelArt';
let { rows, pixelSize = 2 }: { rows: string[]; pixelSize?: number } = $props();
const width = $derived(rows[0]?.length ?? 0);
const height = $derived(rows.length);
const runs = $derived(pixelRuns(rows));
</script>
<svg
class="pixel-icon"
viewBox="0 0 {width} {height}"
width={width * pixelSize}
height={height * pixelSize}
shape-rendering="crispEdges"
aria-hidden="true"
>
{#each runs as run (`${run.x}-${run.y}`)}
<rect x={run.x} y={run.y} width={run.width} height="1" />
{/each}
</svg>
<style>
.pixel-icon {
display: block;
fill: currentColor;
}
</style>
+266
View File
@@ -0,0 +1,266 @@
<script lang="ts">
import { pixelRuns } from '#lib/pet/pixelArt';
import { petHeight, petWidth, robotGridSize, type Point } from '#lib/pet/world';
import type { PetActivity, PetExpression } from '#lib/pet/behavior';
let { expression, activity }: { expression: PetExpression; activity: PetActivity | null } = $props();
const bodyRows = [
'............',
'.....XX.....',
'.XXXXXXXXXX.',
'.X........X.',
'.X........X.',
'.X........X.',
'.XXXXXXXXXX.',
'...XXXXXX...',
'...XXXXXX...',
'...XXXXXX...',
'...X....X...',
'..XX....XX..'
];
const bodyRuns = pixelRuns(bodyRows);
const leftArmDown: Point[] = [{ x: 0, y: 8 }, { x: 1, y: 8 }];
const rightArmDown: Point[] = [{ x: 10, y: 8 }, { x: 11, y: 8 }];
const leftArmUp: Point[] = [{ x: 1, y: 7 }, { x: 0, y: 6 }];
const rightArmUp: Point[] = [{ x: 10, y: 7 }, { x: 11, y: 6 }, { x: 11, y: 5 }];
const rightArmOut: Point[] = [{ x: 10, y: 7 }, { x: 11, y: 7 }, { x: 11, y: 6 }];
const armPoses: Partial<Record<PetActivity, Point[][]>> = {
wave: [
[...leftArmDown, ...rightArmUp],
[...leftArmDown, ...rightArmOut]
],
dance: [
[...leftArmUp, ...rightArmDown],
[...leftArmDown, ...rightArmUp]
]
};
const happyEyePixels: Point[] = [
{ x: 2, y: 4 },
{ x: 3, y: 3 },
{ x: 4, y: 4 },
{ x: 7, y: 4 },
{ x: 8, y: 3 },
{ x: 9, y: 4 }
];
const codeFrames = [
[
{ x: 3, y: 3, width: 4 },
{ x: 3, y: 4, width: 2 },
{ x: 4, y: 5, width: 4 }
],
[
{ x: 3, y: 3, width: 2 },
{ x: 4, y: 4, width: 4 },
{ x: 3, y: 5, width: 3 }
]
];
const armFrames = $derived((activity && armPoses[activity]) || [[...leftArmDown, ...rightArmDown]]);
</script>
<svg
class="robot activity-{activity ?? 'none'}"
viewBox="0 0 {robotGridSize} {robotGridSize}"
width={petWidth}
height={petHeight}
shape-rendering="crispEdges"
aria-hidden="true"
>
<rect class="antenna-light" class:powered-down={expression === 'sleeping'} x="5" y="0" width="2" height="1" />
{#each bodyRuns as run (`${run.x}-${run.y}`)}
<rect class="frame" x={run.x} y={run.y} width={run.width} height="1" />
{/each}
{#each armFrames as armPixels, frameIndex (frameIndex)}
<g class="frame arm-frame" class:alternating={armFrames.length > 1} class:second-frame={frameIndex === 1}>
{#each armPixels as pixel (`${pixel.x}-${pixel.y}`)}
<rect x={pixel.x} y={pixel.y} width="1" height="1" />
{/each}
</g>
{/each}
<rect class="screen" x="2" y="3" width="8" height="3" />
<rect class="chest-light" x="5" y="8" width="2" height="1" />
{#if activity === 'compute'}
{#each codeFrames as lines, frameIndex (frameIndex)}
<g class="code alternating" class:second-frame={frameIndex === 1}>
{#each lines as codeLine (`${codeLine.x}-${codeLine.y}`)}
<rect x={codeLine.x} y={codeLine.y} width={codeLine.width} height="1" />
{/each}
</g>
{/each}
{:else if activity === 'charge'}
<rect class="battery-track" x="3" y="4" width="6" height="1" />
<rect class="battery-fill" x="3" y="4" width="6" height="1" />
{:else if expression === 'awake'}
<g class="eyes" class:blinking={activity !== 'scan'} class:scanning={activity === 'scan'}>
<rect x="3" y="3" width="2" height="2" />
<rect x="7" y="3" width="2" height="2" />
</g>
{:else if expression === 'sleeping'}
<g class="eyes powered-down">
<rect x="3" y="4" width="2" height="1" />
<rect x="7" y="4" width="2" height="1" />
</g>
{:else}
<g class="eyes">
{#each happyEyePixels as pixel (`${pixel.x}-${pixel.y}`)}
<rect x={pixel.x} y={pixel.y} width="1" height="1" />
{/each}
</g>
{/if}
</svg>
<style>
.robot {
display: block;
}
.frame {
fill: var(--accent);
}
.screen {
fill: var(--dark-background, var(--background));
}
.eyes {
fill: var(--cyan);
}
.code {
fill: var(--green);
}
.battery-track {
fill: var(--muted);
opacity: 0.4;
}
.battery-fill {
fill: var(--green);
transform-box: fill-box;
transform-origin: left center;
animation: battery-charge 1.8s steps(6, jump-start) infinite;
}
.chest-light {
fill: var(--yellow);
}
.activity-charge .chest-light {
fill: var(--green);
animation: light-flash 0.5s steps(1) infinite;
}
.antenna-light {
fill: var(--red);
animation: light-flash 1.6s steps(1) infinite;
}
.activity-scan .antenna-light {
fill: var(--green);
animation-duration: 0.4s;
}
.powered-down {
opacity: 0.35;
animation: none;
}
.blinking {
transform-box: fill-box;
transform-origin: center;
animation: blink 4s infinite;
}
.scanning {
animation: scan-eyes 1.4s steps(1) infinite;
}
.alternating {
animation: show-first-frame 0.5s steps(1) infinite;
}
.alternating.second-frame {
animation-name: show-second-frame;
}
@keyframes show-first-frame {
0% {
opacity: 1;
}
50% {
opacity: 0;
}
}
@keyframes show-second-frame {
0% {
opacity: 0;
}
50% {
opacity: 1;
}
}
@keyframes light-flash {
0% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
@keyframes scan-eyes {
0% {
transform: translateX(0);
}
25% {
transform: translateX(-1px);
}
50% {
transform: translateX(0);
}
75% {
transform: translateX(1px);
}
}
@keyframes battery-charge {
from {
transform: scaleX(0);
}
to {
transform: scaleX(1);
}
}
@keyframes blink {
0%,
94%,
100% {
transform: scaleY(1);
}
97% {
transform: scaleY(0.1);
}
}
</style>