Add desktop games, robot pet and TCG app
This commit is contained in:
1 parent
812503251b
commit
c664f573b8
55 files changed
+5090
-15
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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 }))
|
||||
);
|
||||
}
|
||||
@@ -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)];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in new issue
Block a user