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

+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;
}