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

+16
View File
@@ -33,6 +33,22 @@
{ "label": "btop", "icon": "ph:chart-line-duotone", "open": { "app": "btop" } }
]
},
{
"name": "apps",
"side": "right",
"icons": [
{ "label": "tcg", "icon": "mdi:pokeball", "open": { "app": "tcg" } }
]
},
{
"name": "games",
"side": "right",
"icons": [
{ "label": "snake", "icon": "mdi:snake", "open": { "app": "snake" } },
{ "label": "breakout", "icon": "mdi:wall", "open": { "app": "breakout" } },
{ "label": "robot", "icon": "ph:robot-duotone", "open": { "app": "pet" } }
]
},
{
"name": "socials",
"icons": [
+35
View File
@@ -0,0 +1,35 @@
[
{
"id": "cynthias-garchomp",
"name": "Cynthia's Garchomp",
"cards": [
{ "count": 4, "name": "Cynthia's Gible", "set": "DRI", "category": "pokemon" },
{ "count": 4, "name": "Cynthia's Gabite", "set": "DRI", "category": "pokemon" },
{ "count": 3, "name": "Cynthia's Garchomp ex", "set": "DRI", "category": "pokemon" },
{ "count": 2, "name": "Cynthia's Roselia", "set": "DRI", "category": "pokemon" },
{ "count": 2, "name": "Cynthia's Roserade", "set": "DRI", "category": "pokemon" },
{ "count": 1, "name": "Munkidori", "set": "TWM", "category": "pokemon" },
{ "count": 1, "name": "Cynthia's Spiritomb", "set": "DRI", "category": "pokemon" },
{ "count": 4, "name": "Arven", "category": "trainer" },
{ "count": 4, "name": "Iono", "category": "trainer" },
{ "count": 4, "name": "Buddy-Buddy Poffin", "category": "trainer" },
{ "count": 4, "name": "Cynthia's Power Weight", "category": "trainer" },
{ "count": 3, "name": "Boss's Orders", "category": "trainer" },
{ "count": 2, "name": "Technical Machine: Evolution", "category": "trainer" },
{ "count": 2, "name": "Team Rocket's Watchtower", "category": "trainer" },
{ "count": 2, "name": "Counter Catcher", "category": "trainer" },
{ "count": 2, "name": "Earthen Vessel", "category": "trainer" },
{ "count": 1, "name": "Night Stretcher", "category": "trainer" },
{ "count": 1, "name": "Crispin", "category": "trainer" },
{ "count": 1, "name": "Super Rod", "category": "trainer" },
{ "count": 1, "name": "Team Rocket's Petrel", "category": "trainer" },
{ "count": 1, "name": "Artazon", "category": "trainer" },
{ "count": 1, "name": "Unfair Stamp", "category": "trainer" },
{ "count": 1, "name": "Professor's Research", "category": "trainer" },
{ "count": 1, "name": "Rare Candy", "category": "trainer" },
{ "count": 6, "name": "Fighting Energy", "category": "energy" },
{ "count": 1, "name": "Luminous Energy", "category": "energy" },
{ "count": 1, "name": "Darkness Energy", "category": "energy" }
]
}
]
+37
View File
@@ -8,6 +8,10 @@
import { imageFileName } from '#lib/desktop/imageViewer';
import { defaultFloatingRect, floatingStyle, moveRect, resizeRect } from '#lib/desktop/floating';
import SystemMonitor from '../btop/SystemMonitor.svelte';
import Snake from '../games/Snake.svelte';
import Breakout from '../games/Breakout.svelte';
import DesktopPet from '../pet/DesktopPet.svelte';
import TcgBrowser from '../tcg/TcgBrowser.svelte';
import DesktopIcons from './DesktopIcons.svelte';
import Window from './Window.svelte';
@@ -98,6 +102,10 @@
</div>
<div class="tiling-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
{#if !isNarrow}
<DesktopPet {areaWidth} {areaHeight} />
{/if}
{#if pageOpen}
<Window title="kitty" path={pageTitle} icon={pageIcon} scrollKey={pageKey} {...windowProps(pageWindowId)} onclose={closePage}>
{@render children()}
@@ -141,6 +149,35 @@
<SystemMonitor />
</Window>
{/each}
{#each windowManager.games as gameWindow (gameWindow.id)}
<Window
title={gameWindow.game}
path="games"
icon="ph:game-controller-duotone"
{...windowProps(gameWindow.id)}
onclose={() => windowManager.closeWindow(gameWindow.id)}
>
{#if gameWindow.game === 'snake'}
<Snake focused={windowManager.focusedId === gameWindow.id} />
{:else if gameWindow.game === 'breakout'}
<Breakout focused={windowManager.focusedId === gameWindow.id} />
{/if}
</Window>
{/each}
{#each windowManager.tcgBrowsers as browser (browser.id)}
<Window
title="tcg"
path="card browser"
icon="mdi:pokeball"
{...windowProps(browser.id)}
onclose={() => windowManager.closeWindow(browser.id)}
>
<TcgBrowser />
</Window>
{/each}
</div>
</div>
+45 -11
View File
@@ -1,23 +1,43 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { desktop } from '#lib/content';
import { desktop, type DesktopSide } from '#lib/content';
import { runDesktopAction } from '#lib/desktop/actions';
const icons = desktop.iconGroups.flatMap((group) => group.icons);
function iconsOnSide(side: DesktopSide) {
return desktop.iconGroups.filter((group) => (group.side ?? 'left') === side).flatMap((group) => group.icons);
}
const iconSides = [
{ side: 'left', label: 'Desktop', icons: iconsOnSide('left') },
{ side: 'right', label: 'Desktop games', icons: iconsOnSide('right') }
];
</script>
<nav class="desktop-icons" aria-label="Desktop">
{#each icons as item (item.label)}
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
<span class="icon-box">
<Icon icon={item.icon} width="36" />
</span>
<span class="icon-label">{item.label}</span>
</button>
<div class="desktop-icon-sides">
{#each iconSides as iconSide (iconSide.side)}
{#if iconSide.icons.length}
<nav class="desktop-icons {iconSide.side}" aria-label={iconSide.label}>
{#each iconSide.icons as item (item.label)}
<button class="desktop-icon" title={item.label} onclick={() => runDesktopAction(item.open)}>
<span class="icon-box">
<Icon icon={item.icon} width="36" />
</span>
<span class="icon-label">{item.label}</span>
</button>
{/each}
</nav>
{/if}
{/each}
</nav>
</div>
<style>
.desktop-icon-sides {
display: flex;
justify-content: space-between;
gap: 4px;
height: 100%;
}
.desktop-icons {
display: grid;
grid-auto-flow: column;
@@ -29,6 +49,10 @@
align-content: start;
}
.desktop-icons.right {
direction: rtl;
}
.desktop-icon {
display: flex;
flex-direction: column;
@@ -38,6 +62,7 @@
background: none;
border: none;
color: var(--foreground);
direction: ltr;
}
.desktop-icon:focus-visible {
@@ -86,6 +111,11 @@
}
@media (max-width: 767px) {
.desktop-icon-sides {
flex-direction: column;
height: auto;
}
.desktop-icons {
grid-auto-flow: row;
grid-template-columns: repeat(auto-fill, 104px);
@@ -94,5 +124,9 @@
justify-content: center;
height: auto;
}
.desktop-icons.right {
direction: ltr;
}
}
</style>
+216
View File
@@ -0,0 +1,216 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
advanceBreakout,
createBreakoutGame,
fieldHeight,
fieldWidth,
launchBall,
movePaddleTo,
type BreakoutStatus
} from '#lib/games/breakout';
import { loadHighScore, saveHighScore } from '#lib/games/highScore';
import GameShell from './GameShell.svelte';
import BreakoutCanvas from './BreakoutCanvas.svelte';
let { focused }: { focused: boolean } = $props();
const gameName = 'breakout';
const boardFrameSize = 6;
const longestFrameSeconds = 0.05;
const steeringKeys: Record<string, number> = {
ArrowLeft: -1,
a: -1,
h: -1,
ArrowRight: 1,
d: 1,
l: 1
};
const launchKeys = ['ArrowUp', 'w', 'k', 'Enter'];
const statusMessages: Record<BreakoutStatus, string> = {
serving: 'press space or click to launch',
running: '',
paused: 'paused, press space to resume',
over: 'game over, press r to restart'
};
let game = $state(createBreakoutGame());
let highScore = $state(0);
let boardElement = $state<HTMLDivElement>();
let areaWidth = $state(0);
let areaHeight = $state(0);
const heldKeys = new Set<string>();
const canvasWidth = $derived(
Math.max(0, Math.floor(Math.min(areaWidth - boardFrameSize, ((areaHeight - boardFrameSize) * fieldWidth) / fieldHeight)))
);
const canvasHeight = $derived(Math.round((canvasWidth * fieldHeight) / fieldWidth));
const isActive = $derived(game.status === 'serving' || game.status === 'running');
function normalizedKey(event: KeyboardEvent): string {
return event.key.length === 1 ? event.key.toLowerCase() : event.key;
}
function steering(): number {
let direction = 0;
heldKeys.forEach((key) => (direction += steeringKeys[key] ?? 0));
return Math.sign(direction);
}
function restart() {
game = createBreakoutGame();
}
function primaryAction() {
if (game.status === 'serving') launchBall(game);
else if (game.status === 'paused') game.status = 'running';
else if (game.status === 'over') restart();
}
function togglePause() {
if (game.status === 'running') game.status = 'paused';
else primaryAction();
}
function handleKeydown(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const key = normalizedKey(event);
if (key in steeringKeys) heldKeys.add(key);
else if (key === ' ' || key === 'p') togglePause();
else if (launchKeys.includes(key)) primaryAction();
else if (key === 'r') restart();
else return;
event.preventDefault();
}
function handleKeyup(event: KeyboardEvent) {
heldKeys.delete(normalizedKey(event));
}
function handlePointerMove(event: PointerEvent) {
if (!isActive || !boardElement || canvasWidth === 0) return;
const boardLeft = boardElement.getBoundingClientRect().left + boardFrameSize / 2;
movePaddleTo(game, ((event.clientX - boardLeft) / canvasWidth) * fieldWidth);
}
function handleBoardClick() {
primaryAction();
boardElement?.focus({ preventScroll: true });
}
function updateHighScore() {
if (game.score <= highScore) return;
highScore = game.score;
saveHighScore(gameName, highScore);
}
$effect(() => {
if (!isActive) return;
let frameId = 0;
let lastTime = performance.now();
const frame = (time: number) => {
const seconds = Math.min(longestFrameSeconds, (time - lastTime) / 1000);
lastTime = time;
advanceBreakout(game, seconds, steering());
updateHighScore();
frameId = requestAnimationFrame(frame);
};
frameId = requestAnimationFrame(frame);
return () => cancelAnimationFrame(frameId);
});
$effect(() => {
if (focused) {
boardElement?.focus({ preventScroll: true });
return;
}
heldKeys.clear();
if (game.status === 'running') game.status = 'paused';
});
onMount(() => {
highScore = loadHighScore(gameName);
});
</script>
<GameShell
title="breakout"
stats={[
{ label: 'score', value: game.score },
{ label: 'lives', value: game.lives },
{ label: 'level', value: game.level },
{ label: 'best', value: highScore }
]}
hint="arrows / ad / hl or mouse to move, space to launch or pause, r to restart"
touchHint="drag to move the paddle, tap to launch"
>
<div class="board-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
<div
class="board"
bind:this={boardElement}
tabindex="-1"
role="presentation"
onkeydown={handleKeydown}
onkeyup={handleKeyup}
onpointermove={handlePointerMove}
onpointerdown={handlePointerMove}
onclick={handleBoardClick}
>
<BreakoutCanvas paddleX={game.paddleX} ball={game.ball} bricks={game.bricks} width={canvasWidth} height={canvasHeight} />
{#if statusMessages[game.status]}
<div class="overlay {game.status}">{statusMessages[game.status]}</div>
{/if}
</div>
</div>
</GameShell>
<style>
.board-area {
display: grid;
flex: 1;
min-height: 0;
place-items: center;
overflow: hidden;
}
.board {
position: relative;
padding: 2px;
border: 1px solid var(--border-inactive);
outline: none;
cursor: none;
touch-action: none;
}
.board:focus-visible {
border-color: var(--accent);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 70%, transparent);
color: var(--foreground);
text-align: center;
pointer-events: none;
}
.overlay.serving {
align-items: end;
padding-bottom: 22%;
background: none;
color: var(--muted);
}
</style>
@@ -0,0 +1,73 @@
<script lang="ts">
import {
ballRadius,
brickHeight,
brickWidth,
fieldWidth,
paddleHeight,
paddleWidth,
paddleY,
type Ball,
type Brick
} from '#lib/games/breakout';
import { themeState } from '#lib/theme/theme.svelte';
let {
paddleX,
ball,
bricks,
width,
height
}: { paddleX: number; ball: Ball; bricks: Brick[]; width: number; height: number } = $props();
const brickRowColors = ['--red', '--orange', '--yellow', '--green', '--cyan', '--blue'];
let canvasElement = $state<HTMLCanvasElement>();
let pixelRatio = $state(1);
const canvasWidth = $derived(Math.round(width * pixelRatio));
const canvasHeight = $derived(Math.round(height * pixelRatio));
function drawField(context: CanvasRenderingContext2D) {
const style = getComputedStyle(canvasElement!);
const themeColor = (name: string) => style.getPropertyValue(name).trim();
const scale = canvasWidth / fieldWidth;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, canvasWidth, canvasHeight);
context.setTransform(scale, 0, 0, scale, 0, 0);
bricks.forEach((brick) => {
if (brick.broken) return;
context.fillStyle = themeColor(brickRowColors[brick.row % brickRowColors.length]);
context.fillRect(brick.x, brick.y, brickWidth, brickHeight);
});
context.fillStyle = themeColor('--accent');
context.fillRect(paddleX, paddleY, paddleWidth, paddleHeight);
context.fillStyle = themeColor('--foreground');
context.beginPath();
context.arc(ball.x, ball.y, ballRadius, 0, Math.PI * 2);
context.fill();
}
$effect(() => {
width;
pixelRatio = window.devicePixelRatio || 1;
});
$effect(() => {
themeState.currentId;
const context = canvasElement?.getContext('2d');
if (context && canvasWidth > 0) drawField(context);
});
</script>
<canvas bind:this={canvasElement} width={canvasWidth} height={canvasHeight} style="width: {width}px; height: {height}px"></canvas>
<style>
canvas {
display: block;
}
</style>
@@ -0,0 +1,65 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { Direction } from '#lib/games/snake';
let { onsteer }: { onsteer: (direction: Direction) => void } = $props();
const buttons: { direction: Direction; icon: string }[] = [
{ direction: 'up', icon: 'mdi:chevron-up' },
{ direction: 'left', icon: 'mdi:chevron-left' },
{ direction: 'down', icon: 'mdi:chevron-down' },
{ direction: 'right', icon: 'mdi:chevron-right' }
];
</script>
<div class="direction-pad">
{#each buttons as button (button.direction)}
<button class={button.direction} aria-label="Move {button.direction}" onpointerdown={() => onsteer(button.direction)}>
<Icon icon={button.icon} width="26" />
</button>
{/each}
</div>
<style>
.direction-pad {
display: grid;
grid-template-columns: repeat(3, 52px);
grid-template-rows: repeat(2, 52px);
grid-template-areas:
'. up .'
'left down right';
gap: 6px;
justify-content: center;
touch-action: manipulation;
}
button {
display: grid;
place-items: center;
padding: 0;
background: var(--background);
border: 2px solid var(--accent);
color: var(--accent);
}
button:active {
background: var(--accent);
color: var(--background);
}
.up {
grid-area: up;
}
.left {
grid-area: left;
}
.down {
grid-area: down;
}
.right {
grid-area: right;
}
</style>
+83
View File
@@ -0,0 +1,83 @@
<script lang="ts">
import type { Snippet } from 'svelte';
interface GameStat {
label: string;
value: number;
}
let {
title,
stats,
hint,
touchHint = '',
children
}: { title: string; stats: GameStat[]; hint: string; touchHint?: string; children: Snippet } = $props();
</script>
<div class="game">
<header class="score-line">
<span class="title">{title}</span>
{#each stats as stat (stat.label)}
<span>{stat.label} <span class="value">{stat.value}</span></span>
{/each}
</header>
{@render children()}
<footer class="hint muted">
<span class="keyboard-hint">{hint}</span>
{#if touchHint}
<span class="touch-hint">{touchHint}</span>
{/if}
</footer>
</div>
<style>
.game {
display: flex;
flex-direction: column;
gap: 0.5rem;
height: 100%;
min-height: 0;
padding: 0.5rem 0.75rem 0.75rem;
}
.score-line {
display: flex;
flex-shrink: 0;
justify-content: space-between;
gap: 1rem;
font-size: 0.85rem;
}
.title {
color: var(--accent);
font-weight: 700;
}
.value {
color: var(--yellow);
font-weight: 700;
}
.hint {
flex-shrink: 0;
font-size: 0.75rem;
text-align: center;
}
.touch-hint {
display: none;
}
@media (pointer: coarse) {
.keyboard-hint {
display: none;
}
.touch-hint {
display: inline;
}
}
</style>
+182
View File
@@ -0,0 +1,182 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
advanceSnake,
createSnakeGame,
queueDirection,
tickDelay,
type Direction,
type SnakeStatus
} from '#lib/games/snake';
import { loadHighScore, saveHighScore } from '#lib/games/highScore';
import GameShell from './GameShell.svelte';
import DirectionPad from './DirectionPad.svelte';
import SnakeCanvas from './SnakeCanvas.svelte';
let { focused }: { focused: boolean } = $props();
const gameName = 'snake';
const boardFrameSize = 6;
const keyDirections: Record<string, Direction> = {
ArrowUp: 'up',
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right',
w: 'up',
s: 'down',
a: 'left',
d: 'right',
k: 'up',
j: 'down',
h: 'left',
l: 'right'
};
const statusMessages: Record<SnakeStatus, string> = {
ready: 'press space or an arrow key to start',
running: '',
paused: 'paused, press space to resume',
over: 'game over, press r to restart',
won: 'you win, press r to play again'
};
let game = $state(createSnakeGame());
let highScore = $state(0);
let boardElement = $state<HTMLDivElement>();
let areaWidth = $state(0);
let areaHeight = $state(0);
const canvasSize = $derived(Math.max(0, Math.floor(Math.min(areaWidth, areaHeight)) - boardFrameSize));
const isFinished = $derived(game.status === 'over' || game.status === 'won');
function restart() {
game = createSnakeGame();
game.status = 'running';
}
function steer(direction: Direction) {
if (isFinished) return;
queueDirection(game, direction);
game.status = 'running';
}
function togglePause() {
if (isFinished) restart();
else game.status = game.status === 'running' ? 'paused' : 'running';
}
function handleOverlayClick() {
togglePause();
boardElement?.focus({ preventScroll: true });
}
function tick() {
advanceSnake(game);
if (game.score <= highScore) return;
highScore = game.score;
saveHighScore(gameName, highScore);
}
function handleKeydown(event: KeyboardEvent) {
if (event.altKey || event.ctrlKey || event.metaKey) return;
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
const direction = keyDirections[key];
if (direction) steer(direction);
else if (key === ' ' || key === 'p') togglePause();
else if (key === 'r' || key === 'Enter') restart();
else return;
event.preventDefault();
}
$effect(() => {
if (game.status !== 'running') return;
const timer = setInterval(tick, tickDelay(game.score));
return () => clearInterval(timer);
});
$effect(() => {
if (focused) boardElement?.focus({ preventScroll: true });
else if (game.status === 'running') game.status = 'paused';
});
onMount(() => {
highScore = loadHighScore(gameName);
});
</script>
<GameShell
title="snake"
stats={[
{ label: 'score', value: game.score },
{ label: 'best', value: highScore }
]}
hint="arrows / wasd / hjkl to move, space to pause, r to restart"
>
<div class="board-area" bind:clientWidth={areaWidth} bind:clientHeight={areaHeight}>
<div
class="board"
bind:this={boardElement}
tabindex="-1"
role="presentation"
onkeydown={handleKeydown}
>
<SnakeCanvas snake={game.snake} food={game.food} size={canvasSize} />
{#if statusMessages[game.status]}
<button class="overlay" tabindex="-1" onclick={handleOverlayClick}>{statusMessages[game.status]}</button>
{/if}
</div>
</div>
<div class="touch-controls">
<DirectionPad onsteer={steer} />
</div>
</GameShell>
<style>
.board-area {
display: grid;
flex: 1;
min-height: 0;
place-items: center;
overflow: hidden;
}
.board {
position: relative;
padding: 2px;
border: 1px solid var(--border-inactive);
outline: none;
}
.board:focus-visible {
border-color: var(--accent);
}
.overlay {
position: absolute;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: color-mix(in srgb, var(--background) 70%, transparent);
border: none;
color: var(--foreground);
font: inherit;
text-align: center;
cursor: pointer;
}
.touch-controls {
display: none;
}
@media (pointer: coarse) {
.touch-controls {
display: block;
}
}
</style>
@@ -0,0 +1,69 @@
<script lang="ts">
import { boardSize, type Point } from '#lib/games/snake';
import { themeState } from '#lib/theme/theme.svelte';
let { snake, food, size }: { snake: Point[]; food: Point | null; size: number } = $props();
const emptyCellOpacity = 0.06;
let canvasElement = $state<HTMLCanvasElement>();
let pixelRatio = $state(1);
const canvasSize = $derived(Math.round(size * pixelRatio));
function themeColor(name: string): string {
return getComputedStyle(canvasElement!).getPropertyValue(name).trim();
}
function drawBoard(context: CanvasRenderingContext2D) {
const gap = Math.max(1, Math.round(pixelRatio));
const cellSpan = (canvasSize - gap) / boardSize;
const fillCell = (point: Point) => {
const left = gap + Math.round(point.x * cellSpan);
const top = gap + Math.round(point.y * cellSpan);
const right = Math.round((point.x + 1) * cellSpan);
const bottom = Math.round((point.y + 1) * cellSpan);
context.fillRect(left, top, right - left, bottom - top);
};
context.clearRect(0, 0, canvasSize, canvasSize);
context.globalAlpha = emptyCellOpacity;
context.fillStyle = themeColor('--foreground');
for (let y = 0; y < boardSize; y++) {
for (let x = 0; x < boardSize; x++) fillCell({ x, y });
}
context.globalAlpha = 1;
if (food) {
context.fillStyle = themeColor('--red');
fillCell(food);
}
context.fillStyle = themeColor('--green');
snake.slice(1).forEach(fillCell);
context.fillStyle = themeColor('--accent');
if (snake[0]) fillCell(snake[0]);
}
$effect(() => {
size;
pixelRatio = window.devicePixelRatio || 1;
});
$effect(() => {
themeState.currentId;
const context = canvasElement?.getContext('2d');
if (context && canvasSize > 0) drawBoard(context);
});
</script>
<canvas bind:this={canvasElement} width={canvasSize} height={canvasSize} style="width: {size}px; height: {size}px"></canvas>
<style>
canvas {
display: block;
}
</style>
+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>
@@ -0,0 +1,251 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { deckSizeLimit } from '#lib/tcg/cardKind';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import { copyText, downloadDeckJson, downloadDeckText } from '#lib/tcg/deckExport';
import BtopBox from '../btop/BtopBox.svelte';
const statusMilliseconds = 1600;
let exportStatus = $state('');
let statusTimer: ReturnType<typeof setTimeout> | undefined;
const fillPercent = $derived(Math.min(100, (deckBuilder.total / deckSizeLimit) * 100));
const isEmpty = $derived(deckBuilder.total === 0);
function showStatus(message: string) {
clearTimeout(statusTimer);
exportStatus = message;
statusTimer = setTimeout(() => (exportStatus = ''), statusMilliseconds);
}
async function copyDeck() {
const copied = await copyText(deckBuilder.exportText());
showStatus(copied ? 'copied for PTCG Live' : 'copy failed');
}
function clearDeck() {
if (window.confirm(`Remove all ${deckBuilder.total} cards from ${deckBuilder.name}?`)) deckBuilder.clear();
}
</script>
<aside class="deck-panel">
<BtopBox title="deck" number={3} color="green">
<input
class="deck-name"
value={deckBuilder.name}
oninput={(event) => deckBuilder.rename(event.currentTarget.value)}
aria-label="Deck name"
spellcheck="false"
/>
<div class="progress" class:full={deckBuilder.total === deckSizeLimit}>
<span class="progress-label">{deckBuilder.total}/{deckSizeLimit} cards</span>
<span class="progress-track"><span class="progress-fill" style="width: {fillPercent}%"></span></span>
</div>
{#if isEmpty}
<p class="empty muted">Hover a card and press + to add it. Up to 4 copies of a card, any number of basic energy.</p>
{:else}
<div class="sections">
{#each deckBuilder.sections as section (section.category)}
<section>
<h4>{section.title} <span class="muted">{section.total}</span></h4>
<ul>
{#each section.entries as entry (entry.card.id)}
<li class="entry">
<img src={entry.card.image} alt="" data-no-viewer />
<span class="entry-count">{entry.count}</span>
<span class="entry-name" title={entry.card.name}>{entry.card.name}</span>
<span class="entry-buttons">
<button aria-label="Remove one {entry.card.name}" onclick={() => deckBuilder.remove(entry.card.id)}>
<Icon icon="mdi:minus" width="14" />
</button>
<button
aria-label="Add one {entry.card.name}"
disabled={!deckBuilder.canAdd(entry.card)}
onclick={() => deckBuilder.increase(entry.card.id)}
>
<Icon icon="mdi:plus" width="14" />
</button>
</span>
</li>
{/each}
</ul>
</section>
{/each}
</div>
{/if}
</BtopBox>
<BtopBox title="export" number={4} color="yellow">
<div class="actions">
<button disabled={isEmpty} onclick={copyDeck}><Icon icon="mdi:content-copy" width="15" /> copy list</button>
<button disabled={isEmpty} onclick={() => downloadDeckText(deckBuilder.name, deckBuilder.exportText())}>
<Icon icon="mdi:file-document-outline" width="15" /> .txt
</button>
<button disabled={isEmpty} onclick={() => downloadDeckJson(deckBuilder.name, deckBuilder.entries)}>
<Icon icon="mdi:code-json" width="15" /> .json
</button>
<button class="danger" disabled={isEmpty} onclick={clearDeck}><Icon icon="mdi:delete-outline" width="15" /> clear</button>
</div>
{#if exportStatus}
<p class="status">{exportStatus}</p>
{/if}
</BtopBox>
</aside>
<style>
.deck-panel {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 0.5rem;
}
.deck-name {
width: 100%;
margin-bottom: 0.5rem;
padding: 0.2rem 0;
background: none;
border: none;
border-bottom: 1px dashed var(--border-inactive);
outline: none;
color: var(--accent);
font: inherit;
font-weight: 700;
}
.deck-name:focus {
border-bottom-color: var(--accent);
}
.progress {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-bottom: 0.6rem;
font-size: 0.78rem;
}
.progress-track {
height: 6px;
background: color-mix(in srgb, var(--foreground) 10%, transparent);
}
.progress-fill {
display: block;
height: 100%;
background: var(--yellow);
transition: width 0.15s;
}
.progress.full .progress-fill {
background: var(--green);
}
.empty {
margin: 0;
font-size: 0.78rem;
}
.sections {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
h4 {
margin: 0 0 0.3rem;
color: var(--orange);
font-size: 0.8rem;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
.entry {
display: grid;
grid-template-columns: 22px 1.4rem minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem;
padding: 0.15rem 0;
font-size: 0.78rem;
}
.entry img {
width: 22px;
aspect-ratio: 63 / 88;
object-fit: cover;
}
.entry-count {
color: var(--accent);
font-weight: 700;
text-align: right;
}
.entry-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.entry-buttons {
display: flex;
gap: 2px;
}
.entry-buttons button,
.actions button {
display: inline-flex;
align-items: center;
justify-content: center;
background: none;
border: 1px solid var(--border-inactive);
color: var(--foreground);
}
.entry-buttons button {
width: 20px;
height: 20px;
padding: 0;
}
.entry-buttons button:hover:not(:disabled),
.actions button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
button:disabled {
opacity: 0.35;
cursor: default;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.35rem;
}
.actions button {
gap: 0.3rem;
padding: 0.25rem 0.4rem;
font-size: 0.78rem;
}
.actions .danger:hover:not(:disabled) {
border-color: var(--red);
color: var(--red);
}
.status {
margin: 0.5rem 0 0;
color: var(--green);
font-size: 0.78rem;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { cardApiUrl } from '#lib/tcg/api';
import type { TcgCard } from '#lib/tcg/types';
import BtopBox from '../btop/BtopBox.svelte';
import EnergyTag from './EnergyTag.svelte';
let { card }: { card: TcgCard } = $props();
const cardKind = $derived([card.supertype, ...(card.subtypes ?? [])].filter(Boolean).join(' / '));
const setNumber = $derived(card.set.printedTotal ? `${card.number}/${card.set.printedTotal}` : card.number);
const hasMoves = $derived((card.abilities?.length ?? 0) + (card.attacks?.length ?? 0) > 0);
</script>
<article class="card-detail">
<div class="image-column">
<img class="card-image" src={card.images.large} alt={card.name} />
<p class="muted zoom-hint">click the card to zoom</p>
</div>
<div class="info-column">
<BtopBox title="info" number={1}>
<dl class="facts">
{#if cardKind}<dt>type</dt><dd>{cardKind}</dd>{/if}
{#if card.hp}<dt>hp</dt><dd class="hp">{card.hp}</dd>{/if}
{#if card.types?.length}
<dt>energy</dt>
<dd class="tags">{#each card.types as type (type)}<EnergyTag {type} />{/each}</dd>
{/if}
<dt>set</dt><dd>{card.set.name} <span class="muted">{card.set.series}</span></dd>
{#if setNumber}<dt>number</dt><dd>{setNumber}</dd>{/if}
{#if card.rarity}<dt>rarity</dt><dd>{card.rarity}</dd>{/if}
{#if card.weaknesses?.length}
<dt>weakness</dt>
<dd>{card.weaknesses.map((weakness) => `${weakness.type} ${weakness.value}`).join(', ')}</dd>
{/if}
{#if card.resistances?.length}
<dt>resistance</dt>
<dd>{card.resistances.map((resistance) => `${resistance.type} ${resistance.value}`).join(', ')}</dd>
{/if}
{#if card.artist}<dt>artist</dt><dd>{card.artist}</dd>{/if}
</dl>
</BtopBox>
{#if hasMoves}
<BtopBox title="moves" number={2} color="red">
<div class="moves">
{#each card.abilities ?? [] as ability (ability.name)}
<section class="move">
<h3><span class="ability-label">{ability.type ?? 'Ability'}</span> {ability.name}</h3>
{#if ability.text}<p>{ability.text}</p>{/if}
</section>
{/each}
{#each card.attacks ?? [] as attack (attack.name)}
<section class="move">
<h3>
<span class="tags">{#each attack.cost ?? [] as cost, index (index)}<EnergyTag type={cost} />{/each}</span>
{attack.name}
{#if attack.damage}<span class="damage">{attack.damage}</span>{/if}
</h3>
{#if attack.text}<p>{attack.text}</p>{/if}
</section>
{/each}
</div>
</BtopBox>
{/if}
{#if card.flavorText}
<p class="flavor muted">"{card.flavorText}"</p>
{/if}
<a class="api-link" href={cardApiUrl(card.id)} target="_blank" rel="noopener noreferrer">
<Icon icon="mdi:code-json" width="16" /> {card.id}.json
</a>
</div>
</article>
<style>
.card-detail {
display: grid;
grid-template-columns: minmax(0, 300px) minmax(0, 1fr);
gap: 1.5rem;
align-items: start;
}
.image-column {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.4rem;
}
.card-image {
width: 100%;
cursor: zoom-in;
}
.zoom-hint {
margin: 0;
font-size: 0.72rem;
}
.info-column {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 0.6rem;
}
.facts {
display: grid;
grid-template-columns: max-content 1fr;
gap: 0.3rem 1rem;
margin: 0;
font-size: 0.82rem;
}
dt {
color: var(--muted);
}
dd {
margin: 0;
}
.hp {
color: var(--red);
font-weight: 700;
}
.tags {
display: inline-flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.moves {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.move + .move {
padding-top: 0.6rem;
border-top: 1px dashed color-mix(in srgb, var(--foreground) 15%, transparent);
}
h3 {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.4rem;
margin: 0 0 0.25rem;
font-size: 0.88rem;
}
.ability-label {
color: var(--magenta);
}
.damage {
margin-left: auto;
color: var(--yellow);
font-weight: 700;
}
p {
margin: 0;
font-size: 0.8rem;
line-height: 1.5;
}
.flavor {
font-style: italic;
}
.api-link {
display: inline-flex;
align-items: center;
gap: 0.35rem;
font-size: 0.8rem;
}
@container (max-width: 640px) {
.card-detail {
grid-template-columns: minmax(0, 1fr);
}
.card-image {
max-width: 260px;
}
}
</style>
+149
View File
@@ -0,0 +1,149 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import type { TcgCard } from '#lib/tcg/types';
let {
cards,
countOf,
canAdd,
onopen,
onadd
}: {
cards: TcgCard[];
countOf: (card: TcgCard) => number;
canAdd: (card: TcgCard) => boolean;
onopen: (card: TcgCard) => void;
onadd: (card: TcgCard) => void;
} = $props();
</script>
<ul class="card-grid">
{#each cards as card (card.id)}
{@const count = countOf(card)}
<li class="card" class:in-deck={count > 0}>
<button class="art" title="{card.name} ({card.set.name})" onclick={() => onopen(card)}>
<img src={card.images.small} alt={card.name} loading="lazy" data-no-viewer />
</button>
{#if count > 0}
<span class="count">x{count}</span>
{/if}
<button class="add" disabled={!canAdd(card)} aria-label="Add {card.name} to deck" onclick={() => onadd(card)}>
<Icon icon="mdi:plus" width="18" />
</button>
<span class="card-name">{card.name}</span>
<span class="card-set muted">{card.set.name}</span>
</li>
{/each}
</ul>
<style>
.card-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 1rem 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
.card {
position: relative;
display: flex;
flex-direction: column;
gap: 0.15rem;
min-width: 0;
}
.art {
display: block;
padding: 0;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 2px solid transparent;
transition:
transform 0.12s,
border-color 0.12s;
}
.art:hover,
.art:focus-visible {
border-color: var(--accent);
outline: none;
transform: translateY(-3px);
}
.in-deck .art {
border-color: var(--green);
}
img {
display: block;
width: 100%;
aspect-ratio: 63 / 88;
object-fit: contain;
}
.count {
position: absolute;
top: 6px;
left: 6px;
padding: 0 0.35rem;
background: var(--green);
color: var(--background);
font-size: 0.8rem;
font-weight: 700;
}
.add {
position: absolute;
top: 6px;
right: 6px;
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
background: var(--background);
border: 1px solid var(--accent);
color: var(--accent);
opacity: 0;
transition: opacity 0.12s;
}
.card:hover .add,
.add:focus-visible {
opacity: 1;
}
.add:hover:not(:disabled) {
background: var(--accent);
color: var(--background);
}
.add:disabled {
border-color: var(--border-inactive);
color: var(--muted);
cursor: not-allowed;
}
.card-name,
.card-set {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.card-name {
margin-top: 0.3rem;
font-size: 0.8rem;
}
.card-set {
font-size: 0.7rem;
}
@media (pointer: coarse) {
.add {
opacity: 1;
}
}
</style>
+155
View File
@@ -0,0 +1,155 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import type { TcgCard } from '#lib/tcg/types';
import CardDetail from './CardDetail.svelte';
let { card, canBuild, onclose }: { card: TcgCard; canBuild: boolean; onclose: () => void } = $props();
let dialogElement = $state<HTMLDivElement>();
const countInDeck = $derived(deckBuilder.countOf(card.id));
$effect(() => {
dialogElement?.focus({ preventScroll: true });
});
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.stopPropagation();
onclose();
}
}
</script>
<div class="modal-layer">
<button class="backdrop" aria-label="Close card" onclick={onclose}></button>
<div class="dialog" role="dialog" aria-label={card.name} tabindex="-1" bind:this={dialogElement} onkeydown={handleKeydown}>
<header class="dialog-header">
<h2>{card.name}</h2>
<div class="header-actions">
{#if canBuild}
<span class="in-deck muted">{countInDeck} in deck</span>
<button class="deck-button" disabled={countInDeck === 0} onclick={() => deckBuilder.remove(card.id)} aria-label="Remove one from deck">
<Icon icon="mdi:minus" width="16" />
</button>
<button class="deck-button add" disabled={!deckBuilder.canAdd(card)} onclick={() => deckBuilder.add(card)}>
<Icon icon="mdi:plus" width="16" /> add to deck
</button>
{/if}
<button class="close-button" aria-label="Close card" onclick={onclose}>
<Icon icon="mdi:close" width="18" />
</button>
</div>
</header>
<div class="dialog-body">
<CardDetail {card} />
</div>
</div>
</div>
<style>
.modal-layer {
position: absolute;
inset: 0;
z-index: 5;
display: grid;
place-items: center;
padding: 1rem;
}
.backdrop {
position: absolute;
inset: 0;
background: color-mix(in srgb, var(--dark-background, var(--background)) 75%, transparent);
border: none;
cursor: default;
}
.dialog {
position: relative;
display: flex;
flex-direction: column;
width: min(880px, 100%);
max-height: 100%;
background: var(--background);
border: 1px solid var(--accent);
box-shadow: 0 16px 40px rgb(0 0 0 / 0.5);
outline: none;
}
.dialog-header {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.6rem 0.9rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
h2 {
margin: 0;
color: var(--accent);
font-size: 1.05rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.4rem;
}
.in-deck {
margin-right: 0.25rem;
font-size: 0.8rem;
}
.deck-button {
display: inline-flex;
align-items: center;
gap: 0.3rem;
height: 28px;
padding: 0 0.5rem;
background: none;
border: 1px solid var(--border-inactive);
font-size: 0.8rem;
}
.deck-button.add {
border-color: var(--accent);
color: var(--accent);
}
.deck-button:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: var(--background);
}
.deck-button:disabled {
opacity: 0.35;
cursor: default;
}
.close-button {
display: grid;
place-items: center;
width: 28px;
height: 28px;
padding: 0;
background: none;
border: none;
color: var(--muted);
}
.close-button:hover {
color: var(--red);
}
.dialog-body {
overflow-y: auto;
overscroll-behavior: contain;
padding: 1rem 1.1rem 1.25rem;
}
</style>
@@ -0,0 +1,244 @@
<script lang="ts">
import { untrack } from 'svelte';
import { buildCardQuery, cardsPerPage, searchCards } from '#lib/tcg/api';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import type { TcgCard } from '#lib/tcg/types';
import CardGrid from './CardGrid.svelte';
import TcgPager from './TcgPager.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
const searchDelayMilliseconds = 350;
let nameInput = $state(untrack(() => browser.searchedName));
let cards = $state<TcgCard[]>([]);
let totalCount = $state(0);
let isLoading = $state(true);
let errorMessage = $state('');
let resultsElement = $state<HTMLDivElement>();
const search = $derived({
name: browser.searchedName,
supertype: browser.supertype,
energyType: browser.energyType,
setId: browser.setId,
page: browser.page
});
const queryPreview = $derived(buildCardQuery(search) || '*');
const totalPages = $derived(Math.max(1, Math.ceil(totalCount / cardsPerPage)));
function handleSearchKeydown(event: KeyboardEvent) {
if (event.key === 'Enter') browser.setSearchedName(nameInput);
}
$effect(() => {
const name = nameInput;
const timer = setTimeout(() => browser.setSearchedName(name), searchDelayMilliseconds);
return () => clearTimeout(timer);
});
$effect(() => {
const currentSearch = search;
const controller = new AbortController();
isLoading = true;
errorMessage = '';
searchCards(currentSearch, controller.signal)
.then((result) => {
cards = result.data;
totalCount = result.totalCount;
isLoading = false;
resultsElement?.scrollTo({ top: 0 });
})
.catch((error: Error) => {
if (controller.signal.aborted) return;
errorMessage = error.message;
isLoading = false;
});
return () => controller.abort();
});
</script>
<div class="card-search">
<header class="toolbar">
<label class="prompt">
<span class="prompt-symbol">tcg&gt;</span>
<input bind:value={nameInput} onkeydown={handleSearchKeydown} placeholder="search cards by name" spellcheck="false" />
</label>
<div class="status">
<code class="query muted" title="Query sent to the API">q={queryPreview}</code>
<span class="count muted">{isLoading ? 'loading...' : `${totalCount.toLocaleString()} cards`}</span>
<TcgPager bind:page={browser.page} {totalPages} />
</div>
</header>
<div class="results" bind:this={resultsElement}>
{#if browser.selectedSet}
{@const set = browser.selectedSet}
<div class="set-banner">
{#if set.images?.logo}
<img class="set-logo" src={set.images.logo} alt={set.name} data-no-viewer />
{/if}
<div class="set-info">
<span class="set-name">{set.name}</span>
<span class="muted">
{set.series}{set.releaseDate ? `, released ${set.releaseDate}` : ''}{set.printedTotal ? `, ${set.printedTotal} cards` : ''}
</span>
</div>
<button class="clear-set" onclick={() => browser.toggleSet(null)}>clear set</button>
</div>
{/if}
{#if errorMessage}
<p class="message error">error: {errorMessage}</p>
{:else if !isLoading && cards.length === 0}
<p class="message muted">No cards match that search.</p>
{:else}
<div class:faded={isLoading}>
<CardGrid
{cards}
countOf={(card) => deckBuilder.countOf(card.id)}
canAdd={(card) => deckBuilder.canAdd(card)}
onopen={(card) => browser.openCard(card)}
onadd={(card) => deckBuilder.add(card)}
/>
</div>
<div class="bottom-pager">
<TcgPager bind:page={browser.page} {totalPages} />
</div>
{/if}
</div>
</div>
<style>
.card-search {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
min-width: 0;
}
.toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 0.5rem 1rem;
padding: 0.6rem 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.prompt {
display: flex;
flex: 1;
align-items: center;
gap: 0.5rem;
min-width: 200px;
}
.prompt-symbol {
color: var(--green);
font-weight: 700;
}
.prompt input {
flex: 1;
min-width: 0;
padding: 0.2rem 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
caret-color: var(--accent);
}
.status {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem 1rem;
font-size: 0.8rem;
}
.query {
max-width: 260px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.results {
flex: 1;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.9rem 0.75rem;
}
.set-banner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
margin-bottom: 1rem;
padding: 0.6rem 0.75rem;
border: 1px solid var(--cyan);
}
.set-logo {
max-width: 140px;
max-height: 48px;
object-fit: contain;
}
.set-info {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
font-size: 0.8rem;
}
.set-name {
color: var(--cyan);
font-size: 0.95rem;
font-weight: 700;
}
.clear-set {
padding: 0.2rem 0.6rem;
background: none;
border: 1px solid var(--border-inactive);
font-size: 0.78rem;
}
.clear-set:hover {
border-color: var(--red);
color: var(--red);
}
.bottom-pager {
display: flex;
justify-content: center;
margin-top: 1.25rem;
}
.faded {
opacity: 0.5;
transition: opacity 0.15s;
}
.message {
margin: 1rem 0;
text-align: center;
}
.error {
color: var(--red);
}
</style>
@@ -0,0 +1,88 @@
<script lang="ts">
import { onMount } from 'svelte';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import FilterSidebar from './FilterSidebar.svelte';
import CardSearchView from './CardSearchView.svelte';
import BuilderDeckPanel from './BuilderDeckPanel.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
onMount(() => deckBuilder.load());
</script>
<div class="deck-builder">
<div class="side filters-column">
<FilterSidebar {browser} />
</div>
<div class="search-column">
<CardSearchView {browser} />
</div>
<div class="side deck-column">
<BuilderDeckPanel />
</div>
</div>
<style>
.deck-builder {
display: grid;
grid-template-columns: 250px minmax(0, 1fr) 290px;
height: 100%;
min-height: 0;
}
.side {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
}
.filters-column {
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.deck-column {
border-left: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.search-column {
min-height: 0;
}
@container (max-width: 980px) {
.deck-builder {
grid-template-columns: minmax(0, 1fr) 270px;
grid-template-rows: auto minmax(0, 1fr);
}
.filters-column {
grid-column: 1 / -1;
max-height: 240px;
border-right: none;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
}
@container (max-width: 640px) {
.deck-builder {
display: flex;
flex-direction: column;
height: auto;
min-height: 100%;
}
.filters-column {
max-height: none;
}
.search-column {
height: 70vh;
}
.deck-column {
border-left: none;
border-top: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
}
</style>
@@ -0,0 +1,96 @@
<script lang="ts">
import type { DeckCard } from '#lib/content';
import type { TcgCard } from '#lib/tcg/types';
let {
deckCard,
card,
onopen
}: {
deckCard: DeckCard;
card: TcgCard | null | undefined;
onopen: (card: TcgCard) => void;
} = $props();
</script>
<button class="tile" disabled={!card} title={deckCard.name} onclick={() => card && onopen(card)}>
<span class="art">
{#if card}
<img src={card.images.small} alt={deckCard.name} loading="lazy" data-no-viewer />
{:else}
<span class="placeholder muted">{card === null ? 'not found' : 'loading...'}</span>
{/if}
<span class="count">x{deckCard.count}</span>
</span>
<span class="name">{deckCard.name}</span>
</button>
<style>
.tile {
display: flex;
flex-direction: column;
gap: 0.3rem;
width: 100%;
padding: 0;
background: none;
border: none;
text-align: left;
}
.tile:disabled {
cursor: default;
}
.art {
position: relative;
display: grid;
place-items: center;
width: 100%;
aspect-ratio: 63 / 88;
background: color-mix(in srgb, var(--foreground) 5%, transparent);
border: 2px solid transparent;
transition:
transform 0.12s,
border-color 0.12s;
}
.tile:hover:not(:disabled) .art,
.tile:focus-visible .art {
border-color: var(--accent);
transform: translateY(-3px);
}
.tile:focus-visible {
outline: none;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
.placeholder {
padding: 0.5rem;
font-size: 0.7rem;
text-align: center;
}
.count {
position: absolute;
right: 6px;
bottom: 6px;
padding: 0 0.4rem;
background: var(--accent);
color: var(--background);
font-size: 0.85rem;
font-weight: 700;
}
.name {
overflow: hidden;
font-size: 0.78rem;
white-space: nowrap;
text-overflow: ellipsis;
}
</style>
+118
View File
@@ -0,0 +1,118 @@
<script lang="ts">
import { untrack } from 'svelte';
import type { TcgDeck } from '#lib/content';
import { deckCardKey, deckSections, deckTotal, lookUpDeckCard } from '#lib/tcg/decks';
import type { TcgCard } from '#lib/tcg/types';
import DeckCardTile from './DeckCardTile.svelte';
let { deck, onopen }: { deck: TcgDeck; onopen: (card: TcgCard) => void } = $props();
const lookupConcurrency = 4;
let foundCards = $state<Record<string, TcgCard | null>>({});
const sections = $derived(deckSections(deck));
$effect(() => {
const currentDeck = deck;
let cancelled = false;
untrack(() => {
const queue = currentDeck.cards.filter((card) => !(deckCardKey(card) in foundCards));
const lookUpNext = async () => {
while (!cancelled && queue.length > 0) {
const card = queue.shift()!;
foundCards[deckCardKey(card)] = await lookUpDeckCard(card).catch(() => null);
}
};
for (let worker = 0; worker < lookupConcurrency; worker++) lookUpNext();
});
return () => (cancelled = true);
});
</script>
<div class="deck-view">
<header class="deck-header">
<h2>{deck.name}</h2>
<div class="summary">
<span class="total">{deckTotal(deck)} cards</span>
{#each sections as section (section.category)}
<span class="summary-item {section.category}">{section.title} {section.total}</span>
{/each}
</div>
</header>
{#each sections as section (section.category)}
<section class="deck-section">
<h3 class={section.category}>{section.title} <span class="muted">{section.total}</span></h3>
<ul class="tile-grid">
{#each section.cards as deckCard (deckCardKey(deckCard))}
<li>
<DeckCardTile {deckCard} card={foundCards[deckCardKey(deckCard)]} {onopen} />
</li>
{/each}
</ul>
</section>
{/each}
</div>
<style>
.deck-view {
display: flex;
flex-direction: column;
gap: 1.25rem;
}
.deck-header {
display: flex;
flex-direction: column;
gap: 0.4rem;
padding-bottom: 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
h2 {
margin: 0;
color: var(--accent);
font-size: 1.15rem;
}
.summary {
display: flex;
flex-wrap: wrap;
gap: 0.4rem 1rem;
font-size: 0.8rem;
}
.total {
color: var(--foreground);
font-weight: 700;
}
h3 {
margin: 0 0 0.6rem;
font-size: 0.9rem;
}
.pokemon {
color: var(--red);
}
.trainer {
color: var(--blue);
}
.energy {
color: var(--yellow);
}
.tile-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
gap: 0.9rem 0.75rem;
margin: 0;
padding: 0;
list-style: none;
}
</style>
+18
View File
@@ -0,0 +1,18 @@
<script lang="ts">
import { energyColor } from '#lib/tcg/energy';
let { type }: { type: string } = $props();
</script>
<span class="energy-tag" style="--energy-color: {energyColor(type)}">{type}</span>
<style>
.energy-tag {
display: inline-block;
padding: 0 0.4rem;
border: 1px solid var(--energy-color);
color: var(--energy-color);
font-size: 0.75rem;
line-height: 1.5;
}
</style>
+100
View File
@@ -0,0 +1,100 @@
<script lang="ts">
import { onMount } from 'svelte';
import { fetchEnergyTypes, fetchSupertypes } from '#lib/tcg/api';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import { energyColor } from '#lib/tcg/energy';
import BtopBox from '../btop/BtopBox.svelte';
import SetFilter from './SetFilter.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
let supertypes = $state<string[]>([]);
let energyTypes = $state<string[]>([]);
onMount(() => {
const controller = new AbortController();
fetchSupertypes(controller.signal)
.then((values) => (supertypes = values))
.catch(() => (supertypes = []));
fetchEnergyTypes(controller.signal)
.then((values) => (energyTypes = values))
.catch(() => (energyTypes = []));
return () => controller.abort();
});
</script>
<aside class="filters">
<BtopBox title="kind" number={1}>
<div class="chips">
{#each supertypes as supertype (supertype)}
<button class="chip" class:active={browser.supertype === supertype} onclick={() => browser.toggleSupertype(supertype)}>
{supertype}
</button>
{/each}
</div>
</BtopBox>
<BtopBox title="energy" number={2} color="magenta">
<div class="chips">
{#each energyTypes as energyType (energyType)}
<button
class="chip energy"
class:active={browser.energyType === energyType}
style="--chip-color: {energyColor(energyType)}"
onclick={() => browser.toggleEnergyType(energyType)}
>
<span class="dot"></span>{energyType}
</button>
{/each}
</div>
</BtopBox>
<SetFilter {browser} />
</aside>
<style>
.filters {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 0.5rem;
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
}
.chip {
--chip-color: var(--accent);
display: inline-flex;
align-items: center;
gap: 0.35rem;
padding: 0.1rem 0.5rem;
background: none;
border: 1px solid var(--border-inactive);
color: var(--foreground);
font-size: 0.78rem;
}
.chip:hover {
border-color: var(--chip-color);
}
.chip.active {
background: var(--chip-color);
border-color: var(--chip-color);
color: var(--background);
}
.dot {
width: 7px;
height: 7px;
background: var(--chip-color);
}
.chip.active .dot {
background: var(--background);
}
</style>
+158
View File
@@ -0,0 +1,158 @@
<script lang="ts">
import Icon from '@iconify/svelte';
import { tcgDecks } from '#lib/content';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import { deckBuilderEntries, deckTotal } from '#lib/tcg/decks';
import BtopBox from '../btop/BtopBox.svelte';
import DeckView from './DeckView.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
let isCopying = $state(false);
const selectedDeck = $derived(tcgDecks.find((deck) => deck.id === browser.deckId));
async function openInBuilder() {
if (!selectedDeck || isCopying) return;
deckBuilder.load();
if (deckBuilder.total > 0 && !window.confirm(`Replace ${deckBuilder.name} in the deck builder with ${selectedDeck.name}?`)) return;
isCopying = true;
const entries = await deckBuilderEntries(selectedDeck);
deckBuilder.replace(selectedDeck.name, entries);
isCopying = false;
browser.tab = 'builder';
}
</script>
<div class="my-decks">
<aside class="deck-list">
<BtopBox title="decks" number={1}>
{#if tcgDecks.length === 0}
<p class="muted">No decks yet.</p>
{:else}
<ul>
{#each tcgDecks as deck (deck.id)}
<li>
<button class:active={deck.id === browser.deckId} onclick={() => (browser.deckId = deck.id)}>
<span class="deck-name">{deck.name}</span>
<span class="muted">{deckTotal(deck)}</span>
</button>
</li>
{/each}
</ul>
{/if}
</BtopBox>
{#if selectedDeck}
<BtopBox title="actions" number={2} color="yellow">
<button class="action" disabled={isCopying} onclick={openInBuilder}>
<Icon icon="mdi:hammer-wrench" width="15" />
{isCopying ? 'loading cards...' : 'open in deck builder'}
</button>
</BtopBox>
{/if}
</aside>
<div class="deck-main">
{#if selectedDeck}
<DeckView deck={selectedDeck} onopen={(card) => browser.openCard(card)} />
{/if}
</div>
</div>
<style>
.my-decks {
display: grid;
grid-template-columns: 230px minmax(0, 1fr);
height: 100%;
min-height: 0;
}
.deck-list,
.deck-main {
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
padding: 0.75rem;
}
.deck-list {
display: flex;
flex-direction: column;
gap: 1.1rem;
padding-top: 1.25rem;
border-right: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.deck-main {
padding: 1rem 1.25rem;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
li button {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0.4rem;
background: none;
border: none;
text-align: left;
font-size: 0.85rem;
}
li button:hover {
color: var(--accent);
}
li button.active {
background: color-mix(in srgb, var(--accent) 18%, transparent);
color: var(--accent);
}
.deck-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.action {
display: inline-flex;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.3rem 0.5rem;
background: none;
border: 1px solid var(--border-inactive);
font-size: 0.8rem;
}
.action:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
p {
margin: 0;
font-size: 0.8rem;
}
@container (max-width: 640px) {
.my-decks {
display: flex;
flex-direction: column;
height: auto;
}
.deck-list {
border-right: none;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
}
</style>
+180
View File
@@ -0,0 +1,180 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import { fetchSets } from '#lib/tcg/api';
import type { TcgBrowserState } from '#lib/tcg/browserState.svelte';
import type { TcgSet } from '#lib/tcg/types';
import BtopBox from '../btop/BtopBox.svelte';
let { browser }: { browser: TcgBrowserState } = $props();
let sets = $state<TcgSet[]>([]);
let setQuery = $state('');
let isLoading = $state(true);
let hasFailed = $state(false);
const seriesGroups = $derived.by(() => {
const query = setQuery.trim().toLowerCase();
const groups: { series: string; sets: TcgSet[] }[] = [];
sets
.filter((set) => !query || `${set.name} ${set.series} ${set.ptcgoCode ?? ''} ${set.id}`.toLowerCase().includes(query))
.forEach((set) => {
const group = groups.find((item) => item.series === set.series);
if (group) group.sets.push(set);
else groups.push({ series: set.series, sets: [set] });
});
return groups;
});
function releaseYear(set: TcgSet): string {
return set.releaseDate?.slice(0, 4) ?? '';
}
onMount(() => {
const controller = new AbortController();
fetchSets(controller.signal)
.then((result) => (sets = result))
.catch(() => (hasFailed = !controller.signal.aborted))
.finally(() => (isLoading = false));
return () => controller.abort();
});
</script>
<BtopBox title="sets" number={3} color="cyan">
<label class="set-search">
<Icon icon="mdi:magnify" width="15" />
<input bind:value={setQuery} placeholder="filter sets" spellcheck="false" />
</label>
<div class="set-list">
{#if isLoading}
<p class="muted">loading sets...</p>
{:else if hasFailed}
<p class="error">could not load sets</p>
{:else if seriesGroups.length === 0}
<p class="muted">no sets match</p>
{:else}
{#each seriesGroups as group (group.series)}
<section>
<h4>{group.series}</h4>
<ul>
{#each group.sets as set (set.id)}
<li>
<button class="set-row" class:active={browser.setId === set.id} title="{set.name} ({set.id})" onclick={() => browser.toggleSet(set)}>
<span class="symbol">
{#if set.images?.symbol}
<img src={set.images.symbol} alt="" loading="lazy" data-no-viewer />
{/if}
</span>
<span class="set-name">{set.name}</span>
<span class="set-code muted">{set.ptcgoCode ?? releaseYear(set)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
</div>
</BtopBox>
<style>
.set-search {
display: flex;
align-items: center;
gap: 0.35rem;
margin-bottom: 0.5rem;
padding-bottom: 0.25rem;
border-bottom: 1px dashed var(--border-inactive);
color: var(--cyan);
}
.set-search input {
flex: 1;
min-width: 0;
padding: 0;
background: none;
border: none;
outline: none;
box-shadow: none;
color: var(--foreground);
font: inherit;
font-size: 0.8rem;
}
.set-list {
max-height: 340px;
overflow-y: auto;
overscroll-behavior: contain;
}
h4 {
position: sticky;
top: 0;
margin: 0.4rem 0 0.15rem;
padding: 0.1rem 0;
background: var(--background);
color: var(--orange);
font-size: 0.75rem;
}
ul {
margin: 0;
padding: 0;
list-style: none;
}
.set-row {
display: grid;
grid-template-columns: 18px minmax(0, 1fr) auto;
align-items: center;
gap: 0.4rem;
width: 100%;
padding: 0.15rem 0.3rem;
background: none;
border: none;
text-align: left;
font-size: 0.78rem;
}
.set-row:hover {
color: var(--cyan);
}
.set-row.active {
background: color-mix(in srgb, var(--cyan) 20%, transparent);
color: var(--cyan);
}
.symbol {
display: grid;
place-items: center;
width: 18px;
height: 18px;
}
.symbol img {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.set-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.set-code {
font-size: 0.7rem;
}
p {
margin: 0.25rem 0;
font-size: 0.78rem;
}
.error {
color: var(--red);
}
</style>
+111
View File
@@ -0,0 +1,111 @@
<script lang="ts">
import { onMount } from 'svelte';
import Icon from '@iconify/svelte';
import { TcgBrowserState, type BrowserTab } from '#lib/tcg/browserState.svelte';
import { deckBuilder } from '#lib/tcg/deckBuilder.svelte';
import MyDecksTab from './MyDecksTab.svelte';
import DeckBuilderTab from './DeckBuilderTab.svelte';
import CardModal from './CardModal.svelte';
const browser = new TcgBrowserState();
const tabs: { id: BrowserTab; label: string; icon: string }[] = [
{ id: 'decks', label: 'my decks', icon: 'ph:cards-duotone' },
{ id: 'builder', label: 'deck builder', icon: 'mdi:hammer-wrench' }
];
onMount(() => deckBuilder.load());
</script>
<div class="tcg-browser">
<nav class="tabs" aria-label="Card browser sections">
{#each tabs as tab (tab.id)}
<button class:active={browser.tab === tab.id} onclick={() => (browser.tab = tab.id)}>
<Icon icon={tab.icon} width="16" />
{tab.label}
{#if tab.id === 'builder' && deckBuilder.total > 0}
<span class="badge">{deckBuilder.total}</span>
{/if}
</button>
{/each}
<span class="source muted">tcg.sirblob.co</span>
</nav>
<div class="tab-content">
{#if browser.tab === 'decks'}
<MyDecksTab {browser} />
{:else}
<DeckBuilderTab {browser} />
{/if}
</div>
{#if browser.openedCard}
<CardModal card={browser.openedCard} canBuild={browser.tab === 'builder'} onclose={() => browser.openCard(null)} />
{/if}
</div>
<style>
.tcg-browser {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
container-type: inline-size;
}
.tabs {
display: flex;
align-items: stretch;
gap: 0.25rem;
padding: 0 0.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--foreground) 10%, transparent);
}
.tabs button {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.5rem 0.8rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: var(--muted);
font-size: 0.85rem;
}
.tabs button:hover {
color: var(--foreground);
}
.tabs button.active {
border-bottom-color: var(--accent);
color: var(--accent);
}
.badge {
padding: 0 0.35rem;
background: var(--accent);
color: var(--background);
font-size: 0.72rem;
font-weight: 700;
}
.source {
align-self: center;
margin-left: auto;
font-size: 0.75rem;
}
.tab-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
@container (max-width: 640px) {
.source {
display: none;
}
}
</style>
+55
View File
@@ -0,0 +1,55 @@
<script lang="ts">
import Icon from '@iconify/svelte';
let { page = $bindable(), totalPages }: { page: number; totalPages: number } = $props();
</script>
<nav class="pager" aria-label="Pages">
<button disabled={page <= 1} aria-label="Previous page" onclick={() => page--}>
<Icon icon="mdi:chevron-left" width="18" />
</button>
<span class="position"><span class="current">{page}</span><span class="muted">/{totalPages}</span></span>
<button disabled={page >= totalPages} aria-label="Next page" onclick={() => page++}>
<Icon icon="mdi:chevron-right" width="18" />
</button>
</nav>
<style>
.pager {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.85rem;
}
button {
display: grid;
place-items: center;
width: 26px;
height: 26px;
padding: 0;
background: none;
border: 1px solid var(--border-inactive);
color: var(--foreground);
}
button:hover:not(:disabled) {
border-color: var(--accent);
color: var(--accent);
}
button:disabled {
opacity: 0.35;
cursor: default;
}
.position {
min-width: 4.5rem;
text-align: center;
}
.current {
color: var(--accent);
font-weight: 700;
}
</style>
+4 -1
View File
@@ -10,6 +10,7 @@ import packagesData from '#content/projects/packages.json';
import hackathonsData from '#content/projects/hackathons.json';
import researchData from '#content/projects/research.json';
import organizationsData from '#content/projects/organizations.json';
import tcgDecksData from '#content/tcgDecks.json';
import type {
AboutContent,
BlogSettings,
@@ -21,7 +22,8 @@ import type {
Project,
ProjectCategory,
ProjectCategoryId,
ResearchGroup
ResearchGroup,
TcgDeck
} from './types';
export type * from './types';
@@ -34,6 +36,7 @@ export const desktop: DesktopContent = desktopData;
export const blogSettings: BlogSettings = blogData;
export const playlist: Playlist = musicData;
export const researchGroups: ResearchGroup[] = researchData;
export const tcgDecks: TcgDeck[] = tcgDecksData as TcgDeck[];
function featuredFirst(projects: Project[]): Project[] {
return [...projects].sort((first, second) => Number(!!second.featured) - Number(!!first.featured));
+33 -1
View File
@@ -139,6 +139,17 @@ export interface RatedTitle {
url: string;
}
export type BookStatus = 'read' | 'reading';
export interface Book {
title: string;
author: string;
rating?: number;
isbn?: string;
url?: string;
status?: BookStatus;
}
export interface LabeledValue {
label: string;
value: string;
@@ -161,14 +172,20 @@ export interface AboutContent {
games: Game[];
shows: RatedTitle[];
movies: RatedTitle[];
books?: Book[];
}
export type DesktopApp = 'btop';
export type GameName = 'snake' | 'breakout';
export type DesktopApp = 'btop' | 'tcg' | 'pet' | GameName;
export type DesktopAction = { page: string } | { terminal: string } | { url: string } | { app: DesktopApp };
export type DesktopSide = 'left' | 'right';
export interface DesktopIconGroup {
name: string;
side?: DesktopSide;
icons: DesktopIcon[];
}
@@ -200,3 +217,18 @@ export interface BlogSettings {
tagline: string;
postsPerPage: number;
}
export type DeckCategory = 'pokemon' | 'trainer' | 'energy';
export interface DeckCard {
count: number;
name: string;
set?: string;
category: DeckCategory;
}
export interface TcgDeck {
id: string;
name: string;
cards: DeckCard[];
}
+4
View File
@@ -1,5 +1,6 @@
import { goto } from '$app/navigation';
import { desktop, type DesktopAction, type DesktopApp, type Workspace } from '#lib/content';
import { petState } from '#lib/pet/petState.svelte';
import { pageWindowId, windowManager } from './windows.svelte';
export function isExternalUrl(url: string): boolean {
@@ -21,6 +22,9 @@ export function openPage(path: string) {
export function openApp(app: DesktopApp) {
if (app === 'btop') windowManager.openSystemMonitor();
else if (app === 'tcg') windowManager.openTcgBrowser();
else if (app === 'pet') petState.toggle();
else windowManager.openGame(app);
}
export function runDesktopAction(action: DesktopAction) {
+19
View File
@@ -38,6 +38,24 @@ export const imageViewerKeybinds: KeybindEntry[] = [
{ keys: ['Esc'], action: 'Close the viewer' }
];
export const snakeKeybinds: KeybindEntry[] = [
{ keys: ['Arrows'], action: 'Steer the snake' },
{ keys: ['WASD'], action: 'Steer the snake' },
{ keys: ['HJKL'], action: 'Steer the snake (vim style)' },
{ keys: ['Space'], action: 'Start or pause' },
{ keys: ['R'], action: 'Restart' }
];
export const breakoutKeybinds: KeybindEntry[] = [
{ keys: ['Left'], action: 'Move the paddle left' },
{ keys: ['Right'], action: 'Move the paddle right' },
{ keys: ['AD'], action: 'Move the paddle' },
{ keys: ['HL'], action: 'Move the paddle (vim style)' },
{ keys: ['Mouse'], action: 'Move the paddle' },
{ keys: ['Space'], action: 'Launch or pause' },
{ keys: ['R'], action: 'Restart' }
];
export const terminalKeybinds: KeybindEntry[] = [
{ keys: ['Tab'], action: 'Complete a command or page name' },
{ keys: ['Up'], action: 'Previous command in history' },
@@ -52,5 +70,6 @@ export const navigationTips: NavigationTip[] = [
{ icon: 'ph:terminal-window-duotone', title: 'Terminal', description: 'Click the clock or press Alt + Enter. Type help to see every command, or cd <page> to open one.' },
{ icon: 'ph:music-notes-duotone', title: 'Bar widgets', description: 'The music, Discord, battery and theme widgets on the right of the bar open more details when clicked.' },
{ icon: 'ph:image-duotone', title: 'Images', description: 'Click any image to open it in the image viewer. Scroll or pinch to zoom and drag to pan.' },
{ icon: 'ph:robot-duotone', title: 'Desktop robot', description: 'Right click the desktop to send the robot there, click it to say hi or drag it around. The robot icon or the pet command turns it on and off.' },
{ icon: 'ph:device-mobile-duotone', title: 'Mobile', description: 'Windows open fullscreen. Use the tabs above the window to switch between them.' }
];
+33 -1
View File
@@ -1,3 +1,5 @@
import type { GameName } from '#lib/content';
export const pageWindowId = 'page';
export interface TerminalWindow {
@@ -20,6 +22,15 @@ export interface SystemMonitorWindow {
id: string;
}
export interface TcgBrowserWindow {
id: string;
}
export interface GameWindow {
id: string;
game: GameName;
}
export interface FloatingRect {
x: number;
y: number;
@@ -31,6 +42,8 @@ class WindowManager {
terminals = $state<TerminalWindow[]>([]);
imageViewers = $state<ImageViewerWindow[]>([]);
systemMonitors = $state<SystemMonitorWindow[]>([]);
games = $state<GameWindow[]>([]);
tcgBrowsers = $state<TcgBrowserWindow[]>([]);
floatingRects = $state<Record<string, FloatingRect | null>>({});
focusedId = $state<string>(pageWindowId);
maximizedId = $state<string | null>(null);
@@ -39,12 +52,16 @@ class WindowManager {
private nextTerminalNumber = 1;
private nextImageViewerNumber = 1;
private nextSystemMonitorNumber = 1;
private nextGameNumber = 1;
private nextTcgBrowserNumber = 1;
get extraWindowIds(): string[] {
return [
...this.terminals.map((terminal) => terminal.id),
...this.imageViewers.map((viewer) => viewer.id),
...this.systemMonitors.map((monitor) => monitor.id)
...this.systemMonitors.map((monitor) => monitor.id),
...this.games.map((gameWindow) => gameWindow.id),
...this.tcgBrowsers.map((browser) => browser.id)
];
}
@@ -71,10 +88,25 @@ class WindowManager {
this.focus(monitor.id);
}
openGame(game: GameName) {
const gameWindow = { id: `${game}-${this.nextGameNumber++}`, game };
this.games.push(gameWindow);
this.floatingRects[gameWindow.id] = null;
this.focus(gameWindow.id);
}
openTcgBrowser() {
const browser = { id: `tcg-${this.nextTcgBrowserNumber++}` };
this.tcgBrowsers.push(browser);
this.focus(browser.id);
}
closeWindow(id: string) {
this.terminals = this.terminals.filter((terminal) => terminal.id !== id);
this.imageViewers = this.imageViewers.filter((viewer) => viewer.id !== id);
this.systemMonitors = this.systemMonitors.filter((monitor) => monitor.id !== id);
this.games = this.games.filter((gameWindow) => gameWindow.id !== id);
this.tcgBrowsers = this.tcgBrowsers.filter((browser) => browser.id !== id);
delete this.floatingRects[id];
if (this.maximizedId === id) this.maximizedId = null;
if (this.focusedId === id) this.focusedId = this.extraWindowIds.at(-1) ?? pageWindowId;
+195
View File
@@ -0,0 +1,195 @@
export type BreakoutStatus = 'serving' | 'running' | 'paused' | 'over';
export interface Brick {
x: number;
y: number;
row: number;
broken: boolean;
}
export interface Ball {
x: number;
y: number;
velocityX: number;
velocityY: number;
}
export interface BreakoutGame {
paddleX: number;
ball: Ball;
bricks: Brick[];
score: number;
lives: number;
level: number;
status: BreakoutStatus;
}
export const fieldWidth = 480;
export const fieldHeight = 360;
export const paddleWidth = 72;
export const paddleHeight = 8;
export const paddleY = fieldHeight - 24;
export const ballRadius = 5;
export const brickRows = 6;
export const brickColumns = 10;
export const brickHeight = 14;
const brickGap = 4;
const brickTop = 40;
const fieldPadding = 8;
const startingLives = 3;
const startingBallSpeed = 260;
const ballSpeedPerLevel = 30;
const paddleSpeed = 420;
const maxBounceAngle = Math.PI / 3;
const maxStepDistance = ballRadius / 2;
export const brickWidth = (fieldWidth - fieldPadding * 2 - brickGap * (brickColumns - 1)) / brickColumns;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
function createBricks(): Brick[] {
const bricks: Brick[] = [];
for (let row = 0; row < brickRows; row++) {
for (let column = 0; column < brickColumns; column++) {
bricks.push({
x: fieldPadding + column * (brickWidth + brickGap),
y: brickTop + row * (brickHeight + brickGap),
row,
broken: false
});
}
}
return bricks;
}
function ballSpeedForLevel(level: number): number {
return startingBallSpeed + (level - 1) * ballSpeedPerLevel;
}
function placeBallOnPaddle(game: BreakoutGame) {
game.ball.x = game.paddleX + paddleWidth / 2;
game.ball.y = paddleY - ballRadius;
game.ball.velocityX = 0;
game.ball.velocityY = 0;
}
export function createBreakoutGame(): BreakoutGame {
const game: BreakoutGame = {
paddleX: (fieldWidth - paddleWidth) / 2,
ball: { x: 0, y: 0, velocityX: 0, velocityY: 0 },
bricks: createBricks(),
score: 0,
lives: startingLives,
level: 1,
status: 'serving'
};
placeBallOnPaddle(game);
return game;
}
export function movePaddleTo(game: BreakoutGame, centerX: number) {
game.paddleX = clamp(centerX - paddleWidth / 2, 0, fieldWidth - paddleWidth);
if (game.status === 'serving') placeBallOnPaddle(game);
}
export function launchBall(game: BreakoutGame) {
if (game.status !== 'serving') return;
const speed = ballSpeedForLevel(game.level);
const angle = (Math.random() - 0.5) * maxBounceAngle;
game.ball.velocityX = speed * Math.sin(angle);
game.ball.velocityY = -speed * Math.cos(angle);
game.status = 'running';
}
function bounceOffWalls(ball: Ball) {
if (ball.x < ballRadius) {
ball.x = ballRadius;
ball.velocityX = Math.abs(ball.velocityX);
} else if (ball.x > fieldWidth - ballRadius) {
ball.x = fieldWidth - ballRadius;
ball.velocityX = -Math.abs(ball.velocityX);
}
if (ball.y < ballRadius) {
ball.y = ballRadius;
ball.velocityY = Math.abs(ball.velocityY);
}
}
function bounceOffPaddle(game: BreakoutGame) {
const ball = game.ball;
const touchesPaddle =
ball.velocityY > 0 &&
ball.y + ballRadius >= paddleY &&
ball.y < paddleY + paddleHeight &&
ball.x >= game.paddleX - ballRadius &&
ball.x <= game.paddleX + paddleWidth + ballRadius;
if (!touchesPaddle) return;
const paddleCenter = game.paddleX + paddleWidth / 2;
const hitOffset = clamp((ball.x - paddleCenter) / (paddleWidth / 2), -1, 1);
const angle = hitOffset * maxBounceAngle;
const speed = Math.hypot(ball.velocityX, ball.velocityY);
ball.velocityX = speed * Math.sin(angle);
ball.velocityY = -speed * Math.cos(angle);
ball.y = paddleY - ballRadius;
}
function overlapsBrick(ball: Ball, brick: Brick): boolean {
return (
ball.x + ballRadius > brick.x &&
ball.x - ballRadius < brick.x + brickWidth &&
ball.y + ballRadius > brick.y &&
ball.y - ballRadius < brick.y + brickHeight
);
}
function startNextLevel(game: BreakoutGame) {
game.level += 1;
game.bricks = createBricks();
game.status = 'serving';
placeBallOnPaddle(game);
}
function breakBricks(game: BreakoutGame) {
const ball = game.ball;
const brick = game.bricks.find((candidate) => !candidate.broken && overlapsBrick(ball, candidate));
if (!brick) return;
brick.broken = true;
game.score += brickRows - brick.row;
const overlapX = Math.min(ball.x + ballRadius - brick.x, brick.x + brickWidth - (ball.x - ballRadius));
const overlapY = Math.min(ball.y + ballRadius - brick.y, brick.y + brickHeight - (ball.y - ballRadius));
if (overlapX < overlapY) ball.velocityX = -ball.velocityX;
else ball.velocityY = -ball.velocityY;
if (game.bricks.every((candidate) => candidate.broken)) startNextLevel(game);
}
function loseLife(game: BreakoutGame) {
game.lives -= 1;
game.status = game.lives > 0 ? 'serving' : 'over';
placeBallOnPaddle(game);
}
function moveBall(game: BreakoutGame, seconds: number) {
const ball = game.ball;
ball.x += ball.velocityX * seconds;
ball.y += ball.velocityY * seconds;
bounceOffWalls(ball);
bounceOffPaddle(game);
breakBricks(game);
if (ball.y - ballRadius > fieldHeight) loseLife(game);
}
export function advanceBreakout(game: BreakoutGame, seconds: number, steering: number) {
if (steering !== 0) movePaddleTo(game, game.paddleX + paddleWidth / 2 + steering * paddleSpeed * seconds);
if (game.status !== 'running') return;
const distance = Math.hypot(game.ball.velocityX, game.ball.velocityY) * seconds;
const steps = Math.max(1, Math.ceil(distance / maxStepDistance));
for (let step = 0; step < steps && game.status === 'running'; step++) moveBall(game, seconds / steps);
}
+19
View File
@@ -0,0 +1,19 @@
function storageKey(gameName: string): string {
return `${gameName}-high-score`;
}
export function loadHighScore(gameName: string): number {
try {
return Number(localStorage.getItem(storageKey(gameName))) || 0;
} catch {
return 0;
}
}
export function saveHighScore(gameName: string, score: number) {
try {
localStorage.setItem(storageKey(gameName), String(score));
} catch {
return;
}
}
+97
View File
@@ -0,0 +1,97 @@
export type Direction = 'up' | 'down' | 'left' | 'right';
export type SnakeStatus = 'ready' | 'running' | 'paused' | 'over' | 'won';
export interface Point {
x: number;
y: number;
}
export interface SnakeGame {
snake: Point[];
direction: Direction;
queuedDirections: Direction[];
food: Point | null;
score: number;
status: SnakeStatus;
}
export const boardSize = 20;
const maxQueuedDirections = 2;
const startingDelay = 130;
const fastestDelay = 60;
const delayDropPerFood = 3;
const directionVectors: Record<Direction, Point> = {
up: { x: 0, y: -1 },
down: { x: 0, y: 1 },
left: { x: -1, y: 0 },
right: { x: 1, y: 0 }
};
const oppositeDirections: Record<Direction, Direction> = {
up: 'down',
down: 'up',
left: 'right',
right: 'left'
};
export function samePoint(first: Point, second: Point): boolean {
return first.x === second.x && first.y === second.y;
}
function isOutsideBoard(point: Point): boolean {
return point.x < 0 || point.y < 0 || point.x >= boardSize || point.y >= boardSize;
}
function randomFreeCell(snake: Point[]): Point | null {
const freeCells: Point[] = [];
for (let y = 0; y < boardSize; y++) {
for (let x = 0; x < boardSize; x++) {
if (!snake.some((part) => part.x === x && part.y === y)) freeCells.push({ x, y });
}
}
return freeCells[Math.floor(Math.random() * freeCells.length)] ?? null;
}
export function createSnakeGame(): SnakeGame {
const middle = Math.floor(boardSize / 2);
const snake = [
{ x: middle, y: middle },
{ x: middle - 1, y: middle },
{ x: middle - 2, y: middle }
];
return { snake, direction: 'right', queuedDirections: [], food: randomFreeCell(snake), score: 0, status: 'ready' };
}
export function queueDirection(game: SnakeGame, direction: Direction) {
const lastDirection = game.queuedDirections.at(-1) ?? game.direction;
if (direction === lastDirection || direction === oppositeDirections[lastDirection]) return;
if (game.queuedDirections.length < maxQueuedDirections) game.queuedDirections.push(direction);
}
export function advanceSnake(game: SnakeGame) {
game.direction = game.queuedDirections.shift() ?? game.direction;
const head = game.snake[0];
const vector = directionVectors[game.direction];
const nextHead = { x: head.x + vector.x, y: head.y + vector.y };
const ateFood = game.food !== null && samePoint(nextHead, game.food);
const body = ateFood ? game.snake : game.snake.slice(0, -1);
if (isOutsideBoard(nextHead) || body.some((part) => samePoint(part, nextHead))) {
game.status = 'over';
return;
}
game.snake = [nextHead, ...body];
if (!ateFood) return;
game.score += 1;
game.food = randomFreeCell(game.snake);
if (!game.food) game.status = 'won';
}
export function tickDelay(score: number): number {
return Math.max(fastestDelay, startingDelay - score * delayDropPerFood);
}
+172
View File
@@ -0,0 +1,172 @@
import { findPath, nearestFreePoint } from './pathfinding';
import {
clampToWorld,
furthestX,
furthestY,
isSpotBlocked,
petHeight,
petWidth,
type PetWorld,
type Point
} from './world';
export type PetAction = 'idle' | 'walk' | 'sleep' | 'hop' | 'held' | 'activity';
export type PetActivity = 'wave' | 'scan' | 'dance' | 'spin' | 'charge' | 'compute';
export type PetExpression = 'awake' | 'sleeping' | 'happy';
export interface Pet {
x: number;
y: number;
facing: 1 | -1;
action: PetAction;
activity: PetActivity | null;
actionTimeLeft: number;
path: Point[];
commanded: boolean;
}
interface SecondsRange {
minimum: number;
maximum: number;
}
const wanderSpeed = 45;
const commandedSpeed = 140;
const hopSeconds = 0.4;
const idleSeconds = { minimum: 2, maximum: 5 };
const sleepSeconds = { minimum: 8, maximum: 16 };
const activitySeconds: Record<PetActivity, SecondsRange> = {
wave: { minimum: 2, maximum: 3 },
scan: { minimum: 3, maximum: 5 },
dance: { minimum: 4, maximum: 7 },
spin: { minimum: 1.6, maximum: 1.6 },
charge: { minimum: 4, maximum: 6 },
compute: { minimum: 3, maximum: 6 }
};
const activities = Object.keys(activitySeconds) as PetActivity[];
const happyActivities: PetActivity[] = ['dance', 'spin'];
const nextActionChances = { walk: 0.5, activity: 0.3, idle: 0.1 };
function randomBetween(minimum: number, maximum: number): number {
return minimum + Math.random() * (maximum - minimum);
}
function setAction(pet: Pet, action: PetAction, seconds: SecondsRange | number = 0) {
pet.action = action;
pet.activity = null;
pet.actionTimeLeft = typeof seconds === 'number' ? seconds : randomBetween(seconds.minimum, seconds.maximum);
}
function startActivity(pet: Pet) {
const activity = activities[Math.floor(Math.random() * activities.length)];
setAction(pet, 'activity', activitySeconds[activity]);
pet.activity = activity;
}
function startWalk(pet: Pet, goal: Point, world: PetWorld, commanded: boolean) {
const isStuck = isSpotBlocked(pet, world);
const origin = isStuck ? nearestFreePoint(pet, world) : { x: pet.x, y: pet.y };
const destination = nearestFreePoint(goal, world);
const path = findPath(origin, destination, world);
if (isStuck) path.unshift(origin);
if (path.length === 0) {
setAction(pet, 'idle', idleSeconds);
return;
}
setAction(pet, 'walk');
pet.path = path;
pet.commanded = commanded;
}
function chooseNextAction(pet: Pet, world: PetWorld) {
const roll = Math.random();
const randomSpot = { x: randomBetween(0, furthestX(world)), y: randomBetween(0, furthestY(world)) };
if (roll < nextActionChances.walk) startWalk(pet, randomSpot, world, false);
else if (roll < nextActionChances.walk + nextActionChances.activity) startActivity(pet);
else if (roll < nextActionChances.walk + nextActionChances.activity + nextActionChances.idle) setAction(pet, 'idle', idleSeconds);
else setAction(pet, 'sleep', sleepSeconds);
}
export function createPet(world: PetWorld): Pet {
const spawn = nearestFreePoint({ x: furthestX(world) / 2, y: furthestY(world) / 2 }, world);
return { ...spawn, facing: 1, action: 'idle', activity: null, actionTimeLeft: 1, path: [], commanded: false };
}
export function petExpression(pet: Pet): PetExpression {
if (pet.action === 'sleep') return 'sleeping';
if (pet.action === 'hop' || pet.action === 'held') return 'happy';
if (pet.activity && happyActivities.includes(pet.activity)) return 'happy';
return 'awake';
}
export function commandPet(pet: Pet, pointX: number, pointY: number, world: PetWorld) {
if (pet.action === 'held') return;
startWalk(pet, { x: pointX - petWidth / 2, y: pointY - petHeight }, world, true);
}
export function hopPet(pet: Pet) {
if (pet.action === 'held') return;
setAction(pet, 'hop', hopSeconds);
}
export function holdPet(pet: Pet) {
setAction(pet, 'held');
pet.path = [];
}
export function dropPet(pet: Pet) {
setAction(pet, 'hop', hopSeconds);
}
export function placePet(pet: Pet, x: number, y: number, world: PetWorld) {
const spot = clampToWorld({ x, y }, world);
pet.x = spot.x;
pet.y = spot.y;
}
function followPath(pet: Pet, seconds: number) {
const waypoint = pet.path[0];
if (!waypoint) {
setAction(pet, 'idle', idleSeconds);
return;
}
const distanceX = waypoint.x - pet.x;
const distanceY = waypoint.y - pet.y;
const remainingDistance = Math.hypot(distanceX, distanceY);
const stepDistance = (pet.commanded ? commandedSpeed : wanderSpeed) * seconds;
if (Math.abs(distanceX) > 1) pet.facing = distanceX > 0 ? 1 : -1;
if (remainingDistance <= stepDistance) {
pet.x = waypoint.x;
pet.y = waypoint.y;
pet.path.shift();
if (pet.path.length === 0) setAction(pet, 'idle', idleSeconds);
return;
}
pet.x += (distanceX / remainingDistance) * stepDistance;
pet.y += (distanceY / remainingDistance) * stepDistance;
}
export function stepPet(pet: Pet, seconds: number, world: PetWorld) {
placePet(pet, pet.x, pet.y, world);
if (pet.action === 'held') return;
if (pet.action === 'walk') {
followPath(pet, seconds);
return;
}
pet.actionTimeLeft -= seconds;
if (pet.actionTimeLeft > 0) return;
if (isSpotBlocked(pet, world)) startWalk(pet, pet, world, false);
else if (pet.action === 'idle') chooseNextAction(pet, world);
else setAction(pet, 'idle', idleSeconds);
}
+146
View File
@@ -0,0 +1,146 @@
import { clampToWorld, furthestX, furthestY, isPathClear, isSpotBlocked, type PetWorld, type Point } from './world';
interface Grid {
columns: number;
rows: number;
blocked: boolean[];
}
const cellSize = 16;
const neighborOffsets = [
{ column: 1, row: 0 },
{ column: -1, row: 0 },
{ column: 0, row: 1 },
{ column: 0, row: -1 },
{ column: 1, row: 1 },
{ column: 1, row: -1 },
{ column: -1, row: 1 },
{ column: -1, row: -1 }
];
function buildGrid(world: PetWorld): Grid {
const columns = Math.floor(furthestX(world) / cellSize) + 1;
const rows = Math.floor(furthestY(world) / cellSize) + 1;
const blocked: boolean[] = [];
for (let row = 0; row < rows; row++) {
for (let column = 0; column < columns; column++) {
blocked.push(isSpotBlocked({ x: column * cellSize, y: row * cellSize }, world));
}
}
return { columns, rows, blocked };
}
function cellIndexOf(point: Point, grid: Grid): number {
const column = Math.min(grid.columns - 1, Math.max(0, Math.round(point.x / cellSize)));
const row = Math.min(grid.rows - 1, Math.max(0, Math.round(point.y / cellSize)));
return row * grid.columns + column;
}
function pointOfCell(index: number, grid: Grid): Point {
return { x: (index % grid.columns) * cellSize, y: Math.floor(index / grid.columns) * cellSize };
}
function distanceBetween(first: Point, second: Point): number {
return Math.hypot(first.x - second.x, first.y - second.y);
}
export function nearestFreePoint(point: Point, world: PetWorld): Point {
const spot = clampToWorld(point, world);
if (!isSpotBlocked(spot, world)) return spot;
const grid = buildGrid(world);
let nearest = spot;
let nearestDistance = Infinity;
grid.blocked.forEach((isBlocked, index) => {
if (isBlocked) return;
const cellPoint = pointOfCell(index, grid);
const distance = distanceBetween(cellPoint, spot);
if (distance < nearestDistance) {
nearest = cellPoint;
nearestDistance = distance;
}
});
return nearest;
}
function neighborsOf(index: number, grid: Grid): { index: number; cost: number }[] {
const column = index % grid.columns;
const row = Math.floor(index / grid.columns);
const isOpen = (targetColumn: number, targetRow: number) =>
targetColumn >= 0 &&
targetRow >= 0 &&
targetColumn < grid.columns &&
targetRow < grid.rows &&
!grid.blocked[targetRow * grid.columns + targetColumn];
return neighborOffsets
.filter((offset) => isOpen(column + offset.column, row + offset.row))
.filter((offset) => offset.column === 0 || offset.row === 0 || (isOpen(column + offset.column, row) && isOpen(column, row + offset.row)))
.map((offset) => ({
index: (row + offset.row) * grid.columns + column + offset.column,
cost: offset.column !== 0 && offset.row !== 0 ? Math.SQRT2 : 1
}));
}
function searchCells(startIndex: number, goalIndex: number, grid: Grid): number[] {
const goalPoint = pointOfCell(goalIndex, grid);
const estimate = (index: number) => distanceBetween(pointOfCell(index, grid), goalPoint) / cellSize;
const costs = new Map<number, number>([[startIndex, 0]]);
const scores = new Map<number, number>([[startIndex, estimate(startIndex)]]);
const cameFrom = new Map<number, number>();
const openCells = new Set<number>([startIndex]);
while (openCells.size > 0) {
let current = -1;
openCells.forEach((index) => {
if (current === -1 || scores.get(index)! < scores.get(current)!) current = index;
});
if (current === goalIndex) {
const route = [current];
while (cameFrom.has(route[0])) route.unshift(cameFrom.get(route[0])!);
return route;
}
openCells.delete(current);
neighborsOf(current, grid).forEach((neighbor) => {
const cost = costs.get(current)! + neighbor.cost;
if (cost >= (costs.get(neighbor.index) ?? Infinity)) return;
cameFrom.set(neighbor.index, current);
costs.set(neighbor.index, cost);
scores.set(neighbor.index, cost + estimate(neighbor.index));
openCells.add(neighbor.index);
});
}
return [];
}
function smoothRoute(start: Point, route: Point[], world: PetWorld): Point[] {
const smoothed: Point[] = [];
let anchor = start;
let index = 0;
while (index < route.length) {
let furthest = index;
while (furthest + 1 < route.length && isPathClear(anchor, route[furthest + 1], world)) furthest++;
smoothed.push(route[furthest]);
anchor = route[furthest];
index = furthest + 1;
}
return smoothed;
}
export function findPath(start: Point, goal: Point, world: PetWorld): Point[] {
if (isPathClear(start, goal, world)) return [goal];
const grid = buildGrid(world);
const cellRoute = searchCells(cellIndexOf(start, grid), cellIndexOf(goal, grid), grid);
if (cellRoute.length === 0) return [];
const route = cellRoute.slice(1).map((index) => pointOfCell(index, grid));
route.push(goal);
return smoothRoute(start, route, world);
}
+29
View File
@@ -0,0 +1,29 @@
const storageKey = 'desktop-pet-enabled';
class PetState {
enabled = $state(true);
load() {
try {
const storedValue = localStorage.getItem(storageKey);
if (storedValue !== null) this.enabled = storedValue === 'true';
} catch {
return;
}
}
setEnabled(enabled: boolean) {
this.enabled = enabled;
try {
localStorage.setItem(storageKey, String(enabled));
} catch {
return;
}
}
toggle() {
this.setEnabled(!this.enabled);
}
}
export const petState = new PetState();
+11
View File
@@ -0,0 +1,11 @@
export interface PixelRun {
x: number;
y: number;
width: number;
}
export function pixelRuns(rows: string[]): PixelRun[] {
return rows.flatMap((row, y) =>
[...row.matchAll(/X+/g)].map((match) => ({ x: match.index ?? 0, y, width: match[0].length }))
);
}
+72
View File
@@ -0,0 +1,72 @@
import type { PetActivity } from './behavior';
export type VoiceMoment = 'click' | 'command' | 'arrive' | 'pickup' | 'drop' | 'sleep' | 'wake' | 'chatter' | PetActivity;
export const voiceLines: Record<VoiceMoment, string[]> = {
wave: ['hi there!', 'hello, visitor!', 'o/', 'welcome!', 'hey you!'],
scan: ['scanning...', 'area secure', 'running diagnostics', 'no bugs detected', 'searching for wifi'],
dance: ['beep boop bop', 'dance mode on', 'feel the bass', 'groove.exe', 'robot dance!'],
spin: ['wheee', 'barrel roll!', 'spinning up', '360 no scope'],
charge: ['charging...', 'battery low', 'need juice', 'plugging in', 'recharging cells'],
compute: ['computing...', 'processing', 'crunching numbers', 'thinking very hard', 'solving p = np'],
click: [
'beep!',
'boop',
'hello!',
'01101000 01101001',
'bzzt',
'that tickles',
'systems nominal',
'need something?',
'ping received',
'pong!',
'hey, watch the antenna',
'hello, world!',
'running at 60 fps',
'i am not a virus, promise',
'did you try turning me off and on?',
'sudo make me a sandwich',
'all cores happy',
'404: joke not found'
],
command: [
'on my way',
'moving out',
'roger',
'affirmative',
'copy that',
'en route',
'calculating path...',
'rerouting...',
'as you wish',
'target acquired',
'executing order',
'beep boop, going'
],
arrive: ['destination reached', 'made it', 'arrived', 'here!', 'what now?', 'awaiting orders', 'path complete'],
pickup: ['whoa!', 'put me down!', 'wheee', 'gyro error', 'i can fly!', 'hey!', 'motion sickness...'],
drop: ['landed', 'oof', 'stuck the landing', 'gravity online', 'recalibrating...', 'thanks for the lift'],
sleep: ['powering down...', 'entering sleep mode', 'low power mode', 'saving state...'],
wake: ['rebooting...', 'back online', 'good morning!', 'boot complete', 'systems restored'],
chatter: [
'scanning desktop...',
'nice wallpaper',
'checking for updates',
'defragmenting...',
'try right clicking the desktop',
'i like it here',
'compiling thoughts...',
'have you played snake yet?',
'try alt + space',
'hmm...',
'*whirr*',
'cache cleared',
'pinging localhost',
'type help in a terminal'
]
};
export function randomVoiceLine(moment: VoiceMoment): string {
const lines = voiceLines[moment];
return lines[Math.floor(Math.random() * lines.length)];
}
+62
View File
@@ -0,0 +1,62 @@
export interface Point {
x: number;
y: number;
}
export interface Rect {
left: number;
top: number;
right: number;
bottom: number;
}
export interface PetWorld {
width: number;
height: number;
obstacles: Rect[];
}
export const robotGridSize = 12;
export const robotPixelSize = 6;
export const petWidth = robotGridSize * robotPixelSize;
export const petHeight = robotGridSize * robotPixelSize;
const obstacleMargin = 2;
const lineCheckStep = 8;
function clamp(value: number, minimum: number, maximum: number): number {
return Math.min(Math.max(value, minimum), maximum);
}
export function furthestX(world: PetWorld): number {
return Math.max(0, world.width - petWidth);
}
export function furthestY(world: PetWorld): number {
return Math.max(0, world.height - petHeight);
}
export function clampToWorld(point: Point, world: PetWorld): Point {
return { x: clamp(point.x, 0, furthestX(world)), y: clamp(point.y, 0, furthestY(world)) };
}
export function isSpotBlocked(spot: Point, world: PetWorld): boolean {
return world.obstacles.some(
(obstacle) =>
spot.x < obstacle.right + obstacleMargin &&
spot.x + petWidth > obstacle.left - obstacleMargin &&
spot.y < obstacle.bottom + obstacleMargin &&
spot.y + petHeight > obstacle.top - obstacleMargin
);
}
export function isPathClear(from: Point, to: Point, world: PetWorld): boolean {
const distance = Math.hypot(to.x - from.x, to.y - from.y);
const steps = Math.max(1, Math.ceil(distance / lineCheckStep));
for (let step = 0; step <= steps; step++) {
const progress = step / steps;
const spot = { x: from.x + (to.x - from.x) * progress, y: from.y + (to.y - from.y) * progress };
if (isSpotBlocked(spot, world)) return false;
}
return true;
}
+111
View File
@@ -0,0 +1,111 @@
import type { CardSearch, TcgCard, TcgPage, TcgSet } from './types';
export const tcgApiUrl = 'https://tcg.sirblob.co';
export const cardsPerPage = 24;
const cardFields = [
'id',
'name',
'supertype',
'subtypes',
'hp',
'types',
'rarity',
'number',
'artist',
'flavorText',
'images',
'set',
'abilities',
'attacks',
'weaknesses',
'resistances'
].join(',');
export function buildCardQuery(search: CardSearch): string {
const clauses: string[] = [];
const name = search.name.trim().replace(/["*]/g, '');
if (name) clauses.push(name.includes(' ') ? `name:"${name}"` : `name:${name}*`);
if (search.supertype) clauses.push(`supertype:"${search.supertype}"`);
if (search.energyType) clauses.push(`types:${search.energyType.toLowerCase()}`);
if (search.setId) clauses.push(`set.id:${search.setId}`);
return clauses.join(' ');
}
export function cardApiUrl(cardId: string): string {
return `${tcgApiUrl}/cards/${encodeURIComponent(cardId)}`;
}
async function getJson<Result>(path: string, params: Record<string, string>, signal?: AbortSignal): Promise<Result> {
const url = new URL(path, tcgApiUrl);
Object.entries(params).forEach(([key, value]) => {
if (value) url.searchParams.set(key, value);
});
const response = await fetch(url, { signal });
if (response.status === 429) {
const retrySeconds = response.headers.get('Retry-After') ?? 'a few';
throw new Error(`rate limited, try again in ${retrySeconds} seconds`);
}
const body = await response.json().catch(() => null);
if (!response.ok) throw new Error(body?.error?.message ?? `request failed with status ${response.status}`);
return body as Result;
}
export function searchCards(search: CardSearch, signal?: AbortSignal): Promise<TcgPage<TcgCard>> {
return getJson(
'/cards',
{
q: buildCardQuery(search),
page: String(search.page),
pageSize: String(cardsPerPage),
select: cardFields
},
signal
);
}
export async function findCardByName(name: string, setCode?: string): Promise<TcgCard | null> {
const nameClause = `name:"${name.replace(/"/g, '')}"`;
const queries = setCode ? [`${nameClause} set.ptcgoCode:${setCode}`, nameClause] : [nameClause];
for (const query of queries) {
const result = await getJson<TcgPage<TcgCard>>('/cards', { q: query, pageSize: '1', select: cardFields });
if (result.data[0]) return result.data[0];
}
return null;
}
export async function fetchEnergyTypes(signal?: AbortSignal): Promise<string[]> {
const body = await getJson<{ data: string[] }>('/types', {}, signal);
return body.data;
}
const setFields = 'id,name,series,releaseDate,ptcgoCode,printedTotal,total,images';
const setsPerPage = 250;
export async function fetchSets(signal?: AbortSignal): Promise<TcgSet[]> {
const sets: TcgSet[] = [];
let page = 1;
let totalCount = Infinity;
while (sets.length < totalCount) {
const result = await getJson<TcgPage<TcgSet>>(
'/sets',
{ page: String(page), pageSize: String(setsPerPage), select: setFields, orderBy: '-releaseDate' },
signal
);
sets.push(...result.data);
totalCount = result.totalCount;
if (result.data.length === 0) break;
page += 1;
}
return sets;
}
export async function fetchSupertypes(signal?: AbortSignal): Promise<string[]> {
const body = await getJson<{ data: string[] }>('/supertypes', {}, signal);
return body.data;
}
+41
View File
@@ -0,0 +1,41 @@
import { tcgDecks } from '#lib/content';
import type { TcgCard, TcgSet } from './types';
export type BrowserTab = 'decks' | 'builder';
export class TcgBrowserState {
tab = $state<BrowserTab>('decks');
deckId = $state(tcgDecks[0]?.id ?? '');
searchedName = $state('');
supertype = $state('');
energyType = $state('');
selectedSet = $state<TcgSet | null>(null);
setId = $derived(this.selectedSet?.id ?? '');
page = $state(1);
openedCard = $state<TcgCard | null>(null);
setSearchedName(name: string) {
if (name === this.searchedName) return;
this.searchedName = name;
this.page = 1;
}
toggleSupertype(supertype: string) {
this.supertype = this.supertype === supertype ? '' : supertype;
this.page = 1;
}
toggleEnergyType(energyType: string) {
this.energyType = this.energyType === energyType ? '' : energyType;
this.page = 1;
}
toggleSet(set: TcgSet | null) {
this.selectedSet = set && set.id !== this.setId ? set : null;
this.page = 1;
}
openCard(card: TcgCard | null) {
this.openedCard = card;
}
}
+28
View File
@@ -0,0 +1,28 @@
import type { DeckCategory } from '#lib/content';
import type { TcgCard } from './types';
export const deckSizeLimit = 60;
export const copyLimit = 4;
export const categoryTitles: Record<DeckCategory, string> = {
pokemon: 'Pokemon',
trainer: 'Trainer',
energy: 'Energy'
};
export const categoryOrder = Object.keys(categoryTitles) as DeckCategory[];
function plainText(text: string): string {
return text.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
}
export function cardCategory(card: Pick<TcgCard, 'supertype'>): DeckCategory {
const supertype = plainText(card.supertype ?? '');
if (supertype.startsWith('trainer')) return 'trainer';
if (supertype.startsWith('energy')) return 'energy';
return 'pokemon';
}
export function isBasicEnergy(card: Pick<TcgCard, 'supertype' | 'subtypes'>): boolean {
return cardCategory(card) === 'energy' && (card.subtypes ?? []).some((subtype) => plainText(subtype) === 'basic');
}
+147
View File
@@ -0,0 +1,147 @@
import type { DeckCategory } from '#lib/content';
import { cardCategory, categoryOrder, categoryTitles, copyLimit, deckSizeLimit, isBasicEnergy } from './cardKind';
import type { TcgCard } from './types';
export interface BuilderCard {
id: string;
name: string;
supertype?: string;
subtypes?: string[];
image: string;
setCode?: string;
number?: string;
}
export interface BuilderEntry {
card: BuilderCard;
count: number;
}
export interface BuilderSection {
category: DeckCategory;
title: string;
total: number;
entries: BuilderEntry[];
}
const storageKey = 'tcg-deck-builder';
const defaultDeckName = 'my deck';
export function toBuilderCard(card: TcgCard): BuilderCard {
return {
id: card.id,
name: card.name,
supertype: card.supertype,
subtypes: card.subtypes,
image: card.images.small,
setCode: card.set.ptcgoCode,
number: card.number
};
}
class DeckBuilderState {
name = $state(defaultDeckName);
entries = $state<BuilderEntry[]>([]);
private loaded = false;
total = $derived(this.entries.reduce((sum, entry) => sum + entry.count, 0));
sections = $derived<BuilderSection[]>(
categoryOrder
.map((category) => {
const entries = this.entries.filter((entry) => cardCategory(entry.card) === category);
return {
category,
title: categoryTitles[category],
total: entries.reduce((sum, entry) => sum + entry.count, 0),
entries
};
})
.filter((section) => section.entries.length > 0)
);
load() {
if (this.loaded) return;
this.loaded = true;
try {
const stored = JSON.parse(localStorage.getItem(storageKey) ?? 'null');
if (!stored) return;
this.name = typeof stored.name === 'string' ? stored.name : defaultDeckName;
this.entries = Array.isArray(stored.entries) ? stored.entries : [];
} catch {
return;
}
}
private save() {
try {
localStorage.setItem(storageKey, JSON.stringify({ name: this.name, entries: this.entries }));
} catch {
return;
}
}
countOf(cardId: string): number {
return this.entries.find((entry) => entry.card.id === cardId)?.count ?? 0;
}
private copiesNamed(name: string): number {
return this.entries.filter((entry) => entry.card.name === name).reduce((sum, entry) => sum + entry.count, 0);
}
canAdd(card: Pick<TcgCard, 'name' | 'supertype' | 'subtypes'>): boolean {
if (this.total >= deckSizeLimit) return false;
return isBasicEnergy(card) || this.copiesNamed(card.name) < copyLimit;
}
add(card: TcgCard) {
if (!this.canAdd(card)) return;
const entry = this.entries.find((item) => item.card.id === card.id);
if (entry) entry.count += 1;
else this.entries.push({ card: toBuilderCard(card), count: 1 });
this.save();
}
increase(cardId: string) {
const entry = this.entries.find((item) => item.card.id === cardId);
if (!entry || !this.canAdd(entry.card)) return;
entry.count += 1;
this.save();
}
remove(cardId: string) {
const entry = this.entries.find((item) => item.card.id === cardId);
if (!entry) return;
entry.count -= 1;
if (entry.count <= 0) this.entries = this.entries.filter((item) => item.card.id !== cardId);
this.save();
}
rename(name: string) {
this.name = name;
this.save();
}
replace(name: string, entries: BuilderEntry[]) {
this.name = name;
this.entries = entries;
this.save();
}
clear() {
this.entries = [];
this.save();
}
exportText(): string {
const lines = this.sections.flatMap((section) => [
`${section.title}: ${section.total}`,
...section.entries.map((entry) => [entry.count, entry.card.name, entry.card.setCode, entry.card.number].filter(Boolean).join(' ')),
''
]);
return [...lines, `Total Cards: ${this.total}`].join('\n');
}
}
export const deckBuilder = new DeckBuilderState();
+42
View File
@@ -0,0 +1,42 @@
import type { BuilderEntry } from './deckBuilder.svelte';
function fileSafeName(name: string): string {
return name.trim().toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'deck';
}
export function downloadFile(fileName: string, content: string, mimeType: string) {
const url = URL.createObjectURL(new Blob([content], { type: mimeType }));
const link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
}
export function downloadDeckText(deckName: string, text: string) {
downloadFile(`${fileSafeName(deckName)}.txt`, text, 'text/plain');
}
export function downloadDeckJson(deckName: string, entries: BuilderEntry[]) {
const deck = {
name: deckName,
total: entries.reduce((sum, entry) => sum + entry.count, 0),
cards: entries.map((entry) => ({
count: entry.count,
id: entry.card.id,
name: entry.card.name,
set: entry.card.setCode,
number: entry.card.number
}))
};
downloadFile(`${fileSafeName(deckName)}.json`, JSON.stringify(deck, null, '\t'), 'application/json');
}
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+58
View File
@@ -0,0 +1,58 @@
import type { DeckCard, DeckCategory, TcgDeck } from '#lib/content';
import { findCardByName } from './api';
import { categoryOrder, categoryTitles } from './cardKind';
import { toBuilderCard, type BuilderEntry } from './deckBuilder.svelte';
import type { TcgCard } from './types';
export interface DeckSection {
category: DeckCategory;
title: string;
total: number;
cards: DeckCard[];
}
const lookups = new Map<string, Promise<TcgCard | null>>();
export function deckCardKey(card: DeckCard): string {
return `${card.name}|${card.set ?? ''}`;
}
export function deckSections(deck: TcgDeck): DeckSection[] {
return categoryOrder
.map((category) => {
const cards = deck.cards.filter((card) => card.category === category);
return {
category,
title: categoryTitles[category],
total: cards.reduce((sum, card) => sum + card.count, 0),
cards
};
})
.filter((section) => section.cards.length > 0);
}
export function deckTotal(deck: TcgDeck): number {
return deck.cards.reduce((sum, card) => sum + card.count, 0);
}
export function lookUpDeckCard(card: DeckCard): Promise<TcgCard | null> {
const key = deckCardKey(card);
const existing = lookups.get(key);
if (existing) return existing;
const lookup = findCardByName(card.name, card.set).catch((error) => {
lookups.delete(key);
throw error;
});
lookups.set(key, lookup);
return lookup;
}
export async function deckBuilderEntries(deck: TcgDeck): Promise<BuilderEntry[]> {
const entries: BuilderEntry[] = [];
for (const deckCard of deck.cards) {
const card = await lookUpDeckCard(deckCard).catch(() => null);
if (card) entries.push({ card: toBuilderCard(card), count: deckCard.count });
}
return entries;
}
+17
View File
@@ -0,0 +1,17 @@
const energyColors: Record<string, string> = {
fire: 'var(--red)',
water: 'var(--blue)',
grass: 'var(--green)',
lightning: 'var(--yellow)',
psychic: 'var(--magenta)',
fairy: 'var(--magenta)',
fighting: 'var(--orange)',
dragon: 'var(--orange)',
darkness: 'var(--muted)',
metal: 'var(--cyan)',
colorless: 'var(--foreground)'
};
export function energyColor(type: string): string {
return energyColors[type.toLowerCase()] ?? 'var(--foreground)';
}
+71
View File
@@ -0,0 +1,71 @@
export interface TcgImages {
small: string;
large: string;
}
export interface TcgSet {
id: string;
name: string;
series: string;
printedTotal?: number;
total?: number;
ptcgoCode?: string;
releaseDate?: string;
images?: {
symbol?: string;
logo?: string;
};
}
export interface TcgAttack {
name: string;
cost?: string[];
damage?: string;
text?: string;
}
export interface TcgAbility {
name: string;
type?: string;
text?: string;
}
export interface TcgTypeValue {
type: string;
value: string;
}
export interface TcgCard {
id: string;
name: string;
supertype?: string;
subtypes?: string[];
hp?: string;
types?: string[];
rarity?: string;
number?: string;
artist?: string;
flavorText?: string;
images: TcgImages;
set: TcgSet;
abilities?: TcgAbility[];
attacks?: TcgAttack[];
weaknesses?: TcgTypeValue[];
resistances?: TcgTypeValue[];
}
export interface TcgPage<Item> {
data: Item[];
page: number;
pageSize: number;
count: number;
totalCount: number;
}
export interface CardSearch {
name: string;
supertype: string;
energyType: string;
setId: string;
page: number;
}
+37
View File
@@ -0,0 +1,37 @@
import type { GameName } from '#lib/content';
import { line, span } from '../output';
import type { Command } from './types';
const gameDescriptions: Record<GameName, string> = {
snake: 'Eat the food and avoid your own tail',
breakout: 'Bounce the ball and clear every brick'
};
const gameNames = Object.keys(gameDescriptions) as GameName[];
const launchCommands: Command[] = gameNames.map((game) => ({
name: game,
description: gameDescriptions[game],
hidden: true,
run: (_args, context) => context.openApp(game)
}));
const gamesCommand: Command = {
name: 'games',
description: 'List or launch the games',
usage: 'games [name]',
argumentOptions: () => gameNames,
run: (args, context) => {
const requestedGame = gameNames.find((game) => game === args[0]?.toLowerCase());
if (requestedGame) {
context.openApp(requestedGame);
return;
}
gameNames.forEach((game) =>
context.print(line(span(game.padEnd(12), 'accent', { bold: true }), gameDescriptions[game]))
);
context.print(line(span('Run ', 'muted'), span('games <name>', 'accent'), span(' to play', 'muted')));
}
};
export const gameCommands: Command[] = [gamesCommand, ...launchCommands];
+4
View File
@@ -4,6 +4,8 @@ import { blankLine, errorLine, line, span } from '../output';
import { infoCommands } from './info';
import { systemCommands } from './system';
import { pacmanCommand } from './pacman';
import { gameCommands } from './games';
import { petCommand } from './pet';
import type { Command, CommandContext } from './types';
export type { Command, CommandContext } from './types';
@@ -44,6 +46,8 @@ export const commands: Command[] = [
...infoCommands,
...systemCommands,
pacmanCommand,
...gameCommands,
petCommand,
...pageShortcuts,
...socialShortcuts
];
+19
View File
@@ -0,0 +1,19 @@
import { petState } from '#lib/pet/petState.svelte';
import { line, span } from '../output';
import type { Command } from './types';
export const petCommand: Command = {
name: 'pet',
aliases: ['robot'],
description: 'Show or hide the desktop robot',
usage: 'pet [on|off]',
argumentOptions: () => ['on', 'off'],
run: (args, context) => {
const choice = args[0]?.toLowerCase();
if (choice === 'on' || choice === 'off') petState.setEnabled(choice === 'on');
else petState.toggle();
const status = petState.enabled ? 'robot booted up' : 'robot powered down';
context.print(line(span('pet: ', 'muted'), span(status, 'accent')));
}
};
+6
View File
@@ -134,6 +134,12 @@ export const systemCommands: Command[] = [
description: 'Open the system monitor',
run: (_args, context) => context.openApp('btop')
},
{
name: 'tcg',
aliases: ['cards', 'pokemon'],
description: 'Open the Pokemon TCG card browser',
run: (_args, context) => context.openApp('tcg')
},
{
name: 'pwd',
hidden: true,
+18 -1
View File
@@ -5,6 +5,8 @@
desktopKeybinds,
imageViewerKeybinds,
navigationTips,
snakeKeybinds,
breakoutKeybinds,
terminalKeybinds,
workspaceKeybinds
} from '#lib/desktop/keybindGuide';
@@ -20,7 +22,8 @@
{ id: 'navigation', label: 'Navigation', color: 'blue' },
{ id: 'desktop', label: 'Desktop', color: 'magenta' },
{ id: 'terminal', label: 'Terminal', color: 'green' },
{ id: 'viewer', label: 'Image viewer', color: 'orange' }
{ id: 'viewer', label: 'Image viewer', color: 'orange' },
{ id: 'games', label: 'Games', color: 'yellow' }
];
</script>
@@ -74,6 +77,20 @@
<KeybindTable entries={imageViewerKeybinds} />
<p class="muted hint"><Icon icon="mdi:gesture-pinch" width="16" /> Scroll or pinch to zoom, drag to pan and swipe to change images.</p>
</Section>
<Section title="Games" id="games">
<div class="columns">
<div>
<h3>Snake</h3>
<KeybindTable entries={snakeKeybinds} />
</div>
<div>
<h3>Breakout</h3>
<KeybindTable entries={breakoutKeybinds} />
</div>
</div>
<p class="muted hint"><Icon icon="mdi:gesture-tap" width="16" /> On touch screens use the arrow pad in snake, and drag to move the paddle in breakout.</p>
</Section>
</div>
<style>