From c664f573b8653b18b843e2b5cc78ae8c8d238674 Mon Sep 17 00:00:00 2001 From: SirBlob Date: Sun, 1 Nov 2026 18:42:16 -0500 Subject: [PATCH] Add desktop games, robot pet and TCG app --- content/desktop.json | 16 + content/tcgDecks.json | 35 ++ src/lib/components/desktop/Desktop.svelte | 37 ++ .../components/desktop/DesktopIcons.svelte | 56 +- src/lib/components/games/Breakout.svelte | 216 ++++++++ .../components/games/BreakoutCanvas.svelte | 73 +++ src/lib/components/games/DirectionPad.svelte | 65 +++ src/lib/components/games/GameShell.svelte | 83 +++ src/lib/components/games/Snake.svelte | 182 +++++++ src/lib/components/games/SnakeCanvas.svelte | 69 +++ src/lib/components/pet/DesktopPet.svelte | 485 ++++++++++++++++++ src/lib/components/pet/MoveMarker.svelte | 77 +++ src/lib/components/pet/PixelIcon.svelte | 29 ++ src/lib/components/pet/RobotSprite.svelte | 266 ++++++++++ .../components/tcg/BuilderDeckPanel.svelte | 251 +++++++++ src/lib/components/tcg/CardDetail.svelte | 194 +++++++ src/lib/components/tcg/CardGrid.svelte | 149 ++++++ src/lib/components/tcg/CardModal.svelte | 155 ++++++ src/lib/components/tcg/CardSearchView.svelte | 244 +++++++++ src/lib/components/tcg/DeckBuilderTab.svelte | 88 ++++ src/lib/components/tcg/DeckCardTile.svelte | 96 ++++ src/lib/components/tcg/DeckView.svelte | 118 +++++ src/lib/components/tcg/EnergyTag.svelte | 18 + src/lib/components/tcg/FilterSidebar.svelte | 100 ++++ src/lib/components/tcg/MyDecksTab.svelte | 158 ++++++ src/lib/components/tcg/SetFilter.svelte | 180 +++++++ src/lib/components/tcg/TcgBrowser.svelte | 111 ++++ src/lib/components/tcg/TcgPager.svelte | 55 ++ src/lib/content/index.ts | 5 +- src/lib/content/types.ts | 34 +- src/lib/desktop/actions.ts | 4 + src/lib/desktop/keybindGuide.ts | 19 + src/lib/desktop/windows.svelte.ts | 34 +- src/lib/games/breakout.ts | 195 +++++++ src/lib/games/highScore.ts | 19 + src/lib/games/snake.ts | 97 ++++ src/lib/pet/behavior.ts | 172 +++++++ src/lib/pet/pathfinding.ts | 146 ++++++ src/lib/pet/petState.svelte.ts | 29 ++ src/lib/pet/pixelArt.ts | 11 + src/lib/pet/voiceLines.ts | 72 +++ src/lib/pet/world.ts | 62 +++ src/lib/tcg/api.ts | 111 ++++ src/lib/tcg/browserState.svelte.ts | 41 ++ src/lib/tcg/cardKind.ts | 28 + src/lib/tcg/deckBuilder.svelte.ts | 147 ++++++ src/lib/tcg/deckExport.ts | 42 ++ src/lib/tcg/decks.ts | 58 +++ src/lib/tcg/energy.ts | 17 + src/lib/tcg/types.ts | 71 +++ src/lib/terminal/commands/games.ts | 37 ++ src/lib/terminal/commands/index.ts | 4 + src/lib/terminal/commands/pet.ts | 19 + src/lib/terminal/commands/system.ts | 6 + src/routes/keybinds/+page.svelte | 19 +- 55 files changed, 5090 insertions(+), 15 deletions(-) create mode 100644 content/tcgDecks.json create mode 100644 src/lib/components/games/Breakout.svelte create mode 100644 src/lib/components/games/BreakoutCanvas.svelte create mode 100644 src/lib/components/games/DirectionPad.svelte create mode 100644 src/lib/components/games/GameShell.svelte create mode 100644 src/lib/components/games/Snake.svelte create mode 100644 src/lib/components/games/SnakeCanvas.svelte create mode 100644 src/lib/components/pet/DesktopPet.svelte create mode 100644 src/lib/components/pet/MoveMarker.svelte create mode 100644 src/lib/components/pet/PixelIcon.svelte create mode 100644 src/lib/components/pet/RobotSprite.svelte create mode 100644 src/lib/components/tcg/BuilderDeckPanel.svelte create mode 100644 src/lib/components/tcg/CardDetail.svelte create mode 100644 src/lib/components/tcg/CardGrid.svelte create mode 100644 src/lib/components/tcg/CardModal.svelte create mode 100644 src/lib/components/tcg/CardSearchView.svelte create mode 100644 src/lib/components/tcg/DeckBuilderTab.svelte create mode 100644 src/lib/components/tcg/DeckCardTile.svelte create mode 100644 src/lib/components/tcg/DeckView.svelte create mode 100644 src/lib/components/tcg/EnergyTag.svelte create mode 100644 src/lib/components/tcg/FilterSidebar.svelte create mode 100644 src/lib/components/tcg/MyDecksTab.svelte create mode 100644 src/lib/components/tcg/SetFilter.svelte create mode 100644 src/lib/components/tcg/TcgBrowser.svelte create mode 100644 src/lib/components/tcg/TcgPager.svelte create mode 100644 src/lib/games/breakout.ts create mode 100644 src/lib/games/highScore.ts create mode 100644 src/lib/games/snake.ts create mode 100644 src/lib/pet/behavior.ts create mode 100644 src/lib/pet/pathfinding.ts create mode 100644 src/lib/pet/petState.svelte.ts create mode 100644 src/lib/pet/pixelArt.ts create mode 100644 src/lib/pet/voiceLines.ts create mode 100644 src/lib/pet/world.ts create mode 100644 src/lib/tcg/api.ts create mode 100644 src/lib/tcg/browserState.svelte.ts create mode 100644 src/lib/tcg/cardKind.ts create mode 100644 src/lib/tcg/deckBuilder.svelte.ts create mode 100644 src/lib/tcg/deckExport.ts create mode 100644 src/lib/tcg/decks.ts create mode 100644 src/lib/tcg/energy.ts create mode 100644 src/lib/tcg/types.ts create mode 100644 src/lib/terminal/commands/games.ts create mode 100644 src/lib/terminal/commands/pet.ts diff --git a/content/desktop.json b/content/desktop.json index 69f2838..9751e39 100644 --- a/content/desktop.json +++ b/content/desktop.json @@ -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": [ diff --git a/content/tcgDecks.json b/content/tcgDecks.json new file mode 100644 index 0000000..b234eab --- /dev/null +++ b/content/tcgDecks.json @@ -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" } + ] + } +] diff --git a/src/lib/components/desktop/Desktop.svelte b/src/lib/components/desktop/Desktop.svelte index 8933664..9451b0e 100644 --- a/src/lib/components/desktop/Desktop.svelte +++ b/src/lib/components/desktop/Desktop.svelte @@ -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 @@
+ {#if !isNarrow} + + {/if} + {#if pageOpen} {@render children()} @@ -141,6 +149,35 @@ {/each} + + {#each windowManager.games as gameWindow (gameWindow.id)} + windowManager.closeWindow(gameWindow.id)} + > + {#if gameWindow.game === 'snake'} + + {:else if gameWindow.game === 'breakout'} + + {/if} + + {/each} + + {#each windowManager.tcgBrowsers as browser (browser.id)} + windowManager.closeWindow(browser.id)} + > + + + {/each} +
diff --git a/src/lib/components/desktop/DesktopIcons.svelte b/src/lib/components/desktop/DesktopIcons.svelte index 2892b96..7111235 100644 --- a/src/lib/components/desktop/DesktopIcons.svelte +++ b/src/lib/components/desktop/DesktopIcons.svelte @@ -1,23 +1,43 @@ - + diff --git a/src/lib/components/games/Breakout.svelte b/src/lib/components/games/Breakout.svelte new file mode 100644 index 0000000..ba7b3fe --- /dev/null +++ b/src/lib/components/games/Breakout.svelte @@ -0,0 +1,216 @@ + + + +
+ +
+
+ + diff --git a/src/lib/components/games/BreakoutCanvas.svelte b/src/lib/components/games/BreakoutCanvas.svelte new file mode 100644 index 0000000..742a9df --- /dev/null +++ b/src/lib/components/games/BreakoutCanvas.svelte @@ -0,0 +1,73 @@ + + + + + diff --git a/src/lib/components/games/DirectionPad.svelte b/src/lib/components/games/DirectionPad.svelte new file mode 100644 index 0000000..87d267c --- /dev/null +++ b/src/lib/components/games/DirectionPad.svelte @@ -0,0 +1,65 @@ + + +
+ {#each buttons as button (button.direction)} + + {/each} +
+ + diff --git a/src/lib/components/games/GameShell.svelte b/src/lib/components/games/GameShell.svelte new file mode 100644 index 0000000..fd5ad5d --- /dev/null +++ b/src/lib/components/games/GameShell.svelte @@ -0,0 +1,83 @@ + + +
+
+ {title} + {#each stats as stat (stat.label)} + {stat.label} {stat.value} + {/each} +
+ + {@render children()} + +
+ {hint} + {#if touchHint} + {touchHint} + {/if} +
+
+ + diff --git a/src/lib/components/games/Snake.svelte b/src/lib/components/games/Snake.svelte new file mode 100644 index 0000000..8b340bd --- /dev/null +++ b/src/lib/components/games/Snake.svelte @@ -0,0 +1,182 @@ + + + +
+ +
+ +
+ +
+
+ + diff --git a/src/lib/components/games/SnakeCanvas.svelte b/src/lib/components/games/SnakeCanvas.svelte new file mode 100644 index 0000000..aa7ea28 --- /dev/null +++ b/src/lib/components/games/SnakeCanvas.svelte @@ -0,0 +1,69 @@ + + + + + diff --git a/src/lib/components/pet/DesktopPet.svelte b/src/lib/components/pet/DesktopPet.svelte new file mode 100644 index 0000000..2e8a99c --- /dev/null +++ b/src/lib/components/pet/DesktopPet.svelte @@ -0,0 +1,485 @@ + + + + +
+ {#if pet && petState.enabled} + {#each markers as marker (marker.id)} + + {/each} + + + {/if} +
+ + diff --git a/src/lib/components/pet/MoveMarker.svelte b/src/lib/components/pet/MoveMarker.svelte new file mode 100644 index 0000000..6f2e046 --- /dev/null +++ b/src/lib/components/pet/MoveMarker.svelte @@ -0,0 +1,77 @@ + + + + {#each frames as frame (frame.index)} + + {/each} + + + diff --git a/src/lib/components/pet/PixelIcon.svelte b/src/lib/components/pet/PixelIcon.svelte new file mode 100644 index 0000000..4aef7e6 --- /dev/null +++ b/src/lib/components/pet/PixelIcon.svelte @@ -0,0 +1,29 @@ + + + + + diff --git a/src/lib/components/pet/RobotSprite.svelte b/src/lib/components/pet/RobotSprite.svelte new file mode 100644 index 0000000..5719b68 --- /dev/null +++ b/src/lib/components/pet/RobotSprite.svelte @@ -0,0 +1,266 @@ + + + + + diff --git a/src/lib/components/tcg/BuilderDeckPanel.svelte b/src/lib/components/tcg/BuilderDeckPanel.svelte new file mode 100644 index 0000000..b240678 --- /dev/null +++ b/src/lib/components/tcg/BuilderDeckPanel.svelte @@ -0,0 +1,251 @@ + + + + + diff --git a/src/lib/components/tcg/CardDetail.svelte b/src/lib/components/tcg/CardDetail.svelte new file mode 100644 index 0000000..dfc3930 --- /dev/null +++ b/src/lib/components/tcg/CardDetail.svelte @@ -0,0 +1,194 @@ + + +
+
+ {card.name} +

click the card to zoom

+
+ +
+ +
+ {#if cardKind}
type
{cardKind}
{/if} + {#if card.hp}
hp
{card.hp}
{/if} + {#if card.types?.length} +
energy
+
{#each card.types as type (type)}{/each}
+ {/if} +
set
{card.set.name} {card.set.series}
+ {#if setNumber}
number
{setNumber}
{/if} + {#if card.rarity}
rarity
{card.rarity}
{/if} + {#if card.weaknesses?.length} +
weakness
+
{card.weaknesses.map((weakness) => `${weakness.type} ${weakness.value}`).join(', ')}
+ {/if} + {#if card.resistances?.length} +
resistance
+
{card.resistances.map((resistance) => `${resistance.type} ${resistance.value}`).join(', ')}
+ {/if} + {#if card.artist}
artist
{card.artist}
{/if} +
+
+ + {#if hasMoves} + +
+ {#each card.abilities ?? [] as ability (ability.name)} +
+

{ability.type ?? 'Ability'} {ability.name}

+ {#if ability.text}

{ability.text}

{/if} +
+ {/each} + + {#each card.attacks ?? [] as attack (attack.name)} +
+

+ {#each attack.cost ?? [] as cost, index (index)}{/each} + {attack.name} + {#if attack.damage}{attack.damage}{/if} +

+ {#if attack.text}

{attack.text}

{/if} +
+ {/each} +
+
+ {/if} + + {#if card.flavorText} +

"{card.flavorText}"

+ {/if} + + + {card.id}.json + +
+
+ + diff --git a/src/lib/components/tcg/CardGrid.svelte b/src/lib/components/tcg/CardGrid.svelte new file mode 100644 index 0000000..76c708c --- /dev/null +++ b/src/lib/components/tcg/CardGrid.svelte @@ -0,0 +1,149 @@ + + + + + diff --git a/src/lib/components/tcg/CardModal.svelte b/src/lib/components/tcg/CardModal.svelte new file mode 100644 index 0000000..9737f3d --- /dev/null +++ b/src/lib/components/tcg/CardModal.svelte @@ -0,0 +1,155 @@ + + + + + diff --git a/src/lib/components/tcg/CardSearchView.svelte b/src/lib/components/tcg/CardSearchView.svelte new file mode 100644 index 0000000..99f7498 --- /dev/null +++ b/src/lib/components/tcg/CardSearchView.svelte @@ -0,0 +1,244 @@ + + + + + diff --git a/src/lib/components/tcg/DeckBuilderTab.svelte b/src/lib/components/tcg/DeckBuilderTab.svelte new file mode 100644 index 0000000..b5d577b --- /dev/null +++ b/src/lib/components/tcg/DeckBuilderTab.svelte @@ -0,0 +1,88 @@ + + +
+
+ +
+
+ +
+
+ +
+
+ + diff --git a/src/lib/components/tcg/DeckCardTile.svelte b/src/lib/components/tcg/DeckCardTile.svelte new file mode 100644 index 0000000..103015a --- /dev/null +++ b/src/lib/components/tcg/DeckCardTile.svelte @@ -0,0 +1,96 @@ + + + + + diff --git a/src/lib/components/tcg/DeckView.svelte b/src/lib/components/tcg/DeckView.svelte new file mode 100644 index 0000000..b76b5b5 --- /dev/null +++ b/src/lib/components/tcg/DeckView.svelte @@ -0,0 +1,118 @@ + + +
+
+

{deck.name}

+
+ {deckTotal(deck)} cards + {#each sections as section (section.category)} + {section.title} {section.total} + {/each} +
+
+ + {#each sections as section (section.category)} +
+

{section.title} {section.total}

+
    + {#each section.cards as deckCard (deckCardKey(deckCard))} +
  • + +
  • + {/each} +
+
+ {/each} +
+ + diff --git a/src/lib/components/tcg/EnergyTag.svelte b/src/lib/components/tcg/EnergyTag.svelte new file mode 100644 index 0000000..26ef27c --- /dev/null +++ b/src/lib/components/tcg/EnergyTag.svelte @@ -0,0 +1,18 @@ + + +{type} + + diff --git a/src/lib/components/tcg/FilterSidebar.svelte b/src/lib/components/tcg/FilterSidebar.svelte new file mode 100644 index 0000000..513070f --- /dev/null +++ b/src/lib/components/tcg/FilterSidebar.svelte @@ -0,0 +1,100 @@ + + + + + diff --git a/src/lib/components/tcg/MyDecksTab.svelte b/src/lib/components/tcg/MyDecksTab.svelte new file mode 100644 index 0000000..34af221 --- /dev/null +++ b/src/lib/components/tcg/MyDecksTab.svelte @@ -0,0 +1,158 @@ + + +
+ + +
+ {#if selectedDeck} + browser.openCard(card)} /> + {/if} +
+
+ + diff --git a/src/lib/components/tcg/SetFilter.svelte b/src/lib/components/tcg/SetFilter.svelte new file mode 100644 index 0000000..5e14cc6 --- /dev/null +++ b/src/lib/components/tcg/SetFilter.svelte @@ -0,0 +1,180 @@ + + + + + +
+ {#if isLoading} +

loading sets...

+ {:else if hasFailed} +

could not load sets

+ {:else if seriesGroups.length === 0} +

no sets match

+ {:else} + {#each seriesGroups as group (group.series)} +
+

{group.series}

+
    + {#each group.sets as set (set.id)} +
  • + +
  • + {/each} +
+
+ {/each} + {/if} +
+
+ + diff --git a/src/lib/components/tcg/TcgBrowser.svelte b/src/lib/components/tcg/TcgBrowser.svelte new file mode 100644 index 0000000..9fce9a4 --- /dev/null +++ b/src/lib/components/tcg/TcgBrowser.svelte @@ -0,0 +1,111 @@ + + +
+ + +
+ {#if browser.tab === 'decks'} + + {:else} + + {/if} +
+ + {#if browser.openedCard} + browser.openCard(null)} /> + {/if} +
+ + diff --git a/src/lib/components/tcg/TcgPager.svelte b/src/lib/components/tcg/TcgPager.svelte new file mode 100644 index 0000000..f8fb455 --- /dev/null +++ b/src/lib/components/tcg/TcgPager.svelte @@ -0,0 +1,55 @@ + + + + + diff --git a/src/lib/content/index.ts b/src/lib/content/index.ts index c615b64..fc4d81a 100644 --- a/src/lib/content/index.ts +++ b/src/lib/content/index.ts @@ -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)); diff --git a/src/lib/content/types.ts b/src/lib/content/types.ts index 9ecb8bd..14c170c 100644 --- a/src/lib/content/types.ts +++ b/src/lib/content/types.ts @@ -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[]; +} diff --git a/src/lib/desktop/actions.ts b/src/lib/desktop/actions.ts index d19e37c..9a51704 100644 --- a/src/lib/desktop/actions.ts +++ b/src/lib/desktop/actions.ts @@ -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) { diff --git a/src/lib/desktop/keybindGuide.ts b/src/lib/desktop/keybindGuide.ts index 48b04d7..a573667 100644 --- a/src/lib/desktop/keybindGuide.ts +++ b/src/lib/desktop/keybindGuide.ts @@ -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 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.' } ]; diff --git a/src/lib/desktop/windows.svelte.ts b/src/lib/desktop/windows.svelte.ts index c617684..d4f9334 100644 --- a/src/lib/desktop/windows.svelte.ts +++ b/src/lib/desktop/windows.svelte.ts @@ -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([]); imageViewers = $state([]); systemMonitors = $state([]); + games = $state([]); + tcgBrowsers = $state([]); floatingRects = $state>({}); focusedId = $state(pageWindowId); maximizedId = $state(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; diff --git a/src/lib/games/breakout.ts b/src/lib/games/breakout.ts new file mode 100644 index 0000000..800a5b2 --- /dev/null +++ b/src/lib/games/breakout.ts @@ -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); +} diff --git a/src/lib/games/highScore.ts b/src/lib/games/highScore.ts new file mode 100644 index 0000000..265d327 --- /dev/null +++ b/src/lib/games/highScore.ts @@ -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; + } +} diff --git a/src/lib/games/snake.ts b/src/lib/games/snake.ts new file mode 100644 index 0000000..751fc13 --- /dev/null +++ b/src/lib/games/snake.ts @@ -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 = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 } +}; + +const oppositeDirections: Record = { + 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); +} diff --git a/src/lib/pet/behavior.ts b/src/lib/pet/behavior.ts new file mode 100644 index 0000000..a390886 --- /dev/null +++ b/src/lib/pet/behavior.ts @@ -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 = { + 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); +} diff --git a/src/lib/pet/pathfinding.ts b/src/lib/pet/pathfinding.ts new file mode 100644 index 0000000..98778b3 --- /dev/null +++ b/src/lib/pet/pathfinding.ts @@ -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([[startIndex, 0]]); + const scores = new Map([[startIndex, estimate(startIndex)]]); + const cameFrom = new Map(); + const openCells = new Set([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); +} diff --git a/src/lib/pet/petState.svelte.ts b/src/lib/pet/petState.svelte.ts new file mode 100644 index 0000000..b112459 --- /dev/null +++ b/src/lib/pet/petState.svelte.ts @@ -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(); diff --git a/src/lib/pet/pixelArt.ts b/src/lib/pet/pixelArt.ts new file mode 100644 index 0000000..6408ba7 --- /dev/null +++ b/src/lib/pet/pixelArt.ts @@ -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 })) + ); +} diff --git a/src/lib/pet/voiceLines.ts b/src/lib/pet/voiceLines.ts new file mode 100644 index 0000000..01ff561 --- /dev/null +++ b/src/lib/pet/voiceLines.ts @@ -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 = { + 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)]; +} diff --git a/src/lib/pet/world.ts b/src/lib/pet/world.ts new file mode 100644 index 0000000..5885956 --- /dev/null +++ b/src/lib/pet/world.ts @@ -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; +} diff --git a/src/lib/tcg/api.ts b/src/lib/tcg/api.ts new file mode 100644 index 0000000..bf6c741 --- /dev/null +++ b/src/lib/tcg/api.ts @@ -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(path: string, params: Record, signal?: AbortSignal): Promise { + 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> { + 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 { + const nameClause = `name:"${name.replace(/"/g, '')}"`; + const queries = setCode ? [`${nameClause} set.ptcgoCode:${setCode}`, nameClause] : [nameClause]; + + for (const query of queries) { + const result = await getJson>('/cards', { q: query, pageSize: '1', select: cardFields }); + if (result.data[0]) return result.data[0]; + } + return null; +} + +export async function fetchEnergyTypes(signal?: AbortSignal): Promise { + 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 { + const sets: TcgSet[] = []; + let page = 1; + let totalCount = Infinity; + + while (sets.length < totalCount) { + const result = await getJson>( + '/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 { + const body = await getJson<{ data: string[] }>('/supertypes', {}, signal); + return body.data; +} diff --git a/src/lib/tcg/browserState.svelte.ts b/src/lib/tcg/browserState.svelte.ts new file mode 100644 index 0000000..4c04230 --- /dev/null +++ b/src/lib/tcg/browserState.svelte.ts @@ -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('decks'); + deckId = $state(tcgDecks[0]?.id ?? ''); + searchedName = $state(''); + supertype = $state(''); + energyType = $state(''); + selectedSet = $state(null); + setId = $derived(this.selectedSet?.id ?? ''); + page = $state(1); + openedCard = $state(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; + } +} diff --git a/src/lib/tcg/cardKind.ts b/src/lib/tcg/cardKind.ts new file mode 100644 index 0000000..5441876 --- /dev/null +++ b/src/lib/tcg/cardKind.ts @@ -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 = { + 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): 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): boolean { + return cardCategory(card) === 'energy' && (card.subtypes ?? []).some((subtype) => plainText(subtype) === 'basic'); +} diff --git a/src/lib/tcg/deckBuilder.svelte.ts b/src/lib/tcg/deckBuilder.svelte.ts new file mode 100644 index 0000000..136b072 --- /dev/null +++ b/src/lib/tcg/deckBuilder.svelte.ts @@ -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([]); + + private loaded = false; + + total = $derived(this.entries.reduce((sum, entry) => sum + entry.count, 0)); + + sections = $derived( + 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): 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(); diff --git a/src/lib/tcg/deckExport.ts b/src/lib/tcg/deckExport.ts new file mode 100644 index 0000000..34dfcf5 --- /dev/null +++ b/src/lib/tcg/deckExport.ts @@ -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 { + try { + await navigator.clipboard.writeText(text); + return true; + } catch { + return false; + } +} diff --git a/src/lib/tcg/decks.ts b/src/lib/tcg/decks.ts new file mode 100644 index 0000000..4123e99 --- /dev/null +++ b/src/lib/tcg/decks.ts @@ -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>(); + +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 { + 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 { + 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; +} diff --git a/src/lib/tcg/energy.ts b/src/lib/tcg/energy.ts new file mode 100644 index 0000000..086c1a7 --- /dev/null +++ b/src/lib/tcg/energy.ts @@ -0,0 +1,17 @@ +const energyColors: Record = { + 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)'; +} diff --git a/src/lib/tcg/types.ts b/src/lib/tcg/types.ts new file mode 100644 index 0000000..863637f --- /dev/null +++ b/src/lib/tcg/types.ts @@ -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 { + data: Item[]; + page: number; + pageSize: number; + count: number; + totalCount: number; +} + +export interface CardSearch { + name: string; + supertype: string; + energyType: string; + setId: string; + page: number; +} diff --git a/src/lib/terminal/commands/games.ts b/src/lib/terminal/commands/games.ts new file mode 100644 index 0000000..b43002c --- /dev/null +++ b/src/lib/terminal/commands/games.ts @@ -0,0 +1,37 @@ +import type { GameName } from '#lib/content'; +import { line, span } from '../output'; +import type { Command } from './types'; + +const gameDescriptions: Record = { + 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 ', 'accent'), span(' to play', 'muted'))); + } +}; + +export const gameCommands: Command[] = [gamesCommand, ...launchCommands]; diff --git a/src/lib/terminal/commands/index.ts b/src/lib/terminal/commands/index.ts index a90d9b4..7944065 100644 --- a/src/lib/terminal/commands/index.ts +++ b/src/lib/terminal/commands/index.ts @@ -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 ]; diff --git a/src/lib/terminal/commands/pet.ts b/src/lib/terminal/commands/pet.ts new file mode 100644 index 0000000..b94c0b7 --- /dev/null +++ b/src/lib/terminal/commands/pet.ts @@ -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'))); + } +}; diff --git a/src/lib/terminal/commands/system.ts b/src/lib/terminal/commands/system.ts index 9c054d2..6709c1e 100644 --- a/src/lib/terminal/commands/system.ts +++ b/src/lib/terminal/commands/system.ts @@ -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, diff --git a/src/routes/keybinds/+page.svelte b/src/routes/keybinds/+page.svelte index 2b3892b..018a18b 100644 --- a/src/routes/keybinds/+page.svelte +++ b/src/routes/keybinds/+page.svelte @@ -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' } ]; @@ -74,6 +77,20 @@

Scroll or pinch to zoom, drag to pan and swipe to change images.

+ +
+
+
+

Snake

+ +
+
+

Breakout

+ +
+
+

On touch screens use the arrow pad in snake, and drag to move the paddle in breakout.

+