commit 93b018e42ba7ac459157490a38dac128e7a36cc5 Author: deepseek Date: Sun Aug 23 07:01:24 2026 +0000 LifeTown Online — browser multiplayer life-sim MVP - Server-authoritative Node/WS server: sessions, zones (town + instanced homes + public venue interiors), NPC routines, economy, relationships, persistence - TypeScript + Three.js client: character creator, Maple Court district, enterable venues (café, city hall, gym, store, arcade), build mode with 58-item furniture catalog, needs/mood systems, phone UI, day/night + weather, procedural audio - Verified by two-client e2e protocol suite and headless-browser walkthrough diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a11dea9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/ +saves/ +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..fcecdc7 --- /dev/null +++ b/README.md @@ -0,0 +1,123 @@ +# LifeTown Online 🏙️ + +An **original browser-based multiplayer life simulation** — create a character, move into +Maple Court, get a job, furnish your home, befriend the townsfolk and other players, and +live an ongoing simulated life. No installation, no plugins: it runs in a modern desktop +or mobile browser. + +> All characters, world design, UI, code, audio and visuals are original to this project. +> No copyrighted assets, names, logos or UI from any existing game are used. + +--- + +## Quick start + +```bash +npm install # three, ws, vite +npm run build # bundles the client into client/dist +npm start # serves game + WebSocket on http://127.0.0.1:7788 +``` + +Open **http://127.0.0.1:7788** in two browser windows to play multiplayer. + +Environment overrides: `GAME_PORT` (default `7788`). Player saves are JSON files in `saves/`. + +## Controls + +| Action | Desktop | Mobile | +|---|---|---| +| Move | WASD / arrows | virtual joystick (bottom-left) | +| Run | hold Shift | joystick edge / 🏃 button | +| Interact | E or 👋 Act | ✋ button | +| Dance emote | F or 💃 | — | +| Orbit camera | left-drag | drag on scene | +| Zoom | wheel | pinch area | +| Chat focus | Enter | tap chat box | + +Walk up to glowing POI rings (café, gym, pond, stage…) or furniture at home and press **E**. + +## What's in the MVP + +- **Character creator** — live 3D preview; body type, height, skin tones, hair styles & + colours, eyes, brows, nose, mouth, outfits, shoes, accessories, voice, age and up to 3 + personality traits. +- **One living district — Maple Court** — main street with café, City Hall, gym, home-goods + store and arcade. **Every venue is enterable**: walk to a street-facing door and press E to + step into a shared furnished interior with activity spots (order coffee, hit the gym mats, + browse the shop catalogue, play the arcade); apartment doors lead straight into your home. + store, apartments, arcade façade, central plaza with fountain & stage, park with pond, + food truck, bus stop, benches, lamps and trees. +- **Needs & mood** — hunger, energy, hygiene, fun, social, comfort decay over time; + weighted mood engine with 13 moods + transient moodlets drive facial expressions and HUD. +- **NPCs with routines** — 10 original townsfolk walk POI-to-POI on daily schedules, + chatter ambiently and react to your interactions. +- **Jobs & economy** — 12 careers × 5 ranks with pay, XP and promotions; shifts worked at + real workplaces; TownCoins earned/spent across shops, homes, food and gifts. +- **Housing & build mode** — studio apartment instanced per player (5 more tiers purchasable), + drag-to-place furniture on a snap grid with rotate/duplicate/sell tools, 58-item catalogue + whose items boost needs recovery, cooking speed and productivity. +- **Friendship & romance** — contextual interaction wheels (Friendly/Romantic/Funny/Negative) + gated by two independent relationship meters with tier labels and partner status. +- **Multiplayer** — server-authoritative sessions/rosters/economy/relationships over one + WebSocket; 12 Hz snapshots with client-side interpolation; zone instances for town and + every home; invitations to visit; global + local chat with speech bubbles. +- **Day/night & weather** — full day cycle (24 min/day) with dawn/dusk skies, sun/moon + lighting, window glow and street lamps; sunny/cloudy/rain/storm/fog with rain particles, + puddles that soak in, fog banks and lightning flashes. +- **Mini-games** — fishing (timing bar) and cooking (memory) feeding back into needs & fun. +- **Phone** — Messages, Contacts (relationship bars), Map, Careers, Bank (transaction log), + LifeFeed social posts with in-game screenshots, Calendar of recurring town events, Home + (teleport, property market, invites). +- **Progression** — player levels gate catalog/homes; 8 achievements incl. First Home, + Social Butterfly, Millionaire, Interior Designer. +- **Save/load** — profiles autosave server-side (money, job, house, layout, inventory, + relationships, achievements, needs); clients resume via token. +- **Audio** — fully procedural WebAudio: location-based music (home/town/night), rain beds, + footsteps, door/cash/UI blips, achievement jingles. + +## Architecture + +``` +lifetown/ +├─ shared/data.mjs # single source of truth: jobs, catalog, NPCs, interactions… +├─ server/ +│ ├─ index.mjs # Node http static server + ws upgrade (/ws) +│ ├─ game.mjs # GameServer: sessions, zones, snapshots, NPCs, clock/weather, +│ │ # economy, relationships, achievements, persistence +│ └─ persist.mjs # atomic JSON profile storage +├─ client/ +│ ├─ index.html # shell: title, creator, HUD, phone, build bar, modals +│ └─ src/ +│ ├─ gfx/engine.ts # renderer, ACES tone-mapping, day/night lighting rig +│ ├─ gfx/avatar.ts # modular stylized characters + procedural animation set +│ ├─ gfx/town.ts # procedural district geometry +│ ├─ gfx/interior.ts # instanced home shells + grid scaffolding +│ ├─ gfx/furniture.ts # 58 procedural furniture builders +│ ├─ gfx/weatherfx.ts # rain particles, puddles, lightning hooks +│ ├─ controller.ts # input, collision, camera rig, network transform sync +│ ├─ world.ts # actor manager, interpolation, bubbles/name-tags +│ ├─ systems.ts # needs simulation + mood derivation +│ ├─ audio.ts # WebAudio sequencer & SFX synth +│ └─ ui/* # HUD, chat, phone apps, dialogs/minigames, build UI, creator +└─ scripts/ + ├─ e2e.mjs # 20-check two-client protocol test + └─ shot.mjs # headless-browser walkthrough of every screen +``` + +**Performance notes:** static geometry is modest-draw-call by construction, shadows come +from a single tight sun cascade, remote players interpolate between 12 Hz snapshots, and +interiors are separate zone scenes so only one district is ever loaded. + +## Verification + +```bash +node scripts/e2e.mjs # exercises auth, movement sync, chat, social gates, jobs, + # cooldowns, shopping, housing, zones, invites, persistence +node scripts/shot.mjs # drives the real client headless through all major screens +``` + +## Roadmap beyond MVP + +Beach zone & swimming, nightclub with DJ events, vehicles (bicycle → sports car), +weddings & festivals, group activities, Postgres/Redis backing stores, WebRTC voice, +seasonal events, pets, and expanded careers with skill mini-courses at the university. diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..f6bd198 --- /dev/null +++ b/client/index.html @@ -0,0 +1,197 @@ + + + + + + + LifeTown Online + + + +
+ + + +
+
+
+
LifeTown
+
your town · your people · your story
+
+ +
+ + +
+
+
An original multiplayer life simulation · v0.1 MVP
+
+ + + + + + + + +
+ + + diff --git a/client/src/audio.ts b/client/src/audio.ts new file mode 100644 index 0000000..c5c042d --- /dev/null +++ b/client/src/audio.ts @@ -0,0 +1,162 @@ +// LifeTown Online — fully procedural audio: location music, ambience, SFX. +// No external assets; everything is synthesised with WebAudio. + +type TrackId = 'home' | 'town' | 'park' | 'date' | 'night'; + +export class AudioEngine { + ctx: AudioContext | null = null; + master!: GainNode; + musicGain!: GainNode; + sfxGain!: GainNode; + rainGain!: GainNode; + private track: TrackId | null = null; + private stepTimer: number | null = null; + private step = 0; + private nextStepAt = 0; + enabled = true; + + init() { + if (this.ctx) return; + this.ctx = new AudioContext(); + this.master = this.ctx.createGain(); this.master.gain.value = 0.8; + this.master.connect(this.ctx.destination); + this.musicGain = this.ctx.createGain(); this.musicGain.gain.value = 0.34; this.musicGain.connect(this.master); + this.sfxGain = this.ctx.createGain(); this.sfxGain.gain.value = 0.5; this.sfxGain.connect(this.master); + // rain noise loop + const buf = this.noiseBuffer(2); + const src = this.ctx.createBufferSource(); src.buffer = buf; src.loop = true; + const filt = this.ctx.createBiquadFilter(); filt.type = 'lowpass'; filt.frequency.value = 900; + this.rainGain = this.ctx.createGain(); this.rainGain.gain.value = 0; + src.connect(filt); filt.connect(this.rainGain); this.rainGain.connect(this.master); + src.start(); + } + + setEnabled(on: boolean) { + this.enabled = on; + if (this.ctx) this.master.gain.value = on ? 0.8 : 0; + } + + private noiseBuffer(sec: number) { + const len = this.ctx!.sampleRate * sec; + const buf = this.ctx!.createBuffer(1, len, this.ctx!.sampleRate); + const d = buf.getChannelData(0); + for (let i = 0; i < len; i++) d[i] = Math.random() * 2 - 1; + return buf; + } + + setTrack(t: TrackId) { + if (this.track === t) return; + this.track = t; + this.step = 0; + } + + setRain(amount: number) { + if (!this.ctx) return; + this.rainGain.gain.linearRampToValueAtTime(amount * 0.14, (this.ctx.currentTime + 1)); + } + + /** call every frame; schedules notes just-in-time */ + tick() { + if (!this.ctx || !this.enabled || !this.track) return; + const bpmByTrack: Record = { home: 72, town: 112, park: 92, date: 84, night: 120 }; + const bpm = bpmByTrack[this.track]; + const beat = 60 / bpm / 2; // 8th notes + const now = this.ctx.currentTime; + if (this.nextStepAt < now) this.nextStepAt = now + 0.05; + while (this.nextStepAt < now + 0.25) { + this.scheduleStep(this.step++, this.nextStepAt, this.track); + this.nextStepAt += beat; + } + } + + private scheduleStep(step: number, at: number, tr: TrackId) { + const bar = Math.floor(step / 16) % 4; + const s = step % 16; + const N = (n: number) => 440 * Math.pow(2, (n - 69) / 12); + if (tr === 'town') { + const bass = [45, 45, 50, 52]; + if (s % 4 === 0) this.pluck(N(bass[bar]) - 12, at, .3, 'triangle', .18); + if (s % 2 === 0) this.pluck(N([72, 76, 79, 81][(s / 2 + bar) % 4]), at, .22, 'square', .05); + if (s === 8 || s === 12) this.pluck(N(64 + bar), at, .3, 'sawtooth', .04); + if (s % 8 === 4) this.hat(at, .05); + if (s % 4 === 0) this.kick(at); + } else if (tr === 'home') { + const chords = [[57, 60, 64], [55, 59, 62], [53, 57, 60], [55, 58, 62]]; + if (s === 0) for (const n of chords[bar]) this.pad(N(n), at, 2.2, .05); + if (s === 10) this.pluck(N(chords[bar][2] + 12), at, .5, 'sine', .06); + } else if (tr === 'park') { + if (s % 4 === 0) this.pluck(N([67, 69, 64, 62][bar]), at, .4, 'sine', .09); + if (s % 8 === 6) this.pluck(N([74, 76, 71, 69][bar]), at, .5, 'triangle', .06); + if (s === 0) for (const n of [48, 55]) this.pad(N(n), at, 1.6, .03); + } else if (tr === 'date') { + if (s % 8 === 0) { for (const n of [65, 69, 72]) this.pad(N(n - 12), at, 2.4, .04); } + if (s % 4 === 2) this.pluck(N([77, 76, 72, 69][bar]), at, .6, 'sine', .05); + } else { // night + if (s % 4 === 0) this.kick(at); + if (s % 4 === 2) this.hat(at, .07); + if (s % 2 === 0) this.pluck(N([57, 57, 60, 62][bar]) + (s % 8 === 6 ? 3 : 0), at, .2, 'sawtooth', .045); + } + } + + private pluck(freq: number, at: number, dur: number, type: OscillatorType, gain: number) { + const o = this.ctx!.createOscillator(); o.type = type; o.frequency.value = freq; + const g = this.ctx!.createGain(); + g.gain.setValueAtTime(gain, at); + g.gain.exponentialRampToValueAtTime(0.0001, at + dur); + o.connect(g); g.connect(this.musicGain); + o.start(at); o.stop(at + dur + 0.05); + } + private pad(freq: number, at: number, dur: number, gain: number) { + const o = this.ctx!.createOscillator(); o.type = 'sine'; o.frequency.value = freq; + const o2 = this.ctx!.createOscillator(); o2.type = 'triangle'; o2.frequency.value = freq * 1.005; + const g = this.ctx!.createGain(); + g.gain.setValueAtTime(0, at); + g.gain.linearRampToValueAtTime(gain, at + 0.4); + g.gain.linearRampToValueAtTime(0, at + dur); + o.connect(g); o2.connect(g); g.connect(this.musicGain); + o.start(at); o.stop(at + dur); o2.start(at); o2.stop(at + dur); + } + private kick(at: number) { + const o = this.ctx!.createOscillator(); o.type = 'sine'; + o.frequency.setValueAtTime(130, at); o.frequency.exponentialRampToValueAtTime(42, at + 0.11); + const g = this.ctx!.createGain(); + g.gain.setValueAtTime(.22, at); g.gain.exponentialRampToValueAtTime(.001, at + 0.16); + o.connect(g); g.connect(this.musicGain); o.start(at); o.stop(at + 0.2); + } + private hat(at: number, gain: number) { + const src = this.ctx!.createBufferSource(); src.buffer = this.noiseBuffer(0.06); + const f = this.ctx!.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 6500; + const g = this.ctx!.createGain(); + g.gain.setValueAtTime(gain, at); g.gain.exponentialRampToValueAtTime(.0001, at + 0.05); + src.connect(f); f.connect(g); g.connect(this.musicGain); + src.start(at); + } + + // ---------------- SFX ---------------- + private blip(freq: number, dur: number, type: OscillatorType, gain = 0.2, slideTo?: number) { + if (!this.ctx || !this.enabled) return; + const t = this.ctx.currentTime; + const o = this.ctx.createOscillator(); o.type = type; o.frequency.setValueAtTime(freq, t); + if (slideTo) o.frequency.exponentialRampToValueAtTime(slideTo, t + dur); + const g = this.ctx.createGain(); + g.gain.setValueAtTime(gain, t); g.gain.exponentialRampToValueAtTime(.0001, t + dur); + o.connect(g); g.connect(this.sfxGain); + o.start(t); o.stop(t + dur + 0.02); + } + click() { this.blip(600, .07, 'square', .08); } + pop() { this.blip(300, .12, 'sine', .18, 520); } + cash() { this.blip(880, .09, 'square', .12); setTimeout(() => this.blip(1320, .14, 'square', .12), 70); } + door() { this.blip(180, .18, 'triangle', .15, 90); } + err() { this.blip(220, .16, 'sawtooth', .1, 160); } + footstep(run: boolean) { this.blip(90 + Math.random() * 30, .05, 'triangle', run ? .06 : .04); } + thunder() { this.blip(55 + Math.random() * 20, .9, 'sawtooth', .12, 26); } + achievement() { + [523, 659, 784, 1047].forEach((f, i) => setTimeout(() => this.blip(f, .18, 'triangle', .16), i * 95)); + } + levelup() { [392, 494, 587, 784].forEach((f, i) => setTimeout(() => this.blip(f, .16, 'square', .1), i * 80)); } +} + +export const audio = new AudioEngine(); + +// resume on first gesture +window.addEventListener('pointerdown', () => { audio.init(); audio.ctx?.resume?.(); }, { once: false }); diff --git a/client/src/controller.ts b/client/src/controller.ts new file mode 100644 index 0000000..a2b294a --- /dev/null +++ b/client/src/controller.ts @@ -0,0 +1,175 @@ +// LifeTown Online — player controller: input, movement & collision, camera rig, +// interaction scanning, network transform sync. +import * as THREE from 'three'; +import { Engine } from './gfx/engine'; +import { Town } from './gfx/town'; +import { Interior } from './gfx/interior'; +import { S, bus } from './state'; +import { net } from './net'; +import { audio } from './audio'; + +export interface InputState { + x: number; z: number; // joystick / axes (-1..1) + run: boolean; +} + +const WALK = 3.4, RUN = 6.0; + +export class Controller { + input: InputState = { x: 0, z: 0, run: false }; + yaw = Math.PI; // camera orbit yaw + pitch = 0.62; + dist = 11; + private targetPos = new THREE.Vector3(0, 1.2, 0); + private keys = new Set(); + private lastSent = 0; + private lastFx = { x: 0, z: 0 }; + frozen = false; // during dialogs/sleep + mode: 'live' | 'build' = 'live'; + onPrompt: ((info: { label: string; key?: string; cb: () => void } | null) => void) | null = null; + + constructor( + public engine: Engine, + public town: Town | null, + public interior: Interior | null, + public getPlayerPos: () => THREE.Vector3, + public scanInteract: () => { label: string; cb: () => void } | null, + ) { + window.addEventListener('keydown', (e) => { + if (e.target instanceof HTMLInputElement || e.target instanceof HTMLSelectElement || e.target instanceof HTMLTextAreaElement) return; + this.keys.add(e.code); + if (e.code === 'KeyE') this.tryInteract(); + if (e.code === 'KeyF') bus.emit('act:dance'); + }); + window.addEventListener('keyup', (e) => this.keys.delete(e.code)); + // mouse orbit + const el = engine.renderer.domElement; + let dragging = false, lx = 0, ly = 0; + el.addEventListener('pointerdown', (e) => { if (e.button === 0 && !S.buildMode) { dragging = true; lx = e.clientX; ly = e.clientY; } }); + window.addEventListener('pointerup', () => dragging = false); + window.addEventListener('pointermove', (e) => { + if (!dragging) return; + this.yaw -= (e.clientX - lx) * 0.0052; + this.pitch = THREE.MathUtils.clamp(this.pitch + (e.clientY - ly) * 0.0035, 0.12, 1.25); + lx = e.clientX; ly = e.clientY; + }); + el.addEventListener('wheel', (e) => { + this.dist = THREE.MathUtils.clamp(this.dist + e.deltaY * 0.011, 4, 26); + }, { passive: true }); + } + + setInterior(i: Interior | null) { this.interior = i; } + + tryInteract() { + if (this.frozen || S.chatOpen) return; + const hit = this.scanInteract(); + if (hit) { audio.click(); hit.cb(); } + } + + update(dt: number, myAvatar: { group: THREE.Group; setAnim(a: string): void; getAnim(): string } | null) { + const cam = this.engine.camera; + if (!myAvatar) { this.updateCamera(dt); return; } + const g = myAvatar.group; + + // ---- gather input ---- + let ix = this.input.x, iz = this.input.z; + if (!S.chatOpen && !this.frozen) { + if (this.keys.has('KeyW') || this.keys.has('ArrowUp')) iz -= 1; + if (this.keys.has('KeyS') || this.keys.has('ArrowDown')) iz += 1; + if (this.keys.has('KeyA') || this.keys.has('ArrowLeft')) ix -= 1; + if (this.keys.has('KeyD') || this.keys.has('ArrowRight')) ix += 1; + } + const run = this.input.run || this.keys.has('ShiftLeft') || this.keys.has('ShiftRight'); + const mag = Math.min(1, Math.hypot(ix, iz)); + const moving = mag > 0.06 && !this.frozen && !S.chatOpen && this.mode === 'live'; + + if (moving) { + // camera-relative direction + const sin = Math.sin(this.yaw), cos = Math.cos(this.yaw); + const dx = ix * cos - iz * sin; + const dz = ix * sin + iz * cos; + const len = Math.hypot(dx, dz) || 1; + const speed = (run ? RUN : WALK) * Math.min(1, mag); + let nx = g.position.x + (dx / len) * speed * dt; + let nz = g.position.z + (dz / len) * speed * dt; + + // collisions + if (this.interior) { + const b = this.interior.bounds(); + nx = THREE.MathUtils.clamp(nx, -b.hw, b.hw); + nz = THREE.MathUtils.clamp(nz, -b.hd, b.hd); + // furniture rough blocking (circle vs footprint) + for (const f of this.interior.furniture) { + const dx2 = nx - f.group.position.x, dz2 = nz - f.group.position.z; + const d2 = Math.hypot(dx2, dz2); + if (d2 < 0.55 && d2 > 0.001) { nx = f.group.position.x + dx2 / d2 * 0.55; nz = f.group.position.z + dz2 / d2 * 0.55; } + } + } else if (this.town) { + nx = THREE.MathUtils.clamp(nx, -72, 72); + nz = THREE.MathUtils.clamp(nz, -46, 46); + for (const c of this.town.colliders) { + if (nx > c.x - c.hw - 0.35 && nx < c.x + c.hw + 0.35 && nz > c.z - c.hd - 0.35 && nz < c.z + c.hd + 0.35) { + // push out along smallest penetration + const pxl = nx - (c.x - c.hw - 0.35), pxr = (c.x + c.hw + 0.35) - nx; + const pzl = nz - (c.z - c.hd - 0.35), pzr = (c.z + c.hd + 0.35) - nz; + const m = Math.min(pxl, pxr, pzl, pzr); + if (m === pxl) nx = c.x - c.hw - 0.35; + else if (m === pxr) nx = c.x + c.hw + 0.35; + else if (m === pzl) nz = c.z - c.hd - 0.35; + else nz = c.z + c.hd + 0.35; + } + } + } + g.position.set(nx, 0, nz); + g.rotation.y = Math.atan2(dx, dz); + + const anim = run ? 'run' : 'walk'; + myAvatar.setAnim(anim); + // footsteps + const moved = Math.hypot(nx - this.lastFx.x, nz - this.lastFx.z); + if (moved > 1.1) { this.lastFx.x = nx; this.lastFx.z = nz; audio.footstep(run); } + } else if (this.mode === 'live') { + if (['walk', 'run'].includes(myAvatar.getAnim())) myAvatar.setAnim('idle'); + } + + // ---- network sync (throttled) ---- + const now = performance.now(); + if (now - this.lastSent > 66) { + this.lastSent = now; + net.send({ t: 'move', x: +g.position.x.toFixed(2), z: +g.position.z.toFixed(2), ry: +g.rotation.y.toFixed(2), anim: myAvatar.getAnim() }); + } + + this.updateCamera(dt, g.position); + } + + private focusOverride: { pos: THREE.Vector3; until: number } | null = null; + socialFocus(pos: THREE.Vector3) { + this.focusOverride = { pos: pos.clone(), until: performance.now() + 2600 }; + } + + updateCamera(dt: number, playerPos?: THREE.Vector3) { + const cam = this.engine.camera; + let target = this.targetPos; + if (playerPos) target = playerPos; + if (this.focusOverride) { + if (performance.now() > this.focusOverride.until) this.focusOverride = null; + else target = this.focusOverride.pos; + } + const wantDist = S.buildMode ? 16 : this.dist; + const wantPitch = S.buildMode ? 1.05 : this.pitch; + const curDist = cam.position.distanceTo(new THREE.Vector3(target.x, 0, target.z)); + const k = 1 - Math.pow(0.0001, dt); // smoothing + const dist = THREE.MathUtils.lerp(curDist || wantDist, wantDist, Math.min(1, dt * 5)); + + const ox = Math.sin(this.yaw) * Math.cos(wantPitch * 0.9); + const oz = Math.cos(this.yaw) * Math.cos(wantPitch * 0.9); + const oy = Math.sin(wantPitch); + cam.position.set( + target.x + ox * dist, + (target.y ?? 0) + oy * dist, + target.z + oz * dist + ); + cam.lookAt(target.x, (target.y ?? 0) + 1.1, target.z); + void k; + } +} diff --git a/client/src/gfx/avatar.ts b/client/src/gfx/avatar.ts new file mode 100644 index 0000000..0c668e7 --- /dev/null +++ b/client/src/gfx/avatar.ts @@ -0,0 +1,463 @@ +// LifeTown Online — stylized original characters: modular avatar builder + +// procedural animation controller (walk, run, dance, emotes, expressions). +import * as THREE from 'three'; + +const SKINS = ['#f7d7c4', '#eab98f', '#c98a5b', '#8a5a37', '#5d3a21', '#f2a97e']; + +function mat(color: string | number, rough = 0.85, metal = 0) { + return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal }); +} + +function limb(len: number, thick: number, material: THREE.Material) { + const g = new THREE.CapsuleGeometry(thick, len - thick * 2, 4, 10); + const m = new THREE.Mesh(g, material); + m.position.y = -len / 2; + m.castShadow = true; + return m; +} + +// --------------------------------------------------------------- +export class Avatar { + group = new THREE.Group(); + private bodyG = new THREE.Group(); + private hips = new THREE.Group(); + private legL = new THREE.Group(); + private legR = new THREE.Group(); + private shinL = new THREE.Group(); + private shinR = new THREE.Group(); + private armL = new THREE.Group(); + private armR = new THREE.Group(); + private head = new THREE.Group(); + private eyeL!: THREE.Mesh; private eyeR!: THREE.Mesh; + private irisL!: THREE.Mesh; private irisR!: THREE.Mesh; + private browL!: THREE.Mesh; private browR!: THREE.Mesh; + private mouth!: THREE.Mesh; + private headTop = new THREE.Group(); + + private anim = 'idle'; + private phase = Math.random() * 10; + private blinkAt = 2 + Math.random() * 4; + private blinkT = 0; + private expr = 'happy'; + private moodSprite: THREE.Sprite | null = null; + + constructor(app: any, npcColorSeed?: number) { + const a = app || {}; + const skin = a.skin || SKINS[npcColorSeed ? npcColorSeed % SKINS.length : 1]; + const topColor = a.topColor ?? (npcColorSeed != null ? hslColor(npcColorSeed * 47 % 360, .55, .6) : '#4f8fd9'); + const bottomColor = a.bottomColor ?? '#33415c'; + const hairColor = a.hairColor || '#4a2f1d'; + const shoeColor = a.shoes === 'boots' ? '#5a4632' : a.shoes === 'loafers' ? '#2e2a28' : '#e8e8ee'; + const bodyType = a.bodyType || 'average'; + const height = THREE.MathUtils.clamp(a.height ?? 1, .85, 1.12); + + const skinM = mat(skin, .65); + const topM = mat(topColor, .9); + const bottomM = mat(bottomColor, .9); + const shoeM = mat(shoeColor, .8); + const hairM = mat(hairColor, .95); + const whiteM = mat('#ffffff', .3); + + // proportions + const widthK = bodyType === 'slim' ? 0.86 : bodyType === 'plump' ? 1.22 : 1; + + // ---- legs ---- + for (const [side, hip, shin] of [[-1, this.legL, this.shinL], [1, this.legR, this.shinR]] as const) { + hip.position.set(side * 0.155 * widthK, 0.92, 0); + const thigh = limb(0.48, 0.105, bottomM); thigh.position.y = -0.24; hip.add(thigh); + shin.position.y = -0.48; hip.add(shin); + const calf = limb(0.44, 0.09, bottomM); calf.position.y = -0.2; shin.add(calf); + const shoe = new THREE.Mesh(new THREE.BoxGeometry(0.19, 0.11, 0.32), shoeM); + shoe.position.set(0, -0.43, 0.05); shoe.castShadow = true; shin.add(shoe); + this.hips.add(hip); + } + + // ---- torso ---- + const torso = new THREE.Mesh(new THREE.CapsuleGeometry(0.26 * widthK, 0.5, 6, 14), topM); + torso.position.y = 1.24; torso.scale.set(widthK, 1, 0.92); + torso.castShadow = true; + this.bodyG.add(torso); + + if (a.outfitBottom === 'skirt') { + const skirt = new THREE.Mesh(new THREE.ConeGeometry(0.34 * widthK, 0.42, 14, 1, true), bottomM); + skirt.position.y = 0.86; skirt.castShadow = true; this.bodyG.add(skirt); + } + + // ---- arms ---- + for (const [side, arm] of [[-1, this.armL], [1, this.armR]] as const) { + arm.position.set(side * (0.27 * widthK + 0.075), 1.52, 0); + const sleeve = limb(0.66, 0.085, topM); arm.add(sleeve); + const hand = new THREE.Mesh(new THREE.SphereGeometry(0.085, 10, 8), skinM); + hand.position.y = -0.68; hand.castShadow = true; arm.add(hand); + this.bodyG.add(arm); + } + + // ---- head ---- + this.head.position.y = 1.78; + const skull = new THREE.Mesh(new THREE.SphereGeometry(0.315, 20, 16), skinM); + skull.scale.set(1, 1.06, 0.96); skull.castShadow = true; + this.head.add(skull); + const faceShape = a.faceShape || 'round'; + if (faceShape === 'oval') skull.scale.set(.94, 1.14, .96); + if (faceShape === 'square') skull.scale.set(1.03, 1, .99); + + // ears + for (const side of [-1, 1]) { + const ear = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 6), skinM); + ear.position.set(side * 0.3, 0, 0); this.head.add(ear); + } + + // eyes (white + iris + highlight) + const eyeGeo = new THREE.SphereGeometry(0.072, 12, 10); + const irisGeo = new THREE.SphereGeometry(0.038, 10, 8); + const irisColor = a.eyeColor || '#3a5a40'; + const eyeStyle = a.eyes || 'round'; + const mkEye = (side: number) => { + const holder = new THREE.Group(); holder.position.set(side * 0.115, 0.05, 0.26); + const w = new THREE.Mesh(eyeGeo, whiteM); + if (eyeStyle === 'sleepy') w.scale.set(1.15, 0.7, 0.8); + if (eyeStyle === 'wide') w.scale.set(1.05, 1.2, 0.85); + holder.add(w); + const iris = new THREE.Mesh(irisGeo, mat(irisColor, .35)); + iris.position.z = 0.045; holder.add(iris); + const glint = new THREE.Mesh(new THREE.SphereGeometry(0.013, 6, 5), mat('#ffffff', .2)); + glint.position.set(0.015, 0.02, 0.075); holder.add(glint); + return { holder, w, iris }; + }; + const eL = mkEye(-1), eR = mkEye(1); + this.eyeL = eL.w; this.eyeR = eR.w; this.irisL = eL.iris; this.irisR = eR.iris; + this.head.add(eL.holder, eR.holder); + + // brows + const browGeo = new THREE.BoxGeometry(0.1, 0.022, 0.02); + const browM = mat(hairColor, .9); + this.browL = new THREE.Mesh(browGeo, browM); this.browL.position.set(-0.115, 0.155, 0.285); + this.browR = new THREE.Mesh(browGeo, browM); this.browR.position.set(0.115, 0.155, 0.285); + const browStyle = a.brows || 'natural'; + if (browStyle === 'thick') { this.browL.scale.y = 2; this.browR.scale.y = 2; } + if (browStyle === 'thin') { this.browL.scale.y = 0.55; this.browR.scale.y = 0.55; } + this.head.add(this.browL, this.browR); + + // nose + mouth + const nose = new THREE.Mesh(new THREE.SphereGeometry(0.035, 8, 6), skinM); + nose.position.set(0, -0.03, 0.31); + if ((a.nose || 'button') === 'round') { nose.scale.setScalar(1.5); nose.position.y = -0.04; } + if ((a.nose || 'button') === 'small') nose.scale.setScalar(0.7); + this.head.add(nose); + this.mouth = new THREE.Mesh(new THREE.TorusGeometry(0.075, 0.017, 8, 14, Math.PI), mat('#a04a4a', .6)); + this.mouth.position.set(0, -0.115, 0.28); + this.mouth.rotation.z = Math.PI; // default smile (arc opens downward->up) + if ((a.mouth || 'smile') === 'neutral') this.mouth.rotation.z = Math.PI / 2; + if ((a.mouth || 'smile') === 'grin') { this.mouth.scale.set(1.25, 1.25, 1); } + this.head.add(this.mouth); + + // ---- hair ---- + this.buildHair(a.hair || 'short', hairM); + this.head.add(this.headTop); + + // accessory + this.buildAccessory(a.accessory || 'none'); + + this.bodyG.add(this.hips, this.head); + this.group.add(this.bodyG); + + // soft blob shadow (fake AO under feet) + const blob = new THREE.Mesh( + new THREE.CircleGeometry(0.42, 20), + new THREE.MeshBasicMaterial({ color: 0x000000, transparent: true, opacity: 0.28, depthWrite: false }) + ); + blob.rotation.x = -Math.PI / 2; blob.position.y = 0.02; + this.group.add(blob); + + this.group.scale.set(1, height, 1); + } + + private buildHair(style: string, hairM: THREE.Material) { + const add = (m: THREE.Object3D) => this.headTop.add(m); + const cap = () => { + const c = new THREE.Mesh(new THREE.SphereGeometry(0.325, 18, 14, 0, Math.PI * 2, 0, Math.PI * 0.55), hairM); + c.position.y = 0.045; c.castShadow = true; return c; + }; + switch (style) { + case 'bald': break; + case 'long': { + add(cap()); + const back = new THREE.Mesh(new THREE.BoxGeometry(0.52, 0.5, 0.16), hairM); + back.position.set(0, -0.18, -0.22); add(back); + break; + } + case 'ponytail': { + add(cap()); + const tail = new THREE.Mesh(new THREE.CapsuleGeometry(0.07, 0.3, 4, 8), hairM); + tail.position.set(0, 0.05, -0.36); tail.rotation.x = 0.5; add(tail); + break; + } + case 'curly': { + for (let i = 0; i < 7; i++) { + const s = new THREE.Mesh(new THREE.SphereGeometry(0.13, 10, 8), hairM); + const ang = (i / 7) * Math.PI * 2; + s.position.set(Math.cos(ang) * 0.2, 0.22 + (i % 2) * 0.06, Math.sin(ang) * 0.2 - 0.03); + add(s); + } + break; + } + case 'bob': { + add(cap()); + const ring = new THREE.Mesh(new THREE.TorusGeometry(0.27, 0.09, 10, 18), hairM); + ring.rotation.x = Math.PI / 2; ring.position.y = -0.05; ring.scale.z = 0.8; add(ring); + break; + } + case 'spiky': { + for (let i = 0; i < 5; i++) { + const sp = new THREE.Mesh(new THREE.ConeGeometry(0.06, 0.22, 6), hairM); + sp.position.set((i - 2) * 0.11, 0.34, -0.02 + Math.abs(i - 2) * -0.04); + sp.rotation.z = (i - 2) * -0.18; add(sp); + } + add(cap()); + break; + } + default: add(cap()); // short + } + } + + private buildAccessory(acc: string) { + const add = (m: THREE.Object3D) => this.headTop.add(m); + if (acc === 'glasses') { + const gm = mat('#222831', .4, .4); + for (const side of [-1, 1]) { + const rim = new THREE.Mesh(new THREE.TorusGeometry(0.085, 0.012, 8, 16), gm); + rim.position.set(side * 0.115, 0.05, 0.3); add(rim); + } + const bridge = new THREE.Mesh(new THREE.BoxGeometry(0.07, 0.014, 0.014), gm); + bridge.position.set(0, 0.06, 0.3); add(bridge); + } else if (acc === 'cap') { + const cm = mat('#e2564a', .8); + const dome = new THREE.Mesh(new THREE.SphereGeometry(0.33, 14, 10, 0, Math.PI * 2, 0, Math.PI * 0.5), cm); + dome.position.y = 0.1; add(dome); + const brim = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, 0.03, 14, 1, false, -Math.PI / 2.6, Math.PI / 1.3), cm); + brim.position.set(0, 0.12, 0.16); add(brim); + } else if (acc === 'beanie') { + const bm = mat('#3f7fd9', .9); + const dome = new THREE.Mesh(new THREE.SphereGeometry(0.33, 14, 10, 0, Math.PI * 2, 0, Math.PI * 0.55), bm); + dome.position.y = 0.08; add(dome); + const pom = new THREE.Mesh(new THREE.SphereGeometry(0.06, 8, 8), mat('#fff', .8)); + pom.position.y = 0.42; add(pom); + } + } + + setAnim(a: string) { if (this.anim !== a) { this.anim = a; this.phase = 0; } } + getAnim() { return this.anim; } + + setExpressionFromMood(mood: string) { + const map: Record = { + Happy: 'happy', Excited: 'excited', Relaxed: 'relaxed', Confident: 'confident', Inspired: 'happy', + Romantic: 'romantic', Sad: 'sad', Angry: 'angry', Bored: 'flat', Embarrassed: 'oops', + Lonely: 'sad', Tired: 'tired', Stressed: 'angry', + }; + this.expr = map[mood] || 'happy'; + } + + /** position of head top in world space (for bubbles/nametags) */ + headWorldPos(out: THREE.Vector3) { + out.set(0, 2.35, 0); + this.group.updateMatrixWorld(); + out.applyMatrix4(this.group.matrixWorld); + return out; + } + + update(dt: number) { + const p = (this.phase += dt); + const L = this.legL, R = this.legR, LA = this.armL, RA = this.armR; + const sL = this.shinL, sR = this.shinR; + // reset base targets + let bobY = 0, leanX = 0; + let lL = 0, lR = 0, sLr = 0.05, sRr = 0.05, aL = 0, aR = 0, aLz = 0.09, aRz = -0.09; + let headNod = 0, headTilt = 0; + + switch (this.anim) { + case 'walk': { + const k = p * 7.2; + lL = Math.sin(k) * 0.55; lR = Math.sin(k + Math.PI) * 0.55; + sLr = Math.max(0.06, -Math.sin(k - 0.6) * 0.7); sRr = Math.max(0.06, -Math.sin(k + Math.PI - 0.6) * 0.7); + aL = Math.sin(k + Math.PI) * 0.42; aR = Math.sin(k) * 0.42; + bobY = Math.abs(Math.sin(k)) * 0.045; leanX = 0.05; + break; + } + case 'run': { + const k = p * 11.5; + lL = Math.sin(k) * 0.95; lR = Math.sin(k + Math.PI) * 0.95; + sLr = Math.max(0.1, -Math.sin(k - 0.7)) * 1.1; sRr = Math.max(0.1, -Math.sin(k + Math.PI - 0.7)) * 1.1; + aL = Math.sin(k + Math.PI) * 0.85 - 0.25; aR = Math.sin(k) * 0.85 - 0.25; + bobY = Math.abs(Math.sin(k)) * 0.09; leanX = 0.22; + break; + } + case 'dance': { + const k = p * 6.4; + bobY = Math.abs(Math.sin(k)) * 0.12; + this.bodyG.rotation.y = Math.sin(k * 0.5) * 0.35; + aLz = 0.4 + Math.sin(k) * 1.5; aRz = -0.4 - Math.sin(k + Math.PI) * 1.5; + aL = -0.6; aR = -0.6; + lL = Math.sin(k) * 0.25; lR = -Math.sin(k) * 0.25; + headTilt = Math.sin(k * 0.5) * 0.2; + break; + } + case 'wave': { + aR = -2.5; aRz = -0.5 + Math.sin(p * 8) * 0.45; aL = 0.1; + headTilt = 0.08; + break; + } + case 'talk': { + const k = p * 3.2; + aR = -0.5 + Math.sin(k) * 0.25; aRz = -0.35; + headNod = Math.sin(k * 1.4) * 0.1; + this.mouth.scale.setScalar(1 + Math.abs(Math.sin(k * 3)) * 0.5); + break; + } + case 'laugh': { + const k = p * 9; + bobY = Math.abs(Math.sin(k)) * 0.06; leanX = -0.12 + Math.abs(Math.sin(k)) * 0.1; + aL = -0.4; aR = -0.4; headNod = -0.15; + this.mouth.scale.set(1.4, 1.4, 1.4); + break; + } + case 'sit': { + lL = -1.45; lR = -1.45; sLr = 1.35; sRr = 1.35; + bobY = -0.42; aL = -0.35; aR = -0.35; aLz = 0.18; aRz = -0.18; + break; + } + case 'sleep': { + this.bodyG.rotation.x = -Math.PI / 2; + this.bodyG.position.y = -0.62; + lL = 0.08; lR = -0.08; aL = 0.15; aR = -0.15; sLr = 0.15; sRr = 0.15; + headTilt = 0.12; + this.applyExpr(); + this.updateBlink(dt, 0.25); + return; + } + case 'hug': { aL = -1.9; aR = -1.9; aLz = 0.5; aRz = -0.5; leanX = 0.18; break; } + case 'kiss': { aL = -0.7; aR = -0.7; leanX = 0.22; headTilt = 0.18; break; } + case 'holdhands': { aR = -0.55; aRz = -0.5; break; } + case 'highfive': { aR = -2.7; aRz = -0.15; break; } + case 'gift': { aL = -1.5; aR = -1.5; aLz = 0.22; aRz = -0.22; leanX = 0.12; break; } + case 'flirt': { headTilt = 0.22; aR = -1.2; aRz = -0.9; this.mouth.scale.set(0.8, 1.2, 0.8); break; } + case 'argue': { + const k = p * 8; + aL = -1 + Math.sin(k) * 0.3; aR = -1 - Math.sin(k) * 0.3; + aLz = 0.7; aRz = -0.7; headNod = Math.sin(k * 0.7) * 0.12; + break; + } + case 'celebrate': { + const k = p * 5; + bobY = Math.abs(Math.sin(k)) * 0.22; + aLz = 2.6; aRz = -2.6; headNod = -0.1; + break; + } + case 'eat': { + const k = p * 4; + aR = -1.9 + Math.sin(k) * 0.35; aRz = -0.3; + chewMouth(this.mouth, k); + break; + } + case 'work': { + const k = p * 10; + aL = -1.05 + Math.sin(k) * 0.06; aR = -1.05 - Math.sin(k) * 0.06; + aLz = 0.3; aRz = -0.3; leanX = 0.14; headNod = 0.18 + Math.sin(k * 0.2) * 0.03; + break; + } + default: { // idle + const k = p * 1.8; + bobY = Math.sin(k) * 0.02; + aL = Math.sin(k * 0.9) * 0.05; aR = Math.sin(k * 0.9 + 1) * 0.05; + headNod = Math.sin(k * 0.7) * 0.04; + break; + } + } + this.bodyG.rotation.x += (leanX - this.bodyG.rotation.x) * Math.min(1, dt * 10); + if (this.anim !== 'dance') this.bodyG.rotation.y *= Math.max(0, 1 - dt * 6); + if (this.anim !== 'sleep') this.bodyG.position.y = 0; + + this.bodyG.position.y = this.anim === 'sleep' ? -0.62 : bobY; + L.rotation.x = lL; R.rotation.x = lR; + sL.rotation.x = sLr; sR.rotation.x = sRr; + LA.rotation.x += (aL - LA.rotation.x) * Math.min(1, dt * 12); + RA.rotation.x += (aR - RA.rotation.x) * Math.min(1, dt * 12); + LA.rotation.z += (aLz - LA.rotation.z) * Math.min(1, dt * 12); + RA.rotation.z += (aRz - RA.rotation.z) * Math.min(1, dt * 12); + this.head.rotation.x = headNod; + this.head.rotation.z = headTilt; + + if (this.anim !== 'talk' && this.anim !== 'eat' && this.anim !== 'flirt' && this.anim !== 'laugh') { + this.mouth.scale.lerp(new THREE.Vector3(1, 1, 1), Math.min(1, dt * 6)); + } + this.applyExpr(); + this.updateBlink(dt, 1); + } + + private updateBlink(dt: number, rate: number) { + this.blinkAt -= dt * rate; + if (this.blinkAt <= 0) { this.blinkT = 0.13; this.blinkAt = 2.5 + Math.random() * 4; } + if (this.blinkT > 0) { + this.blinkT -= dt; + const s = 0.12; + this.eyeL.scale.y = s; this.eyeR.scale.y = s; + } else { + this.eyeL.scale.y += (1 - this.eyeL.scale.y) * Math.min(1, dt * 18); + this.eyeR.scale.y = this.eyeL.scale.y; + } + } + + private applyExpr() { + const e = this.expr; + const bl = this.browL, br = this.browR; + let browAng = 0, browY = 0; + switch (e) { + case 'angry': browAng = 0.35; browY = -0.02; this.mouth.rotation.z = 0; this.mouth.position.y = -0.135; break; // frown + case 'sad': browAng = -0.3; this.mouth.rotation.z = 0; this.mouth.position.y = -0.14; break; + case 'tired': browAng = 0.15; browY = 0.01; this.mouth.rotation.z = 0; this.mouth.position.y = -0.125; break; + case 'flat': this.mouth.rotation.z = Math.PI / 2; this.mouth.position.y = -0.12; break; + case 'oops': browAng = -0.25; browY = 0.02; this.mouth.rotation.z = 0; this.mouth.scale.set(0.7, 0.7, 0.7); break; + case 'romantic': this.mouth.rotation.z = Math.PI * 0.96; this.mouth.position.y = -0.11; break; + default: this.mouth.rotation.z = Math.PI; this.mouth.position.y = -0.115; // smile + } + bl.rotation.z += (browAng - bl.rotation.z) * 0.2; + br.rotation.z += (-browAng - br.rotation.z) * 0.2; + bl.position.y = 0.155 + browY; br.position.y = 0.155 + browY; + } + + dispose() { + this.group.traverse((o: any) => { + if (o.geometry) o.geometry.dispose(); + if (o.material) { Array.isArray(o.material) ? o.material.forEach((mm: any) => mm.dispose()) : o.material.dispose(); } + }); + } +} + +function chewMouth(mouth: THREE.Mesh, k: number) { + const s = 1 + Math.abs(Math.sin(k * 2)) * 0.6; + mouth.scale.set(s * 0.8, 1, 0.8); +} + +function hslColor(h: number, s: number, l: number): string { + const c = new THREE.Color().setHSL(h / 360, s, l); + return '#' + c.getHexString(); +} + +/** Derive an appearance object for an NPC definition. */ +export function npcAppearance(def: any, index: number): any { + const hairs = ['short', 'long', 'curly', 'bob', 'ponytail', 'spiky']; + return { + skin: SKINS[index % SKINS.length], + hairColor: '#' + new THREE.Color(def.hairColor).getHexString(), + hair: hairs[index % hairs.length], + eyeColor: ['#3a5a40', '#3d5a80', '#6b4226'][index % 3], + topColor: '#' + new THREE.Color(def.color).getHexString(), + bottomColor: ['#33415c', '#3d3a4a', '#4a4038'][index % 3], + outfitBottom: 'jeans', + bodyType: ['average', 'slim', 'plump'][index % 3], + height: 0.92 + ((index * 37) % 20) / 100, + faceShape: ['round', 'oval', 'square'][index % 3], + eyes: ['round', 'wide', 'sleepy'][index % 3], + accessory: ['none', 'glasses', 'none', 'cap'][index % 4], + name: def.name, + }; +} diff --git a/client/src/gfx/engine.ts b/client/src/gfx/engine.ts new file mode 100644 index 0000000..31f7b70 --- /dev/null +++ b/client/src/gfx/engine.ts @@ -0,0 +1,107 @@ +// LifeTown Online — three.js engine: renderer, scene, lighting, day/night sky. +import * as THREE from 'three'; + +const DAY_SKY = new THREE.Color(0x8ecdf2); +const DUSK_SKY = new THREE.Color(0xf2a06b); +const NIGHT_SKY = new THREE.Color(0x10142e); +const DAWN_SKY = new THREE.Color(0xf7c48f); +const RAIN_TINT = new THREE.Color(0x6b7891); + +export class Engine { + renderer: THREE.WebGLRenderer; + scene: THREE.Scene; + camera: THREE.PerspectiveCamera; + sun: THREE.DirectionalLight; + hemi: THREE.HemisphereLight; + ambient: THREE.AmbientLight; + moonFill: THREE.DirectionalLight; + worldGroup = new THREE.Group(); // static town / interior geometry + actorGroup = new THREE.Group(); // avatars + fxGroup = new THREE.Group(); // weather particles etc. + private sunBase = { intensity: 2.6 }; + + constructor(canvas: HTMLCanvasElement) { + this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', preserveDrawingBuffer: true }); + this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.renderer.toneMapping = THREE.ACESFilmicToneMapping; + this.renderer.toneMappingExposure = 1.05; + + this.scene = new THREE.Scene(); + this.scene.background = DAY_SKY.clone(); + this.scene.fog = new THREE.Fog(DAY_SKY.clone(), 70, 190); + + this.camera = new THREE.PerspectiveCamera(52, 1, 0.1, 400); + + this.hemi = new THREE.HemisphereLight(0xcfe8ff, 0x8a9a6a, 0.85); + this.scene.add(this.hemi); + this.ambient = new THREE.AmbientLight(0xffffff, 0.12); + this.scene.add(this.ambient); + + this.sun = new THREE.DirectionalLight(0xfff2dd, this.sunBase.intensity); + this.sun.castShadow = true; + this.sun.shadow.mapSize.set(2048, 2048); + const c = this.sun.shadow.camera as THREE.OrthographicCamera; + c.left = -75; c.right = 75; c.top = 75; c.bottom = -75; c.near = 10; c.far = 260; + this.sun.shadow.bias = -0.0004; + this.scene.add(this.sun); + this.scene.add(this.sun.target); + + this.moonFill = new THREE.DirectionalLight(0x8fa8ff, 0.0); + this.moonFill.position.set(-40, 60, -30); + this.scene.add(this.moonFill); + + this.scene.add(this.worldGroup, this.actorGroup, this.fxGroup); + window.addEventListener('resize', () => this.resize()); + this.resize(); + } + + resize() { + const w = window.innerWidth, h = window.innerHeight; + this.renderer.setSize(w, h, false); + this.camera.aspect = w / h; + this.camera.updateProjectionMatrix(); + } + + // minutes 0..1440 ; drives sky colour, sun position/intensity, fog + setTimeOfDay(minutes: number, weather: string) { + const t = minutes / 60; + // sun path: rises 6h, sets 20h + const dayK = Math.max(0, Math.sin(((t - 6) / 14) * Math.PI)); // 0..1 daylight factor + const azimuth = ((t - 6) / 14) * Math.PI; // 0..π across the sky + const sx = Math.cos(azimuth) * 90; + const sy = Math.max(4, Math.sin(azimuth) * 110 + 6); + this.sun.position.set(sx, sy, 38); + this.sun.target.position.set(0, 0, 0); + + // sky colour blend: night -> dawn/dusk -> day + const sky = new THREE.Color(); + if (t < 5 || t > 21.5) sky.copy(NIGHT_SKY); + else if (t < 7.5) sky.copy(NIGHT_SKY).lerp(DAWN_SKY, (t - 5) / 2.5); + else if (t < 9) sky.copy(DAWN_SKY).lerp(DAY_SKY, (t - 7.5) / 1.5); + else if (t < 17.5) sky.copy(DAY_SKY); + else if (t < 19.5) sky.copy(DAY_SKY).lerp(DUSK_SKY, (t - 17.5) / 2); + else sky.copy(DUSK_SKY).lerp(NIGHT_SKY, (t - 19.5) / 2); + + if (weather === 'rain' || weather === 'storm') sky.lerp(RAIN_TINT, weather === 'storm' ? 0.62 : 0.45); + else if (weather === 'cloudy') sky.lerp(new THREE.Color(0x9aa4b8), 0.35); + else if (weather === 'fog') sky.lerp(new THREE.Color(0xb9c3cf), 0.55); + + (this.scene.background as THREE.Color).copy(sky); + (this.scene.fog as THREE.Fog).color.copy(sky); + (this.scene.fog as THREE.Fog).near = weather === 'fog' ? 18 : 60; + (this.scene.fog as THREE.Fog).far = weather === 'fog' ? 80 : weather === 'storm' ? 130 : 200; + + const stormDim = weather === 'storm' ? 0.42 : weather === 'rain' ? 0.6 : weather === 'cloudy' ? 0.72 : weather === 'fog' ? 0.68 : 1; + this.sun.intensity = this.sunBase.intensity * dayK * stormDim; + this.sun.color.setHSL(0.09, 0.5, 0.5 + 0.5 * dayK); // warmer when low + this.hemi.intensity = 0.22 + 0.66 * dayK * stormDim; + this.hemi.color.copy(sky).lerp(new THREE.Color(0xffffff), 0.35); + this.moonFill.intensity = (1 - dayK) * 0.28; + this.renderer.toneMappingExposure = 1.02 + 0.1 * dayK; + } + + render() { this.renderer.render(this.scene, this.camera); } +} diff --git a/client/src/gfx/furniture.ts b/client/src/gfx/furniture.ts new file mode 100644 index 0000000..90fdfd4 --- /dev/null +++ b/client/src/gfx/furniture.ts @@ -0,0 +1,351 @@ +// LifeTown Online — procedural furniture models for the catalogue. +// Every item is built from clean rounded primitives in a warm palette. +import * as THREE from 'three'; +import { CATALOG_BY_ID } from '../../../shared/data.mjs'; + +function mat(color: number | string, rough = 0.85, metal = 0) { + return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal }); +} +function box(w: number, h: number, d: number, m: THREE.Material, x = 0, y = 0, z = 0) { + const mesh = new THREE.Mesh(new THREE.BoxGeometry(w, h, d), m); + mesh.position.set(x, y, z); + mesh.castShadow = true; mesh.receiveShadow = true; + return mesh; +} +function cyl(rt: number, rb: number, h: number, m: THREE.Material, x = 0, y = 0, z = 0, seg = 14) { + const mesh = new THREE.Mesh(new THREE.CylinderGeometry(rt, rb, h, seg), m); + mesh.position.set(x, y, z); + mesh.castShadow = true; + return mesh; +} +const WOOD = [0x9a6b43, 0xb98a5a, 0x7a5236]; +const FABRIC = [0xe8875f, 0x67b56a, 0x8fb7d9, 0xd9c9a8, 0xb58fd9]; + +export function buildFurniture(itemId: string): THREE.Group { + const def = CATALOG_BY_ID[itemId] || { cat: 'decor', id: itemId }; + const g = new THREE.Group(); + const seedNum = [...itemId].reduce((a, c) => a + c.charCodeAt(0), 0); + const wood = WOOD[seedNum % WOOD.length]; + const fabric = FABRIC[seedNum % FABRIC.length]; + + switch (def.cat) { + case 'bed': { + g.add(box(2, 0.32, def.d ?? 3, mat(wood), 0, 0.26)); + g.add(box(1.86, 0.22, (def.d ?? 3) - 0.3, mat('#fdf6ec', .95), 0, 0.52)); + g.add(box(1.86, 0.14, (def.d ?? 3) * 0.55, mat(fabric, .95), 0, 0.62, -0.45)); + g.add(box(1.2, 0.16, 0.42, mat('#ffffff', .95), 0, 0.66, (def.d ?? 3) / 2 - 0.5)); + if ((def.d ?? 3) > 2.4) g.add(box(1.1, 0.16, 0.42, mat('#ffffff', .95), 0.35, 0.66, (def.d ?? 3) / 2 - 0.5)); + g.add(box(2, 0.9, 0.18, mat(wood), 0, 0.65, -(def.d ?? 3) / 2)); + break; + } + case 'sofa': { + const w = def.w ?? 3; + g.add(box(w, 0.42, 1.3, mat(fabric, .95), 0, 0.34)); + g.add(box(w, 0.62, 0.28, mat(fabric, .95), 0, 0.78, -0.51)); + for (const s of [-1, 1]) g.add(box(0.26, 0.62, 1.3, mat(fabric, .95), s * (w / 2 - 0.13), 0.62)); + const cush = Math.max(2, Math.round(w / 1.1)); + for (let i = 0; i < cush; i++) { + g.add(box(w / cush - 0.12, 0.16, 1.05, mat(i % 2 ? '#ffffff' : '#fff2e0', .95), -w / 2 + (i + 0.5) * (w / cush), 0.62, 0.08)); + } + break; + } + case 'table': { + if (itemId === 'table_desk') { + g.add(box(def.w ?? 2, 0.09, def.d ?? 1, mat(wood), 0, 0.76)); + for (const sx of [-1, 1]) for (const sz of [-1, 1]) + g.add(box(0.09, 0.76, 0.09, mat(0x4a4f5a, .6, .3), sx * ((def.w ?? 2) / 2 - 0.1), 0.38, sz * ((def.d ?? 1) / 2 - 0.1))); + } else if (itemId === 'counter_kitchen') { + g.add(box(def.w ?? 1.8, 0.88, def.d ?? 0.9, mat('#e8e2d4', .9))); + g.add(box((def.w ?? 1.8) + 0.06, 0.06, (def.d ?? 0.9) + 0.06, mat(wood), 0, 0.91)); + g.add(box((def.w ?? 1.8) - 0.2, 0.55, 0.03, mat('#cfc8bb', .85), 0, 0.44, (def.d ?? 0.9) / 2)); + } else { + const r = itemId === 'table_coffee' ? 0.7 : 1; + g.add(cyl(r * 0.9, r * 0.9, 0.07, mat(wood), 0, itemId === 'table_coffee' ? 0.42 : 0.74, 0, 20)); + g.add(cyl(0.09, 0.13, itemId === 'table_coffee' ? 0.42 : 0.72, mat(0x4a4f5a, .6, .3), 0, (itemId === 'table_coffee' ? 0.42 : 0.72) / 2, 0, 10)); + g.add(cyl(r * 0.55, r * 0.62, 0.05, mat(0x4a4f5a, .6, .3), 0, 0.03, 0, 16)); + } + break; + } + case 'chair': { + if (itemId === 'chair_office') { + g.add(cyl(0.28, 0.32, 0.06, mat(0x22252e, .8), 0, 0.05, 0, 16)); + g.add(cyl(0.05, 0.05, 0.36, mat(0x4a4f5a, .5, .5), 0, 0.25, 0, 8)); + g.add(box(0.52, 0.1, 0.5, mat(0x35405c, .9), 0, 0.46)); + g.add(box(0.5, 0.56, 0.1, mat(0x35405c, .9), 0, 0.78, -0.22)); + } else if (itemId === 'stool') { + g.add(cyl(0.24, 0.28, 0.07, mat(wood), 0, 0.5, 0, 12)); + for (let i = 0; i < 3; i++) { + const leg = cyl(0.03, 0.04, 0.5, mat(wood), Math.cos(i * 2.1) * 0.15, 0.25, Math.sin(i * 2.1) * 0.15, 8); + leg.rotation.z = Math.cos(i * 2.1) * 0.15; leg.rotation.x = -Math.sin(i * 2.1) * 0.15; + g.add(leg); + } + } else { + g.add(box(0.46, 0.07, 0.46, mat(wood), 0, 0.48)); + g.add(box(0.46, 0.5, 0.06, mat(wood), 0, 0.75, -0.2)); + for (const sx of [-1, 1]) for (const sz of [-1, 1]) + g.add(box(0.05, 0.48, 0.05, mat(wood), sx * 0.19, 0.24, sz * 0.19)); + } + break; + } + case 'tv': { + const w = def.w ?? 1.6; + g.add(box(w, w * 0.58, 0.07, mat('#10131c', .35), 0, (w * 0.58) / 2 + 0.35)); + const screen = new THREE.Mesh(new THREE.PlaneGeometry(w - 0.1, w * 0.58 - 0.1), + new THREE.MeshStandardMaterial({ color: 0x27406b, emissive: 0x3d6ea8, emissiveIntensity: 0.7 })); + screen.position.set(0, (w * 0.58) / 2 + 0.35, 0.045); + g.add(screen); + g.add(box(0.5, 0.05, 0.3, mat(0x22252e, .6), 0, 0.02)); + g.add(box(0.08, 0.35, 0.08, mat(0x22252e, .6), 0, 0.18)); + break; + } + case 'pc': { + g.add(box(1.5, 0.06, 0.7, mat(wood), 0, 0.74)); + for (const sx of [-1, 1]) g.add(box(0.07, 0.74, 0.55, mat(0x4a4f5a, .6, .3), sx * 0.68, 0.37)); + const mon = box(0.95, 0.55, 0.05, mat('#10131c', .35), 0.1, 1.15); + g.add(mon); + const scr = new THREE.Mesh(new THREE.PlaneGeometry(0.85, 0.45), + new THREE.MeshStandardMaterial({ color: 0x1e3a5f, emissive: 0x4fa0d9, emissiveIntensity: 0.8 })); + scr.position.set(0.1, 1.15, 0.03); g.add(scr); + g.add(box(0.42, 0.03, 0.16, mat('#e8e8ee', .8), 0.1, 0.79, 0.18)); // keyboard + g.add(box(0.2, 0.38, 0.45, mat('#22252e', .7), -0.55, 0.96)); // tower + break; + } + case 'fridge': { + const h = itemId === 'fridge_dual' ? 2.0 : 1.75; + g.add(box(def.w ?? 1.2, h, 1, mat(itemId === 'fridge_retro' ? '#9fd8c9' : '#dfe3ea', .55, .1), 0, h / 2)); + g.add(box((def.w ?? 1.2) + 0.02, 0.03, 1.02, mat('#aab2bd', .5), 0, h * 0.62)); + g.add(box(0.05, 0.5, 0.06, mat('#7a8494', .4, .6), (def.w ?? 1.2) / 2 - 0.12, h * 0.68, 0.53)); + break; + } + case 'stove': { + g.add(box(def.w ?? 1.2, 0.92, 0.9, mat('#dfe3ea', .6, .1))); + g.add(box((def.w ?? 1.2) - 0.16, 0.5, 0.04, mat('#4a5060', .5, .2), 0, 0.42, 0.47)); + for (const bx of [-0.28, 0.28]) g.add(cyl(0.14, 0.14, 0.03, mat('#22252e', .8), bx, 0.94, 0, 12)); + break; + } + case 'shower': { + if (itemId === 'bath_tub') { + g.add(box(1.8, 0.55, 1, mat('#f2f4f8', .5), 0, 0.28)); + g.add(box(1.5, 0.1, 0.7, mat('#8fd0e8', .3), 0, 0.5)); + g.add(cyl(0.03, 0.03, 0.9, mat('#aab2bd', .4, .6), -0.75, 1.1, -0.3, 8)); + g.add(box(0.16, 0.05, 0.16, mat('#aab2bd', .4, .6), -0.62, 1.5, -0.3)); + } else { + g.add(box(1.2, 0.12, 1.2, mat('#f2f4f8', .5), 0, 0.06)); + g.add(box(1.2, 2, 0.05, mat('#bcd9e8', .15, .1), 0, 1.06, -0.57)); + g.add(box(0.05, 2, 1.2, mat('#bcd9e8', .15, .1), -0.57, 1.06)); + g.add(cyl(0.02, 0.02, 1.4, mat('#aab2bd', .4, .6), 0.45, 1.7, -0.45, 8)); + g.add(cyl(0.12, 0.02, 0.06, mat('#aab2bd', .4, .6), 0.45, 1.0, -0.3, 10)); + } + break; + } + case 'toilet': { + g.add(cyl(0.26, 0.22, 0.4, mat('#f2f4f8', .45), 0, 0.2, 0.05, 16)); + g.add(cyl(0.29, 0.29, 0.06, mat('#ffffff', .4), 0, 0.43, 0.05, 16)); + g.add(box(0.44, 0.5, 0.2, mat('#f2f4f8', .45), 0, 0.5, -0.24)); + break; + } + case 'sink': { + g.add(cyl(0.09, 0.13, 0.72, mat('#f2f4f8', .5), 0, 0.36, 0, 12)); + g.add(cyl(0.3, 0.24, 0.16, mat('#ffffff', .4), 0, 0.8, 0, 16)); + g.add(cyl(0.02, 0.02, 0.24, mat('#aab2bd', .4, .6), 0, 0.95, -0.1, 8)); + break; + } + case 'wardrobe': { + const w = def.w ?? 1.6, h = 2.05; + g.add(box(w, h, def.d ?? 0.8, mat(wood), 0, h / 2)); + g.add(box(w / 2 - 0.04, h - 0.2, 0.03, mat(wood + 0x111111), -w / 4, h / 2, (def.d ?? 0.8) / 2 + 0.01)); + g.add(box(w / 2 - 0.04, h - 0.2, 0.03, mat(wood), w / 4, h / 2, (def.d ?? 0.8) / 2 + 0.01)); + g.add(cyl(0.015, 0.015, 0.22, mat('#d9c07a', .3, .7), -0.06, h / 2, (def.d ?? 0.8) / 2 + 0.04, 8)); + g.add(cyl(0.015, 0.015, 0.22, mat('#d9c07a', .3, .7), 0.06, h / 2, (def.d ?? 0.8) / 2 + 0.04, 8)); + break; + } + case 'shelf': { + const w = def.w ?? 1.6; + g.add(box(w, 1.8, 0.06, mat(0x4a4038), 0, 0.9, -0.22)); + for (let i = 0; i < 4; i++) g.add(box(w, 0.05, 0.44, mat(wood), 0, 0.3 + i * 0.45)); + // books + let bx = -w / 2 + 0.12; + let shelf = 0; + while (shelf < 3 && bx < w / 2 - 0.1) { + const bw = 0.05 + (seedNum % 3) * 0.012; + g.add(box(bw, 0.26, 0.3, mat([0xef6f91, 0x5f86e8, 0x67b56a, 0xffcf6b][(seedNum + bx * 97 | 0) % 4], .9), bx, 0.45 + shelf * 0.45)); + bx += bw + 0.02; + if (bx > w / 2 - 0.15) { bx = -w / 2 + 0.12; shelf++; } + } + break; + } + case 'plant': { + const potM = mat(itemId === 'plant_cactus' ? '#e0793f' : '#c96f4a', .9); + if (itemId === 'plant_tree') { + g.add(cyl(0.16, 0.2, 0.4, potM, 0, 0.2, 0, 12)); + g.add(cyl(0.06, 0.08, 0.8, mat(0x7a5236), 0, 0.75, 0, 8)); + for (let i = 0; i < 3; i++) { + const lf = new THREE.Mesh(new THREE.SphereGeometry(0.34 - i * 0.06, 10, 8), mat(0x5ea75e, .95)); + lf.position.set(0, 1.25 + i * 0.28, 0); lf.castShadow = true; g.add(lf); + } + } else if (itemId === 'plant_cactus') { + g.add(cyl(0.14, 0.17, 0.24, potM, 0, 0.12, 0, 12)); + g.add(cyl(0.09, 0.11, 0.5, mat(0x4f9850, .95), 0, 0.48, 0, 10)); + const arm = cyl(0.05, 0.06, 0.24, mat(0x4f9850, .95), 0.16, 0.5, 0, 8); + arm.rotation.z = -1.1; + g.add(arm); + } else { + g.add(cyl(0.15, 0.19, 0.28, potM, 0, 0.14, 0, 12)); + for (let i = 0; i < 6; i++) { + const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.12, 8, 6), mat(0x5ea75e, .95)); + leaf.position.set(Math.cos(i) * 0.13, 0.34 + (i % 2) * 0.1, Math.sin(i) * 0.13); + leaf.scale.set(1, 0.6, 1); leaf.castShadow = true; g.add(leaf); + } + } + break; + } + case 'light': { + const glowM = new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: 0xffdf9e, emissiveIntensity: 1.4 }); + if (itemId === 'lamp_floor') { + g.add(cyl(0.14, 0.18, 0.05, mat(0x33384a, .6), 0, 0.03, 0, 12)); + g.add(cyl(0.025, 0.025, 1.4, mat(0x33384a, .6), 0, 0.73, 0, 8)); + const shade = new THREE.Mesh(new THREE.ConeGeometry(0.22, 0.26, 12, 1, true), glowM); + shade.position.y = 1.5; g.add(shade); + } else if (itemId === 'lamp_arc') { + g.add(cyl(0.16, 0.2, 0.05, mat(0x33384a, .6), 0, 0.03, 0, 12)); + const arc = new THREE.Mesh(new THREE.TorusGeometry(0.8, 0.03, 8, 20, Math.PI / 1.6), mat(0x33384a, .5, .5)); + arc.position.set(-0.4, 1.6, 0); arc.rotation.z = Math.PI / 2.6; g.add(arc); + const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), glowM); + bulb.position.set(0.55, 1.85, 0); g.add(bulb); + } else if (itemId === 'light_neon') { + const tube = new THREE.Mesh(new THREE.CapsuleGeometry(0.045, 1.8, 4, 8), + new THREE.MeshStandardMaterial({ color: 0xff7ab8, emissive: 0xff4f9e, emissiveIntensity: 2 })); + tube.rotation.z = Math.PI / 2; tube.position.y = 0.1; g.add(tube); + } else { // table lamp + g.add(cyl(0.1, 0.13, 0.04, mat(wood), 0, 0.02, 0, 10)); + g.add(cyl(0.02, 0.02, 0.26, mat(0x33384a, .6), 0, 0.16, 0, 8)); + const shade = new THREE.Mesh(new THREE.ConeGeometry(0.16, 0.2, 12, 1, true), glowM); + shade.position.y = 0.38; g.add(shade); + } + break; + } + case 'rug': { + const rm = mat(fabric, 1); + const r = itemId === 'rug_round' + ? new THREE.Mesh(new THREE.CircleGeometry(Math.min(def.w ?? 2.4, 2.4) / 2, 24), rm) + : box(def.w ?? 2.4, 0.03, def.d ?? 1.4, rm); + if (r.geometry.type === 'CircleGeometry') { r.rotation.x = -Math.PI / 2; r.position.y = 0.03; } + else r.position.y = 0.02; + r.receiveShadow = true; + g.add(r); + break; + } + case 'paint': { + const frame = box(def.w ?? 1.2, (def.w ?? 1.2) * 0.8, 0.06, mat(wood), 0, 0.9, 0); + g.add(frame); + const c = document.createElement('canvas'); c.width = 128; c.height = 100; + const ctx = c.getContext('2d')!; + if (itemId === 'paint_meadow') { + ctx.fillStyle = '#8ecdf2'; ctx.fillRect(0, 0, 128, 60); + ctx.fillStyle = '#79b56a'; ctx.fillRect(0, 60, 128, 40); + ctx.fillStyle = '#ffcf6b'; ctx.beginPath(); ctx.arc(96, 22, 12, 0, 7); ctx.fill(); + } else if (itemId === 'paint_abstract') { + const cols = ['#ef6f91', '#5f86e8', '#ffcf6b', '#67b56a']; + for (let i = 0; i < 12; i++) { ctx.fillStyle = cols[i % 4]; ctx.fillRect((i * 37) % 110, (i * 23) % 80, 22, 14); } + } else { + ctx.fillStyle = '#e8dcc8'; ctx.fillRect(0, 0, 128, 100); + ctx.fillStyle = '#8a5a37'; ctx.beginPath(); ctx.arc(64, 42, 20, 0, 7); ctx.fill(); + ctx.fillRect(40, 62, 48, 38); + } + const tex = new THREE.CanvasTexture(c); tex.colorSpace = THREE.SRGBColorSpace; + const art = new THREE.Mesh(new THREE.PlaneGeometry((def.w ?? 1.2) - 0.12, (def.w ?? 1.2) * 0.8 - 0.12), + new THREE.MeshBasicMaterial({ map: tex })); + art.position.set(0, 0.9, 0.04); g.add(art); + break; + } + case 'elec': { + if (itemId === 'console') { + g.add(box(0.5, 0.12, 0.36, mat('#22252e', .6), 0, 0.06)); + g.add(box(0.14, 0.02, 0.02, mat('#4fd98a', .4), 0.1, 0.125, 0.1)); + } else if (itemId === 'speaker') { + g.add(box(0.34, 0.55, 0.3, mat('#2b3040', .8), 0, 0.28)); + g.add(cyl(0.11, 0.11, 0.03, mat('#171a24', .9), 0, 0.2, 0.16, 12).rotateX(Math.PI / 2)); + } else if (itemId === 'microwave') { + g.add(box(0.6, 0.36, 0.4, mat('#dfe3ea', .55, .1), 0, 0.18)); + g.add(box(0.42, 0.26, 0.02, mat('#3a3f4e', .5), -0.05, 0.18, 0.21)); + } else if (itemId === 'coffeemaker') { + g.add(box(0.28, 0.4, 0.26, mat('#8a4a3a', .7), 0, 0.2)); + g.add(cyl(0.08, 0.06, 0.1, mat('#e8e8ee', .5), 0, 0.08, 0.1, 10)); + } else { // synth + g.add(box(1.3, 0.12, 0.42, mat('#22252e', .7), 0, 0.72)); + g.add(box(1.2, 0.03, 0.14, mat('#f2f4f8', .6), 0, 0.79, 0.1)); + for (let i = 0; i < 8; i++) g.add(box(0.06, 0.02, 0.1, mat('#171a24', .6), -0.54 + i * 0.155, 0.81, 0.08)); + g.add(box(0.1, 0.5, 0.1, mat(wood), -0.5, 0.35, 0)); + g.add(box(0.1, 0.5, 0.1, mat(wood), 0.5, 0.35, 0)); + } + break; + } + default: { // decor + if (itemId === 'decor_vase') { + g.add(cyl(0.12, 0.16, 0.34, mat('#e0793f', .7), 0, 0.17, 0, 12)); + for (let i = 0; i < 3; i++) { + const fl = new THREE.Mesh(new THREE.SphereGeometry(0.05, 6, 5), mat([0xef6f91, 0xffcf6b, '#ffffff'][i], .8)); + fl.position.set(Math.cos(i * 2.1) * 0.07, 0.42, Math.sin(i * 2.1) * 0.07); g.add(fl); + } + } else if (itemId === 'decor_clock') { + g.add(cyl(0.22, 0.22, 0.05, mat('#f2ead8', .8), 0, 1.2, 0, 20).rotateX(Math.PI / 2)); + g.add(box(0.02, 0.14, 0.01, mat('#333', .6), 0, 1.22, 0.035)); + g.add(box(0.1, 0.02, 0.01, mat('#333', .6), 0.03, 1.2, 0.035)); + } else if (itemId === 'decor_mirror') { + g.add(box(0.8, 1.3, 0.05, mat(wood), 0, 1.1)); + const mir = new THREE.Mesh(new THREE.PlaneGeometry(0.66, 1.16), + new THREE.MeshStandardMaterial({ color: 0xcfe4ee, roughness: 0.08, metalness: 0.7 })); + mir.position.set(0, 1.1, 0.03); g.add(mir); + } else if (itemId === 'decor_aquarium') { + g.add(box(1.2, 0.5, 0.5, mat(wood), 0, 0.25)); + const waterBox = new THREE.Mesh(new THREE.BoxGeometry(1.1, 0.42, 0.42), + new THREE.MeshStandardMaterial({ color: 0x6fc4e8, transparent: true, opacity: 0.55, roughness: .2 })); + waterBox.position.y = 0.72; g.add(waterBox); + for (let i = 0; i < 3; i++) { + const fish = new THREE.Mesh(new THREE.SphereGeometry(0.045, 6, 5), mat([0xef8a5f, 0xffcf6b, 0xef6f91][i], .7)); + fish.scale.set(1.5, 0.8, 0.6); fish.position.set(-0.3 + i * 0.3, 0.68 + i * 0.06, 0); g.add(fish); + } + } else if (itemId === 'guitar') { + const bodyG = new THREE.Mesh(new THREE.CylinderGeometry(0.22, 0.26, 0.09, 16), mat(0xb0672f, .7)); + bodyG.rotation.x = Math.PI / 2; bodyG.position.set(0, 0.9, 0); bodyG.rotation.y = 0; g.add(bodyG); + g.add(box(0.07, 0.7, 0.05, mat(0x6b4226, .8), 0, 1.35, 0)); + } else if (itemId === 'easel') { + for (const lx of [-0.3, 0.3]) { + const legg = box(0.05, 1.4, 0.05, mat(wood), lx, 0.7, 0); + legg.rotation.z = lx > 0 ? -0.12 : 0.12; g.add(legg); + } + g.add(box(0.7, 0.55, 0.04, mat('#f2f4f8', .9), 0, 0.95, 0.06)); + g.add(box(0.4, 0.3, 0.01, mat('#79b56a', .9), 0, 0.95, 0.085)); + } else if (itemId === 'teddy') { + const tm = mat('#c98a5b', .95); + const bod = new THREE.Mesh(new THREE.SphereGeometry(0.16, 10, 8), tm); bod.position.y = 0.18; g.add(bod); + const hed = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), tm); hed.position.y = 0.38; g.add(hed); + for (const ex of [-0.06, 0.06]) { + const ear = new THREE.Mesh(new THREE.SphereGeometry(0.045, 8, 6), tm); ear.position.set(ex, 0.47, 0); g.add(ear); + const eye = new THREE.Mesh(new THREE.SphereGeometry(0.014, 6, 5), mat('#171a24', .5)); eye.position.set(ex, 0.39, 0.1); g.add(eye); + } + } else { + g.add(box(0.4, 0.4, 0.4, mat(fabric), 0, 0.2)); + } + } + } + return g; +} + +/** Which need-fulfilling actions each furniture category supports. */ +export function categoryActions(cat: string): { id: string; label: string; emoji: string }[] { + switch (cat) { + case 'bed': return [{ id: 'sleep', label: 'Sleep', emoji: '😴' }]; + case 'shower': return [{ id: 'wash', label: 'Shower / Bathe', emoji: '🚿' }]; + case 'toilet': return [{ id: 'use_toilet', label: 'Use', emoji: '🚽' }]; + case 'sink': return [{ id: 'wash_hands', label: 'Freshen Up', emoji: '🧼' }]; + case 'tv': return [{ id: 'watch_tv', label: 'Watch TV', emoji: '📺' }]; + case 'pc': return [{ id: 'computer', label: 'Play on PC', emoji: '🖥️' }]; + case 'elec': return [{ id: 'play_games', label: 'Play Games', emoji: '🎮' }]; + case 'fridge': return [{ id: 'snack', label: 'Grab Snack', emoji: '🍎' }]; + case 'stove': return [{ id: 'cook', label: 'Cook Meal', emoji: '🍳' }]; + case 'shelf': return [{ id: 'read', label: 'Read Book', emoji: '📚' }]; + case 'sofa': return [{ id: 'lounge', label: 'Lounge', emoji: '🛋️' }]; + default: return []; + } +} diff --git a/client/src/gfx/interior.ts b/client/src/gfx/interior.ts new file mode 100644 index 0000000..8cbf817 --- /dev/null +++ b/client/src/gfx/interior.ts @@ -0,0 +1,166 @@ +// LifeTown Online — instanced home interiors + build-mode scaffolding. +import * as THREE from 'three'; +import { HOUSES, CATALOG_BY_ID } from '../../../shared/data.mjs'; +import { buildFurniture, categoryActions } from './furniture'; + +export interface PlacedItem { itemId: string; x: number; z: number; rot: number } +export interface FurnitureEntry { item: PlacedItem; group: THREE.Group; cat: string } + +export class Interior { + group = new THREE.Group(); + size: [number, number]; + tier: string; + label = 'Your Home'; + furniture: FurnitureEntry[] = []; + private grid: THREE.GridHelper | null = null; + buildMode = false; + onFurnitureChanged: (() => void) | null = null; + + constructor(tierOrSize: string | [number, number]) { + if (Array.isArray(tierOrSize)) { + this.tier = 'venue'; + this.size = [tierOrSize[0], tierOrSize[1]]; + } else { + const h = HOUSES.find(x => x.id === tierOrSize) || HOUSES[0]; + this.tier = h.id; + this.size = [h.size[0], h.size[1]]; + } + this.buildShell(); + this.setBuildMode(false); + } + + private buildShell() { + const [w, d] = this.size; + const floorM = new THREE.MeshStandardMaterial({ color: 0xd9c9a8, roughness: 0.85 }); + // two-tone wood planks + const floor = new THREE.Mesh(new THREE.PlaneGeometry(w, d), floorM); + floor.rotation.x = -Math.PI / 2; floor.receiveShadow = true; + this.group.add(floor); + for (let i = -Math.floor(d / 2); i < d / 2; i += 1.2) { + const plank = new THREE.Mesh(new THREE.PlaneGeometry(w, 0.06), new THREE.MeshStandardMaterial({ color: 0xcbb894, roughness: .9 })); + plank.rotation.x = -Math.PI / 2; plank.position.set(0, 0.006, i); + this.group.add(plank); + } + + const wallH = 3; + const wallM = new THREE.MeshStandardMaterial({ color: 0xf2e8d8, roughness: 0.95 }); + const accentM = new THREE.MeshStandardMaterial({ color: 0xefd3b8, roughness: 0.95 }); + // back wall (z-) with windows + const back = new THREE.Mesh(new THREE.PlaneGeometry(w, wallH), accentM); + back.position.set(0, wallH / 2, -d / 2); back.receiveShadow = true; + this.group.add(back); + // front wall (z+) with door gap + const doorW = 1.6; + const segW = (w - doorW) / 2; + for (const sx of [-1, 1]) { + const seg = new THREE.Mesh(new THREE.PlaneGeometry(segW, wallH), wallM); + seg.position.set(sx * (doorW / 2 + segW / 2), wallH / 2, d / 2); + seg.rotation.y = Math.PI; seg.receiveShadow = true; + this.group.add(seg); + } + const header = new THREE.Mesh(new THREE.PlaneGeometry(doorW, wallH - 2.4), wallM); + header.position.set(0, 2.4 + (wallH - 2.4) / 2, d / 2); + header.rotation.y = Math.PI; + this.group.add(header); + const doorFrame = new THREE.Mesh(new THREE.BoxGeometry(doorW + 0.24, 2.5, 0.14), + new THREE.MeshStandardMaterial({ color: 0x8a6f56, roughness: .85 })); + doorFrame.position.set(0, 1.25, d / 2 + 0.02); + this.group.add(doorFrame); + const mat0 = new THREE.Mesh(new THREE.PlaneGeometry(1.4, 0.7), new THREE.MeshStandardMaterial({ color: 0xb0574a, roughness: 1 })); + mat0.rotation.x = -Math.PI / 2; mat0.position.set(0, 0.01, d / 2 + 0.55); + this.group.add(mat0); + // side walls + for (const sx of [-1, 1]) { + const side = new THREE.Mesh(new THREE.PlaneGeometry(d, wallH), wallM); + side.position.set(sx * w / 2, wallH / 2, 0); side.rotation.y = -sx * Math.PI / 2; + side.receiveShadow = true; + this.group.add(side); + } + // windows on back wall + const winY = 1.6; + const nWin = Math.max(1, Math.floor(w / 5)); + for (let i = 0; i < nWin; i++) { + const wx = -w / 2 + (i + 0.5) * (w / nWin); + const skyM = new THREE.MeshStandardMaterial({ color: 0xbfe3ff, emissive: 0x9fd0f2, emissiveIntensity: 0.35 }); + const win = new THREE.Mesh(new THREE.PlaneGeometry(1.7, 1.25), skyM); + win.position.set(wx, winY, -d / 2 + 0.02); + this.group.add(win); + const frame = new THREE.Mesh(new THREE.BoxGeometry(1.86, 1.4, 0.08), + new THREE.MeshStandardMaterial({ color: 0xffffff, roughness: .8 })); + frame.position.set(wx, winY, -d / 2 - 0.01); + this.group.add(frame); + } + // ceiling (visible only from below) + const ceil = new THREE.Mesh(new THREE.PlaneGeometry(w, d), new THREE.MeshStandardMaterial({ color: 0xfffaf0, roughness: 1 })); + ceil.rotation.x = Math.PI / 2; ceil.position.y = wallH; + this.group.add(ceil); + // ceiling lamp + const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.12, 10, 8), + new THREE.MeshStandardMaterial({ color: 0xfff2cc, emissive: 0xffe9b0, emissiveIntensity: 1.6 })); + bulb.position.y = wallH - 0.35; + this.group.add(bulb); + this.group.add((() => { + const wire = new THREE.Mesh(new THREE.CylinderGeometry(0.008, 0.008, 0.3, 6), new THREE.MeshBasicMaterial({ color: 0x333333 })); + wire.position.y = wallH - 0.15; return wire; + })()); + } + + setBuildMode(on: boolean) { + this.buildMode = on; + if (on && !this.grid) { + this.grid = new THREE.GridHelper(Math.max(this.size[0], this.size[1]), Math.max(this.size[0], this.size[1]), 0x35c4b5, 0x88a8a0); + (this.grid.material as any).opacity = 0.18; + (this.grid.material as any).transparent = true; + this.grid.position.y = 0.02; + this.group.add(this.grid); + } + if (this.grid) this.grid.visible = on; + } + + snap = 0.5; + + setLayout(layout: PlacedItem[]) { + // clear old + for (const f of this.furniture) { + this.group.remove(f.group); + f.group.traverse((o: any) => { o.geometry?.dispose?.(); }); + } + this.furniture = []; + for (const item of layout || []) { + const def = CATALOG_BY_ID[item.itemId]; + if (!def) continue; + const g = buildFurniture(item.itemId); + let x = item.x, z = item.z; + if (this.buildMode) { x = Math.round(x / this.snap) * this.snap; z = Math.round(z / this.snap) * this.snap; } + g.position.set(x, 0, z); + g.rotation.y = item.rot || 0; + this.group.add(g); + this.furniture.push({ item, group: g, cat: def.cat }); + } + this.onFurnitureChanged?.(); + } + + addFurniture(item: PlacedItem): FurnitureEntry | null { + const def = CATALOG_BY_ID[item.itemId]; + if (!def) return null; + const g = buildFurniture(item.itemId); + g.position.set(item.x, 0, item.z); + g.rotation.y = item.rot || 0; + this.group.add(g); + const entry: FurnitureEntry = { item, group: g, cat: def.cat }; + this.furniture.push(entry); + this.onFurnitureChanged?.(); + return entry; + } + + removeEntry(entry: FurnitureEntry) { + const i = this.furniture.indexOf(entry); + if (i !== -1) this.furniture.splice(i, 1); + this.group.remove(entry.group); + this.onFurnitureChanged?.(); + } + + bounds() { return { hw: this.size[0] / 2 - 0.4, hd: this.size[1] / 2 - 0.4 }; } +} + +export function furnitureActions(cat: string) { return categoryActions(cat); } diff --git a/client/src/gfx/town.ts b/client/src/gfx/town.ts new file mode 100644 index 0000000..40a1eea --- /dev/null +++ b/client/src/gfx/town.ts @@ -0,0 +1,369 @@ +// LifeTown Online — procedural "Maple Court" district (original world design). +import * as THREE from 'three'; +import { BUILDINGS, POIS } from '../../../shared/data.mjs'; + +function mat(color: number | string, rough = 0.9, metal = 0) { + return new THREE.MeshStandardMaterial({ color, roughness: rough, metalness: metal }); +} + +function textTexture(text: string, bg = '#f7ead2', fg = '#5a4632') { + const c = document.createElement('canvas'); + c.width = 512; c.height = 128; + const g = c.getContext('2d')!; + g.fillStyle = bg; g.fillRect(0, 0, c.width, c.height); + g.strokeStyle = fg; g.lineWidth = 10; g.strokeRect(8, 8, c.width - 16, c.height - 16); + g.fillStyle = fg; g.font = '900 72px Nunito, Arial'; g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillText(text, c.width / 2, c.height / 2 + 4); + const t = new THREE.CanvasTexture(c); + t.colorSpace = THREE.SRGBColorSpace; + return t; +} + +export interface Collider { x: number; z: number; hw: number; hd: number } + +export class Town { + group = new THREE.Group(); + colliders: Collider[] = []; + private windowMat = mat('#dff1ff', .4); + private lampHeadMat = mat('#fff6da', .5); + private lampGlows: THREE.Sprite[] = []; + private waterMats: THREE.MeshStandardMaterial[] = []; + poiRings = new Map(); + private pulseT = 0; + + constructor() { + this.windowMat.emissive = new THREE.Color(0xffdf9e); + this.lampHeadMat.emissive = new THREE.Color(0xffe9b0); + this.ground(); + this.roads(); + this.buildings(); + this.plaza(); + this.park(); + this.streetFurniture(); + this.poiMarkers(); + } + + // ---------- layers ---------- + private ground() { + const g = new THREE.Mesh(new THREE.PlaneGeometry(320, 240), mat(0x88c07a, 1)); + g.rotation.x = -Math.PI / 2; g.receiveShadow = true; + this.group.add(g); + // subtle grass tone patches + for (let i = 0; i < 26; i++) { + const p = new THREE.Mesh(new THREE.CircleGeometry(3 + Math.random() * 5, 12), mat(0x7db56f, 1)); + p.rotation.x = -Math.PI / 2; + p.position.set((Math.random() - .5) * 220, 0.01, (Math.random() - .5) * 160); + p.receiveShadow = true; + this.group.add(p); + } + } + + private roads() { + const road = new THREE.Mesh(new THREE.PlaneGeometry(170, 8), mat(0x4a4f5a, 1)); + road.rotation.x = -Math.PI / 2; road.position.set(0, 0.02, 0); road.receiveShadow = true; + this.group.add(road); + // centre dashes + const dashM = mat(0xf2e394, 1); + for (let x = -80; x <= 80; x += 6) { + const d = new THREE.Mesh(new THREE.PlaneGeometry(2.4, 0.28), dashM); + d.rotation.x = -Math.PI / 2; d.position.set(x, 0.03, 0); + this.group.add(d); + } + // sidewalks north & south + for (const z of [5.4, -5.4]) { + const sw = new THREE.Mesh(new THREE.BoxGeometry(170, 0.14, 2.6), mat(0xcfc8bb, 1)); + sw.position.set(0, 0.07, z); sw.receiveShadow = true; sw.castShadow = false; + this.group.add(sw); + } + // plaza path to city hall + park path + const pathM = mat(0xd8cfbf, 1); + const mkPath = (x1: number, z1: number, x2: number, z2: number, w = 2.4) => { + const dx = x2 - x1, dz = z2 - z1; + const len = Math.hypot(dx, dz); + const p = new THREE.Mesh(new THREE.PlaneGeometry(len, w), pathM); + p.rotation.x = -Math.PI / 2; + p.rotation.z = -Math.atan2(dz, dx); + p.position.set((x1 + x2) / 2, 0.03, (z1 + z2) / 2); + p.receiveShadow = true; + this.group.add(p); + }; + mkPath(0, -12, 0, -26); // spawn -> fountain + mkPath(-48, -19, -20, -30); // cafe -> plaza + mkPath(24, -20, 6, -30); // city hall -> plaza + mkPath(54, -19, 42, -22, 2); // gym -> park + mkPath(36, -26, 46, -38, 2); // park -> pond + } + + private buildings() { + for (const b of BUILDINGS) { + const grp = new THREE.Group(); + const body = new THREE.Mesh(new THREE.BoxGeometry(b.w, b.h ?? 8, b.d), mat(b.color)); + body.position.y = (b.h ?? 8) / 2; + body.castShadow = true; body.receiveShadow = true; + grp.add(body); + + // roof slab + trim + const roof = new THREE.Mesh(new THREE.BoxGeometry(b.w + 0.8, 0.7, b.d + 0.8), mat(b.roof)); + roof.position.y = (b.h ?? 8) + 0.35; + roof.castShadow = true; + grp.add(roof); + + // windows facing the street (z<0 row faces +z toward road; z>0 row faces -z) + const winRows = b.id === 'apt_a' || b.id === 'apt_b' ? 3 : 1; + const cols = Math.max(2, Math.floor(b.w / 3.4)); + const frontK = b.z > 0 ? -1 : 1; // street-facing local direction + for (let r = 0; r < winRows; r++) { + for (let ci = 0; ci < cols; ci++) { + const win = new THREE.Mesh( + new THREE.PlaneGeometry(1.15, 1.35), + this.windowMat + ); + const px = -b.w / 2 + (ci + 0.5) * (b.w / cols); + const py = 1.9 + r * 2.3; + win.position.set(px, py, frontK * (b.d / 2 + 0.02)); + win.rotation.y = frontK > 0 ? 0 : Math.PI; + grp.add(win); + } + } + + // door on the street side + handle + const door = new THREE.Mesh(new THREE.PlaneGeometry(1.5, 2.4), mat(0x7a5236, .8)); + const dz = frontK * (b.d / 2 + 0.03); + door.position.set(0, 1.2, dz); + door.rotation.y = frontK > 0 ? 0 : Math.PI; + grp.add(door); + const handle = new THREE.Mesh(new THREE.SphereGeometry(0.05, 8, 6), mat(0xd9c07a, .35, .6)); + handle.position.set(0.5, 1.15, dz + frontK * 0.04); + grp.add(handle); + // steps + const step = new THREE.Mesh(new THREE.BoxGeometry(3, 0.24, 1.2), mat(0xbfb6a6, 1)); + step.position.set(0, 0.12, dz + frontK * 0.7); + grp.add(step); + + // hanging sign above the entrance + const sign = new THREE.Mesh( + new THREE.PlaneGeometry(Math.min(9, b.w * 0.62), 1.5), + new THREE.MeshBasicMaterial({ map: textTexture(b.sign, '#fff4dd', '#4a3a28'), transparent: false }) + ); + sign.position.set(0, (b.h ?? 8) - 0.9, dz + frontK * 0.08); + sign.rotation.y = frontK > 0 ? 0 : Math.PI; + grp.add(sign); + + grp.position.set(b.x, 0, b.z); + this.group.add(grp); + this.colliders.push({ x: b.x, z: b.z, hw: b.w / 2 + 0.3, hd: b.d / 2 + 0.3 }); + } + } + + private plaza() { + const pave = new THREE.Mesh(new THREE.CylinderGeometry(14, 14, 0.12, 40), mat(0xe3d9c8, 1)); + pave.position.set(0, 0.06, -26); pave.receiveShadow = true; + this.group.add(pave); + + // fountain: tiers + water + centrepiece + const stoneM = mat(0xc9b8a0, .95); + const base = new THREE.Mesh(new THREE.CylinderGeometry(3.4, 3.7, 0.8, 24), stoneM); + base.position.set(0, 0.5, -26); base.castShadow = true; + this.group.add(base); + const mid = new THREE.Mesh(new THREE.CylinderGeometry(1.7, 2.1, 0.7, 20), stoneM); + mid.position.set(0, 1.15, -26); mid.castShadow = true; + this.group.add(mid); + const top = new THREE.Mesh(new THREE.CylinderGeometry(0.5, 0.8, 1.1, 16), stoneM); + top.position.set(0, 1.85, -26); top.castShadow = true; + this.group.add(top); + const orb = new THREE.Mesh(new THREE.SphereGeometry(0.45, 16, 12), mat(0x67c7d9, .35, .1)); + orb.position.set(0, 2.6, -26); orb.castShadow = true; + this.group.add(orb); + const water = new THREE.Mesh(new THREE.CircleGeometry(3.25, 26), mat(0x54b8d9, .25, .05)); + water.rotation.x = -Math.PI / 2; water.position.set(0, 0.86, -26); + this.group.add(water); + this.waterMats.push(water.material as THREE.MeshStandardMaterial); + this.colliders.push({ x: 0, z: -26, hw: 3.9, hd: 3.9 }); + + // stage (west of plaza) + const stage = new THREE.Mesh(new THREE.CylinderGeometry(4.6, 4.8, 0.55, 26), mat(0xa9744f, .9)); + stage.position.set(-18, 0.28, -34); stage.receiveShadow = true; stage.castShadow = true; + this.group.add(stage); + const back = new THREE.Mesh(new THREE.BoxGeometry(7, 3.4, 0.4), mat(0x8a63b8, .9)); + back.position.set(-18, 2, -37.4); back.castShadow = true; + this.group.add(back); + for (let i = 0; i < 2; i++) { + const spot = new THREE.Mesh(new THREE.SphereGeometry(0.28, 10, 8), mat(0xfff2b0, .4)); + spot.material.emissive = new THREE.Color(0xffe9a0); + spot.position.set(-18 - 2.4 + i * 4.8, 3.9, -37.2); + this.group.add(spot); + void spot; + } + this.colliders.push({ x: -18, z: -37.4, hw: 3.6, hd: 0.5 }); + } + + private park() { + const lawn = new THREE.Mesh(new THREE.PlaneGeometry(44, 40), mat(0x79b56a, 1)); + lawn.rotation.x = -Math.PI / 2; lawn.position.set(42, 0.04, -30); lawn.receiveShadow = true; + this.group.add(lawn); + // pond + const pond = new THREE.Mesh(new THREE.CircleGeometry(6.4, 30), mat(0x4fa8cc, .25, .05)); + pond.rotation.x = -Math.PI / 2; pond.scale.x = 1.25; pond.position.set(46, 0.06, -40); + this.group.add(pond); + const rim = new THREE.Mesh(new THREE.TorusGeometry(6.6, 0.35, 10, 34), mat(0xcfc0a8, 1)); + rim.rotation.x = -Math.PI / 2; rim.scale.x = 1.25; rim.position.set(46, 0.1, -40); + this.group.add(rim); + this.waterMats.push(pond.material as THREE.MeshStandardMaterial); + // ducks :) + for (const [dx, dz] of [[-2, 1], [1.5, -1]]) { + const duck = new THREE.Group(); + const bd = new THREE.Mesh(new THREE.SphereGeometry(0.18, 8, 6), mat(0xf7d749, .8)); + bd.scale.set(1.3, 1, 1); duck.add(bd); + const hd = new THREE.Mesh(new THREE.SphereGeometry(0.11, 8, 6), mat(0xf7d749, .8)); + hd.position.set(0.2, 0.16, 0); duck.add(hd); + const bk = new THREE.Mesh(new THREE.ConeGeometry(0.045, 0.12, 6), mat(0xe8873c, .8)); + bk.rotation.z = -Math.PI / 2; bk.position.set(0.33, 0.16, 0); duck.add(bk); + duck.position.set(46 + dx, 0.18, -40 + dz); + this.group.add(duck); + } + // trees + for (let i = 0; i < 14; i++) { + const a = (i / 14) * Math.PI * 2; + const tx = 42 + Math.cos(a) * (13 + Math.random() * 6); + const tz = -30 + Math.sin(a) * (13 + Math.random() * 5); + this.tree(tx, tz); + } + // extra town trees along the street + for (const x of [-60, -36, -12, 12, 36, 60]) { this.tree(x, 8.6); this.tree(x + 14, -8.6); } + // flowers + for (let i = 0; i < 30; i++) { + const f = new THREE.Mesh(new THREE.SphereGeometry(0.14, 6, 5), + mat([0xef6f91, 0xf7d749, 0xef8a5f, 0xc77fe0][i % 4], .8)); + f.position.set(42 + (Math.random() - .5) * 36, 0.14, -30 + (Math.random() - .5) * 32); + this.group.add(f); + } + } + + tree(x: number, z: number) { + const g = new THREE.Group(); + const trunkH = 1.4 + Math.random() * 0.8; + const trunk = new THREE.Mesh(new THREE.CylinderGeometry(0.16, 0.22, trunkH, 8), mat(0x7a5236, .95)); + trunk.position.y = trunkH / 2; trunk.castShadow = true; + g.add(trunk); + const greens = [0x5ea75e, 0x6fb96a, 0x4f9850]; + for (let i = 0; i < 3; i++) { + const leaf = new THREE.Mesh(new THREE.SphereGeometry(0.85 - i * 0.18, 10, 8), mat(greens[i % 3], .95)); + leaf.position.set((Math.random() - .5) * 0.5, trunkH + 0.55 + i * 0.5, (Math.random() - .5) * 0.5); + leaf.castShadow = true; + g.add(leaf); + } + g.position.set(x, 0, z); + this.group.add(g); + } + + bench(x: number, z: number, ry = 0) { + const g = new THREE.Group(); + const woodM = mat(0x9a6b43, .9); + const seat = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.1, 0.55), woodM); + seat.position.y = 0.52; seat.castShadow = true; g.add(seat); + const back = new THREE.Mesh(new THREE.BoxGeometry(1.8, 0.5, 0.09), woodM); + back.position.set(0, 0.82, -0.24); back.castShadow = true; g.add(back); + for (const sx of [-0.75, 0.75]) { + const leg = new THREE.Mesh(new THREE.BoxGeometry(0.1, 0.5, 0.5), mat(0x4a4f5a, .7, .3)); + leg.position.set(sx, 0.26, 0); g.add(leg); + } + g.position.set(x, 0, z); g.rotation.y = ry; + this.group.add(g); + } + + private streetFurniture() { + // street lamps + for (let x = -66; x <= 66; x += 22) { + for (const z of [7.6, -7.6]) { + const g = new THREE.Group(); + const pole = new THREE.Mesh(new THREE.CylinderGeometry(0.07, 0.1, 4.4, 8), mat(0x3c4254, .6, .4)); + pole.position.y = 2.2; pole.castShadow = true; g.add(pole); + const arm = new THREE.Mesh(new THREE.BoxGeometry(0.9, 0.08, 0.08), mat(0x3c4254, .6, .4)); + arm.position.set(z > 0 ? -0.4 : 0.4, 4.35, 0); g.add(arm); + const head = new THREE.Mesh(new THREE.SphereGeometry(0.22, 10, 8), this.lampHeadMat); + head.position.set(z > 0 ? -0.8 : 0.8, 4.25, 0); g.add(head); + const glow = new THREE.Sprite(new THREE.SpriteMaterial({ + map: glowTexture(), color: 0xffdf9e, transparent: true, opacity: 0, + blending: THREE.AdditiveBlending, depthWrite: false, + })); + glow.scale.setScalar(3.2); + glow.position.copy(head.position); + g.add(glow); + this.lampGlows.push(glow); + g.position.set(x, 0, z); + this.group.add(g); + } + } + // benches + this.bench(36, -21.6, 0.2); + this.bench(-30, 7.4, Math.PI); + this.bench(6, -33.4, 0.1); + this.bench(-8, -18.6, Math.PI); + // food truck + const truck = new THREE.Group(); + const tb = new THREE.Mesh(new THREE.BoxGeometry(4.6, 2.3, 2), mat(0xf2545b, .8)); + tb.position.y = 1.5; tb.castShadow = true; truck.add(tb); + const tcab = new THREE.Mesh(new THREE.BoxGeometry(1.2, 1.2, 2), mat(0xf7f7f7, .8)); + tcab.position.set(2.6, 0.9, 0); tcab.castShadow = true; truck.add(tcab); + const tw = new THREE.Mesh(new THREE.PlaneGeometry(3, 0.9), new THREE.MeshBasicMaterial({ map: textTexture('TACO COMET', '#ffd98a', '#a03a3a') })); + tw.position.set(0, 2.1, 1.02); truck.add(tw); + for (const wx of [-1.4, 1.6]) for (const wz of [-1.02, 1.02]) { + const wheel = new THREE.Mesh(new THREE.CylinderGeometry(0.38, 0.38, 0.25, 12), mat(0x22252e, .9)); + wheel.rotation.x = Math.PI / 2; wheel.position.set(wx, 0.38, wz); truck.add(wheel); + } + truck.position.set(12, 0, 10); + this.group.add(truck); + this.colliders.push({ x: 12, z: 10, hw: 3.4, hd: 1.6 }); + // bus stop shelter near apartments + const shelter = new THREE.Group(); + const roofS = new THREE.Mesh(new THREE.BoxGeometry(4.4, 0.14, 1.8), mat(0x35c4b5, .8)); + roofS.position.y = 2.6; roofS.castShadow = true; shelter.add(roofS); + for (const sx of [-2, 2]) { + const post = new THREE.Mesh(new THREE.CylinderGeometry(0.06, 0.06, 2.6, 8), mat(0x3c4254, .6, .3)); + post.position.set(sx, 1.3, 0); shelter.add(post); + } + shelter.position.set(-8, 0, 8); + this.group.add(shelter); + } + + private poiMarkers() { + for (const poi of POIS) { + const ring = new THREE.Mesh( + new THREE.RingGeometry(poi.radius * 0.82, poi.radius, 32), + new THREE.MeshBasicMaterial({ color: 0x35c4b5, transparent: true, opacity: 0.35, side: THREE.DoubleSide, depthWrite: false }) + ); + ring.rotation.x = -Math.PI / 2; + ring.position.set(poi.x, 0.05, poi.z); + this.group.add(ring); + this.poiRings.set(poi.id, ring); + } + } + + update(dt: number, minutes: number, _weather: string) { + const t = minutes / 60; + const night = t < 6.2 || t > 19.4 ? 1 : 0; + const glowK = t < 6.2 ? Math.max(0, 1 - (t - 4.6)) : t > 19.4 ? Math.min(1, (t - 19.4) * 1.6) : 0; + this.windowMat.emissiveIntensity = glowK * 1.6; + this.lampHeadMat.emissiveIntensity = glowK * 2.2; + for (const gl of this.lampGlows) (gl.material as THREE.SpriteMaterial).opacity = glowK * 0.75; + this.pulseT += dt; + for (const ring of this.poiRings.values()) { + const m = ring.material as THREE.MeshBasicMaterial; + m.opacity = 0.22 + Math.abs(Math.sin(this.pulseT * 2.4)) * 0.22; + } + for (const w of this.waterMats) { /* gentle shimmer */ } + void night; + } + + disposeAll() { /* static for app lifetime */ } +} + +function glowTexture() { + const c = document.createElement('canvas'); c.width = c.height = 64; + const g = c.getContext('2d')!; + const grad = g.createRadialGradient(32, 32, 2, 32, 32, 30); + grad.addColorStop(0, 'rgba(255,230,170,1)'); + grad.addColorStop(1, 'rgba(255,230,170,0)'); + g.fillStyle = grad; g.fillRect(0, 0, 64, 64); + return new THREE.CanvasTexture(c); +} diff --git a/client/src/gfx/weatherfx.ts b/client/src/gfx/weatherfx.ts new file mode 100644 index 0000000..b9ecba6 --- /dev/null +++ b/client/src/gfx/weatherfx.ts @@ -0,0 +1,89 @@ +// LifeTown Online — rain/storm/fog particle FX + wet-ground puddles. +import * as THREE from 'three'; + +export class WeatherFX { + group = new THREE.Group(); + private rain: THREE.Points | null = null; + private velocities: Float32Array | null = null; + private count = 900; + private wetness = 0; // 0 dry .. 1 soaked + private puddles: THREE.Mesh[] = []; + private lightningT = 0; + + constructor() { + // puddle discs (hidden while dry) — placed around town + const spots: [number, number, number][] = [ + [-8, 3.2, 1], [14, -6, 0.8], [-30, -12, 1.2], [30, 4, 0.9], [-52, -10, 1], + [4, -20, 1.1], [40, -18, 1], [60, -10, 0.8], [-64, -8, 0.9], [22, -24, 0.7], + ]; + for (const [x, z, s] of spots) { + const p = new THREE.Mesh( + new THREE.CircleGeometry(1.6 * s, 16), + new THREE.MeshStandardMaterial({ color: 0x9fc8e8, roughness: 0.05, metalness: 0.35, transparent: true, opacity: 0 }) + ); + p.rotation.x = -Math.PI / 2; + p.position.set(x, 0.045, z); + this.puddles.push(p); + this.group.add(p); + } + this.buildRain(); + } + + private buildRain() { + const geo = new THREE.BufferGeometry(); + const pos = new Float32Array(this.count * 3); + this.velocities = new Float32Array(this.count); + for (let i = 0; i < this.count; i++) { + pos[i * 3] = (Math.random() - .5) * 120; + pos[i * 3 + 1] = Math.random() * 30; + pos[i * 3 + 2] = (Math.random() - .5) * 90; + this.velocities[i] = 22 + Math.random() * 14; + } + geo.setAttribute('position', new THREE.BufferAttribute(pos, 3)); + const material = new THREE.PointsMaterial({ + color: 0xaaccee, size: 0.09, transparent: true, opacity: 0, + depthWrite: false, + }); + this.rain = new THREE.Points(geo, material); + this.rain.visible = false; + this.group.add(this.rain); + } + + update(dt: number, weather: string, camX: number, camZ: number) { + const wantRain = weather === 'rain' ? 0.75 : weather === 'storm' ? 1 : 0; + const mat = this.rain!.material as THREE.PointsMaterial; + if (wantRain > 0) { + this.rain!.visible = true; + mat.opacity += (wantRain * 0.55 - mat.opacity) * Math.min(1, dt * 2); + const posAttr = this.rain!.geometry.getAttribute('position') as THREE.BufferAttribute; + const arr = posAttr.array as Float32Array; + for (let i = 0; i < this.count; i++) { + arr[i * 3 + 1] -= this.velocities![i] * dt; + if (arr[i * 3 + 1] < 0) { + arr[i * 3] = camX + (Math.random() - .5) * 100; + arr[i * 3 + 1] = 26 + Math.random() * 8; + arr[i * 3 + 2] = camZ + (Math.random() - .5) * 80; + } + } + posAttr.needsUpdate = true; + } else if (this.rain!.visible) { + mat.opacity -= dt * 0.4; + if (mat.opacity <= 0) this.rain!.visible = false; + } + + // ground wetness & puddle visibility + this.wetness += ((wantRain > 0 ? 1 : 0) - this.wetness) * Math.min(1, dt * 0.08); + for (const p of this.puddles) { + (p.material as THREE.MeshStandardMaterial).opacity = this.wetness * 0.65; + } + + // lightning + if (weather === 'storm') { + this.lightningT -= dt; + if (this.lightningT <= 0 && Math.random() < 0.02) { + this.lightningT = 0.18; + window.dispatchEvent(new CustomEvent('lt:lightning')); + } + } + } +} diff --git a/client/src/globals.d.ts b/client/src/globals.d.ts new file mode 100644 index 0000000..ab4df7c --- /dev/null +++ b/client/src/globals.d.ts @@ -0,0 +1,2 @@ +// Ambient declarations for JS modules shared with the Node server. +declare module '*.mjs'; diff --git a/client/src/main.ts b/client/src/main.ts new file mode 100644 index 0000000..d6890b1 --- /dev/null +++ b/client/src/main.ts @@ -0,0 +1,666 @@ +// LifeTown Online — main orchestrator: screens, game loop, interactions. +import './style.css'; +import * as THREE from 'three'; +import { Engine } from './gfx/engine'; +import { World } from './world'; +import { Controller } from './controller'; +import { WeatherFX } from './gfx/weatherfx'; +import { S, bus, Appearance } from './state'; +import { net } from './net'; +import { audio } from './audio'; +import { NeedsSys } from './systems'; +import { initHud, showPrompt, hidePrompt } from './ui/hud'; +import { initChat } from './ui/chat'; +import { Creator } from './ui/creator'; +import { initPhone, togglePhone, refreshMapIfOpen } from './ui/phone'; +import { BuildUI, openHouseMarket } from './ui/buildui'; +import { openSocialWheel, closeWheel, runWorkOverlay, sleepSequence, fishingGame, cookingGame } from './ui/dialogs'; +import { openDialog, toast, $, el } from './ui/common'; +import { POIS, JOBS, HOUSES, CATALOG_BY_ID, DOORS, VENUES } from '../../shared/data.mjs'; +import { categoryActions } from './gfx/furniture'; + +// ---------------- singletons ---------------- +const canvas = document.getElementById('gl') as HTMLCanvasElement; +const engine = new Engine(canvas); +const world = new World(engine); +let controller: Controller; +let weatherfx = new WeatherFX(); +engine.fxGroup.add(weatherfx.group); +let buildui: BuildUI; +let booted = false; + +// ---------------- helpers ---------------- +function defaultAppearance(): Appearance { + return { + bodyType: 'average', height: 0.97, skin: '#eab98f', faceShape: 'round', + hair: 'short', hairColor: '#4a2f1d', eyes: 'round', eyeColor: '#3a5a40', + brows: 'natural', nose: 'button', mouth: 'smile', + outfit: { top: 'tee', bottom: 'jeans', dress: null, shoes: 'sneakers', accessory: 'none', topColor: '#4f8fd9', bottomColor: '#33415c' }, + personality: ['Friendly'], voice: 'warm', age: 25, + }; +} + +function myAppearance(): any { + const a = S.profile?.appearance; + return a && Object.keys(a).length ? { ...defaultAppearance(), ...a, ...(a.outfit ? {} : { outfit: defaultAppearance().outfit }) } : defaultAppearance(); +} + +function flatAppearance(app: any) { + // flatten outfit for Avatar builder + return { + ...app, + topColor: app.topColor ?? app.outfit?.topColor, + bottomColor: app.bottomColor ?? app.outfit?.bottomColor, + shoes: app.shoes ?? app.outfit?.shoes, + }; +} + +// ---------------- boot / screens ---------------- +const savedToken = localStorage.getItem('lt.token'); +const savedName = localStorage.getItem('lt.name'); +if (savedName) { + ($('#inp-name') as HTMLInputElement).value = savedName; + ($('#title-hint') as HTMLElement).textContent = `Welcome back, ${savedName}! Your life continues where you left off.`; +} + +$('#btn-play').addEventListener('click', () => { + const name = ($('#inp-name') as HTMLInputElement).value.trim(); + if (name.length < 2) return void (($('#title-hint') as HTMLElement).textContent = 'Pick a name with at least 2 letters.'); + S.name = name; + $('#loading').classList.remove('hidden'); + $('#screen-title').classList.add('hidden'); + net.connect({ name, token: savedToken || undefined }); +}); + +$('#btn-newchar').addEventListener('click', () => { + $('#screen-title').classList.add('hidden'); + $('#screen-creator').classList.remove('hidden'); + new Creator((res) => { + $('#screen-creator').classList.add('hidden'); + $('#loading').classList.remove('hidden'); + localStorage.removeItem('lt.token'); // new identity + S.name = res.name; + net.connect({ name: res.name, appearance: res.appearance }); + }); +}); + +bus.on('srv:error', (m: any) => { + $('#loading').classList.add('hidden'); + if (!S.connected) { + $('#screen-title').classList.remove('hidden'); + ($('#title-hint') as HTMLElement).textContent = m.message || 'Could not join.'; + } else { + toast('Hmm…', m.message || 'That action is not available right now.', '⚠️', 'coral'); + } + audio.err(); +}); +bus.on('net:down', () => { + if (booted) toast('Connection lost', 'Reconnecting to LifeTown…', '📶', 'coral'); +}); + +// ---------------- game start ---------------- +bus.on('game:ready', async () => { + $('#loading').classList.add('hidden'); + if (booted) { + // soft resync after a reconnect + S.zone = 'town'; + await enterZoneVisual('town'); + toast('Back online', 'You are in Maple Court.', '📶'); + return; + } + booted = true; + + // debug/testing handle (harmless in production) + (window as any).__lt = { + S, world, controller, net, + teleport: (x: number, z: number) => { const p = world.me?.avatar.group.position; if (p) p.set(x, 0, z); }, + poiAct, + }; + $('#hud').classList.remove('hidden'); + + initHud(); + initChat(); + initPhone(canWorkHere); + controller = new Controller(engine, null, null, + () => world.me?.avatar.group.position ?? new THREE.Vector3(), + scanInteraction); + buildui = new BuildUI(() => world.interior, engine.camera, canvas); + world.onFocus((pos) => controller.socialFocus(pos)); + + await enterZoneVisual(S.zone === 'town' ? 'town' : S.zone); + world.spawnMe(flatAppearance(myAppearance())); + + setupActionHandlers(); + setupTouch(); + requestAnimationFrame(loop); + + setInterval(() => { + net.send({ t: 'needs_sync', needs: S.needs, moodlets: S.moodlets }); + }, 12000); + setInterval(refreshMapIfOpen, 4000); + + toast(`Welcome to LifeTown, ${S.name}! 🌆`, 'Walk with WASD · tap E to interact · make friends!', '👋', 'gold'); +}); + +async function enterZoneVisual(zoneId: string, homeOpts?: { tier?: string; layout?: any[] }) { + const isHome = zoneId.startsWith('home:'); + const spawn = await world.enterZone( + zoneId, + isHome ? (homeOpts?.tier || S.profile?.houseTier || 'studio') : undefined, + isHome ? (homeOpts?.layout || S.profile?.homeLayout || []) : undefined + ); + controller.town = world.town as any; + controller.setInterior(world.interior); + if (world.me) { + world.me.avatar.group.position.set(spawn.x, 0, spawn.z); + } + updateFurnitureAuras(); + hidePrompt(); + return spawn; +} + +bus.on('srv:zoneOk', async (m: any) => { + S.zone = m.zone; + const visiting = m.zone.startsWith('home:') && m.own === false; + await enterZoneVisual(m.zone, visiting ? { tier: m.tier, layout: m.layout } : undefined); + audio.door(); + if (m.zone === 'town') { + toast('Maple Court', 'You are back on the street.', '🌆'); + } else if (m.zone.startsWith('venue:')) { + const v = VENUES[m.zone]; + if (v) toast(v.name, 'Walk to a glowing spot and press E. The front mat leads outside.', v.name.includes('Café') ? '☕' : v.name.includes('Gym') ? '🏋️' : v.name.includes('Hall') ? '🏛️' : v.name.includes('Mart') ? '🛒' : '🕹️'); + } else if (visiting) { + toast(`Visiting ${m.ownerName || m.zone.slice(5)} 🏡`, 'Mind your manners — their couch, their rules!', '🛋️'); + } else { + toast('Home sweet home 🏠', 'Tap 🔨 Build to decorate. Use furniture by walking up to it.', '🛋️'); + } +}); + +function updateFurnitureAuras() { + const auras: Record = { energyRate: 1, hygieneRate: 1, funRate: 1, comfortRate: 1, cookSpeed: 1, productivity: 1, hungerRate: 1 }; + let comfortBonus = 0, itemCount = 0; + for (const f of world.interior?.furniture || []) { + const def = CATALOG_BY_ID[f.item.itemId]; + if (!def) continue; + itemCount++; + for (const [k, v] of Object.entries(def.fx)) { + if (typeof v === 'number' && k in auras) auras[k] *= v; + } + } + comfortBonus = Math.min(0.5, itemCount * 0.01); + auras.comfortRate *= 1 + comfortBonus; + NeedsSys.auras = auras; +} +world.interiorChangedHook = () => updateFurnitureAuras(); + +// ---------------- action bar ---------------- +function setupActionHandlers() { + document.querySelectorAll('.abtn').forEach(b => b.addEventListener('click', () => { + const act = (b as HTMLElement).dataset.act; + audio.click(); + if (act === 'interact') controller.tryInteract(); + else if (act === 'emote') doDance(); + else if (act === 'phone') togglePhone(); + else if (act === 'map') { togglePhone(true); document.querySelector('.papp-btn[data-app="map"]')?.dispatchEvent(new Event('click')); } + else if (act === 'bag') { ($('#inventory') as HTMLElement).classList.toggle('hidden'); buildui.renderInventory(); } + else if (act === 'home') openHomeMenu(); + else if (act === 'build') toggleBuildMode(); + })); + bus.on('act:dance', () => doDance()); + $('#inv-close')?.addEventListener('click', () => ($('#inventory') as HTMLElement).classList.add('hidden')); + bus.on('build:exit', () => toggleBuildMode(false)); + + // clicking an actor opens the social wheel (short tap only, not camera drags) + canvas.addEventListener('pointerup', ((e: PointerEvent) => { + const downAt = (canvas as any)._pd as number | undefined; + const downX = (canvas as any)._pdx ?? e.clientX; + const moved = Math.abs(e.clientX - downX); + if (downAt && performance.now() - downAt < 240 && moved < 6 && !S.buildMode) { + tryActorPick(e.clientX, e.clientY); + } + (canvas as any)._pd = undefined; + })); + canvas.addEventListener('pointerdown', (e) => { (canvas as any)._pd = performance.now(); (canvas as any)._pdx = e.clientX; }); + + function tryActorPick(cx: number, cy: number) { + if (!world.me) return; + const raycaster = new THREE.Raycaster(); + raycaster.setFromCamera(new THREE.Vector2((cx / window.innerWidth) * 2 - 1, -(cy / window.innerHeight) * 2 + 1), engine.camera); + const groups = [...world.actors.values()].filter(a => a.id !== 'me').map(a => a.avatar.group); + const hits = raycaster.intersectObjects(groups, true); + if (!hits.length) return; + let obj: THREE.Object3D | null = hits[0].object; + while (obj && ![...world.actors.values()].some(a => a.avatar.group === obj)) obj = obj.parent; + const actor = [...world.actors.values()].find(a => a.avatar.group === obj); + if (!actor) return; + if (world.me!.avatar.group.position.distanceTo(actor.avatar.group.position) > 5.5) + return toast('Too far away', `Walk closer to ${actor.name} to chat.`, '🚶', 'coral'); + openSocialWheel({ type: actor.isNpc ? 'npc' : 'player', id: actor.id, name: actor.name }); + } + + function doDance() { + net.send({ t: 'emote', id: 'dance' }); + world.emote('me', 'dance'); + NeedsSys.apply({ fun: +10 }); + } + + function openHomeMenu() { + openDialog('🏠 Home & Travel', (body) => { + const mk = (label: string, desc: string, cb: () => void, primary = false) => { + const b = el('button', primary ? 'btn btn-primary' : 'btn btn-ghost', `${label}`); + b.style.cssText = 'width:100%;margin-bottom:8px'; + b.addEventListener('click', () => { document.getElementById('dialog')!.classList.add('hidden'); cb(); }); + body.appendChild(b); + body.appendChild(el('div', 'dim', desc)).style.marginBottom = '10px'; + }; + mk('🌆 Go to Maple Court (town)', 'Public streets, shops, café, gym, park & pond.', () => net.send({ t: 'zone', id: 'town' }), true); + mk('🏠 Teleport to your home', 'Decorate, sleep, cook, shower.', () => net.send({ t: 'zone', id: `home:${S.name.toLowerCase()}` })); + mk('🏡 Property market', 'Upgrade your home as you level up.', () => openHouseMarket()); + mk('📨 Invite a friend over', 'They must be online.', () => { + const input = el('input') as HTMLInputElement; + input.placeholder = 'Friend name…'; + const btn = el('button', 'btn btn-accent', 'Invite'); + btn.style.marginTop = '6px'; + btn.addEventListener('click', () => net.send({ t: 'invite', to: input.value.trim() })); + const wrap = el('div'); wrap.appendChild(input); wrap.appendChild(btn); + body.appendChild(wrap); + }); + }); + } +} + +function toggleBuildMode(force?: boolean) { + const want = force ?? !S.buildMode; + const ownHome = S.zone === `home:${S.name.toLowerCase()}`; + if (want && S.zone === 'town') return toast('Go home first', 'Build mode works inside your home.', '🏠', 'coral'); + if (want && !ownHome) return toast('Not your place', 'You can only redecorate your own home.', '🚫', 'coral'); + S.buildMode = want; + $('#buildbar').classList.toggle('hidden', !want); + (document.querySelector('[data-act="build"]') as HTMLElement)?.classList.toggle('active', want); + world.interior?.setBuildMode(want); + controller.mode = want ? 'build' : 'live'; + if (want) { + buildui.setTool('place'); + toast('Build Mode 🔨', 'Buy furniture in the Shop, place it, rotate ⟳, sell 🗑.', '🔨'); + } else { + buildui.clearGhost(); + } + audio.click(); +} + +// ---------------- touch controls ---------------- +function setupTouch() { + if (!('ontouchstart' in window)) return; + $('#touch-ui').classList.remove('hidden'); + const base = $('#joy-base'), knob = $('#joy-knob'); + let jid: number | null = null; + const setKnob = (dx: number, dy: number) => { + knob.style.transform = `translate(calc(-50% + ${dx}px), calc(-50% + ${dy}px))`; + }; + base.addEventListener('pointerdown', (e) => { jid = e.pointerId; base.setPointerCapture(jid); move(e); }); + base.addEventListener('pointermove', (e) => { if (e.pointerId === jid) move(e); }); + const end = (e: PointerEvent) => { + if (e.pointerId !== jid) return; + jid = null; controller.input.x = 0; controller.input.z = 0; setKnob(0, 0); + }; + base.addEventListener('pointerup', end); base.addEventListener('pointercancel', end); + function move(e: PointerEvent) { + const r = base.getBoundingClientRect(); + let dx = e.clientX - (r.left + r.width / 2); + let dy = e.clientY - (r.top + r.height / 2); + const max = r.width / 2; + const len = Math.hypot(dx, dy); + if (len > max) { dx = dx / len * max; dy = dy / len * max; } + setKnob(dx, dy); + controller.input.x = dx / max; + controller.input.z = dy / max; + controller.input.run = len / max > 0.92; + } + ($('#tb-interact') as HTMLElement).addEventListener('click', () => controller.tryInteract()); + ($('#tb-jump-run') as HTMLElement).addEventListener('click', () => { + controller.input.run = !controller.input.run; + ($('#tb-jump-run') as HTMLElement).style.background = controller.input.run ? 'linear-gradient(135deg,#ff7e5f,#ffcf6b)' : ''; + }); +} + +// ---------------- interaction scanning ---------------- +let currentPrompt: (() => void) | null = null; + +function scanInteraction(): { label: string; cb: () => void } | null { + // always rescan right before acting so the action matches current position + updatePromptScan(); + if (!currentPrompt) { + // fall back to social target + const near = world.nearestActor(true, 4.2); + if (near) return { label: `Talk to ${near.name}`, cb: () => openSocialWheel({ type: near.isNpc ? 'npc' : 'player', id: near.id, name: near.name }) }; + return null; + } + return { label: '', cb: currentPrompt }; +} + +function updatePromptScan() { + currentPrompt = null; + const mePos = world.me?.avatar.group.position; + if (!mePos) return; + + // ---- interiors (home or venue): exit mat > venue spots > furniture + if (S.zone !== 'town' && world.interior) { + // exit mat sits at the front of every interior room + const exitZ = world.interior.size[1] / 2 - 0.7; + if (Math.hypot(mePos.x - 0, mePos.z - exitZ) < 1.6) { + const isVenue = S.zone.startsWith('venue:'); + showPrompt(isVenue ? 'Step outside' : 'Leave home'); + currentPrompt = () => { net.send({ t: 'zone', id: 'town' }); }; + return; + } + // venue activity spots + if (S.zone.startsWith('venue:')) { + const v = VENUES[S.zone.slice(6)]; + let bestSpot: { label: string; act: string; d: number } | null = null; + for (const sp of v?.spots || []) { + const d = Math.hypot(mePos.x - sp.x, mePos.z - sp.z); + if (d < 1.8 && (!bestSpot || d < bestSpot.d)) bestSpot = { label: sp.label, act: sp.act, d }; + } + if (bestSpot) { + showPrompt(bestSpot.label); + currentPrompt = () => poiAct(bestSpot!.act); + return; + } + } + let best: { label: string; cb: () => void } | null = null; + let bd = 2.0; + for (const f of world.interior.furniture) { + const d = mePos.distanceTo(f.group.position); + const acts = categoryActions(f.cat); + if (acts.length && d < bd) { + bd = d; + best = { label: acts[0].label, cb: () => furnitureAct(f.item.itemId, f.cat, f.group.position) }; + } + } + if (best) { + currentPrompt = best.cb; + showPrompt(best.label); + return; + } + } else if (S.zone === 'town') { + // building doors first — they take priority over outdoor POI rings + for (const door of DOORS) { + if (Math.hypot(mePos.x - door.x, mePos.z - door.z) < 2.1) { + showPrompt(door.label, door.zone === 'home:self' ? 'E' : 'E'); + currentPrompt = () => { + audio.door(); + net.send({ t: 'zone', id: door.zone === 'home:self' ? `home:${S.name.toLowerCase()}` : door.zone }); + }; + return; + } + } + let best: { poi: typeof POIS[0]; d: number } | null = null; + for (const poi of POIS) { + const d = Math.hypot(mePos.x - poi.x, mePos.z - poi.z); + if (d < poi.radius * 1.35 && (!best || d < best.d)) best = { poi, d }; + } + if (best) { + currentPrompt = () => poiAct(best!.poi.id); + showPrompt(best.poi.name); + return; + } + } + const near = world.nearestActor(true, 4.2); + if (near) { showPrompt(`${near.name} — talk`); currentPrompt = () => openSocialWheel({ type: near.isNpc ? 'npc' : 'player', id: near.id, name: near.name }); return; } + hidePrompt(); +} + +function poiAct(id: string) { + switch (id) { + case 'cafe': + case 'foodtruck': { + if (S.money < 12) return toast('Not enough coins', 'A meal costs 12 ◉.', '💸', 'coral'); + S.money -= 12; if (S.profile) S.profile.money = S.money; + net.send({ t: 'move', x: world.me!.avatar.group.position.x, z: world.me!.avatar.group.position.z, ry: 0, anim: 'eat' }); + world.emote('me', 'eat'); + setTimeout(() => { + NeedsSys.apply({ hunger: +45, fun: +6 }); + bus.emit('eco:money'); + toast('Yum! 🍽️', 'Hunger restored.', '🍽️'); + }, 2600); + break; + } + case 'cityhall': openCityHall(); break; + case 'property': (buildui as any).openHouseMarket(); break; + case 'exercise': + case 'gym': { + world.emote('me', 'dance'); + NeedsSys.apply({ energy: -14, fun: +10, happiness: +4 }); + toast('Great workout! 💪', 'Energy used, endorphins gained.', '🏋️'); + break; + } + case 'arcade': { + world.emote('me', 'talk'); + NeedsSys.apply({ fun: +18, energy: -5 }); + S.money = Math.max(0, S.money - 4); if (S.profile) S.profile.money = S.money; + bus.emit('eco:money'); + audio.achievement(); + toast('New high score! 🕹️', 'The Porcupine salutes you.', '🕹️'); + break; + } + case 'store': (buildui as any).openCatalog(); break; + case 'pond': fishingGame((ok, reward) => { + NeedsSys.apply({ fun: ok ? +16 : +4 }); + S.money += reward; if (S.profile) S.profile.money = S.money; + bus.emit('eco:money'); + toast(ok ? `Caught fish! +${reward} ◉ 🐟` : 'The fish won this time…', ok ? 'Sold at the market stall.' : 'Try again!', ok ? '🎣' : '💧'); + }); break; + case 'stage': { + net.send({ t: 'emote', id: 'dance' }); + world.emote('me', 'dance'); + NeedsSys.apply({ fun: +14, social: +6, energy: -6 }); + break; + } + case 'fountain': { + world.emote('me', 'sit'); + NeedsSys.apply({ comfort: +8, fun: +4 }); + toast('Peaceful… ⛲', 'You rest by the Copper Fountain.', '⛲'); + break; + } + default: { + world.emote('me', 'sit'); + NeedsSys.apply({ comfort: +10, energy: +4 }); + toast('Sitting down 🪑', 'Take a breather. Move to stand up.', '🪑'); + } + } +} + +function furnitureAct(itemId: string, cat: string, pos: THREE.Vector3) { + void pos; + switch (cat) { + case 'bed': { + NeedsSys.sleeping = true; + world.emote('me', 'sleep'); + sleepSequence('Good morning! ☀️ You feel refreshed.', 4, () => { + NeedsSys.sleeping = false; + NeedsSys.apply({ energy: +70, hygiene: -18, hunger: -22 }); + world.me?.avatar.setAnim('idle'); + net.send({ t: 'move', x: world.me!.avatar.group.position.x, z: world.me!.avatar.group.position.z, ry: world.me!.avatar.group.rotation.y, anim: 'idle' }); + }); + break; + } + case 'shower': { + world.me?.avatar.setAnim('talk'); + setTimeout(() => NeedsSys.apply({ hygiene: +60, comfort: +10, fun: +4 }), 2200); + toast('Fresh & clean 🚿', '', '🚿'); + break; + } + case 'toilet': NeedsSys.apply({ hygiene: +12 }); toast('', '', '🚽'); break; + case 'sink': NeedsSys.apply({ hygiene: +20 }); break; + case 'fridge': { + NeedsSys.apply({ hunger: +26 }); + world.emote('me', 'eat'); + toast('Snack time 🍎', '+26 hunger', '🍎'); + break; + } + case 'stove': { + if (S.money < 10) return toast('Need ingredients', 'Cooking costs 10 ◉.', '💸', 'coral'); + cookingGame((good) => { + S.money -= 10; bus.emit('eco:money'); + NeedsSys.apply({ + hunger: good ? +58 : +30, + fun: good ? +12 : +3, + happiness: good ? 6 : -2, + }); + world.emote('me', 'eat'); + toast(good ? 'Chef quality! 🍜' : 'Edible, at least…', good ? '+58 hunger' : '+30 hunger', good ? '🍳' : '🥴'); + }); + break; + } + case 'tv': case 'pc': case 'elec': case 'shelf': case 'sofa': { + const gain = cat === 'sofa' ? 8 : 14; + world.emote('me', cat === 'sofa' ? 'sit' : 'talk'); + NeedsSys.apply({ fun: +gain, comfort: +6, energy: +2 }); + toast('Good times ✨', `+${gain} fun`, '🎉'); + break; + } + default: { + const def = CATALOG_BY_ID[itemId]; + toast(def?.name || 'Furniture', 'Looks lovely in here!', '✨'); + } + } +} + +function canWorkHere(): boolean { + const p = S.profile; + if (!p?.job) return false; + const job = JOBS.find(j => j.id === p.job!.id)!; + const mePos = world.me?.avatar.group.position; + if (!mePos) return false; + const poi = POIS.find(x => x.id === job.poi); + const anchor = poi ?? { x: 0, z: -26 }; + return Math.hypot(mePos.x - anchor.x, mePos.z - anchor.z) < 8; +} + +function openCityHall() { + openDialog('🏛️ LifeTown City Hall', (body, close) => { + const p = S.profile; + body.appendChild(el('div', '', `Career desk
${p?.job ? 'You can work shifts at your workplace (see Phone → Jobs).' : 'Choose your calling below — you can change careers anytime.'}`)).style.marginBottom = '12px'; + for (const j of JOBS.slice(0, 12)) { + const active = p?.job?.id === j.id; + const row = el('div', 'stat-row', `${j.title} · ${j.ranks[0]} + ${j.basePay} ◉${active ? 'Current ✓' : ''}`); + if (!active) (row.querySelector('button') as HTMLElement)?.addEventListener('click', () => { + net.send({ t: 'job_select', jobId: j.id }); + close(); + }); + body.appendChild(row); + } + const marketBtn = el('button', 'btn btn-ghost', '🏡 Browse Property Market'); + marketBtn.style.cssText = 'width:100%;margin-top:12px'; + marketBtn.addEventListener('click', openHouseMarket); + body.appendChild(marketBtn); + }); +} + +// ---------------- server events ---------------- +bus.on('srv:workResult', (m: any) => { + audio.cash(); + toast(`Shift complete! +${m.pay} ◉`, `XP +${m.xp}${m.promoted ? ' · PROMOTED! 📈' : ''}`, m.promoted ? '📈' : '💰', m.promoted ? 'gold' : ''); + if (m.promoted) audio.levelup(); + NeedsSys.apply({ energy: -12, fun: -4, happiness: m.promoted ? 12 : 2 }); + NeedsSys.addMoodlet({ id: 'worked', label: 'Productive', emoji: '💼', happiness: 6, expires: Date.now() + 180000 }); +}); +bus.on('srv:socialFx', () => { + // relationship-driven moodlets +}); +bus.on('rel:update', () => { + const rels = Object.values(S.profile?.relationships || {}); + const partner = rels.some(r => r.partner); + if (partner) NeedsSys.addMoodlet({ id: 'romance', label: 'Romance', emoji: '💞', happiness: 10 }); + else NeedsSys.removeMoodlet('Romance'); +}); +window.addEventListener('lt:lightning', () => { + const f = document.createElement('div'); + f.style.cssText = 'position:absolute;inset:0;background:#eaf2ff;z-index:15;opacity:.85;transition:opacity .3s;pointer-events:none'; + document.getElementById('app')!.appendChild(f); + requestAnimationFrame(() => { f.style.opacity = '0'; setTimeout(() => f.remove(), 350); }); + setTimeout(() => audio.thunder(), 200 + Math.random() * 800); +}); + +// ---------------- main loop ---------------- +const clock = new THREE.Clock(); +let promptScanT = 0; +let miniEmitT = 0; +let lastEventDayMin = -1; + +function loop() { + requestAnimationFrame(loop); + const dt = Math.min(clock.getDelta(), 0.066); + + // local clock interpolation between server beats + S.time.minutes += (dt * S.timeScale) / 60; + if (S.time.minutes >= 1440) { S.time.minutes -= 1440; S.time.day++; } + + const minutes = S.time.minutes; + engine.setTimeOfDay(minutes, S.weather); + world.town?.update(dt, minutes, S.weather); + + // actors + camera + const me = world.me; + if (me) { + // anim override expiry back to idle for local player + controller.update(dt, me.avatar); + } + world.update(dt); + + // fx + const cam = engine.camera.position; + weatherfx.update(dt, S.weather, cam.x, cam.z); + audio.setRain(S.weather === 'storm' ? 1 : S.weather === 'rain' ? .6 : 0); + audio.tick(); + + // music by context + const hour = minutes / 60; + if (S.zone === 'town') audio.setTrack(hour >= 21 || hour < 6 ? 'night' : 'town'); + else audio.setTrack('home'); + + // prompts & minimap throttles + promptScanT -= dt; + if (promptScanT <= 0 && !S.buildMode) { promptScanT = 0.18; updatePromptScan(); } + miniEmitT -= dt; + if (miniEmitT <= 0 && world.me) { + miniEmitT = 0.3; + const ps: any[] = []; + for (const a of world.actors.values()) { + if (a.isNpc && S.zone !== 'town') continue; + ps.push({ x: a.avatar.group.position.x, z: a.avatar.group.position.z, me: a.id === 'me' }); + } + bus.emit('mini:positions', ps); + (window as any).__myPosForMap = { x: world.me!.avatar.group.position.x, z: world.me!.avatar.group.position.z }; + } + + // recurring event banner + const dayMin = S.time.day * 1440 + Math.floor(S.time.minutes); + if (lastEventDayMin !== dayMin) { + lastEventDayMin = dayMin; + const hm = Math.floor(S.time.minutes); + if (hm === 1200) showEventBanner('🎶 Plaza Dance Night — meet at the stage!'); + else if (hm === 540) showEventBanner('🍎 Morning Market opened at MapleMart!'); + } + + engine.render(); +} + +function showEventBanner(text: string) { + if (document.getElementById('event-banner')) return; + const b = document.createElement('div'); + b.id = 'event-banner'; + b.textContent = text; + document.getElementById('hud')!.appendChild(b); + audio.levelup(); + setTimeout(() => b.remove(), 30000); +} + +// keep chat Enter focus behaviour +window.addEventListener('keydown', (e) => { + if (e.code === 'Enter' && !S.chatOpen && booted && !(e.target instanceof HTMLInputElement)) { + ($('#chat-input') as HTMLElement).focus(); + } + if (e.code === 'Escape') { closeWheel(); togglePhone(false); } +}); diff --git a/client/src/net.ts b/client/src/net.ts new file mode 100644 index 0000000..ab22f43 --- /dev/null +++ b/client/src/net.ts @@ -0,0 +1,88 @@ +// LifeTown Online — WebSocket client with reconnect + message dispatch to bus. +import { S, bus } from './state'; + +export class Net { + ws: WebSocket | null = null; + private queue: any[] = []; + private retry = 0; + private intentionalClose = false; + url: string; + + constructor() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws'; + this.url = `${proto}://${location.host}/ws`; + } + + connect(opts: { name: string; token?: string; appearance?: any }) { + this.intentionalClose = false; + bus.emit('net:connecting'); + try { this.ws = new WebSocket(this.url); } catch { return this.scheduleRetry(); } + const ws = this.ws!; + ws.onopen = () => { + this.retry = 0; + this.raw({ t: 'hello', name: opts.name, token: opts.token ?? undefined, appearance: opts.appearance ?? undefined }); + // flush queue + for (const m of this.queue) this.raw(m); + this.queue.length = 0; + }; + ws.onmessage = (e) => { + let msg; try { msg = JSON.parse(e.data); } catch { return; } + if (msg.t === 'error') { bus.emit('srv:error', msg); return; } + bus.emit('srv:' + msg.t, msg); + }; + ws.onclose = () => { + S.connected = false; + bus.emit('net:down'); + if (!this.intentionalClose) this.scheduleRetry(); + }; + ws.onerror = () => {}; + } + + private scheduleRetry() { + this.retry++; + const delay = Math.min(1000 * this.retry, 6000); + setTimeout(() => { + const name = S.name || localStorage.getItem('lt.name') || ''; + if (!name) return; + this.connect({ name, token: localStorage.getItem('lt.token') || undefined }); + }, delay); + } + + send(msg: any) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) this.raw(msg); + else this.queue.push(msg); + } + private raw(msg: any) { this.ws?.send(JSON.stringify(msg)); } + close() { this.intentionalClose = true; this.ws?.close(); } +} + +export const net = new Net(); + +// ---- server → state wiring ---------------------------------- +bus.on('srv:welcome', (m: any) => { + S.connected = true; + S.yourId = m.yourId; + S.token = m.token; + const profile = m.profile; + S.profile = profile; + S.money = profile.money; + S.zone = m.zone; + S.time = { day: m.time.day, minutes: m.time.minutes }; + S.timeScale = m.time.scale || 60; + S.weather = m.weather; + S.online = (m.online?.length || 0) + 1; + if (profile.needs) S.needs = { ...S.needs, ...profile.needs }; + localStorage.setItem('lt.token', m.token); + localStorage.setItem('lt.name', profile.name); + bus.emit('game:ready', m); +}); +bus.on('srv:time', (m: any) => { S.time.day = m.day; S.time.minutes = m.minutes; S.weather = m.weather; bus.emit('world:time'); }); +bus.on('srv:weather', (m: any) => { S.weather = m.weather; bus.emit('world:weather'); }); +bus.on('srv:money', (m: any) => { S.money = m.amount; if (S.profile) S.profile.money = m.amount; bus.emit('eco:money'); }); +bus.on('srv:profile', (m: any) => { + S.profile = m.profile; S.money = m.profile.money; + bus.emit('profile:update'); +}); +bus.on('srv:inventory', (m: any) => { if (S.profile) S.profile.inventory = m.inventory; bus.emit('inv:update'); }); +bus.on('srv:jobUpdate', (m: any) => { if (S.profile) S.profile.job = m.job; bus.emit('job:update'); }); +bus.on('srv:relUpdate', (m: any) => { if (S.profile) S.profile.relationships = m.relationships; bus.emit('rel:update'); }); diff --git a/client/src/state.ts b/client/src/state.ts new file mode 100644 index 0000000..9bc5d4d --- /dev/null +++ b/client/src/state.ts @@ -0,0 +1,76 @@ +// LifeTown Online — client state store + tiny event bus. +export type Need = 'hunger' | 'energy' | 'hygiene' | 'fun' | 'social' | 'comfort'; + +export interface Moodlet { id: string; label: string; emoji: string; happiness: number; expires?: number } + +export interface RelEntry { f: number; r: number; partner?: boolean } + +export interface JobState { id: string; rank: number; xp: number } + +export interface Profile { + name: string; money: number; level: number; xp: number; + job: JobState | null; + houseTier: string; housesOwned: string[]; + homeLayout: { itemId: string; x: number; z: number; rot: number }[]; + inventory: { itemId: string }[]; + relationships: Record; + achievements: string[]; + stats: { shifts?: number; dances?: number; giftsSent?: number; partners?: number }; + appearance: any; needs: Record | null; moodlets: Moodlet[]; +} + +export interface Appearance { + bodyType: string; height: number; skin: string; faceShape: string; + hair: string; hairColor: string; eyes: string; eyeColor: string; + brows: string; nose: string; mouth: string; + outfit: { top: string; bottom: string; dress: string | null; shoes: string; accessory: string; topColor: string; bottomColor: string }; + personality: string[]; voice: string; age: number; +} + +type Handler = (data: any) => void; + +class Bus { + private map = new Map>(); + on(ev: string, fn: Handler) { + if (!this.map.has(ev)) this.map.set(ev, new Set()); + this.map.get(ev)!.add(fn); + return () => this.off(ev, fn); + } + once(ev: string, fn: Handler) { + const wrap: Handler = (d) => { this.off(ev, wrap); fn(d); }; + this.on(ev, wrap); + } + off(ev: string, fn: Handler) { this.map.get(ev)?.delete(fn); } + emit(ev: string, data?: any) { this.map.get(ev)?.forEach(fn => { try { fn(data); } catch (e) { console.error(e); } }); } +} +export const bus = new Bus(); + +export const S = { + connected: false, + yourId: '', + token: '' as string, + name: '', + zone: 'town' as string, // 'town' | 'home:' + profile: null as Profile | null, + time: { day: 1, minutes: 450 }, // interpolated locally + timeScale: 60, + weather: 'sunny', + online: 1, + // local simulation + needs: { hunger: 82, energy: 88, hygiene: 90, fun: 70, social: 65, comfort: 75 } as Record, + moodlets: [] as Moodlet[], + money: 0, + chatOpen: false, + buildMode: false, + audioOn: true, +}; + +export function relLabel(f: number): string { + if (f >= 75) return 'Best Friend'; + if (f >= 50) return 'Close Friend'; + if (f >= 25) return 'Friend'; + if (f >= 10) return 'Acquaintance'; + return 'Stranger'; +} + +export function relKey(type: 'npc' | 'p', id: string) { return `${type}:${id.toLowerCase()}`; } diff --git a/client/src/style.css b/client/src/style.css new file mode 100644 index 0000000..3c8c0ab --- /dev/null +++ b/client/src/style.css @@ -0,0 +1,376 @@ +/* ============================================================ + LifeTown Online — original UI identity + Rounded panels, soft shadows, coral/teal brand, friendly type. + ============================================================ */ +:root { + --coral: #ff7e5f; + --coral-deep: #f0643c; + --teal: #35c4b5; + --teal-deep: #1fa396; + --gold: #ffcf6b; + --ink: #141826; + --ink-2: #1d2338; + --paper: #fff7f0; + --text: #ffffff; + --dim: rgba(255,255,255,.62); + --glass: rgba(18, 22, 38, .82); + --glass-border: rgba(255,255,255,.09); + --radius: 18px; + --shadow: 0 10px 30px rgba(0,0,0,.35); + font-size: 16px; +} +* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } +html, body { height: 100%; overflow: hidden; } +body { + font-family: "Nunito", "Quicksand", ui-rounded, "Segoe UI", system-ui, -apple-system, sans-serif; + background: #0d1020; color: var(--text); + user-select: none; -webkit-user-select: none; + touch-action: none; +} +#app { position: fixed; inset: 0; } +#gl { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } + +.glass { + background: var(--glass); + backdrop-filter: blur(14px); + -webkit-backdrop-filter: blur(14px); + border: 1px solid var(--glass-border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.hidden { display: none !important; } + +/* ---------- buttons ---------- */ +.btn { + border: 0; cursor: pointer; color: #fff; + border-radius: 999px; padding: 12px 22px; + font-weight: 800; font-size: 1rem; letter-spacing: .01em; + transition: transform .12s ease, filter .15s ease, box-shadow .15s ease; +} +.btn:active { transform: scale(.96); } +.btn-primary { background: linear-gradient(135deg, var(--coral), var(--gold)); box-shadow: 0 6px 20px rgba(255,126,95,.45); } +.btn-primary:hover { filter: brightness(1.07); } +.btn-accent { background: linear-gradient(135deg, var(--teal), #56e0cf); box-shadow: 0 6px 20px rgba(53,196,181,.4); } +.btn-ghost { background: transparent; border: 2px solid rgba(255,255,255,.25); color: var(--dim); } +.btn-ghost:hover { color: #fff; border-color: rgba(255,255,255,.5); } +.btn-big { font-size: 1.15rem; padding: 14px 30px; width: 100%; } +.iconbtn { + background: rgba(255,255,255,.08); border: 1px solid var(--glass-border); color: #fff; + width: 38px; height: 38px; border-radius: 12px; cursor: pointer; font-size: 1.05rem; + transition: background .15s ease, transform .12s ease; +} +.iconbtn:hover { background: rgba(255,255,255,.16); } +.iconbtn:active { transform: scale(.92); } + +input, select { + background: rgba(255,255,255,.07); border: 1px solid var(--glass-border); + color: #fff; border-radius: 12px; padding: 11px 14px; font: inherit; outline: none; width: 100%; +} +input:focus, select:focus { border-color: var(--teal); box-shadow: 0 0 0 3px rgba(53,196,181,.25); } +select option { color: #14202b; } +input[type="range"] { padding: 0; height: 26px; accent-color: var(--coral); } + +/* ---------- title screen ---------- */ +.screen { position: absolute; inset: 0; z-index: 40; display: flex; align-items: center; justify-content: center; flex-direction: column; } +.title-sky { + position: absolute; inset: 0; + background: + radial-gradient(1200px 500px at 70% -10%, rgba(255,207,107,.28), transparent 60%), + radial-gradient(900px 480px at 15% 110%, rgba(53,196,181,.30), transparent 60%), + linear-gradient(180deg, #10152b 0%, #1a2140 55%, #242f57 100%); +} +.title-sky::after { + content:""; position:absolute; left:0; right:0; bottom:0; height:34%; + background: + radial-gradient(60px 22px at 12% 88%, rgba(255,255,255,.10), transparent 70%), + radial-gradient(90px 30px at 32% 94%, rgba(255,255,255,.08), transparent 70%), + radial-gradient(70px 26px at 58% 90%, rgba(255,255,255,.09), transparent 70%), + radial-gradient(110px 36px at 82% 96%, rgba(255,255,255,.07), transparent 70%); +} +.title-card { position: relative; width: min(420px, 92vw); padding: 34px 30px 26px; text-align: center; animation: rise .6s cubic-bezier(.2,.9,.3,1.2); } +@keyframes rise { from { opacity: 0; transform: translateY(26px) scale(.97);} to { opacity:1; transform:none; } } +.wordmark { + font-size: 3rem; font-weight: 900; letter-spacing: -.02em; + background: linear-gradient(120deg, #ffd98a, var(--coral) 55%, #ff9a76); + -webkit-background-clip: text; background-clip: text; color: transparent; + filter: drop-shadow(0 4px 14px rgba(255,126,95,.35)); +} +.wordmark span { font-weight: 400; } +.wordmark.small { font-size: 1.5rem; margin-bottom: 4px; } +.tagline { color: var(--dim); margin: 6px 0 22px; font-size: .95rem; letter-spacing: .04em; } +.field-row { margin-bottom: 14px; } +#screen-title .btn { margin-top: 10px; } +.hint { margin-top: 14px; font-size: .85rem; color: var(--dim); min-height: 1.2em; } +.title-foot { position: absolute; bottom: 14px; color: var(--dim); font-size: .8rem; letter-spacing: .06em; } + +/* ---------- creator ---------- */ +#screen-creator { background: linear-gradient(180deg,#131a33,#1c2447); } +.creator-layout { display: flex; gap: 18px; width: min(1060px, 96vw); height: min(700px, 94vh); align-items: stretch; } +#creator-preview { flex: 1.1; border-radius: 24px; overflow: hidden; position: relative; + background: radial-gradient(500px 320px at 50% 68%, rgba(255,178,120,.16), transparent 65%), linear-gradient(180deg,#18203f,#232c54); + border: 1px solid var(--glass-border); box-shadow: var(--shadow); } +.creator-panel { flex: 1; display: flex; flex-direction: column; padding: 18px; gap: 10px; } +.creator-scroll { flex: 1; overflow-y: auto; padding-right: 6px; display: flex; flex-direction: column; gap: 14px; scrollbar-width: thin; } +.creator-scroll section { display: flex; flex-direction: column; gap: 7px; } +.creator-scroll label { font-size: .82rem; font-weight: 800; color: var(--dim); text-transform: uppercase; letter-spacing: .08em; margin-top: 4px; } +.chips { display: flex; flex-wrap: wrap; gap: 7px; } +.chip { + padding: 7px 13px; border-radius: 999px; cursor: pointer; font-size: .86rem; font-weight: 700; + background: rgba(255,255,255,.07); border: 1.5px solid transparent; transition: all .13s ease; +} +.chip:hover { background: rgba(255,255,255,.13); } +.chip.sel { background: linear-gradient(135deg, rgba(255,126,95,.28), rgba(255,207,107,.22)); border-color: var(--coral); color: #fff; } +.swatches { display: flex; flex-wrap: wrap; gap: 7px; } +.swatch { width: 30px; height: 30px; border-radius: 50%; cursor: pointer; border: 2.5px solid transparent; outline: 2px solid rgba(255,255,255,.12); transition: transform .12s ease; } +.swatch:hover { transform: scale(1.12); } +.swatch.sel { border-color: #fff; outline-color: rgba(255,255,255,.4); } +.dim { color: var(--dim); font-weight: 600; text-transform: none; letter-spacing: 0; } + +@media (max-width: 760px) { + .creator-layout { flex-direction: column; height: 96vh; } + #creator-preview { min-height: 200px; flex: .7; } +} + +/* ---------- HUD ---------- */ +#hud { position: absolute; inset: 0; pointer-events: none; z-index: 20; } +#hud > * { pointer-events: auto; } + +#topbar { + position: absolute; top: 12px; left: 50%; transform: translateX(-50%); + display: flex; gap: 8px; align-items: center; pointer-events: auto; flex-wrap: wrap; justify-content: center; + max-width: 98vw; +} +.pill { + background: var(--glass); border: 1px solid var(--glass-border); border-radius: 999px; + padding: 8px 15px; font-weight: 800; font-size: .92rem; display: flex; gap: 7px; align-items: center; + backdrop-filter: blur(12px); box-shadow: 0 4px 14px rgba(0,0,0,.3); +} +.coin { color: var(--gold); } +.money-pill #tb-money { color: var(--gold); } +.top-actions { display: flex; gap: 6px; } + +/* toasts */ +#toasts { position: absolute; top: 64px; right: 12px; display: flex; flex-direction: column; gap: 8px; width: min(320px, 84vw); z-index: 60; } +.toast { + background: var(--glass); border: 1px solid var(--glass-border); border-left: 4px solid var(--teal); + border-radius: 14px; padding: 11px 14px; box-shadow: var(--shadow); + animation: slideIn .35s cubic-bezier(.2,.9,.3,1.15), fadeOut .4s ease 4.6s forwards; + display: flex; gap: 10px; align-items: flex-start; cursor: default; +} +.toast.gold { border-left-color: var(--gold); } +.toast.coral { border-left-color: var(--coral); } +.toast .t-icon { font-size: 1.3rem; } +.toast b { display: block; font-size: .9rem; } +.toast p { font-size: .83rem; color: var(--dim); } +@keyframes slideIn { from { opacity: 0; transform: translateX(30px);} to { opacity:1; transform:none;} } +@keyframes fadeOut { to { opacity: 0; visibility: hidden; } } + +/* context panel (right) */ +#context-panel { position: absolute; right: 12px; top: 190px; width: 230px; padding: 12px 14px; max-height: 34vh; overflow-y: auto; } +.cp-title { font-weight: 900; font-size: .8rem; letter-spacing: .1em; color: var(--gold); margin-bottom: 8px; } +.ctx-item { display: flex; gap: 9px; padding: 6px 0; border-bottom: 1px dashed rgba(255,255,255,.08); font-size: .85rem; } +.ctx-item:last-child { border-bottom: 0; } +.ctx-item .ci-ico { font-size: 1.05rem; } +.ctx-item small { color: var(--dim); display: block; } + +/* need rings + mood + action bar */ +#bottombar { position: absolute; left: 50%; transform: translateX(-50%); bottom: 12px; display: flex; flex-direction: column; align-items: center; gap: 9px; pointer-events: none; } +#bottombar > * { pointer-events: auto; } +#need-rings { display: flex; gap: 9px; background: var(--glass); border-radius: 999px; padding: 9px 16px; border: 1px solid var(--glass-border); backdrop-filter: blur(12px); } +.ring { position: relative; width: 44px; height: 44px; display: grid; place-items: center; } +.ring svg { position: absolute; inset: 0; transform: rotate(-90deg); } +.ring .r-bg { stroke: rgba(255,255,255,.12); } +.ring .r-fg { transition: stroke-dashoffset .4s ease; stroke-linecap: round; } +.ring .r-emoji { font-size: 1.05rem; z-index: 1; filter: drop-shadow(0 1px 2px rgba(0,0,0,.5)); } +#mood-chip { display: flex; align-items: center; gap: 8px; padding: 6px 16px; border-radius: 999px; font-weight: 800; font-size: .92rem; } +#mood-emoji { font-size: 1.2rem; } +#action-bar { display: flex; gap: 9px; } +.abtn { + width: 64px; height: 60px; border-radius: 18px; border: 1px solid var(--glass-border); + background: var(--glass); backdrop-filter: blur(12px); color: #fff; cursor: pointer; + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 2px; + font-size: 1.35rem; box-shadow: 0 6px 16px rgba(0,0,0,.35); transition: transform .12s ease, background .15s ease; +} +.abtn span { font-size: .62rem; font-weight: 800; letter-spacing: .06em; color: var(--dim); } +.abtn:hover { background: rgba(40,48,80,.9); } +.abtn:active { transform: scale(.93); } +.abtn.active { background: linear-gradient(135deg, rgba(255,126,95,.75), rgba(255,207,107,.6)); border-color: var(--coral); } + +/* prompt */ +#prompt { + position: absolute; left: 50%; bottom: 172px; transform: translateX(-50%); + background: var(--glass); border: 1px solid var(--glass-border); border-radius: 999px; + padding: 9px 18px; display: flex; gap: 10px; align-items: center; font-weight: 700; font-size: .95rem; + backdrop-filter: blur(10px); box-shadow: var(--shadow); +} +#prompt kbd { background: #fff; color: #16203a; border-radius: 8px; padding: 3px 9px; font-weight: 900; font-family: inherit; font-size: .85rem; box-shadow: 0 2px 0 #c9cbd4; } + +/* speech bubbles anchored via JS transforms */ +#bubble-layer { position: absolute; inset: 0; pointer-events: none; overflow: hidden; } +.bubble { + position: absolute; transform: translate(-50%, -100%); + background: rgba(255,255,255,.96); color: #1c2333; font-weight: 700; font-size: .86rem; + padding: 8px 13px; border-radius: 14px; max-width: 210px; text-align: center; + box-shadow: 0 6px 18px rgba(0,0,0,.3); line-height: 1.25; +} +.bubble::after { + content:""; position: absolute; left: 50%; bottom: -7px; transform: translateX(-50%); + border: 8px solid transparent; border-top-color: rgba(255,255,255,.96); border-bottom: 0; +} +.bubble.npc::before { content:""; } +.bubble.fade { animation: bubbleIn .25s cubic-bezier(.2,.9,.3,1.2), bubbleOut .5s ease 3.8s forwards; } +@keyframes bubbleIn { from { opacity:0; transform:translate(-50%,-90%) scale(.8);} to {opacity:1; transform:translate(-50%,-100%) scale(1);} } +@keyframes bubbleOut { to { opacity:0; } } +.name-tag { + position: absolute; transform: translate(-50%, -100%); pointer-events: none; + font-size: .74rem; font-weight: 900; color: #fff; text-shadow: 0 2px 4px rgba(0,0,0,.8); + background: rgba(16,20,36,.55); padding: 3px 10px; border-radius: 999px; white-space: nowrap; +} + +/* chat */ +#chat-panel { position: absolute; left: 12px; bottom: 12px; width: min(360px, 46vw); pointer-events: auto; } +#chat-log { + max-height: 148px; overflow-y: auto; display: flex; flex-direction: column; gap: 4px; + padding: 10px 12px; background: rgba(14,18,32,.72); border: 1px solid var(--glass-border); + border-radius: 14px; backdrop-filter: blur(10px); scrollbar-width: thin; + mask-image: linear-gradient(to bottom, transparent, black 12%); + -webkit-mask-image: linear-gradient(to bottom, transparent, black 12%); +} +.chat-line { font-size: .84rem; line-height: 1.35; word-break: break-word; } +.chat-line b { color: var(--gold); } +.chat-line.sys { color: var(--teal); font-style: italic; } +.chat-line.glob b { color: #8fb7ff; } +.chat-input-row { display: flex; gap: 6px; margin-top: 6px; } +.chat-input-row input { flex: 1; } +.chat-input-row select { width: 92px; } + +/* touch controls */ +#touch-ui { position: absolute; inset: 0; pointer-events: none; } +#joy-base { + position: absolute; left: 26px; bottom: 96px; width: 128px; height: 128px; border-radius: 50%; + background: rgba(255,255,255,.07); border: 2px solid rgba(255,255,255,.18); pointer-events: auto; + backdrop-filter: blur(6px); +} +#joy-knob { position: absolute; left: 50%; top: 50%; width: 56px; height: 56px; border-radius: 50%; + background: radial-gradient(circle at 35% 30%, #fff, #cfd6ea); transform: translate(-50%,-50%); box-shadow: 0 4px 12px rgba(0,0,0,.4); } +#touch-buttons { position: absolute; right: 22px; bottom: 110px; display: flex; flex-direction: column; gap: 12px; pointer-events: auto; } +#touch-buttons button { + width: 66px; height: 66px; border-radius: 50%; border: 2px solid rgba(255,255,255,.25); + background: rgba(18,22,38,.75); color: #fff; font-size: 1.5rem; backdrop-filter: blur(8px); +} + +/* interaction wheel */ +#wheel { position: absolute; z-index: 45; padding: 10px; display: flex; flex-direction: column; gap: 4px; min-width: 220px; animation: pop .18s cubic-bezier(.2,.9,.3,1.2); } +@keyframes pop { from { opacity: 0; transform: scale(.92);} to { opacity: 1; transform: scale(1);} } +.wheel-group { font-size: .72rem; font-weight: 900; color: var(--gold); letter-spacing: .1em; margin: 6px 6px 2px; text-transform: uppercase; } +.wheel-item { + display: flex; justify-content: space-between; align-items: center; gap: 14px; + padding: 9px 12px; border-radius: 12px; cursor: pointer; font-weight: 700; font-size: .92rem; +} +.wheel-item:hover { background: rgba(255,255,255,.1); } +.wheel-item.locked { opacity: .42; cursor: not-allowed; } +.wheel-item small { color: var(--dim); font-weight: 700; } + +/* phone */ +#phone { position: absolute; right: 14px; bottom: 90px; z-index: 50; } +.phone-frame { + width: 340px; height: min(600px, 78vh); border-radius: 34px; padding: 14px; + background: linear-gradient(160deg, #232a44, #12162a); border: 1px solid rgba(255,255,255,.14); + box-shadow: 0 24px 60px rgba(0,0,0,.55); display: flex; flex-direction: column; gap: 10px; + animation: pop .2s ease; +} +.phone-top { display: flex; justify-content: space-between; font-size: .78rem; font-weight: 800; color: var(--dim); padding: 0 8px; } +#phone-screen { flex: 1; background: linear-gradient(180deg,#171d36,#10142a); border-radius: 20px; overflow-y: auto; padding: 14px; scrollbar-width: thin; touch-action: pan-y; } +.creator-scroll, .cat-grid, #chat-log, #cp-content, .modal-card { touch-action: pan-y; } +.phone-dock { display: flex; justify-content: space-around; gap: 6px; } +.papp-btn { width: 52px; height: 52px; border-radius: 16px; border: 0; font-size: 1.4rem; cursor: pointer; + background: rgba(255,255,255,.08); transition: transform .12s ease, background .15s; position: relative; } +.papp-btn:hover { background: rgba(255,255,255,.15); } +.papp-btn:active { transform: scale(.9); } +.papp-btn.on { background: linear-gradient(135deg, var(--coral), var(--gold)); } +.papp-title { font-weight: 900; font-size: 1.2rem; margin-bottom: 12px; display:flex; align-items:center; gap:8px;} +.contact-card, .list-card { + display: flex; align-items: center; gap: 12px; padding: 10px 12px; border-radius: 14px; + background: rgba(255,255,255,.05); margin-bottom: 8px; +} +.contact-face { width: 42px; height: 42px; border-radius: 50%; display: grid; place-items: center; font-size: 1.2rem; flex-shrink: 0; } +.rel-bar { height: 6px; border-radius: 99px; background: rgba(255,255,255,.12); overflow: hidden; margin-top: 5px; } +.rel-bar i { display: block; height: 100%; border-radius: 99px; background: linear-gradient(90deg, var(--teal), #7fe3d7); } +.rel-bar i.rom { background: linear-gradient(90deg, #ff7e9d, #ffb3c7); } +.stat-row { display: flex; justify-content: space-between; padding: 10px 4px; border-bottom: 1px dashed rgba(255,255,255,.1); font-size: .92rem; } +.stat-row b { color: var(--gold); } +.tx-in { color: #6fe3b1; font-weight: 800; } .tx-out { color: #ff9d9d; font-weight: 800; } +.post-card { background: rgba(255,255,255,.05); border-radius: 16px; padding: 12px; margin-bottom: 10px; } +.post-head { display:flex; gap:10px; align-items:center; margin-bottom:8px; font-weight:800; font-size:.9rem;} +.post-img { width:100%; border-radius:12px; display:block; } +.post-actions { display:flex; gap:16px; padding-top:8px; color:var(--dim); font-size:.85rem; font-weight:700;} + +/* build bar */ +#buildbar { + position: absolute; top: 64px; left: 50%; transform: translateX(-50%); + display: flex; gap: 10px; align-items: center; padding: 10px 14px; flex-wrap: wrap; justify-content: center; + background: var(--glass); border: 1px solid var(--glass-border); border-radius: 20px; backdrop-filter: blur(14px); + box-shadow: var(--shadow); z-index: 30; max-width: 96vw; +} +.bb-title { font-weight: 900; color: var(--gold); letter-spacing: .06em; font-size: .85rem; } +.bb-tools { display: flex; gap: 6px; flex-wrap: wrap; } +.tool { + padding: 8px 14px; border-radius: 999px; border: 1.5px solid transparent; cursor: pointer; + background: rgba(255,255,255,.07); color: #fff; font-weight: 800; font-size: .85rem; +} +.tool:hover { background: rgba(255,255,255,.13); } +.tool.active { border-color: var(--teal); background: rgba(53,196,181,.2); } +#buildbar .btn { padding: 9px 16px; font-size: .85rem; } + +/* catalog / inventory grids */ +.modal { position: absolute; inset: 0; background: rgba(8,10,20,.55); display: grid; place-items: center; z-index: 70; backdrop-filter: blur(4px); } +.modal-card { width: min(720px, 94vw); max-height: 84vh; display: flex; flex-direction: column; padding: 16px; animation: pop .18s ease; } +.modal-card.small { width: min(520px, 94vw); } +.modal-head { display: flex; justify-content: space-between; align-items: center; font-weight: 900; font-size: 1.1rem; margin-bottom: 12px; } +.cat-tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; } +.cat-tab { padding: 7px 14px; border-radius: 999px; background: rgba(255,255,255,.07); cursor: pointer; font-weight: 800; font-size: .82rem; border: 1.5px solid transparent; } +.cat-tab.on { border-color: var(--coral); background: rgba(255,126,95,.18); } +.cat-grid { overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; padding-bottom: 6px; scrollbar-width: thin; } +.cat-item { + border-radius: 16px; padding: 10px; background: rgba(255,255,255,.05); border: 1.5px solid transparent; + display: flex; flex-direction: column; gap: 6px; cursor: pointer; transition: all .13s ease; position: relative; +} +.cat-item:hover { background: rgba(255,255,255,.1); border-color: rgba(255,255,255,.2); } +.cat-item.locked { opacity: .45; } +.cat-item .ci-name { font-weight: 800; font-size: .86rem; line-height: 1.2; } +.cat-item .ci-meta { font-size: .75rem; color: var(--dim); display: flex; justify-content: space-between; } +.cat-item .ci-price { color: var(--gold); font-weight: 900; } +.cat-item .cat-ico { height: 74px; display: grid; place-items: center; font-size: 2.2rem; border-radius: 12px; background: rgba(255,255,255,.04); } +.cat-item .fx-note { font-size: .68rem; color: var(--teal); font-weight: 700; } + +/* minimap */ +#minimap { + position: absolute; right: 12px; bottom: 90px; border-radius: 18px; border: 1px solid var(--glass-border); + background: rgba(14,18,32,.8); backdrop-filter: blur(8px); box-shadow: var(--shadow); +} + +/* loading */ +#loading { position: absolute; inset: 0; z-index: 80; display: flex; flex-direction: column; gap: 18px; align-items: center; justify-content: center; background: #10152b; } +.loader-ring { width: 54px; height: 54px; border-radius: 50%; border: 5px solid rgba(255,255,255,.12); border-top-color: var(--coral); animation: spin 1s linear infinite; } +@keyframes spin { to { transform: rotate(360deg);} } +#loading-text { color: var(--dim); font-weight: 700; letter-spacing: .04em; } + +/* event banner */ +#event-banner { + position: absolute; top: 108px; left: 50%; transform: translateX(-50%); + background: linear-gradient(135deg, rgba(255,126,95,.92), rgba(255,207,107,.88)); + color: #20141f; font-weight: 900; padding: 10px 26px; border-radius: 999px; letter-spacing: .03em; + box-shadow: 0 8px 26px rgba(255,126,95,.5); z-index: 35; animation: pop .3s ease; pointer-events: none; +} + +/* responsive */ +@media (max-width: 900px) { + #context-panel { display: none; } + #minimap { display: none; } + .abtn { width: 52px; height: 52px; font-size: 1.1rem; border-radius: 15px; } + .ring { width: 36px; height: 36px; } + .pill { padding: 6px 11px; font-size: .8rem; } + #chat-panel { width: min(300px, 60vw); } + #chat-log { max-height: 90px; } + .wordmark { font-size: 2.3rem; } +} diff --git a/client/src/systems.ts b/client/src/systems.ts new file mode 100644 index 0000000..8fe1181 --- /dev/null +++ b/client/src/systems.ts @@ -0,0 +1,100 @@ +// LifeTown Online — needs simulation, moodlets & mood derivation (client-side sim, +// synced to server periodically). +import { S, bus, Moodlet, Need } from './state'; +import { NEED_DECAY_PER_GAME_HOUR, MOOD_NEEDS, MOOD_EMOJI } from '../../shared/data.mjs'; + +const clamp = (v: number, a = 0, b = 100) => Math.max(a, Math.min(b, v)); + +export const NeedsSys = { + // multipliers granted by furniture/environment auras + auras: { energyRate: 1, hygieneRate: 1, funRate: 1, comfortRate: 1, hungerRate: 1, cookSpeed: 1, productivity: 1 } as Record, + sleeping: false, + + decayTick(gameMinutesDelta: number) { + const hours = gameMinutesDelta / 60; + const n = S.needs; + n.hunger = clamp(n.hunger - NEED_DECAY_PER_GAME_HOUR.hunger * hours); + n.energy = clamp(n.energy - NEED_DECAY_PER_GAME_HOUR.energy * hours * (this.sleeping ? -2.2 : 1)); + n.hygiene = clamp(n.hygiene - NEED_DECAY_PER_GAME_HOUR.hygiene * hours); + n.fun = clamp(n.fun - NEED_DECAY_PER_GAME_HOUR.fun * hours * this.auras.funRate); + n.social = clamp(n.social - NEED_DECAY_PER_GAME_HOUR.social * hours); + n.comfort = clamp(n.comfort - NEED_DECAY_PER_GAME_HOUR.comfort * hours * this.auras.comfortRate); + if (this.sleeping) n.energy = clamp(n.energy + 14 * hours * this.auras.energyRate); + bus.emit('needs:update'); + this.updateMood(); + }, + + apply(fx: Partial>) { + for (const [k, v] of Object.entries(fx)) { + if (k === 'happiness') continue; + if (k in S.needs) (S.needs as any)[k] = clamp((S.needs as any)[k] + (v as number)); + } + if (fx.happiness) { + this.addMoodlet({ id: 'ev' + Date.now(), label: 'Event', emoji: fx.happiness > 0 ? '✨' : '💔', happiness: fx.happiness, expires: Date.now() + 120000 }); + } + bus.emit('needs:update'); + this.updateMood(); + }, + + addMoodlet(m: Moodlet) { + S.moodlets = S.moodlets.filter(x => x.id !== m.id && x.label !== m.label); + S.moodlets.push(m); + if (S.moodlets.length > 8) S.moodlets.shift(); + bus.emit('mood:moodlets'); + this.updateMood(); + }, + + removeMoodlet(idOrLabel: string) { + S.moodlets = S.moodlets.filter(x => x.id !== idOrLabel && x.label !== idOrLabel); + bus.emit('mood:moodlets'); + this.updateMood(); + }, + + /** -100..100 wellbeing score */ + score(): number { + let acc = 0, wsum = 0; + for (const [need, w] of MOOD_NEEDS) { + const v = (S.needs as any)[need] ?? 50; + acc += (v - 45) * w; + wsum += w; + } + let base = acc / wsum; + for (const m of S.moodlets) base += m.happiness; + return Math.max(-100, Math.min(100, base)); + }, + + mood(): string { + const sc = this.score(); + const n = S.needs; + if (this.sleeping) return 'Relaxed'; + if (n.energy < 18) return 'Tired'; + if (n.hunger < 15) return 'Stressed'; + if (sc > 55) return S.needs.social > 70 && n.fun > 65 ? 'Excited' : 'Happy'; + if (sc > 30) return S.moodlets.some(m => m.label === 'Romance') ? 'Romantic' : 'Confident'; + if (sc > 10) return 'Relaxed'; + if (sc > -10) return S.needs.social < 25 ? 'Lonely' : 'Bored'; + if (sc > -35) return 'Sad'; + return 'Angry'; + }, + + emojiFor(mood: string) { return (MOOD_EMOJI as any)[mood] || '🙂'; }, + + updateMood() { + const m = this.mood(); + if ((S as any)._lastMood !== m) { + (S as any)._lastMood = m; + bus.emit('mood:update', m); + } + }, +}; + +// ---- periodic drivers --------------------------------------- +let lastGameMin = -1; +bus.on('world:time', () => { + const cur = S.time.day * 1440 + S.time.minutes; + if (lastGameMin < 0) lastGameMin = cur; + let d = cur - lastGameMin; + if (d < 0) d += 1440; + lastGameMin = cur; + if (d > 0 && d < 200) NeedsSys.decayTick(d); +}); diff --git a/client/src/ui/buildui.ts b/client/src/ui/buildui.ts new file mode 100644 index 0000000..65e1aa1 --- /dev/null +++ b/client/src/ui/buildui.ts @@ -0,0 +1,281 @@ +// LifeTown Online — build mode: placement tools, catalogue shopping, inventory. +import * as THREE from 'three'; +import { S, bus } from '../state'; +import { $, el, toast, CAT_EMOJI, fmtMoney, openDialog } from './common'; +import { net } from '../net'; +import { CATALOG, CATALOG_BY_ID, HOUSES } from '../../../shared/data.mjs'; +import { Interior, PlacedItem } from '../gfx/interior'; +import { buildFurniture } from '../gfx/furniture'; +import { audio } from '../audio'; + +type Tool = 'place' | 'move' | 'rotate' | 'duplicate' | 'delete'; + +export class BuildUI { + tool: Tool = 'place'; + placingItemId: string | null = null; + ghost: THREE.Group | null = null; + private raycaster = new THREE.Raycaster(); + private pointer = new THREE.Vector2(); + private floorPlane = new THREE.Plane(new THREE.Vector3(0, 1, 0), 0); + private hit = new THREE.Vector3(); + private selectedEntry: any = null; + private dragging: any = null; + + constructor( + public getInterior: () => Interior | null, + public camera: THREE.PerspectiveCamera, + public canvas: HTMLCanvasElement, + ) { + this.initToolbar(); + this.initCatalogUI(); + this.initInventoryUI(); + this.canvas.addEventListener('pointermove', (e) => { + this.pointer.set((e.clientX / window.innerWidth) * 2 - 1, -(e.clientY / window.innerHeight) * 2 + 1); + this.onPointerMove(e); + }); + this.canvas.addEventListener('pointerdown', (e) => this.onCanvasClick(e)); + } + + // ---------- toolbar ---------- + private initToolbar() { + document.querySelectorAll('#buildbar .tool').forEach(b => + b.addEventListener('click', () => this.setTool((b as HTMLElement).dataset.tool as Tool))); + $('#btn-shop').addEventListener('click', () => this.openCatalog()); + $('#btn-exit-build').addEventListener('click', () => bus.emit('build:exit')); + } + + setTool(t: Tool) { + this.tool = t; + document.querySelectorAll('#buildbar .tool').forEach(b => b.classList.toggle('active', (b as HTMLElement).dataset.tool === t)); + if (t !== 'place') this.clearGhost(); + audio.click(); + } + + startPlacing(itemId: string) { + this.placingItemId = itemId; + this.setTool('place'); + this.makeGhost(itemId); + } + + private makeGhost(itemId: string) { + this.clearGhost(); + const def = CATALOG_BY_ID[itemId]; + if (!def) return; + this.ghost = buildFurniture(itemId); + this.ghost.traverse(o => { const m = o as THREE.Mesh; if (m.material) { m.material = (m.material as any).clone(); if ((m.material as any).emissive !== undefined) (m.material as any).emissive = new THREE.Color(0x35c4b5); (m.material as any).transparent = true; (m.material as any).opacity = 0.65; } }); + this.ghost.visible = false; + this.getInterior()?.group.add(this.ghost); + } + clearGhost() { + if (this.ghost) { this.ghost.parent?.remove(this.ghost); this.ghost = null; } + } + + private onPointerMove(e: PointerEvent) { + void e; + if (!S.buildMode || !this.ghost || !this.placingItemId) return; + const int = this.getInterior(); if (!int) return; + this.raycaster.setFromCamera(this.pointer, this.camera); + if (!this.raycaster.ray.intersectPlane(this.floorPlane, this.hit)) return; + const b = int.bounds(); + const sx = Math.round(THREE.MathUtils.clamp(this.hit.x, -b.hw, b.hw) / int.snap) * int.snap; + const sz = Math.round(THREE.MathUtils.clamp(this.hit.z, -b.hd, b.hd) / int.snap) * int.snap; + this.ghost.position.set(sx, 0, sz); + this.ghost.visible = true; + } + + private onCanvasClick(e: MouseEvent) { + if (!S.buildMode || e.target !== this.canvas) return; + const int = this.getInterior(); if (!int) return; + if (this.tool === 'place' && this.placingItemId && this.ghost && this.ghost.visible) { + // must own a copy in inventory + const invIdx = S.profile?.inventory.findIndex(i => i.itemId === this.placingItemId) ?? -1; + if (invIdx === -1) return toast('Not in inventory', 'Buy it at MapleMart first.', '🛒'); + S.profile!.inventory.splice(invIdx, 1); + bus.emit('inv:update'); + const item: PlacedItem = { + itemId: this.placingItemId, + x: +this.ghost.position.x.toFixed(2), + z: +this.ghost.position.z.toFixed(2), + rot: this.ghost.rotation.y, + }; + int.addFurniture(item); + this.persistLayout(int); + audio.pop(); + toast('Placed!', `${CATALOG_BY_ID[item.itemId].name} placed.`, '🔨'); + return; + } + if (this.tool === 'delete' || this.tool === 'rotate' || this.tool === 'move' || this.tool === 'duplicate') { + // pick nearest furniture to click + this.raycaster.setFromCamera(this.pointer, this.camera); + const hits = this.raycaster.intersectObjects(int.furniture.map(f => f.group), true); + if (!hits.length) return; + let obj: THREE.Object3D | null = hits[0].object; + while (obj && !int.furniture.some(f => f.group === obj)) obj = obj.parent; + const entry = int.furniture.find(f => f.group === obj); + if (!entry) return; + this.selectedEntry = entry; + if (this.tool === 'delete') { + const refund = Math.round((CATALOG_BY_ID[entry.item.itemId]?.price ?? 0) * 0.6); + int.removeEntry(entry); + net.send({ t: 'sell_placed', itemId: entry.item.itemId }); + this.persistLayout(int); + toast('Removed', `${refund} TC refunded by MapleMart`, '🗑'); + audio.pop(); + } else if (this.tool === 'rotate') { + entry.item.rot = (entry.item.rot + Math.PI / 4) % (Math.PI * 2); + entry.group.rotation.y = entry.item.rot; + this.persistLayout(int); + audio.click(); + } else if (this.tool === 'duplicate') { + // duplicating requires owning another copy; charge nothing but check inventory + const invIdx = S.profile?.inventory.findIndex(i => i.itemId === entry.item.itemId) ?? -1; + if (invIdx === -1) return toast('No spare copy', 'Duplicate needs the same item in your inventory.', '⧉'); + S.profile!.inventory.splice(invIdx, 1); + const copy: PlacedItem = { itemId: entry.item.itemId, x: +(entry.item.x + 1).toFixed(2), z: entry.item.z, rot: entry.item.rot }; + int.addFurniture(copy); + this.persistLayout(int); + audio.pop(); + } else if (this.tool === 'move') { + // drag mode: move with pointer until next click + this.dragging = entry; + toast('Moving…', 'Click a spot to drop it.', '✥'); + } + return; + } + if (this.dragging) { + this.dragging.item.x = +this.hit.x.toFixed(2); + this.dragging.item.z = +this.hit.z.toFixed(2); + this.dragging.group.position.x = this.dragging.item.x; + this.dragging.group.position.z = this.dragging.item.z; + this.persistLayout(int); + this.dragging = null; + audio.pop(); + } + } + + persistLayout(int: Interior) { + const layout = int.furniture.map(f => f.item); + if (S.profile) S.profile.homeLayout = layout; + net.send({ t: 'place_home', layout }); + } + + update() { /* ghost follows in pointermove */ } + + // ---------- catalog ---------- + private activeTab = 'all'; + private initCatalogUI() { + $('#catalog-close').addEventListener('click', () => $('#catalog').classList.add('hidden')); + const tabs = $('#cat-tabs'); + const cats: string[] = ['all', ...new Set(CATALOG.map((c: any) => c.cat))]; + for (const c of cats) { + const t = el('div', 'cat-tab' + (c === 'all' ? ' on' : ''), c === 'all' ? 'All' : (c[0].toUpperCase() + c.slice(1))); + t.addEventListener('click', () => { + this.activeTab = c; + tabs.querySelectorAll('.cat-tab').forEach(x => x.classList.toggle('on', x === t)); + this.renderCatalogGrid(); + }); + tabs.appendChild(t); + } + this.renderCatalogGrid(); + bus.on('inv:update', () => this.renderCatalogGrid()); + bus.on('eco:money', () => this.renderCatalogGrid()); + } + + renderCatalogGrid() { + const grid = $('#cat-grid'); + if ($('#catalog').classList.contains('hidden')) return; + grid.innerHTML = ''; + const lvl = S.profile?.level ?? 1; + for (const item of CATALOG) { + if (this.activeTab !== 'all' && item.cat !== this.activeTab) continue; + const ownedCount = S.profile?.inventory.filter(i => i.itemId === item.id).length ?? 0; + const locked = lvl < item.lvl; + const poor = S.money < item.price; + const fxNotes: string[] = []; + for (const [k, v] of Object.entries(item.fx)) { + if (k.endsWith('Rate') || k === 'cookSpeed' || k === 'productivity') fxNotes.push(`${k.replace('Rate', '')} +${Math.round(((v as number) - 1) * 100)}%`); + if (v === true) fxNotes.push(k.replace('Aura', ' aura')); + } + const card = el('div', 'cat-item' + (locked ? ' locked' : ''), ` +
${CAT_EMOJI[item.cat] || '📦'}
+
${item.name}
+
${fxNotes.slice(0, 2).join(' · ')}
+
${item.price.toLocaleString()} ◉Lv ${item.lvl}${ownedCount ? ` · own ×${ownedCount}` : ''}
`); + card.addEventListener('click', () => { + if (locked) return toast('Locked', `Unlocks at level ${item.lvl}.`, '🔒'); + if (poor) return toast('Too pricey', `You need ${fmtMoney(item.price)} TC.`, '💸'); + net.send({ t: 'buy_item', itemId: item.id }); + audio.cash(); + // streamline: in build mode go straight to placing the new piece + if (S.buildMode && S.zone !== 'town') { + $('#catalog').classList.add('hidden'); + this.startPlacing(item.id); + toast('Place it!', 'Click inside your home to drop it (grid-snapped).', '🔨'); + } + }); + grid.appendChild(card); + } + } + + openCatalog() { + $('#catalog').classList.remove('hidden'); + this.renderCatalogGrid(); + audio.click(); + } + + // ---------- inventory ---------- + private initInventoryUI() { + $('#inv-close').addEventListener('click', () => $('#inventory').classList.add('hidden')); + bus.on('inv:update', () => this.renderInventory()); + bus.on('profile:update', () => this.renderInventory()); + } + + renderInventory() { + if ($('#inventory').classList.contains('hidden')) return; + const grid = $('#inv-grid'); + grid.innerHTML = ''; + const inv = S.profile?.inventory || []; + if (!inv.length) grid.appendChild(el('div', 'dim', 'Empty — visit MapleMart in town!')); + for (const slot of inv) { + const item = CATALOG_BY_ID[slot.itemId]; + if (!item) continue; + const card = el('div', 'cat-item', ` +
${CAT_EMOJI[item.cat] || '📦'}
+
${item.name}
+
Sell ${Math.round(item.price * .6)} ◉
`); + const actions = el('div'); actions.style.cssText = 'display:flex;gap:6px'; + if (S.zone !== 'town') { + const placeBtn = el('button', 'btn btn-accent', 'Place'); + placeBtn.style.cssText = 'flex:1;padding:7px;font-size:.78rem'; + placeBtn.addEventListener('click', () => { + if (!S.buildMode) { toast('Build Mode first', 'Tap 🔨 Build, then Place.', '🔨'); return; } + $('#inventory').classList.add('hidden'); + this.startPlacing(slot.itemId); + }); + actions.appendChild(placeBtn); + } + const sellBtn = el('button', 'btn btn-ghost', 'Sell'); + sellBtn.style.cssText = 'padding:7px;font-size:.78rem'; + sellBtn.addEventListener('click', () => { net.send({ t: 'sell_item', itemId: slot.itemId }); audio.cash(); }); + actions.appendChild(sellBtn); + card.appendChild(actions); + grid.appendChild(card); + } + } +} + +// house market helper reused by Home app & city hall dialog +export function openHouseMarket() { + openDialog('🏡 Property Market', (body) => { + for (const h of HOUSES) { + const owned = S.profile?.housesOwned.includes(h.id); + const locked = (S.profile?.level ?? 1) < h.lvl; + const row = el('div', 'stat-row', `${h.name}
${h.size[0]}×${h.size[1]} m · Level ${h.lvl}+
+ ${owned ? 'Owned ✓' : `${h.price.toLocaleString()} ◉`}`); + if (!owned && !locked) { + (row.querySelector('button') as HTMLElement).addEventListener('click', () => net.send({ t: 'buy_house', houseId: h.id })); + } + body.appendChild(row); + } + }); +} diff --git a/client/src/ui/chat.ts b/client/src/ui/chat.ts new file mode 100644 index 0000000..1476844 --- /dev/null +++ b/client/src/ui/chat.ts @@ -0,0 +1,44 @@ +// LifeTown Online — chat panel: say (zone + bubble) & global channels. +import { S, bus } from '../state'; +import { $, el } from './common'; +import { net } from '../net'; +import { audio } from '../audio'; +import { MAX_CHAT_LEN } from '../../../shared/data.mjs'; + +export function initChat() { + const log = $('#chat-log'); + const input = $('#chat-input') as HTMLInputElement; + const channel = $('#chat-channel') as HTMLSelectElement; + + const addLine = (html: string, cls = '') => { + const line = el('div', `chat-line ${cls}`, html); + log.appendChild(line); + while (log.children.length > 60) log.firstChild!.remove(); + log.scrollTop = log.scrollHeight; + }; + (window as any).__chatSys = (msg: string) => addLine(msg, 'sys'); + bus.on('chat:sys', (m: string) => addLine(m, 'sys')); + + bus.on('srv:chat', (m: any) => { + const cls = m.channel === 'global' ? 'glob' : ''; + addLine(`${m.from}${m.channel === 'global' ? ' 🌐' : ''}: ${escapeHtml(m.text)}`, cls); + if (m.from !== S.name) audio.pop(); + }); + + const send = () => { + const text = input.value.trim(); + if (!text) return; + net.send({ t: 'chat', text, channel: channel.value }); + input.value = ''; + }; + input.addEventListener('keydown', (e) => { + e.stopPropagation(); + if (e.key === 'Enter') { send(); input.blur(); } + if (e.key === 'Escape') input.blur(); + }); + channel.addEventListener('change', () => input.focus()); +} + +export function escapeHtml(s: string) { + return s.replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c] as string)); +} diff --git a/client/src/ui/common.ts b/client/src/ui/common.ts new file mode 100644 index 0000000..530e8d1 --- /dev/null +++ b/client/src/ui/common.ts @@ -0,0 +1,61 @@ +// LifeTown Online — UI helpers shared across modules. +import { bus } from '../state'; +import { audio } from '../audio'; + +export function $(sel: string): HTMLElement { return document.querySelector(sel) as HTMLElement; } +export function $$(sel: string): HTMLElement[] { return [...document.querySelectorAll(sel)] as any; } + +export function el(tag: string, cls?: string, html?: string): HTMLElement { + const e = document.createElement(tag); + if (cls) e.className = cls; + if (html != null) e.innerHTML = html; + return e; +} + +let toastN = 0; +export function toast(title: string, text = '', icon = '✨', kind: '' | 'gold' | 'coral' = '', onClick?: () => void) { + const box = $('#toasts'); + const t = el('div', `toast ${kind}`); + t.innerHTML = `${icon}
${title}

${text}

`; + if (onClick) t.addEventListener('click', () => { onClick(); t.remove(); }); + box.appendChild(t); + toastN++; + setTimeout(() => { t.remove(); toastN--; }, 5200); + while (box.children.length > 4) box.firstChild!.remove(); +} +bus.on('srv:notify', (m: any) => { + toast(m.title || '', m.text || '', m.icon || '✨', m.title === 'Promotion!' ? 'gold' : '', + m.action?.type === 'visit' ? () => netVisit(m.action.home) : undefined); +}); +bus.on('srv:achievement', (m: any) => { + audio.achievement(); + toast(`🏆 Achievement — ${m.name}`, m.desc, '🏆', 'gold'); +}); + +import { net } from '../net'; +function netVisit(home: string) { net.send({ t: 'zone', id: home }); } + +export function openDialog(title: string, buildBody: (body: HTMLElement, close: () => void) => void) { + const modal = $('#dialog'); + $('#dialog-title').textContent = title; + const body = $('#dialog-body'); + body.innerHTML = ''; + modal.classList.remove('hidden'); + const close = () => modal.classList.add('hidden'); + ($('#dialog-close') as HTMLElement).onclick = close; + buildBody(body, close); + audio.click(); +} +export function closeDialog() { $('#dialog').classList.add('hidden'); } + +export function fmtMoney(n: number): string { + return n >= 1e6 ? `${(n / 1e6).toFixed(2)}M` : n.toLocaleString('en-US'); +} + +/** emoji icon per furniture category */ +export const CAT_EMOJI: Record = { + bed: '🛏️', sofa: '🛋️', table: '🪵', chair: '🪑', tv: '📺', pc: '🖥️', + fridge: '🧊', stove: '🍳', shower: '🚿', toilet: '🚽', sink: '🚰', + wardrobe: '🗄️', shelf: '📚', plant: '🪴', decor: '🕯️', light: '💡', + rug: '🧶', paint: '🖼️', elec: '🎮', +}; diff --git a/client/src/ui/creator.ts b/client/src/ui/creator.ts new file mode 100644 index 0000000..c69d777 --- /dev/null +++ b/client/src/ui/creator.ts @@ -0,0 +1,218 @@ +// LifeTown Online — character creator with live 3D preview. +import * as THREE from 'three'; +import { Avatar } from '../gfx/avatar'; +import { TRAITS } from '../../../shared/data.mjs'; +import { $, el } from './common'; + +const SKINS = ['#f7d7c4', '#f2c09a', '#eab98f', '#c98a5b', '#9c6644', '#71472c', '#5d3a21']; +const HAIRC = ['#0f0d0b', '#2e1e12', '#4a2f1d', '#6b4226', '#8a5a37', '#b08968', '#d9b08c', '#c7b8ae', '#b53f3f', '#33539e', '#3f7d5a', '#d977c1']; +const EYEC = ['#3a5a40', '#3d5a80', '#6b4226', '#2f2f2f', '#3f7d5a', '#7a3b8f']; +const TOPC = ['#4f8fd9', '#ef6f91', '#67b56a', '#ffcf6b', '#8a63b8', '#f2f4f8', '#33384a', '#ef8a5f', '#54b8d9', '#b85c6e']; + +export interface CreatorResult { name: string; appearance: any } + +export class Creator { + private app: any = { + bodyType: 'average', height: 0.97, skin: SKINS[2], faceShape: 'round', + hair: 'short', hairColor: HAIRC[3], eyes: 'round', eyeColor: EYEC[1], + brows: 'natural', nose: 'button', mouth: 'smile', + outfitTop: 'tee', outfitBottom: 'jeans', shoes: 'sneakers', accessory: 'none', + topColor: TOPC[0], bottomColor: '#33415c', + personality: ['Friendly'], voice: 'warm', age: 25, + }; + private renderer!: THREE.WebGLRenderer; + private scene = new THREE.Scene(); + private camera!: THREE.PerspectiveCamera; + private avatarHolder = new THREE.Group(); + private avatar: Avatar | null = null; + private raf = 0; + private rebuildQueued = false; + + constructor(private onDone: (r: CreatorResult) => void) { + this.buildUI(); + this.buildPreview(); + } + + // ---------------- preview ---------------- + private buildPreview() { + const host = $('#creator-preview'); + const canvas = document.createElement('canvas'); + canvas.style.cssText = 'position:absolute;inset:0;width:100%;height:100%;'; + host.appendChild(canvas); + this.renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); + this.renderer.setPixelRatio(Math.min(devicePixelRatio, 2)); + this.renderer.outputColorSpace = THREE.SRGBColorSpace; + this.camera = new THREE.PerspectiveCamera(40, 1, 0.1, 50); + + this.scene.add(new THREE.HemisphereLight(0xfff4e0, 0x8898c8, 1.15)); + const key = new THREE.DirectionalLight(0xffffff, 1.6); key.position.set(2.5, 4, 3); + this.scene.add(key); + const rim = new THREE.DirectionalLight(0xffd9a8, 0.8); rim.position.set(-3, 2.5, -2.5); + this.scene.add(rim); + // pedestal + const disc = new THREE.Mesh(new THREE.CylinderGeometry(1.05, 1.15, 0.16, 36), + new THREE.MeshStandardMaterial({ color: 0x35c4b5, roughness: .6 })); + disc.position.y = -0.08; + this.scene.add(disc); + this.scene.add(this.avatarHolder); + + const resize = () => { + const w = host.clientWidth || 400, h = host.clientHeight || 500; + this.renderer.setSize(w, h, false); + this.camera.aspect = w / h; this.camera.updateProjectionMatrix(); + }; + window.addEventListener('resize', resize); + resize(); + + let dragX = 0; + canvas.addEventListener('pointerdown', e => { dragX = e.clientX; }); + canvas.addEventListener('pointermove', e => { + if (e.buttons === 1) { this.avatarHolder.rotation.y += (e.clientX - dragX) * 0.011; dragX = e.clientX; } + }); + + this.rebuildAvatar(); + const clock = new THREE.Clock(); + const loop = () => { + this.raf = requestAnimationFrame(loop); + if (!this.rebuildQueued && this.avatar) this.avatar.update(clock.getDelta()); + if (!this.rebuildQueued) this.avatarHolder.position.y = 0; + this.renderer.render(this.scene, this.camera); + }; + loop(); + } + + private rebuildAvatar() { + this.rebuildQueued = false; + if (this.avatar) { this.avatarHolder.remove(this.avatar.group); this.avatar.dispose(); } + this.avatar = new Avatar({ ...this.app, height: 1 }, undefined); + // creator uses fixed display scale; height slider shown as body proportion + this.avatar.group.scale.setScalar(0.98 + (this.app.height - 0.97) * 0.25); + this.avatar.setAnim('idle'); + this.avatarHolder.add(this.avatar.group); + } + + private queueRebuild() { + if (this.rebuildQueued) return; + this.rebuildQueued = true; + setTimeout(() => this.rebuildAvatar(), 30); + } + + // ---------------- UI ---------------- + private buildUI() { + const set = (k: string, v: any, rebuild = true) => { + this.app[k] = v; + if (rebuild) this.queueRebuild(); else this.refreshChips(); + }; + + const chipsFor = (containerSel: string, options: [string, string][], key: string, cb?: () => void) => { + const box = document.querySelector(containerSel) as HTMLElement; + box.innerHTML = ''; + for (const [val, label] of options) { + const c = el('div', 'chip', label); + c.dataset.val = val; + c.addEventListener('click', () => { set(key, val); cb?.(); audioClick(); }); + box.appendChild(c); + } + this.chipBoxes.push({ box, key }); + }; + this.chipBoxes = []; + + $('#cr-name').addEventListener('input', () => { this.app.name = ($('#cr-name') as HTMLInputElement).value; }); + $('#cr-age').addEventListener('input', () => { + this.app.age = +($('#cr-age') as HTMLInputElement).value; + $('#cr-age-val').textContent = String(this.app.age); + }); + $('#cr-height').addEventListener('input', () => { + this.app.height = +($('#cr-height') as HTMLInputElement).value / 100; + if (this.avatar) this.avatar.group.scale.setScalar(0.98 + (this.app.height - 0.97) * 0.25); + }); + + chipsFor('[data-cr="bodyType"]', [['slim', 'Slim'], ['average', 'Average'], ['plump', 'Plump']], 'bodyType'); + chipsFor('[data-cr="hair"]', [['short', 'Short'], ['long', 'Long'], ['ponytail', 'Ponytail'], ['curly', 'Curly'], ['bob', 'Bob'], ['spiky', 'Spiky'], ['bald', 'Bald']], 'hair'); + chipsFor('[data-cr="eyes"]', [['round', 'Round'], ['wide', 'Wide'], ['sleepy', 'Sleepy']], 'eyes'); + chipsFor('[data-cr="outfitTop"]', [['tee', 'Tee'], ['shirt', 'Shirt'], ['hoodie', 'Hoodie'], ['jacket', 'Jacket']], 'outfitTop'); + chipsFor('[data-cr="outfitBottom"]', [['jeans', 'Jeans'], ['shorts', 'Shorts'], ['skirt', 'Skirt'], ['trousers', 'Trousers']], 'outfitBottom'); + chipsFor('[data-cr="shoes"]', [['sneakers', 'Sneakers'], ['boots', 'Boots'], ['loafers', 'Loafers']], 'shoes'); + chipsFor('[data-cr="accessory"]', [['none', 'None'], ['glasses', 'Glasses'], ['cap', 'Cap'], ['beanie', 'Beanie']], 'accessory'); + chipsFor('[data-cr="voice"]', [['warm', 'Warm'], ['bright', 'Bright'], ['deep', 'Deep'], ['soft', 'Soft']], 'voice'); + + // personality multi-select (max 3) + const pbox = document.querySelector('[data-cr="personality"]') as HTMLElement; + for (const t of TRAITS) { + const c = el('div', 'chip', t); + c.addEventListener('click', () => { + const has = this.app.personality.includes(t); + if (has) this.app.personality = this.app.personality.filter((x: string) => x !== t); + else if (this.app.personality.length < 3) this.app.personality.push(t); + else return; + [...pbox.children].forEach(ch => (ch as HTMLElement).classList.toggle('sel', this.app.personality.includes(((ch as HTMLElement).dataset.trait)))); + audioClick(); + }); + (c as HTMLElement).dataset.trait = t; + pbox.appendChild(c); + } + + // swatches + const swatchFor = (sel: string, colors: string[], key: string) => { + const box = document.querySelector(sel) as HTMLElement; + for (const col of colors) { + const s = el('div', 'swatch'); + (s as HTMLElement).style.background = col; + s.dataset.val = col; + s.addEventListener('click', () => { set(key, col); audioClick(); }); + box.appendChild(s); + } + this.swatchBoxes.push({ box, key }); + }; + this.swatchBoxes = []; + swatchFor('[data-cr="skin"]', SKINS, 'skin'); + swatchFor('[data-cr="hairColor"]', HAIRC, 'hairColor'); + swatchFor('[data-cr="eyeColor"]', EYEC, 'eyeColor'); + swatchFor('[data-cr="topColor"]', TOPC, 'topColor'); + swatchFor('[data-cr="bottomColor"]', ['#33415c', '#4a4038', '#6b4226', '#22252e', '#7a3b8f', '#3f7d5a', '#b85c6e'], 'bottomColor'); + + // extra face detail chips + chipsFor('[data-cr="brows"]', [['natural', 'Natural'], ['thick', 'Thick'], ['thin', 'Thin']], 'brows'); + chipsFor('[data-cr="nose"]', [['button', 'Button'], ['round', 'Round'], ['small', 'Small']], 'nose'); + chipsFor('[data-cr="mouth"]', [['smile', 'Smile'], ['neutral', 'Neutral'], ['grin', 'Grin']], 'mouth'); + this.refreshChips(); + + $('#btn-startlife').addEventListener('click', () => { + const name = (($('#cr-name') as HTMLInputElement).value || '').trim() || + `Resident${Math.floor(Math.random() * 900 + 100)}`; + cancelAnimationFrame(this.raf); + this.onDone({ + name, + appearance: { + ...this.app, + faceShape: 'round', + outfit: { + top: this.app.outfitTop, bottom: this.app.outfitBottom, dress: null, + shoes: this.app.shoes, accessory: this.app.accessory, + topColor: this.app.topColor, bottomColor: this.app.bottomColor, + }, + }, + }); + }); + } + + private chipBoxes: { box: HTMLElement; key: string }[] = []; + private swatchBoxes: { box: HTMLElement; key: string }[] = []; + + private refreshChips() { + for (const { box, key } of this.chipBoxes) { + for (const ch of [...box.children] as HTMLElement[]) { + ch.classList.toggle('sel', ch.dataset.val === String(this.app[key])); + } + } + for (const { box, key } of this.swatchBoxes) { + for (const ch of [...box.children] as HTMLElement[]) { + ch.classList.toggle('sel', ch.dataset.val === String(this.app[key])); + } + } + } +} + +function audioClick() { + import('../audio').then(({ audio }) => audio.click()); +} diff --git a/client/src/ui/dialogs.ts b/client/src/ui/dialogs.ts new file mode 100644 index 0000000..d320137 --- /dev/null +++ b/client/src/ui/dialogs.ts @@ -0,0 +1,192 @@ +// LifeTown Online — interaction wheel, minigames, work & sleep overlays. +import { S, relLabel } from '../state'; +import { $, el, openDialog, closeDialog, toast } from './common'; +import { net } from '../net'; +import { INTERACTIONS, INTERACTION_BY_ID, JOBS } from '../../../shared/data.mjs'; +import { audio } from '../audio'; + +// ---------------- social wheel ---------------- +export function openSocialWheel(target: { type: 'npc' | 'player'; id: string; name: string }) { + const key = target.type === 'npc' ? `npc:${target.id}` : `p:${target.name.toLowerCase()}`; + const rel = S.profile?.relationships[key] || { f: 0, r: 0 }; + const wheel = $('#wheel'); + wheel.innerHTML = `
${target.name} · ${relLabel(rel.f)}${rel.partner ? ' 💞' : ''}
`; + const groups = ['Friendly', 'Romantic', 'Funny', 'Negative']; + for (const gname of groups) { + const items = INTERACTIONS.filter(i => i.group === gname); + if (!items.length) continue; + wheel.appendChild(el('div', 'wheel-group', gname)); + for (const it of items) { + const locked = rel.f < (it.req.f || 0) || rel.r < (it.req.r || 0); + const row = el('div', 'wheel-item' + (locked ? ' locked' : ''), + `${it.label}${it.cost ? ` ◉${it.cost}` : ''}${locked ? needsLabel(it) : ''}`); + if (!locked) row.addEventListener('click', () => { + wheel.classList.add('hidden'); + net.send({ t: 'social', targetType: target.type, targetId: target.id, action: it.id }); + audio.click(); + }); + wheel.appendChild(row); + } + } + // position near center-bottom of screen + wheel.classList.remove('hidden'); + const wr = wheel.getBoundingClientRect(); + wheel.style.left = Math.max(10, window.innerWidth / 2 - wr.width / 2) + 'px'; + wheel.style.top = Math.max(70, window.innerHeight * 0.3 - wr.height / 2) + 'px'; +} +function needsLabel(it: any) { + const f = it.req.f || 0, r = it.req.r || 0; + return f > 0 ? `${f} friendship` : `${r} romance`; +} +export function closeWheel() { $('#wheel').classList.add('hidden'); } + +// click outside closes +window.addEventListener('pointerdown', (e) => { + const w = $('#wheel'); + if (!w.classList.contains('hidden') && !w.contains(e.target as Node)) closeWheel(); +}); + +// ---------------- work overlay ---------------- +export function runWorkOverlay(onDone: () => void) { + const p = S.profile; + if (!p?.job) return toast('No job', 'Get hired at City Hall first.', '💼'); + const job = JOBS.find(j => j.id === p.job!.id)!; + const ov = el('div'); + ov.style.cssText = 'position:absolute;inset:0;z-index:65;background:rgba(10,12,24,.55);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;backdrop-filter:blur(2px)'; + ov.innerHTML = ` +
+
${jobEmoji(job.id)}
+ Working as ${job.ranks[p.job.rank]}… +
+ +
+
Stay focused!
+
`; + document.body.appendChild(ov); + let t = 0; + const total = 6; + const iv = setInterval(() => { + t += 0.1; + (ov.querySelector('#work-fill') as HTMLElement).style.width = Math.min(100, (t / total) * 100) + '%'; + if (Math.random() < .06) (ov.querySelector('#work-tip') as HTMLElement).textContent = + ['Almost there…', 'Coffee break soon?', 'Nice rhythm!', 'The boss is watching 👀', 'You got this!'][Math.floor(Math.random() * 5)]; + if (t >= total) { clearInterval(iv); ov.remove(); onDone(); } + }, 100); +} +export function jobEmoji(id: string) { + return ({ barista: '☕', chef: '🍳', programmer: '💻', designer: '🎨', doctor: '🩺', teacher: '📚', + police: '👮', engineer: '🔧', musician: '🎸', influencer: '📱', athlete: '🏅', artist: '🖌️' } as any)[id] || '💼'; +} + +// ---------------- sleep ---------------- +export function sleepSequence(wakeText: string, hoursSlept: number, onWake: () => void) { + const ov = el('div'); + ov.style.cssText = 'position:absolute;inset:0;z-index:66;background:#05060d;opacity:0;transition:opacity 1.2s ease;display:flex;align-items:center;justify-content:center;color:#9aa4c8;font-weight:800;font-size:1.1rem'; + ov.textContent = '😴 Sleeping…'; + document.body.appendChild(ov); + requestAnimationFrame(() => ov.style.opacity = '1'); + setTimeout(() => { + ov.innerHTML = `🌅 ${wakeText}`; + ov.style.opacity = '0.92'; + setTimeout(() => { + ov.style.opacity = '0'; + setTimeout(() => ov.remove(), 1100); + onWake(); + }, 1400); + }, 1200 + hoursSlept * 60); + void hoursSlept; +} + +// ---------------- fishing minigame ---------------- +export function fishingGame(onFinish: (success: boolean, reward: number) => void) { + openDialog('🎣 Fishing at Willow Pond', (body, close) => { + body.innerHTML = ` +

Tap STOP when the marker is in the green zone! 3 catches to win big.

+
+
+
+
+ +
Catches: 0 / 3
`; + const bar = body.querySelector('#fish-bar') as HTMLElement; + const zone = body.querySelector('#fish-zone') as HTMLElement; + const mark = body.querySelector('#fish-mark') as HTMLElement; + let pos = 0, dir = 1, speed = 62, score = 0, round = 0, running = true; + const zw = 18 + Math.random() * 8; + let zx = Math.random() * (100 - zw); + zone.style.left = zx + '%'; zone.style.width = zw + '%'; + let last = performance.now(); + const tick = () => { + if (!running) return; + const now = performance.now(); const dt = (now - last) / 1000; last = now; + pos += dir * speed * dt; + if (pos > 100) { pos = 100; dir = -1; } + if (pos < 0) { pos = 0; dir = 1; } + mark.style.left = `calc(${pos}% - 3px)`; + requestAnimationFrame(tick); + }; + tick(); + (body.querySelector('#fish-stop') as HTMLElement).addEventListener('click', () => { + const inZone = pos >= zx && pos <= zx + zw; + round++; + if (inZone) { score++; audio.cash(); } else audio.err(); + speed += 16; zw > 8 && (zx = Math.random() * (100 - zw)); + zone.style.width = Math.max(9, zw - round * 2) + '%'; + (body.querySelector('#fish-score') as HTMLElement).textContent = `Catches: ${score} / 3`; + if (round >= 3) { + running = false; + const success = score >= 2; + const reward = success ? 25 + score * 12 : 5; + setTimeout(() => { close(); onFinish(success, reward); }, 500); + } + }); + }); +} + +// ---------------- cooking minigame ---------------- +export function cookingGame(onDone: (good: boolean) => void) { + const ingredients = ['🍅', '🧄', '🧅', '🫑', '🍄', '🧀']; + const order = [0, 1, 2, 3].map(() => Math.floor(Math.random() * ingredients.length)); + openDialog('🍳 Cooking a Meal', (body, close) => { + body.innerHTML = ` +

Memorise the recipe order, then repeat it!

+
+
+
Watch closely…
`; + const show = body.querySelector('#cook-show') as HTMLElement; + show.textContent = order.map(i => ingredients[i]).join(' '); + setTimeout(() => { show.textContent = '?'.repeat(order.length); (body.querySelector('#cook-msg') as HTMLElement).textContent = 'Now repeat it!'; }, 1800); + const pads = body.querySelector('#cook-pads') as HTMLElement; + const inputSeq: number[] = []; + ingredients.forEach((ing, idx) => { + const b = el('button', 'btn btn-ghost', ing); + b.style.fontSize = '1.6rem'; + b.addEventListener('click', () => { + inputSeq.push(idx); + show.textContent = inputSeq.map(i => ingredients[i]).join(' '); + audio.click(); + if (inputSeq.length === order.length) { + const good = inputSeq.every((v, i) => v === order[i]); + (body.querySelector('#cook-msg') as HTMLElement).textContent = good ? 'Delicious! 👨‍🍳' : 'Burnt… 🥴'; + setTimeout(() => { close(); onDone(good); }, 700); + } + }); + pads.appendChild(b); + }); + }); +} + +// ---------------- confirm ---------------- +export function confirmBox(title: string, text: string, yesLabel: string, onYes: () => void) { + openDialog(title, (body, close) => { + body.appendChild(el('p', '', text)).style.marginBottom = '14px'; + const row = el('div'); row.style.cssText = 'display:flex;gap:8px'; + const y = el('button', 'btn btn-primary', yesLabel); + y.addEventListener('click', () => { close(); onYes(); }); + const n = el('button', 'btn btn-ghost', 'Cancel'); + n.addEventListener('click', close); + row.append(n, y); + body.appendChild(row); + }); +} +void INTERACTION_BY_ID; diff --git a/client/src/ui/hud.ts b/client/src/ui/hud.ts new file mode 100644 index 0000000..50a0524 --- /dev/null +++ b/client/src/ui/hud.ts @@ -0,0 +1,169 @@ +// LifeTown Online — HUD: top bar, need rings, mood, context panel, minimap, prompt. +import { S, bus } from '../state'; +import { $, el, fmtMoney } from './common'; +import { NeedsSys } from '../systems'; +import { BUILDINGS, POIS, WEATHER_EMOJI, VENUES } from '../../../shared/data.mjs'; + +const NEED_META: [string, string, string][] = [ + ['hunger', '🍔', '#ff8a5c'], + ['energy', '⚡', '#ffd166'], + ['hygiene', '🫧', '#6fd3e8'], + ['fun', '🎉', '#ef6f91'], + ['social', '💬', '#7f9df7'], + ['comfort', '🛋️', '#67b56a'], +]; + +export function initHud() { + // ---- need rings ---- + const rings = $('#need-rings'); + const ringEls: Record = {}; + for (const [id, emoji, color] of NEED_META) { + const d = el('div', 'ring'); + d.title = id; + d.innerHTML = ` + + + + + ${emoji}`; + rings.appendChild(d); + ringEls[id] = { fg: d.querySelector('.r-fg')!, val: 100 }; + } + bus.on('needs:update', () => { + for (const [id] of NEED_META) { + const v = (S.needs as any)[id] ?? 50; + const r = ringEls[id]; + if (Math.abs(r.val - v) > 1) { + r.val = v; + const c = 2 * Math.PI * 18; + r.fg.style.strokeDashoffset = String(c * (1 - v / 100)); + r.fg.style.stroke = v < 22 ? '#ff5d5d' : ''; + } + } + }); + + // ---- mood chip ---- + bus.on('mood:update', (m: string) => { + $('#mood-emoji').textContent = NeedsSys.emojiFor(m); + $('#mood-label').textContent = m; + }); + // initial paint + setTimeout(() => bus.emit('mood:update', NeedsSys.mood()), 400); + + // ---- top bar tickers ---- + const paintTop = () => { + $('#tb-money').textContent = fmtMoney(S.money); + const mins = Math.floor(S.time.minutes); + const hh = String(Math.floor(mins / 60)).padStart(2, '0'); + const mm = String(mins % 60).padStart(2, '0'); + $('#tb-clock').textContent = `${hh}:${mm}`; + $('#tb-day').textContent = `Day ${S.time.day}`; + ( $('#tb-weather') as HTMLElement).textContent = (WEATHER_EMOJI as any)[S.weather] || '☀️'; + $('#tb-zone').textContent = zoneLabel(S.zone); + $('#tb-online').textContent = String(S.online); + }; + bus.on('world:time', paintTop); + bus.on('eco:money', () => { paintTop(); }); + bus.on('profile:update', paintTop); + setInterval(paintTop, 2000); + + // ---- context panel ---- + const renderContext = () => { + const box = $('#cp-content'); + box.innerHTML = ''; + const items: [string, string, string][] = []; + const p = S.profile; + if (p?.job) { + items.push(['💼', `Career: ${p.job.id}`, 'Tap Phone → Jobs to check progress']); + } else { + items.push(['💼', 'No job yet', 'Visit City Hall desk in town']); + } + const mins = S.time.minutes; + if (mins >= 1200 && mins < 1320) items.push(['🎶', 'Plaza Dance Night', 'Until 22:00 · at the stage']); + else if (mins >= 540 && mins < 660) items.push(['🍎', 'Morning Market', 'Until 11:00 · MapleMart']); + const lowNeed = NEED_META.find(([id]) => (S.needs as any)[id] < 25); + if (lowNeed) items.push([lowNeed[1], `Low ${lowNeed[0]}!`, 'Take care of yourself']); + const friendsNearby = Object.entries(p?.relationships || {}).filter(([k, v]) => k.startsWith('npc:') && v.f >= 25).length; + items.push(['🌱', `${friendsNearby} townsfolk friendships`, 'Chat daily to grow bonds']); + for (const [ico, t, sub] of items.slice(0, 4)) { + box.appendChild(el('div', 'ctx-item', `${ico}
${t}${sub}
`)); + } + }; + setInterval(renderContext, 5000); + setTimeout(renderContext, 800); + + // ---- minimap ---- + const canvas = $('#minimap') as HTMLCanvasElement; + const g = canvas.getContext('2d')!; + const W2M = 180 / 160; // world -80..80 → px + const drawMini = (players: { x: number; z: number; me?: boolean }[]) => { + if (S.zone !== 'town') { drawHomeMini(g); return; } + g.clearRect(0, 0, 180, 180); + g.fillStyle = '#1b2438'; g.fillRect(0, 0, 180, 180); + const X = (x: number) => 90 + x * W2M; + const Z = (z: number) => 96 + z * W2M; + // road + g.fillStyle = '#39415a'; g.fillRect(0, Z(-4), 180, 8 * W2M); + // plaza + g.fillStyle = '#4a4550'; g.beginPath(); g.arc(X(0), Z(-26), 14 * W2M, 0, 7); g.fill(); + // park + g.fillStyle = '#2e5231'; g.fillRect(X(42 - 22), Z(-30 - 20), 44 * W2M, 40 * W2M); + g.fillStyle = '#3a6ea5'; g.beginPath(); g.arc(X(46), Z(-40), 6 * W2M, 0, 7); g.fill(); + // buildings + g.fillStyle = '#8a7a5f'; + for (const b of BUILDINGS) g.fillRect(X(b.x - b.w / 2), Z(b.z - b.d / 2), b.w * W2M, b.d * W2M); + // POIs + g.fillStyle = '#35c4b5'; + for (const p of POIS) { g.beginPath(); g.arc(X(p.x), Z(p.z), 2.2, 0, 7); g.fill(); } + // players + for (const pl of players) { + g.fillStyle = pl.me ? '#ffd166' : '#ff8a5c'; + g.beginPath(); g.arc(X(pl.x), Z(pl.z), pl.me ? 3.4 : 2.6, 0, 7); g.fill(); + } + }; + let lastPlayers: any[] = []; + bus.on('mini:positions', (ps: any[]) => { lastPlayers = ps; }); + setInterval(() => drawMini(lastPlayers), 450); + + // audio + fullscreen buttons + $('#btn-audio').addEventListener('click', () => { + import('../audio').then(({ audio }) => { + audio.setEnabled(!audio.enabled); + ($('#btn-audio') as HTMLElement).textContent = audio.enabled ? '🔊' : '🔇'; + }); + }); + $('#btn-fullscreen').addEventListener('click', () => { + if (document.fullscreenElement) document.exitFullscreen(); + else document.documentElement.requestFullscreen?.(); + }); +} + +function drawHomeMini(g: CanvasRenderingContext2D) { + g.clearRect(0, 0, 180, 180); + g.fillStyle = '#241f33'; g.fillRect(0, 0, 180, 180); + g.fillStyle = '#4a3f56'; g.fillRect(30, 30, 120, 110); + const label = zoneLabel(S.zone).toUpperCase(); + g.fillStyle = '#35c4b5'; g.font = `700 ${label.length > 12 ? 11 : 13}px Nunito`; g.textAlign = 'center'; + g.fillText(label.slice(0, 22), 90, 92); +} + +/** Friendly name for the current zone id */ +export function zoneLabel(zone: string): string { + if (zone === 'town') return 'Maple Court'; + if (zone.startsWith('venue:')) return VENUES[zone]?.name || 'Venue'; + return zone === `home:${S.name.toLowerCase()}` ? 'Your Home' : "Friend's Home"; +} + +// prompt helpers used by main +let promptHideTimer: any; +export function showPrompt(label: string, key = 'E') { + clearTimeout(promptHideTimer); + $('#prompt').classList.remove('hidden'); + $('#prompt-key').textContent = key; + $('#prompt-text').textContent = label; +} +export function hidePrompt() { + clearTimeout(promptHideTimer); + promptHideTimer = setTimeout(() => $('#prompt').classList.add('hidden'), 60); +} diff --git a/client/src/ui/phone.ts b/client/src/ui/phone.ts new file mode 100644 index 0000000..6bd6ecb --- /dev/null +++ b/client/src/ui/phone.ts @@ -0,0 +1,334 @@ +// LifeTown Online — in-game smartphone: Messages, Contacts, Map, Jobs, Bank, +// Social feed, Calendar, Home. Original app design. +import { S, bus, relLabel } from '../state'; +import { $, el } from './common'; +import { net } from '../net'; +import { NPCS, JOBS, HOUSES, XP_PER_RANK, AREA_POINTS } from '../../../shared/data.mjs'; +import { BUILDINGS, POIS } from '../../../shared/data.mjs'; +import { escapeHtml } from './chat'; + +type Msg = { from: string; text: string; channel: string; ts: number }; +const history: Msg[] = []; +bus.on('srv:chat', (m: any) => { history.push(m as Msg); if (history.length > 120) history.shift(); }); + +interface Tx { label: string; amt: number; ts: number } +const txs: Tx[] = []; +let lastMoney: number | null = null; +setInterval(() => { + if (lastMoney != null && S.profile && S.money !== lastMoney) { + txs.unshift({ label: S.money > lastMoney ? 'Income' : 'Purchase', amt: S.money - lastMoney, ts: Date.now() }); + if (txs.length > 30) txs.pop(); + } + lastMoney = S.money; +}, 900); + +export const phoneState = { open: false, app: 'messages' }; + +export function initPhone(openWorkCheck: () => boolean) { + const dock = $('#phone-dock'); + const apps: [string, string, () => void][] = [ + ['messages', '💬', () => renderMessages()], + ['contacts', '👥', () => renderContacts()], + ['map', '🗺️', () => renderMap()], + ['jobs', '💼', () => renderJobs(openWorkCheck)], + ['bank', '🏦', () => renderBank()], + ['social', '📸', () => renderSocial()], + ['calendar', '📅', () => renderCalendar()], + ['home', '🏠', () => renderHomeApp()], + ]; + for (const [id, icon, open] of apps) { + const b = el('button', 'papp-btn', icon); + b.dataset.app = id; + b.addEventListener('click', () => { setApp(id); open(); audioClick(); }); + dock.appendChild(b); + } + + // top bar clock + setInterval(() => { + const mins = Math.floor(S.time.minutes); + $('#phone-clock').textContent = + `${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')} · D${S.time.day}`; + }, 2000); +} + +export function togglePhone(force?: boolean) { + const p = $('#phone'); + const show = force ?? p.classList.contains('hidden'); + p.classList.toggle('hidden', !show); + phoneState.open = show; + if (show) { + (document.querySelector(`.papp-btn[data-app="${phoneState.app}"]`) as HTMLElement)?.click(); + if (!phoneState.app) renderMessages(); + } +} + +function setApp(id: string) { + phoneState.app = id; + document.querySelectorAll('.papp-btn').forEach(b => b.classList.toggle('on', (b as HTMLElement).dataset.app === id)); +} + +function screen() { return $('#phone-screen'); } + +// ---------------- apps ---------------- +function renderMessages() { + const box = screen(); + box.innerHTML = `
💬 Messages
`; + const list = el('div'); + if (!history.length) list.appendChild(el('div', 'dim', 'No messages yet — say hi in town or on Global!')); + for (const m of [...history].reverse().slice(0, 40)) { + list.appendChild(el('div', 'list-card', + `
${m.from.slice(0, 1).toUpperCase()}
+
${escapeHtml(m.from)} ${m.channel === 'global' ? '🌐' : ''}
${escapeHtml(m.text)}
`)); + } + box.appendChild(list); +} + +function relEntryOf(key: string) { return S.profile?.relationships[key]; } + +function renderContacts() { + const box = screen(); + box.innerHTML = `
👥 Contacts
`; + for (const npc of NPCS) { + const r = relEntryOf(`npc:${npc.id}`) || { f: 0, r: 0 }; + const face = `#${THREEless(npc.color)}`; + box.appendChild(el('div', 'list-card', ` +
${npc.name.slice(0, 1)}
+
+ ${npc.name} · ${npc.role}
+ ${relLabel(r.f)}${r.partner ? ' 💞 Partner' : ''} +
+ ${r.r > 5 ? `
` : ''} +
`)); + } + // online players + const players = Object.entries(S.profile?.relationships || {}).filter(([k]) => k.startsWith('p:')); + if (players.length) { + box.appendChild(el('div', 'papp-title', '🌐 Players met')); + for (const [k, v] of players) { + const nm = k.slice(2); + box.appendChild(el('div', 'list-card', ` +
${nm.slice(0, 1).toUpperCase()}
+
${escapeHtml(nm)}
${relLabel(v.f)}${v.partner ? ' 💞' : ''} +
+ `)); + } + box.querySelectorAll('[data-invite]').forEach(b => b.addEventListener('click', () => { + net.send({ t: 'invite', to: (b as HTMLElement).dataset.invite }); + })); + } +} +// tiny helper to hex a number without importing three +function THREEless(n: number) { return n.toString(16).padStart(6, '0'); } + +let mapCanvasDraw: (() => void) | null = null; +function renderMap() { + const box = screen(); + box.innerHTML = `
🗺️ Town Map
`; + const c = document.createElement('canvas'); + c.width = 300; c.height = 300; c.style.cssText = 'width:100%;border-radius:16px;background:#141c30'; + box.appendChild(c); + const legend = el('div', 'dim', ''); + legend.style.cssText = 'font-size:.78rem;margin-top:8px;line-height:1.6'; + legend.innerHTML = POIS.map(p => `${poiIcon(p.kind)} ${p.name}`).join('
'); + box.appendChild(legend); + mapCanvasDraw = () => drawTownMap(c); + mapCanvasDraw(); +} + +export function refreshMapIfOpen() { mapCanvasDraw?.(); } + +function poiIcon(kind: string) { + return ({ eat: '🍽️', jobs: '🏛️', exercise: '🏋️', shop: '🛒', fish: '🎣', stage: '🎶', sit: '🪑', fountain: '⛲', swim: '🏊' } as any)[kind] || '📍'; +} + +function drawTownMap(c: HTMLCanvasElement) { + const g = c.getContext('2d')!; + g.clearRect(0, 0, 300, 300); + g.fillStyle = '#16203a'; g.fillRect(0, 0, 300, 300); + const X = (x: number) => 150 + x * 1.75; + const Z = (z: number) => 158 + z * 1.75; + g.fillStyle = '#3a4462'; g.fillRect(10, Z(-4), 280, 14); + g.fillStyle = '#454f70'; g.beginPath(); g.arc(X(0), Z(-26), 25, 0, 7); g.fill(); + g.fillStyle = '#2e5231'; g.fillRect(X(20), Z(-50), 44 * 1.75 * 0.9, 40 * 1.75 * 0.85); + g.fillStyle = '#3a6ea5'; g.beginPath(); g.arc(X(46), Z(-40), 11, 0, 7); g.fill(); + for (const b of BUILDINGS) { + g.fillStyle = '#' + b.color.toString(16).padStart(6, '0'); + g.fillRect(X(b.x - b.w / 2), Z(b.z - b.d / 2), b.w * 1.6, b.d * 1.6); + } + g.font = '700 11px Nunito'; g.fillStyle = '#fff'; + for (const p of POIS) { + g.beginPath(); g.fillStyle = '#35c4b5'; g.arc(X(p.x), Z(p.z), 5, 0, 7); g.fill(); + g.fillStyle = '#dbe7ff'; + const short = p.name.split(' ')[0]; + g.fillText(short, X(p.x) - g.measureText(short).width / 2, Z(p.z) - 9); + } + // me + if ((window as any).__myPosForMap) { + const mp = (window as any).__myPosForMap; + g.fillStyle = '#ffd166'; g.beginPath(); g.arc(X(mp.x), Z(mp.z), 7, 0, 7); g.fill(); + g.strokeStyle = '#ffd166aa'; g.lineWidth = 3; g.beginPath(); g.arc(X(mp.x), Z(mp.z), 12, 0, 7); g.stroke(); + } +} + +function renderJobs(canWork: () => boolean) { + const box = screen(); + box.innerHTML = `
💼 Careers
`; + const job = S.profile?.job; + if (job) { + const def = JOBS.find(j => j.id === job.id)!; + const nextXp = XP_PER_RANK[Math.min(job.rank + 1, XP_PER_RANK.length - 1)]; + const prevXp = XP_PER_RANK[job.rank] ?? 0; + const pct = Math.min(100, ((job.xp - prevXp) / Math.max(1, nextXp - prevXp)) * 100); + const card = el('div', 'post-card', ` + ${def.ranks[job.rank]} · ${def.title}
+ Pay ≈ ${Math.round(def.basePay * (1 + job.rank * .55))} TC / shift · Hours ${def.hours[0]}:00–${def.hours[1]}:00 +
+
Next: ${def.ranks[Math.min(job.rank + 1, def.ranks.length - 1)]}
`); + box.appendChild(card); + const workBtn = el('button', canWork() ? 'btn btn-accent' : 'btn btn-ghost', canWork() ? '🔨 Work a shift now' : '🔨 Work (go to your workplace)'); + workBtn.style.cssText = 'width:100%;margin-bottom:14px'; + workBtn.addEventListener('click', async () => { + if (!canWork()) { + const { toast } = await import('./common'); + return toast('Wrong place', `Head to the ${def.title}'s workplace on the map, then work.`, '📍', 'coral'); + } + const { runWorkOverlay } = await import('./dialogs'); + togglePhone(false); + runWorkOverlay(() => net.send({ t: 'job_work' })); + }); + box.appendChild(workBtn); + } else { + box.appendChild(el('div', 'dim', 'You are unemployed. Visit the City Hall desk in Maple Court, or pick any career below:')); + } + for (const j of JOBS) { + const active = job?.id === j.id; + const c = el('div', 'list-card', ` +
${j.title.slice(0, 1)}
+
${j.title}
${j.ranks[0]} → ${j.ranks[j.ranks.length - 1]} · base ${j.basePay} TC
+ ${active ? 'Current' : ``}`); + if (!active) { + (c.querySelector('button') as HTMLElement).addEventListener('click', () => { + net.send({ t: 'job_select', jobId: j.id }); + setTimeout(renderJobs.bind(null, canWork), 400); + }); + } + box.appendChild(c); + } +} + +function renderBank() { + const box = screen(); + box.innerHTML = ` +
🏦 TownBank
+
+
BALANCE
+
${S.money.toLocaleString()} ◉
+
Level ${S.profile?.level ?? 1} resident
+
`; + const list = el('div'); + if (!txs.length) list.appendChild(el('div', 'dim', 'No transactions yet.')); + for (const t of txs.slice(0, 20)) { + list.appendChild(el('div', 'stat-row', + `${t.label}
${new Date(t.ts).toLocaleTimeString()}
+ ${t.amt >= 0 ? '+' : ''}${t.amt} ◉`)); + } + box.appendChild(list); + bus.once('eco:money', () => { if (phoneState.open && phoneState.app === 'bank') renderBank(); }); +} + +function renderSocial() { + const box = screen(); + box.innerHTML = `
📸 LifeFeed
`; + const composer = el('div', 'post-card'); + composer.innerHTML = ` +
+ + +
`; + box.appendChild(composer); + const feedKey = 'lt.posts'; + const getPosts = (): any[] => { try { return JSON.parse(localStorage.getItem(feedKey) || '[]'); } catch { return []; } }; + const addPost = (p: any) => { const arr = getPosts(); arr.unshift(p); localStorage.setItem(feedKey, JSON.stringify(arr.slice(0, 30))); }; + composer.querySelector('#btn-post')!.addEventListener('click', () => { + const txt = (composer.querySelector('#post-text') as HTMLInputElement).value.trim(); + if (!txt) return; + addPost({ name: S.name, text: txt, ts: Date.now(), likes: 0 }); + (composer.querySelector('#post-text') as HTMLInputElement).value = ''; + renderSocial(); + }); + composer.querySelector('#btn-snap')!.addEventListener('click', () => { + try { + const gl = document.querySelector('#gl') as HTMLCanvasElement; + const img = gl.toDataURL('image/jpeg', 0.6); + addPost({ name: S.name, text: '📸 Snapshot from ' + (S.zone === 'town' ? 'Maple Court' : 'my home'), img, ts: Date.now(), likes: 0 }); + renderSocial(); + } catch { alertMsg('Screenshot failed — try again.'); } + }); + for (const p of getPosts()) { + const card = el('div', 'post-card', ` +
${escapeHtml((p.name || '?').slice(0, 1))}
+
${escapeHtml(p.name)}
${timeAgo(p.ts)}
+
${escapeHtml(p.text || '')}
+ ${p.img ? `` : ''} +
❤️ ${p.likes}💬 Reply↗ Share
`); + (card.querySelector('.post-actions span') as HTMLElement).addEventListener('click', () => { p.likes++; card.querySelector('.post-actions span')!.textContent = `❤️ ${p.likes}`; }); + box.appendChild(card); + } +} + +function renderCalendar() { + const box = screen(); + box.innerHTML = `
📅 Town Calendar
`; + const events: [number, string, string, string][] = [ + [540, 'Morning Market', 'MapleMart plaza stalls — fresh deals daily', '🍎'], + [720, 'Lunch Rush at Taco Comet', 'Chef Rosa cooks up specials', '🌮'], + [1200, 'Plaza Dance Night', 'Meet at the stage — everyone dances!', '🎶'], + ]; + const mins = S.time.minutes; + for (const [startHm, title, desc, ico] of events) { + const active = mins >= startHm && mins < startHm + 120; + box.appendChild(el('div', 'list-card' + (active ? '' : ''), ` +
${ico}
+
${title} @ ${String(Math.floor(startHm / 60)).padStart(2, '0')}:00
${desc}
+ ${active ? 'NOW' : ''}`)); + } + box.appendChild(el('div', 'dim', `Day ${S.time.day} in LifeTown · seasons & festivals coming soon`)); +} + +function renderHomeApp() { + const box = screen(); + box.innerHTML = `
🏠 Home
`; + const tier = HOUSES.find(h => h.id === S.profile?.houseTier) || HOUSES[0]; + box.appendChild(el('div', 'post-card', `${tier.name}
Your current home · ${tier.size[0]}×${tier.size[1]} m`)); + const row = el('div', '', ''); row.style.cssText = 'display:flex;gap:8px;margin-bottom:12px'; + const goHome = el('button', 'btn btn-accent', '🏠 Teleport Home'); goHome.style.flex = '1'; + goHome.addEventListener('click', () => net.send({ t: 'zone', id: `home:${S.name.toLowerCase()}` })); + const goOut = el('button', 'btn btn-primary', '🌆 To Town'); goOut.style.flex = '1'; + goOut.addEventListener('click', () => net.send({ t: 'zone', id: 'town' })); + row.append(goHome, goOut); + box.appendChild(row); + + box.appendChild(el('div', 'papp-title', '🏡 Property Market')); + for (const h of HOUSES) { + const owned = S.profile?.housesOwned.includes(h.id); + const locked = (S.profile?.level ?? 1) < h.lvl; + const c = el('div', 'list-card', ` +
🏘️
+
${h.name}
${h.price === 0 ? 'Starter home' : h.price.toLocaleString() + ' ◉ · Level ' + h.lvl + '+'}
+ ${owned ? 'Owned ✓' : locked ? '🔒' : ``}`); + if (!owned && !locked) { + (c.querySelector('button') as HTMLElement).addEventListener('click', () => net.send({ t: 'buy_house', houseId: h.id })); + } + box.appendChild(c); + } +} + +function timeAgo(ts: number) { + const d = Math.floor((Date.now() - ts) / 1000); + if (d < 60) return 'just now'; + if (d < 3600) return Math.floor(d / 60) + ' min ago'; + return Math.floor(d / 3600) + ' h ago'; +} +function alertMsg(t: string) { import('./common').then(({ toast }) => toast('Oops', t, '⚠️')); } +function audioClick() { import('../audio').then(({ audio }) => audio.click()); } +void AREA_POINTS; diff --git a/client/src/world.ts b/client/src/world.ts new file mode 100644 index 0000000..94271f3 --- /dev/null +++ b/client/src/world.ts @@ -0,0 +1,301 @@ +// LifeTown Online — world/actor manager: local player, remote players, NPCs, +// interpolation, name-tags and speech bubbles. +import * as THREE from 'three'; +import { Avatar, npcAppearance } from './gfx/avatar'; +import { Engine } from './gfx/engine'; +import { Town } from './gfx/town'; +import { Interior } from './gfx/interior'; +import { S, bus } from './state'; +import { net } from './net'; +import { NPCS, VENUES } from '../../shared/data.mjs'; +import { audio } from './audio'; + +interface Actor { + id: string; name: string; appearance: any; + avatar: Avatar; isNpc: boolean; + tx: number; tz: number; tRy: number; + overrideAnimUntil?: number; overrideAnim?: string; + tagEl: HTMLElement; +} + +const V = new THREE.Vector3(); + +export class World { + actors = new Map(); + town: Town | null = null; + interior: Interior | null = null; + interiorChangedHook: (() => void) | null = null; + + constructor(public engine: Engine) { + bus.on('srv:playerJoin', (m: any) => this.join(m.id, m.name, m.appearance)); + bus.on('srv:roster', (m: any) => { + for (const p of m.players || []) this.join(p.id, p.name, p.appearance); + }); + bus.on('srv:playerLeave', (m: any) => this.remove(m.id)); + bus.on('srv:snapshot', (m: any) => this.snapshot(m)); + bus.on('srv:bubble', (m: any) => this.bubble(m.id, m.text)); + bus.on('srv:emote', (m: any) => this.emote(m.id, m.emote)); + bus.on('srv:socialFx', (m: any) => this.socialFx(m)); + bus.on('world:clearActors', () => this.clearRemote()); + } + + // ---------------- lifecycle ---------------- + spawnMe(appearance: any) { + this.remove('me'); + const avatar = new Avatar(appearance); + avatar.group.position.set(0, 0, 12); + this.engine.actorGroup.add(avatar.group); + const tagEl = makeTag(appearance.name || S.name, '#ffd98a'); + this.actors.set('me', { id: 'me', name: appearance.name || S.name, appearance, avatar, isNpc: false, tx: 0, tz: 12, tRy: 0, tagEl }); + } + get me() { return this.actors.get('me'); } + + join(id: string, name: string, appearance: any) { + if (!appearance) return; + if (id === S.yourId && this.actors.has('me')) return; + if (this.actors.has(id)) { // refresh appearance + const a = this.actors.get(id)!; + a.name = name; refreshTag(a.tagEl, name); + return; + } + const avatar = new Avatar({ ...appearance, name }); + avatar.group.position.set((Math.random() - .5) * 8, 0, 12); + this.engine.actorGroup.add(avatar.group); + const tagEl = makeTag(name); + this.actors.set(id, { id, name, appearance, avatar, isNpc: false, tx: 0, tz: 12, tRy: 0, tagEl }); + } + + spawnNpcs() { + NPCS.forEach((def, i) => { + const app = npcAppearance(def, i); + const avatar = new Avatar(app, i); + avatar.group.position.set(def.home === 'cafe' ? -48 : 0, 0, 12); + this.engine.actorGroup.add(avatar.group); + const tagEl = makeTag(def.name, '#a7f3e3'); + this.actors.set(def.id, { + id: def.id, name: def.name, appearance: app, avatar, isNpc: true, + tx: def.home === 'cafe' ? -48 : 0, tz: 12, tRy: 0, tagEl, + }); + }); + } + + remove(id: string) { + const a = this.actors.get(id); + if (!a) return; + this.engine.actorGroup.remove(a.avatar.group); + a.avatar.dispose(); + a.tagEl.remove(); + this.actors.delete(id); + } + + clearRemote() { + for (const id of [...this.actors.keys()]) if (id !== 'me') this.remove(id); + } + + // ---------------- networking ---------------- + private snapshot(m: any) { + for (const p of m.players) { + const a = this.actors.get(p.id); + if (!a) continue; + a.tx = p.x; a.tz = p.z; a.tRy = p.ry; + if (!a.overrideAnimUntil || performance.now() > a.overrideAnimUntil) a.avatar.setAnim(p.anim); + } + for (const n of m.npcs || []) { + const a = this.actors.get(n.id); + if (!a) continue; + a.tx = n.x; a.tz = n.z; a.tRy = n.ry; + if (!a.overrideAnimUntil || performance.now() > a.overrideAnimUntil) a.avatar.setAnim(n.anim); + } + } + + emote(id: string, emote: string) { + const a = this.actors.get(id); + if (!a) return; + const map: Record = { wave: 'wave', dance: 'dance', laugh: 'laugh', angry: 'argue', sleep: 'sleep', sit: 'sit', sad: 'talk', eat: 'eat', work: 'work', read: 'talk' }; + a.avatar.setAnim(map[emote] || 'wave'); + if (emote !== 'sit' && emote !== 'sleep') a.overrideAnimUntil = performance.now() + 4000; + a.overrideAnim = map[emote]; + } + + socialFx(m: any) { + const from = this.actors.get(m.from); + const to = this.actors.get(m.to); + const dur = 3000; + if (from) { from.avatar.setAnim(m.anim); from.overrideAnimUntil = performance.now() + dur; } + if (to) { to.avatar.setAnim(m.anim); to.overrideAnimUntil = performance.now() + dur; } + for (const line of m.lines || []) this.bubble(m.from, line, true); + // emoji pop + if ((m.emoji || '') !== '') { + const anchor = from ? from : to; + if (anchor) popEmoji(this.engine, anchor.avatar.group.position, m.emoji); + } + // gentle camera focus when I'm involved or nearby + if (from && (m.from === 'me' || m.to === 'me')) { + const mid = new THREE.Vector3(); + if (from && to) mid.copy(from.avatar.group.position).add(to.avatar.group.position).multiplyScalar(0.5); + else mid.copy(from ? from.avatar.group.position : to!.avatar.group.position); + (this as any)._focusCb?.(mid); + } + audio.pop(); + } + + onFocus(cb: (pos: THREE.Vector3) => void) { (this as any)._focusCb = cb; } + + bubble(id: string, text: string, long = false) { + const a = this.actors.get(id); + if (!a || !text) return; + const el = document.createElement('div'); + el.className = 'bubble fade' + (a.isNpc ? ' npc' : ''); + el.textContent = text; + document.getElementById('bubble-layer')!.appendChild(el); + const life = long ? 5200 : 4400; + (el as any)._anchor = a; + bubbles.push({ el, until: performance.now() + life }); + setTimeout(() => { el.remove(); const i = bubbles.findIndex(b => b.el === el); if (i >= 0) bubbles.splice(i, 1); }, life); + } + + nearestActor(excludeMe = true, maxDist = 4.2): Actor | null { + const me = this.me; + if (!me) return null; + let best: Actor | null = null, bd = maxDist; + for (const a of this.actors.values()) { + if (excludeMe && a.id === 'me') continue; + const d = me.avatar.group.position.distanceTo(a.avatar.group.position); + if (d < bd) { bd = d; best = a; } + } + return best; + } + + // ---------------- per-frame ---------------- + update(dt: number) { + const now = performance.now(); + for (const a of this.actors.values()) { + const g = a.avatar.group; + if (a.id === 'me') { + a.avatar.update(dt); + continue; + } + // interpolate + const k = Math.min(1, dt * 9); + const dx = a.tx - g.position.x, dz = a.tz - g.position.z; + g.position.x += dx * k; g.position.z += dz * k; + let dr = a.tRy - g.rotation.y; + while (dr > Math.PI) dr -= Math.PI * 2; + while (dr < -Math.PI) dr += Math.PI * 2; + g.rotation.y += dr * k; + a.avatar.update(dt); + void now; + } + this.projectOverlays(); + } + + private projectOverlays() { + const cam = this.engine.camera; + const w = window.innerWidth, h = window.innerHeight; + for (const a of this.actors.values()) { + a.avatar.headWorldPos(V); + V.y += 0.25; + const dist = V.distanceTo(cam.position); + const sp = V.clone().project(cam); + const visible = sp.z < 1 && dist < 46; + if (!visible) { a.tagEl.style.display = 'none'; continue; } + a.tagEl.style.display = ''; + a.tagEl.style.left = ((sp.x * 0.5 + 0.5) * w) + 'px'; + a.tagEl.style.top = ((-sp.y * 0.5 + 0.5) * h - 14) + 'px'; + } + for (const b of bubbles) { + const a = (b.el as any)._anchor as Actor; + if (!a) continue; + a.avatar.headWorldPos(V); V.y += 0.55; + const sp = V.clone().project(cam); + b.el.style.left = ((sp.x * 0.5 + 0.5) * window.innerWidth) + 'px'; + b.el.style.top = ((-sp.y * 0.5 + 0.5) * window.innerHeight) + 'px'; + } + } + + /** Build the requested zone's static scene content; returns spawn point */ + async enterZone(zone: string, homeTier?: string, layout?: any[]): Promise<{ x: number; z: number }> { + // clear previous (and free GPU resources) + if (this.town) { this.engine.worldGroup.remove(this.town.group); disposeGroup(this.town.group); this.town = null; } + if (this.interior) { this.engine.worldGroup.remove(this.interior.group); disposeGroup(this.interior.group); this.interior = null; } + this.clearRemote(); + + if (zone === 'town') { + this.town = new Town(); + this.engine.worldGroup.add(this.town.group); + this.spawnNpcs(); + audio.setTrack('town'); + return { x: 0, z: 12 }; + } + + if (zone.startsWith('venue:')) { + const v = VENUES[zone]; + if (!v) return { x: 0, z: 4 }; + this.interior = new Interior([v.size[0], v.size[1]]); + this.interior.label = v.name; + this.interior.setLayout(v.items || []); + this.interior.onFurnitureChanged = () => this.interiorChangedHook?.(); + this.engine.worldGroup.add(this.interior.group); + audio.setTrack('town'); + return { x: 0, z: v.size[1] / 2 - 1.4 }; + } + + // otherwise: a personal home zone + this.interior = new Interior(homeTier || 'studio'); + this.interior.label = 'Your Home'; + this.interior.setLayout(layout || []); + this.interior.onFurnitureChanged = () => this.interiorChangedHook?.(); + this.engine.worldGroup.add(this.interior.group); + audio.setTrack('home'); + return { x: 0, z: (this.interior.size[1] / 2) - 1.2 }; + } +} + +// ---------------- DOM helpers ---------------- +const bubbles: { el: HTMLElement; until: number }[] = []; + +function makeTag(name: string, color?: string): HTMLElement { + const el = document.createElement('div'); + el.className = 'name-tag'; + if (color) el.style.color = color; + el.textContent = name; + document.getElementById('bubble-layer')!.appendChild(el); + return el; +} +function refreshTag(el: HTMLElement, name: string) { el.textContent = name; } + +function disposeGroup(root: THREE.Object3D) { + root.traverse((o) => { + const m = o as THREE.Mesh; + if (m.geometry) m.geometry.dispose(); + if (m.material) { + const mats = Array.isArray(m.material) ? m.material : [m.material]; + for (const mm of mats) { + const std = mm as THREE.MeshStandardMaterial; + if (std.map) std.map.dispose(); + mm.dispose(); + } + } + }); +} + +function popEmoji(engine: Engine, pos: THREE.Vector3, emoji: string) { + const c = document.createElement('canvas'); + c.width = c.height = 96; + const g = c.getContext('2d')!; + g.font = '64px serif'; g.textAlign = 'center'; g.textBaseline = 'middle'; + g.fillText(emoji, 48, 52); + const tex = new THREE.CanvasTexture(c); + const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tex, transparent: true, depthWrite: false })); + sp.scale.setScalar(0.9); + sp.position.set(pos.x, pos.y + 2.6, pos.z); + engine.fxGroup.add(sp); + const t0 = performance.now(); + const anim = () => { + const t = (performance.now() - t0) / 1000; + sp.position.y = pos.y + 2.6 + t * 1.2; + (sp.material as THREE.SpriteMaterial).opacity = Math.max(0, 1 - t / 1.4); + if (t < 1.4) requestAnimationFrame(anim); else { engine.fxGroup.remove(sp); tex.dispose(); } + }; + anim(); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..149354f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1165 @@ +{ + "name": "lifetown-online", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "lifetown-online", + "version": "0.1.0", + "dependencies": { + "three": "^0.170.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/three": "^0.185.4", + "playwright-core": "^1.62.1", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.5.tgz", + "integrity": "sha512-jfkGfTwhQpsiSckPF8r9bU3pn3vyd72NlWaO+TgEO6WPSDnUhXzrNYCHBMOYj0ACaUgjm6eERLF+XV9a6RstoA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.5.tgz", + "integrity": "sha512-oGVqyQlxnrz9/ty89oHpU857VUHEl5/Xu4R2lS+aivCTrNnSsbiENzTnNaBsjxH0CNWGPhzHArOLFwo+oKXveA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.5.tgz", + "integrity": "sha512-bW7B8xMEq8n99Q3ieEcPRGuphurdZAaFzQc9Efyyw3FL6DZO6pMy9xhdN+kBoD7Sy05xNXSr4OyPPnpkYriS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.5.tgz", + "integrity": "sha512-YSwBS86QeHOGlrxJ1PSOIZSkzRL/JmKeunhc+lV6M1a6En8QuVCD/T/qIA0J4Gd2Y86RIOBYrLcOUtqGh9+/1w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.5.tgz", + "integrity": "sha512-2fST8lILgl7cKbme/1KDdPCmbXbG+gqoV3bHp19L0ypX/3akYMBVdOunPleRCwonoLnXOZ/0F+Mt/v8POFmfcQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.5.tgz", + "integrity": "sha512-cpIxQCP9J+EVad0a6LO1kY3ZGODlk80VlI+2I96B8xMcdHZ4pLVhfQ49JFpYqjPF91FFkQWftf57YlDcTiw9yQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.5.tgz", + "integrity": "sha512-r9fGh3eFs3e/udWh5ZjXQtxiYK/xoFxQaYR/cELxac/Udkl5Th+IsFm0CX3Kl9hmUH/we7EoMpjJgeQNnE0+IA==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.5.tgz", + "integrity": "sha512-xdvFdp7OM6KLJviJT2g/YuRSUjnZgGHk4RNgwIbN7X6cPugOucV60DdHXWzsBVCUdrGb6qSXnJQrrAKMmQuj3Q==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.5.tgz", + "integrity": "sha512-rRqILAndyzHzP7T9NFQrq+4HFWNhqkqkKur7eiBpfLmz01PO0JKx5Vchu3YllE4YXI/Ftgq/szrDWg5GJ0mI8g==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.5.tgz", + "integrity": "sha512-Gf4X3qVMucayUvux6aXXPgXovocSFUC0rrffDuPI/S2nHhNMhjcZxsrAFYCOF350PRreW1XwzFj3CT/3bKsWCw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.5.tgz", + "integrity": "sha512-+s5qA0TNM0qm8PK/a5gt/1Hpx+NV08uSuCncvhziIlQzT6AEV2fnUQo7eBtFTFO0nA9scauvoR2HusfXmQnO4w==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.5.tgz", + "integrity": "sha512-ybb6QvWwWJCbBWqERpc8K3pYVGIrXlG8MEQ8IIuJY6Y9KdHQxoFoNyfkAOtKn1VHu3KuLidXvwrvGR1mEjeWCw==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.5.tgz", + "integrity": "sha512-nZb1DtnOyhCmYvsC8A2CwOkopVg+IS1+fPUa7rMOAXtNw5+lLCLLPqd6XAiNrGtoQKsbvIBOwsHnBH/3wnb4HQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.5.tgz", + "integrity": "sha512-yMbj63Sp89ryrXLWyz+sy+fYD2HpOnMCLGbe4Oa1smclFSUukdtD/BgdiHaAetJNb74URD8U4hM+qG5KVzMEkg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.5.tgz", + "integrity": "sha512-mhoan3OJw2kYV/e1jtIdmvUZgyBFeA6zGWsOswmR0Tg19TQbowZuR+JMLID6spbbBN7Zee2ejrgmy3+FxGrIdA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.5.tgz", + "integrity": "sha512-5ZTLmjWbb1VZdjuyhe83K/8QO0/h11midQCBP+X5OYn32ra7eOBoM0ZqtaY4nkgNsYgmdVhMYPoyVPTjUpHf3w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.5.tgz", + "integrity": "sha512-m53kG+br6PGxOTmgBEM2DHSDs9RVjsyEbUwjJPJGTFm1grWOG8EKJggDCTb60unD4Tjby8fi7/m9XfkEWasVWg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.5.tgz", + "integrity": "sha512-6RHPJR1g/uvdYU8uXBnfq3nlqyZCP82Fr6NHgfGoaIeSh0YEqnX/x6uA9MmJJbnSH7swqX4F+CkGdUF+6doiQA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.5.tgz", + "integrity": "sha512-xs+OXQtEXgpXT0DmA5+U3qnRZHdCST/5HRQxS8wSPZTUZN/EMWeHuSIod32LQklTBZBV9DyfncKBQ8n5V3eFdw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.5.tgz", + "integrity": "sha512-e7hD+sl3s+mcLQDZ8pbudBVsdG6r5yN4w3LqG2TJ8sQHDpblWj5lrJs/3m01Cvlxbt4x13zu5thLjgypgtkYzw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.5.tgz", + "integrity": "sha512-GiyJaCf+WpMub/17aPcKk27QMl5W6f+KhdPTjlFOn5akH5Wa/DCM9Stdx5cDfmasyKB08MqpVQ1uJE2RkkpbXg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.5.tgz", + "integrity": "sha512-+OQ8U2DdoEfXl8T4Fb18AjmEwbXMerKDKCL8yCPAYhKCEEKoul7rkbeGCBFCbAlaGaa7pmtRTpkAJM2LE/i5FA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.5.tgz", + "integrity": "sha512-KanvAZrPKbDBFwrgiU9yEVpQoox9QPV1WZOXX7HudJQY+eSlu82CtWxDU8WtuRRvtN5EGkLczkd6Y6DTcvm9wA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.5.tgz", + "integrity": "sha512-1aC3UEWTtRl3RK3VpDJ/Tqk1XI4SLTmXIthAq6wRWo8XiSXJNd+VprJM4/1P4+i6HIaFEFlVi9sTTziniD2tOQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.5.tgz", + "integrity": "sha512-/gDJaRs4gl0NPIwqCz+6PkpmhhjRAD2j6P4rSNHBzUkO3naEx2mIU0pRle1vUNRQ7mE/+8OOeXLTv/J56FKiQg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.185.4", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.185.4.tgz", + "integrity": "sha512-gAsBIC07NIFrxjbf7tH2t71c38uulFfk/RFoC7FNBSjMRAQ8J1x/RBvusX0N5PJouaYFJawXQqfCQ0RKUx/1nA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.18", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.18.tgz", + "integrity": "sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/playwright-core": { + "version": "1.62.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz", + "integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/postcss": { + "version": "8.5.26", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.26.tgz", + "integrity": "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.17", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/rollup": { + "version": "4.62.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.5.tgz", + "integrity": "sha512-/tqMfgP7GPA3PHhCmuiS4vIjrSVhHLgY++i+dhbG462euyAj7FpM4D9uq1X3BgjlqRdpcOrYhcQtfiQLNc8tqw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.62.5", + "@rollup/rollup-android-arm64": "4.62.5", + "@rollup/rollup-darwin-arm64": "4.62.5", + "@rollup/rollup-darwin-x64": "4.62.5", + "@rollup/rollup-freebsd-arm64": "4.62.5", + "@rollup/rollup-freebsd-x64": "4.62.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.5", + "@rollup/rollup-linux-arm-musleabihf": "4.62.5", + "@rollup/rollup-linux-arm64-gnu": "4.62.5", + "@rollup/rollup-linux-arm64-musl": "4.62.5", + "@rollup/rollup-linux-loong64-gnu": "4.62.5", + "@rollup/rollup-linux-loong64-musl": "4.62.5", + "@rollup/rollup-linux-ppc64-gnu": "4.62.5", + "@rollup/rollup-linux-ppc64-musl": "4.62.5", + "@rollup/rollup-linux-riscv64-gnu": "4.62.5", + "@rollup/rollup-linux-riscv64-musl": "4.62.5", + "@rollup/rollup-linux-s390x-gnu": "4.62.5", + "@rollup/rollup-linux-x64-gnu": "4.62.5", + "@rollup/rollup-linux-x64-musl": "4.62.5", + "@rollup/rollup-openbsd-x64": "4.62.5", + "@rollup/rollup-openharmony-arm64": "4.62.5", + "@rollup/rollup-win32-arm64-msvc": "4.62.5", + "@rollup/rollup-win32-ia32-msvc": "4.62.5", + "@rollup/rollup-win32-x64-gnu": "4.62.5", + "@rollup/rollup-win32-x64-msvc": "4.62.5", + "fsevents": "~2.3.2" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5fc9809 --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "lifetown-online", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "LifeTown Online - original browser multiplayer life simulation (MVP)", + "scripts": { + "build": "vite build", + "dev": "vite", + "start": "node server/index.mjs" + }, + "dependencies": { + "three": "^0.170.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/three": "^0.185.4", + "playwright-core": "^1.62.1", + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/scripts/e2e.mjs b/scripts/e2e.mjs new file mode 100644 index 0000000..320c48e --- /dev/null +++ b/scripts/e2e.mjs @@ -0,0 +1,171 @@ +// LifeTown Online — end-to-end server verification with two simulated clients. +import WebSocket from 'ws'; +import fs from 'node:fs'; + +// fresh fixtures: remove previous test saves so character creation is exercised +for (const f of ['e2e_a', 'e2e_b']) { + try { fs.unlinkSync(new globalThis.URL(`../saves/${f}.json`, import.meta.url)); } catch {} +} + +const WS_URL = 'ws://127.0.0.1:7788/ws'; +let failures = 0; +function ok(name, cond, extra = '') { + console.log(`${cond ? '✅' : '❌'} ${name}${extra ? ' — ' + extra : ''}`); + if (!cond) failures++; +} +const wait = (ms) => new Promise(r => setTimeout(r, ms)); + +class Client { + constructor(name) { + this.name = name; + this.inbox = []; + this.ws = new WebSocket(WS_URL); + this.ws.on('message', (d) => { + const m = JSON.parse(d.toString()); + this.inbox.push(m); + }); + this.opened = new Promise(res => this.ws.on('open', res)); + } + async send(msg) { await this.opened; this.ws.send(JSON.stringify(msg)); } + async until(pred, timeout = 4000, label = '') { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + const hit = this.inbox.find(pred); + if (hit) return hit; + await wait(40); + } + throw new Error(`timeout waiting for ${label || pred}`); + } + clear() { this.inbox.length = 0; } +} + +const appearanceA = { + bodyType: 'average', height: 1, skin: '#eab98f', hair: 'short', hairColor: '#4a2f1d', + eyes: 'round', eyeColor: '#3a5a40', outfitTop: 'tee', topColor: '#4f8fd9', bottomColor: '#33415c', +}; + +async function main() { + const a = new Client('E2E_A'); + const b = new Client('E2E_B'); + + // ---- create characters ---- + await a.send({ t: 'hello', name: 'E2E_A', appearance: appearanceA }); + const wa = await a.until(m => m.t === 'welcome' || m.t === 'error', 5000, 'welcome A'); + ok('A welcome', wa.t === 'welcome', `money=${wa.profile?.money} token=${!!wa.token}`); + + await b.send({ t: 'hello', name: 'E2E_B', appearance: {} }); + const wb = await b.until(m => m.t === 'welcome' || m.t === 'error', 5000, 'welcome B'); + ok('B welcome', wb.t === 'welcome'); + + // join visibility: A was already in town -> B gets roster; B joining -> A gets playerJoin + const rosterB = await b.until(m => m.t === 'roster' && m.players.some(p => p.id === wa.yourId), 4000, 'roster with A'); + ok('B received roster including A', !!rosterB); + const joinA = await a.until(m => m.t === 'playerJoin' && m.id === wb.yourId, 4000, 'playerJoin B'); + ok('A notified of B joining (playerJoin)', !!joinA); + + // duplicate name protection + const c = new Client('E2E_A'); + await c.send({ t: 'hello', name: 'E2E_A' }); + const wc = await c.until(m => m.t === 'error', 4000, 'dup-name error'); + ok('duplicate name rejected', wc.code === 'taken'); + c.ws.close(); + + // ---- movement sync ---- + await wait(300); + a.clear(); b.clear(); + await a.send({ t: 'move', x: 3.5, z: 11, ry: 1, anim: 'walk' }); + await wait(300); + const snapB = b.inbox.filter(m => m.t === 'snapshot').find(s => s.players.some(p => p.id === wa.yourId)); + ok('B sees A moving in snapshot', !!snapB); + + // ---- chat ---- + b.clear(); + await a.send({ t: 'chat', text: 'Hello from A!', channel: 'global' }); + const chatB = await b.until(m => m.t === 'chat' && m.from === 'E2E_A', 3000, 'chat relay'); + ok('global chat relayed', chatB.text.includes('Hello from A')); + + // ---- player-player social ---- + a.clear(); + await a.send({ t: 'social', targetType: 'player', targetId: wb.yourId, action: 'hello' }); + const relA = await a.until(m => m.t === 'relUpdate', 3000, 'relUpdate A'); + const relAB = relA.relationships['p:e2e_b']; + ok('social hello raises friendship', relAB && relAB.f >= 2, `f=${relAB?.f}`); + + // locked interaction (flirt needs romance gates fine at r0? flirt req.r=0 so allowed; use holdhands req.r=25) + a.clear(); + await a.send({ t: 'social', targetType: 'player', targetId: wb.yourId, action: 'holdhands' }); + const errLocked = await a.until(m => m.t === 'error', 3000, 'locked error'); + ok('gated interaction blocked', errLocked.code === 'locked', errLocked.message); + + // ---- jobs & economy ---- + a.clear(); + await a.send({ t: 'job_select', jobId: 'barista' }); + const ju = await a.until(m => m.t === 'jobUpdate', 3000, 'jobUpdate'); + ok('job selected', ju.job?.id === 'barista'); + + a.clear(); + await a.send({ t: 'job_work' }); + const wr = await a.until(m => m.t === 'workResult', 3000, 'workResult'); + ok('work shift pays', wr.pay > 0, `pay=${wr.pay} money=${wr.money}`); + + a.clear(); + await a.send({ t: 'job_work' }); + const cdErr = await a.until(m => m.t === 'error', 3000, 'cooldown error'); + ok('work cooldown enforced', cdErr.code === 'cooldown'); + + // ---- shopping ---- + a.clear(); + await a.send({ t: 'buy_item', itemId: 'bed_basic' }); + const inv = await a.until(m => m.t === 'inventory', 3000, 'inventory update'); + ok('item purchased into inventory', inv.inventory.some(i => i.itemId === 'bed_basic')); + + // house purchase should fail on funds/level + a.clear(); + await a.send({ t: 'buy_house', houseId: 'mansion' }); + const houseErr = await a.until(m => m.t === 'error', 3000, 'house locked'); + ok('mansion gated', houseErr.code === 'level' || houseErr.code === 'poor'); + + // ---- home layout persistence ---- + a.clear(); + await a.send({ t: 'place_home', layout: [{ itemId: 'bed_basic', x: -2, z: -2, rot: 0 }, { itemId: 'plant_fern', x: 2.5, z: 1.5, rot: 1 }] }); + const ls = await a.until(m => m.t === 'layoutSaved', 3000, 'layoutSaved'); + ok('home layout saved', ls.count === 2); + + // ---- zone switching ---- + a.clear(); + await a.send({ t: 'zone', id: `home:e2e_a` }); + const zo = await a.until(m => m.t === 'zoneOk', 3000, 'zoneOk home'); + ok('entered own home', zo.zone === 'home:e2e_a'); + + // B cannot enter A's home uninvited + b.clear(); + await b.send({ t: 'zone', id: 'home:e2e_a' }); + const lockHome = await b.until(m => m.t === 'error', 3000, 'home locked'); + ok('stranger blocked from home', lockHome.code === 'locked'); + + // invitation flow + a.clear(); b.clear(); + await a.send({ t: 'invite', to: 'E2E_B' }); + const notifB = await b.until(m => m.t === 'notify' && m.action?.type === 'visit', 3000, 'invite notify'); + await b.send({ t: 'zone', id: notifB.action.home }); + const zoB = await b.until(m => m.t === 'zoneOk', 3000, 'zoneOk visit'); + ok('invited friend can visit home', zoB.zone === 'home:e2e_a'); + + // back to town + b.clear(); + await b.send({ t: 'zone', id: 'town' }); + const zoT = await b.until(m => m.t === 'zoneOk', 3000, 'back to town'); + ok('returned to town', zoT.zone === 'town'); + + // time & weather broadcast + const timeMsg = await a.until(m => m.t === 'time', 4000, 'time broadcast'); + ok('server clock ticking', typeof timeMsg.minutes === 'number', `${timeMsg.day} day, ${Math.round(timeMsg.minutes)} min`); + + a.ws.close(); b.ws.close(); + await wait(200); + + console.log(failures ? `\n${failures} FAILURES` : '\n🎉 ALL E2E CHECKS PASSED'); + process.exit(failures ? 1 : 0); +} + +main().catch(e => { console.error('E2E crashed:', e.message); process.exit(1); }); diff --git a/scripts/shot.mjs b/scripts/shot.mjs new file mode 100644 index 0000000..14bbb15 --- /dev/null +++ b/scripts/shot.mjs @@ -0,0 +1,99 @@ +// LifeTown Online - headless verification: one short browser session per screen +import { chromium } from 'playwright-core'; +import fs from 'node:fs'; + +const EXE = '/root/.cache/ms-playwright/chromium_headless_shell-1148/chrome-linux/headless_shell'; +const URL = 'http://127.0.0.1:7788'; +const OUT = '/tmp/lifetown-shots'; +fs.mkdirSync(OUT, { recursive: true }); +const ARGS = ['--use-gl=swiftshader', '--enable-unsafe-swiftshader', '--no-sandbox', '--disable-dev-shm-usage', '--disable-gpu-sandbox']; + +const allErrors = []; + +async function withPage(fn, label) { + let browser; + try { + browser = await chromium.launch({ executablePath: EXE, args: ARGS, timeout: 30000 }); + const page = await browser.newPage({ viewport: { width: 960, height: 600 } }); + page.on('pageerror', (e) => allErrors.push(`[${label}] ${String(e).slice(0, 250)}`)); + page.on('console', (m) => { if (m.type() === 'error') allErrors.push(`[${label}] CONSOLE ${m.text().slice(0, 200)}`); }); + await page.goto(URL, { waitUntil: 'domcontentloaded', timeout: 20000 }); + await fn(page); + console.log(`[shot] OK ${label}`); + } catch (e) { + console.log(`[shot] FAIL ${label}: ${String(e).slice(0, 140)}`); + } finally { + try { await browser?.close(); } catch {} + } +} + +async function enterGame(page) { + const uname = 'Sunny' + Math.floor(Math.random() * 900000 + 100000); + await page.waitForTimeout(700); + await page.click('#btn-newchar', { force: true }); + await page.fill('#cr-name', uname); + await page.click('#btn-startlife', { force: true }); + await page.waitForTimeout(4200); +} + +(async () => { + await withPage(async (page) => { + await page.waitForTimeout(1100); + await page.screenshot({ path: `${OUT}/1-title.png` }); + }, 'title'); + + await withPage(async (page) => { + await page.waitForTimeout(700); + await page.click('#btn-newchar', { force: true }); + await page.fill('#cr-name', 'Sunny' + Math.floor(Math.random() * 900000 + 100000)); + await page.waitForTimeout(1200); + await page.screenshot({ path: `${OUT}/2-creator.png` }); + }, 'creator'); + + await withPage(async (page) => { + await enterGame(page); + await page.screenshot({ path: `${OUT}/3-town.png` }); + await page.keyboard.down('KeyW'); + await page.waitForTimeout(1200); + await page.keyboard.up('KeyW'); + await page.waitForTimeout(400); + await page.screenshot({ path: `${OUT}/4-walked.png` }); + }, 'town'); + + await withPage(async (page) => { + await enterGame(page); + await page.click('[data-act="phone"]', { force: true }); + await page.waitForTimeout(900); + await page.screenshot({ path: `${OUT}/5-phone.png` }); + }, 'phone'); + + await withPage(async (page) => { + await enterGame(page); + await page.click('[data-act="home"]', { force: true }); + await page.waitForTimeout(600); + await page.screenshot({ path: `${OUT}/6-homemenu.png` }); + const btns = await page.$$('#dialog-body button'); + if (btns[1]) await btns[1].click({ force: true }); + await page.waitForTimeout(3200); + await page.screenshot({ path: `${OUT}/7-home.png` }); + }, 'home'); + + await withPage(async (page) => { + await enterGame(page); + await page.click('[data-act="home"]', { force: true }); + await page.waitForTimeout(500); + const btns = await page.$$('#dialog-body button'); + if (btns[1]) await btns[1].click({ force: true }); + await page.waitForTimeout(3000); + await page.click('[data-act="build"]', { force: true }); + await page.waitForTimeout(700); + await page.click('#btn-shop', { force: true }); + await page.waitForTimeout(900); + await page.screenshot({ path: `${OUT}/8-catalog.png` }); + await page.click('#catalog-close', { force: true }); + await page.waitForTimeout(400); + await page.screenshot({ path: `${OUT}/9-buildmode.png` }); + }, 'build'); + + console.log(allErrors.length ? `PAGE ERRORS (${allErrors.length}):\n` + allErrors.slice(0, 10).join('\n') : 'NO PAGE ERRORS'); +})(); diff --git a/server/game.mjs b/server/game.mjs new file mode 100644 index 0000000..4da6f89 --- /dev/null +++ b/server/game.mjs @@ -0,0 +1,636 @@ +// LifeTown Online — authoritative game server: sessions, zones, snapshots, +// NPC simulation, world clock/weather, economy, relationships, persistence. +import { WebSocket } from 'ws'; +import { + TIME_SCALE, DAY_MINUTES, JOBS, XP_PER_RANK, WORK_SECONDS, WORK_COOLDOWN, + NPCS, AREA_POINTS, INTERACTION_BY_ID, REL_MAX, ROMANCE_PARTNER_AT, + CATALOG_BY_ID, HOUSES, MAX_CHAT_LEN, WEATHER_STATES, + FRIEND_TIERS, NEEDS, VENUES, +} from '../shared/data.mjs'; +import { loadProfile, saveProfile, newProfile, findProfileByToken, listProfileNames, sanitizeName } from './persist.mjs'; + +const TICK_MS = 1000 / 12; // snapshot rate +const MOVE_RATE_MS = 66; // expected client movement packet interval +const MAX_SPEED = 6.5; // metres/sec sanity cap + +const clamp = (v, a, b) => Math.max(a, Math.min(b, v)); +const dist2d = (a, b) => Math.hypot(a.x - b.x, a.z - b.z); + +function relKeyOf(targetType, id) { return targetType === 'npc' ? `npc:${id}` : `p:${String(id).toLowerCase()}`; } + +function friendLabel(f) { + let label = FRIEND_TIERS[0].label; + for (const t of FRIEND_TIERS) if (f >= t.at) label = t.label; + return label; +} + +export class GameServer { + constructor(wss) { + this.wss = wss; + this.sessions = new Set(); // all live sessions + this.byZone = new Map(); // zoneId -> Set + this.npcs = NPCS.map(def => ({ + id: def.id, name: def.name, role: def.role, color: def.color, + x: 0, z: 12, ry: 0, anim: 'idle', + target: null, waitUntil: 0, lineAt: Date.now() + 8000 + Math.random() * 20000, + })); + // start the world at 07:30 day 1 (1 real second = TIME_SCALE game-seconds) + const startGameMin = 7 * 60 + 30; + const elapsedRealSec = startGameMin * 60 / TIME_SCALE; + this.t0 = Date.now() - elapsedRealSec * 1000; + this.day = 1; + this.weather = 'sunny'; + this.nextWeatherRoll = Date.now() + 30000; + + this.interval = setInterval(() => this.tick(), TICK_MS); + this.slowInterval = setInterval(() => this.slowTick(), 2000); + console.log(`[LifeTown] world initialised — ${this.npcs.length} townsfolk ready`); + } + + // ---------------- world clock --------------------------- + gameTime() { + const totalGameSec = Math.floor((Date.now() - this.t0) / 1000) * TIME_SCALE; + const totalMin = Math.floor(totalGameSec / 60); + return { day: Math.floor(totalMin / DAY_MINUTES) + 1, minutes: totalMin % DAY_MINUTES }; + } + + // ---------------- connection lifecycle ------------------ + onConnection(ws) { + ws.on('message', (data) => this.onMessage(ws, data)); + ws.on('close', () => this.onClose(ws)); + ws.on('error', () => {}); + ws.isAlive = true; + } + + onClose(ws) { + const s = ws.__session; + if (!s) return; + this.sessions.delete(s); + this.leaveZone(s); + try { saveProfile(s.profile); } catch {} + this.broadcastToZone(s.zone, 'playerLeave', { id: s.id }, s); + console.log(`[LifeTown] ${s.profile.name} left the town`); + } + + leaveZone(s) { + const set = this.byZone.get(s.zone); + if (set) { set.delete(s); if (!set.size) this.byZone.delete(s.zone); } + } + joinZone(s, zoneId) { + this.leaveZone(s); + s.zone = zoneId; + if (!this.byZone.has(zoneId)) this.byZone.set(zoneId, new Set()); + this.byZone.get(zoneId).add(s); + // tell zone about the newcomer (full appearance so clients build the avatar) + this.broadcastToZone(zoneId, 'playerJoin', { id: s.id, name: s.profile.name, appearance: s.publicAppearance(), zone: zoneId }, s); + // roster reply to mover: everyone currently here + const roster = [...(this.byZone.get(zoneId) || [])].filter(o => o !== s) + .map(o => ({ id: o.id, name: o.profile.name, appearance: o.publicAppearance() })); + this.send(s.ws, 'roster', { zone: zoneId, players: roster }); + } + + // ---------------- messaging primitives ------------------- + send(ws, t, data) { if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ t, ...data })); } + sendSession(s, t, data) { this.send(s.ws, t, data); } + broadcast(t, data) { for (const s of this.sessions) this.sendSession(s, t, data); } + broadcastToZone(zoneId, t, data, exclude) { + const set = this.byZone.get(zoneId); + if (!set) return; + for (const s of set) if (s !== exclude) this.sendSession(s, t, data); + } + + onMessage(ws, raw) { + let msg; + try { msg = JSON.parse(raw.toString()); } catch { return; } + if (!msg || typeof msg.t !== 'string') return; + const s = ws.__session; + + switch (msg.t) { + case 'hello': return this.hello(ws, msg); + case 'ping': return this.send(ws, 'pong', {}); + } + if (!s) return; + switch (msg.t) { + case 'move': return this.move(s, msg); + case 'chat': return this.chat(s, msg); + case 'emote': return this.emote(s, msg); + case 'social': return this.social(s, msg); + case 'zone': return this.switchZone(s, msg); + case 'invite': return this.invite(s, msg); + case 'job_select': return this.jobSelect(s, msg); + case 'job_work': return this.jobWork(s, msg); + case 'buy_item': return this.buyItem(s, msg); + case 'buy_house': return this.buyHouse(s, msg); + case 'place_home': return this.placeHome(s, msg); + case 'sell_item': return this.sellItem(s, msg); + case 'sell_placed': return this.sellPlaced(s, msg); + case 'needs_sync': return this.needsSync(s, msg); + case 'gift_player': return this.giftPlayer(s, msg); + } + } + + // ---------------- hello / character creation ------------- + hello(ws, msg) { + const name = sanitizeName(msg.name); + if (name.length < 2) return this.send(ws, 'error', { code: 'name', message: 'Name must be 2-20 letters.' }); + + let profile = null; + if (msg.token) profile = findProfileByToken(msg.token); + if (!profile) { + profile = loadProfile(name); + if (profile && (!msg.token || profile.token !== msg.token)) { + // name belongs to another save; refuse politely + return this.send(ws, 'error', { code: 'taken', message: `The name "${name}" already lives in LifeTown. Pick another.` }); + } + if (!profile) { + profile = newProfile(name, msg.appearance || {}); + saveProfile(profile); + } + } + if (this.sessions.size >= 64) return this.send(ws, 'error', { code: 'full', message: 'Town is full right now (64 residents online).' }); + + const s = { + id: `${profile.name.toLowerCase()}#${Math.random().toString(36).slice(2, 7)}`, + ws, profile, + zone: 'town', x: 0, z: 12, ry: 0, anim: 'idle', + lastMoveAt: Date.now(), socialCooldownAt: {}, lastNeedsSync: 0, + publicAppearance: () => ({ + bodyType: profile.appearance?.bodyType ?? 'average', + height: profile.appearance?.height ?? 1, + skin: profile.appearance?.skin ?? '#eab98f', + hair: profile.appearance?.hair ?? 'short', + hairColor: profile.appearance?.hairColor ?? '#4a2f1d', + eyeColor: profile.appearance?.eyeColor ?? '#3a5a40', + topColor: profile.appearance?.outfit?.topColor ?? '#4f8fd9', + bottomColor: profile.appearance?.outfit?.bottomColor ?? '#33415c', + outfitTop: profile.appearance?.outfit?.top ?? 'tee', + outfitBottom: profile.appearance?.outfit?.bottom ?? 'jeans', + dress: profile.appearance?.outfit?.dress ?? null, + shoes: profile.appearance?.outfit?.shoes ?? 'sneakers', + accessory: profile.appearance?.outfit?.accessory ?? 'none', + name: profile.name, + }), + }; + ws.__session = s; + this.sessions.add(s); + + const time = this.gameTime(); + this.joinZone(s, 'town'); + this.send(ws, 'welcome', { + yourId: s.id, token: profile.token, zone: 'town', + profile: this.clientProfile(profile), + time: { ...time, scale: TIME_SCALE }, + weather: this.weather, + spawn: { x: 0, z: 12 }, + online: [...this.sessions].filter(o => o !== s).map(o => ({ id: o.id, name: o.profile.name })), + residentsOnline: this.sessions.size, + }); + this.broadcastToZone('town', 'notify', { title: 'Welcome!', text: `${profile.name} just arrived in LifeTown.`, icon: '👋' }, s); + console.log(`[LifeTown] ${profile.name} joined (${this.sessions.size} online)`); + } + + clientProfile(p) { + return { + name: p.name, money: p.money, level: p.level, xp: p.xp, + job: p.job, houseTier: p.houseTier, housesOwned: p.housesOwned, + homeLayout: p.homeLayout, inventory: p.inventory, + relationships: p.relationships, achievements: p.achievements, + stats: p.stats, appearance: p.appearance, needs: p.needs, moodlets: p.moodlets || [], + }; + } + + // ---------------- movement ------------------------------- + move(s, msg) { + const now = Date.now(); + const dt = Math.min((now - s.lastMoveAt) / 1000, 1); + const nx = Number(msg.x), nz = Number(msg.z); + if (!Number.isFinite(nx) || !Number.isFinite(nz)) return; + const d = Math.hypot(nx - s.x, nz - s.z); + if (d > MAX_SPEED * dt + 2.5) { + // implausible jump: clamp toward previous position but stay responsive + const k = (MAX_SPEED * dt + 2.5) / d; + s.x += (nx - s.x) * k; s.z += (nz - s.z) * k; + } else { s.x = clamp(nx, -72, 72); s.z = clamp(nz, -46, 46); } + s.ry = Number(msg.ry) || s.ry; + s.anim = typeof msg.anim === 'string' ? String(msg.anim).slice(0, 16) : s.anim; + s.lastMoveAt = now; + } + + // ---------------- chat & emotes -------------------------- + chat(s, msg) { + const text = String(msg.text || '').slice(0, MAX_CHAT_LEN).trim(); + if (!text) return; + if (msg.channel === 'global') { + this.broadcast('chat', { from: s.profile.name, text, channel: 'global', ts: Date.now() }); + } else { + this.broadcastToZone(s.zone, 'chat', { from: s.profile.name, text, channel: 'say', ts: Date.now() }); + this.broadcastToZone(s.zone, 'bubble', { id: s.id, text }); + } + } + + emote(s, msg) { + const id = String(msg.id || '').slice(0, 16); + this.broadcastToZone(s.zone, 'emote', { id: s.id, emote: id }); + if (id === 'dance') { + s.profile.stats.dances = (s.profile.stats.dances || 0) + 1; + this.checkAchievements(s); + } + } + + // ---------------- zones --------------------------------- + switchZone(s, msg) { + const want = String(msg.id || ''); + if (want === 'town') { + s.x = 0; s.z = 12; s.anim = 'idle'; + this.joinZone(s, 'town'); + this.sendSession(s, 'zoneOk', { zone: 'town', spawn: { x: 0, z: 12 } }); + return; + } + if (want.startsWith('venue:')) { + if (!VENUES[want]) return this.sendSession(s, 'error', { code: 'nozone', message: 'That place does not exist.' }); + s.x = 0; s.z = 4; s.anim = 'idle'; + this.joinZone(s, want); + this.sendSession(s, 'zoneOk', { zone: want, spawn: { x: 0, z: 4 } }); + return; + } + if (want.startsWith('home:')) { + const owner = want.slice(5); + const isSelf = owner === s.profile.name.toLowerCase(); + const rel = s.profile.relationships[`p:${owner}`]; + const allowed = isSelf || (rel && rel.f >= 25) || (s.invitedTo === want); + if (!allowed) return this.sendSession(s, 'error', { code: 'locked', message: 'You need an invitation (or friendship ≥ Friend) to visit that home.' }); + s.x = 0; s.z = 6; s.anim = 'idle'; + this.joinZone(s, want); + const ownerProf = loadProfile(owner); + this.sendSession(s, 'zoneOk', { + zone: want, spawn: { x: 0, z: 6 }, + tier: ownerProf?.houseTier || 'studio', + layout: ownerProf?.homeLayout || [], + own: isSelf, + }); + } + } + + invite(s, msg) { + const targetName = sanitizeName(msg.to).toLowerCase(); + const target = [...this.sessions].find(o => o.profile.name.toLowerCase() === targetName); + if (!target) return this.sendSession(s, 'error', { code: 'offline', message: 'That resident is not online.' }); + target.invitedTo = `home:${s.profile.name.toLowerCase()}`; + this.sendSession(target, 'notify', { title: 'Invitation', text: `${s.profile.name} invited you to their home!`, icon: '🏠', action: { type: 'visit', home: `home:${s.profile.name.toLowerCase()}` } }); + this.sendSession(s, 'notify', { title: 'Invite sent', text: `${target.profile.name} can now visit your home.`, icon: '📨' }); + } + + giftPlayer(s, msg) { + const targetName = sanitizeName(msg.to).toLowerCase(); + const target = [...this.sessions].find(o => o.profile.name.toLowerCase() === targetName); + if (!target || target.zone !== s.zone) return this.sendSession(s, 'error', { code: 'range', message: 'They are not nearby.' }); + if (dist2d(s, target) > 6) return this.sendSession(s, 'error', { code: 'range', message: 'Walk closer to hand over the gift.' }); + const cost = 40; + if (s.profile.money < cost) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins (need 40).' }); + s.profile.money -= cost; + s.profile.stats.giftsSent++; + this.applyRelDelta(s.profile, 'p', target.profile.name, { df: 2, dr: 6 }); + this.applyRelDelta(target.profile, 'p', s.profile.name, { df: 3, dr: 4 }); + target.profile.money += 20; // small thank-you kickback keeps economy friendly + this.broadcastToZone(s.zone, 'socialFx', { + from: s.id, to: target.id, anim: 'gift', emoji: '🎁', + lines: [`${s.profile.name} gives ${target.profile.name} a present!`], + }); + this.sendSession(s, 'money', { amount: s.profile.money }); + this.sendSession(target, 'money', { amount: target.profile.money }); + this.pushRelUpdate(s); this.pushRelUpdate(target); + this.checkAchievements(s); + } + + // ---------------- social interactions -------------------- + social(s, msg) { + const action = INTERACTION_BY_ID[String(msg.action || '')]; + if (!action) return; + const type = msg.targetType === 'player' ? 'p' : 'npc'; + const now = Date.now(); + if ((s.socialCooldownAt[action.id] || 0) > now) return this.sendSession(s, 'error', { code: 'cooldown', message: 'Give it a moment…' }); + + // locate target + let target = null, targetName = '', targetWs = null; + if (type === 'npc') { + const zset = this.byZone.get('town'); + if (s.zone !== 'town') return this.sendSession(s, 'error', { code: 'range', message: 'Townsfolk are out in the district.' }); + target = this.npcs.find(n => n.id === msg.targetId); + targetName = target ? target.name : ''; + if (!zset || !zset.has(s)) return; + } else { + target = [...this.sessions].find(o => o.id === msg.targetId && o.zone === s.zone); + if (!target) return this.sendSession(s, 'error', { code: 'range', message: 'They are not here anymore.' }); + targetName = target.profile.name; targetWs = target; + } + if (!target) return; + if (dist2d(s, target) > 4.5) return this.sendSession(s, 'error', { code: 'range', message: 'Too far away — walk closer first.' }); + + const rel = s.profile.relationships[relKeyOf(type, type === 'npc' ? msg.targetId : targetName)] || { f: 0, r: 0 }; + if (rel.f < (action.req.f || 0) || rel.r < (action.req.r || 0)) + return this.sendSession(s, 'error', { code: 'locked', message: `Your bond isn't strong enough yet (need ${Math.max(action.req.f || 0, action.req.r || 0)}).` }); + + if (action.cost) { + if (s.profile.money < action.cost) return this.sendSession(s, 'error', { code: 'poor', message: `Costs ${action.cost} TC.` }); + s.profile.money -= action.cost; + this.sendSession(s, 'money', { amount: s.profile.money }); + } + + // apply relationship deltas (initiator gains slightly less romance than target loses none — both grow) + const df = action.df | 0, dr = action.dr | 0; + const relA = this.applyRelDelta(s.profile, type, type === 'npc' ? msg.targetId : targetName, { df, dr }); + let relB = null; + if (type === 'p') relB = this.applyRelDelta(target.profile, 'p', s.profile.name, { df: Math.ceil(df * 0.8), dr }); + + s.socialCooldownAt[action.id] = now + 1500; + + // visible outcome + const line = (action.lines && action.lines.length) + ? action.lines[Math.floor(Math.random() * action.lines.length)].replace('{name}', targetName) + : ''; + this.broadcastToZone(s.zone, 'socialFx', { + from: s.id, to: target.id ?? msg.targetId, anim: action.anim, group: action.group, emoji: action.group === 'Negative' ? '💢' : '💬', + lines: line ? [`${s.profile.name}: ${line}`] : [], + }); + if (action.anim === 'dance') { s.profile.stats.dances++; } + + // NPC reaction: chance to answer with a friendly/negative line based on resulting friendship + if (type === 'npc') { + const willRespond = Math.random() < 0.8; + if (willRespond) { + const good = (df + dr) >= 0 && relA.f >= 10; + const replies = good + ? ['That made my day!', 'You are sweet, you know that?', 'LifeTown suits you.', 'Ha! Good one.', 'Same time tomorrow?', 'You always know what to say.'] + : ['Wow. Just… wow.', 'I did NOT need that today.', 'Please be kinder.']; + const delay = 900 + Math.random() * 1200; + setTimeout(() => { + if (target) this.broadcastToZone('town', 'bubble', { id: target.id, text: replies[Math.floor(Math.random() * replies.length)] }); + }, delay); + } + } else if (targetWs) { + this.pushRelUpdate(target); + } + + this.pushRelUpdate(s); + this.checkAchievements(s); + } + + applyRelDelta(profile, type, id, { df, dr }) { + const key = relKeyOf(type, id); + const rel = profile.relationships[key] || { f: 0, r: 0 }; + rel.f = clamp(rel.f + df, 0, REL_MAX); + rel.r = clamp(rel.r + dr, 0, REL_MAX); + const wasPartner = !!rel.partner; + if (!wasPartner && rel.r >= ROMANCE_PARTNER_AT) { + rel.partner = true; + profile.stats.partners = (profile.stats.partners || 0) + 1; + this.send(this.wsOf(profile), 'notify', { title: 'It\'s official!', text: `You and ${id} are partners now! 💞`, icon: '💞' }); + } + if (wasPartner && rel.r < ROMANCE_PARTNER_AT - 15) rel.partner = false; // breakup by decay + profile.relationships[key] = rel; + return rel; + } + + pushRelUpdate(s) { + this.sendSession(s, 'relUpdate', { relationships: s.profile.relationships }); + } + + wsOf(profile) { + const found = [...this.sessions].find(x => x.profile === profile); + return found ? found.ws : null; + } + + // ---------------- jobs & economy ------------------------- + jobSelect(s, msg) { + const job = JOBS.find(j => j.id === msg.jobId); + if (!job) return; + if (s.profile.job && s.profile.job.id === job.id) return; + const wasEmployed = !!s.profile.job; + s.profile.job = { id: job.id, rank: wasEmployed ? 0 : 0, xp: 0 }; + this.sendSession(s, 'jobUpdate', { job: s.profile.job }); + this.sendSession(s, 'notify', { title: 'New career!', text: `You are now a ${job.ranks[0]} at ${job.title}.`, icon: '💼' }); + this.checkAchievements(s); + saveProfile(s.profile); + } + + jobWork(s, msg) { + const p = s.profile; + if (!p.job) return this.sendSession(s, 'error', { code: 'nojob', message: 'Get a job at City Hall first!' }); + const now = Date.now(); + if (now - (p.workLastAt || 0) < WORK_COOLDOWN * 1000) { + const wait = Math.ceil((WORK_COOLDOWN * 1000 - (now - p.workLastAt)) / 1000); + return this.sendSession(s, 'error', { code: 'cooldown', message: `Next shift available in ${wait}s.` }); + } + p.workLastAt = now; + const job = JOBS.find(j => j.id === p.job.id); + const rank = clamp(p.job.rank, 0, job.ranks.length - 1); + const pay = Math.round(job.basePay * (1 + rank * 0.55) * (0.95 + Math.random() * 0.1)); + const xpGain = 12 + rank * 4; + p.job.xp += xpGain; + p.money += pay; + p.stats.shifts++; + let promoted = false; + while (p.job.rank < job.ranks.length - 1 && p.job.xp >= XP_PER_RANK[p.job.rank + 1]) { + p.job.rank++; promoted = true; + } + this.sendSession(s, 'workResult', { pay, xp: xpGain, promoted, rank: p.job.rank, ranks: job.ranks, money: p.money }); + this.sendSession(s, 'money', { amount: p.money }); + if (promoted) { + this.sendSession(s, 'notify', { title: 'Promotion!', text: `You are now ${job.ranks[p.job.rank]}!`, icon: '📈' }); + p.level = Math.min(40, p.level + 1); + } + this.checkAchievements(s); + saveProfile(p); + } + + buyItem(s, msg) { + const item = CATALOG_BY_ID[msg.itemId]; + const p = s.profile; + if (!item) return; + if (p.level < item.lvl) return this.sendSession(s, 'error', { code: 'level', message: `Unlocks at level ${item.lvl}.` }); + if (p.money < item.price) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins.' }); + p.money -= item.price; + p.inventory.push({ itemId: item.id }); + this.sendSession(s, 'inventory', { inventory: p.inventory }); + this.sendSession(s, 'money', { amount: p.money }); + this.sendSession(s, 'notify', { title: 'Purchased', text: `${item.name} added to your inventory.`, icon: '🛒' }); + saveProfile(p); + } + + sellItem(s, msg) { + const p = s.profile; + const idx = p.inventory.findIndex(it => it.itemId === msg.itemId); + if (idx === -1) return; + const item = CATALOG_BY_ID[msg.itemId]; + const refund = Math.round(item.price * 0.6); + p.inventory.splice(idx, 1); + p.money += refund; + this.sendSession(s, 'inventory', { inventory: p.inventory }); + this.sendSession(s, 'money', { amount: p.money }); + this.sendSession(s, 'notify', { title: 'Sold', text: `Sold ${item.name} for ${refund} TC.`, icon: '💰' }); + saveProfile(p); + } + + sellPlaced(s, msg) { + const item = CATALOG_BY_ID[msg.itemId]; + if (!item) return; + const refund = Math.round(item.price * 0.6); + s.profile.money += refund; + this.sendSession(s, 'money', { amount: s.profile.money }); + this.sendSession(s, 'notify', { title: 'Sold furniture', text: `Removed ${item.name} — refunded ${refund} TC.`, icon: '🗑' }); + this.saveLayoutDebounced(s); + } + + buyHouse(s, msg) { + const house = HOUSES.find(h => h.id === msg.houseId); + const p = s.profile; + if (!house) return; + if (p.housesOwned.includes(house.id)) return; + if (p.level < house.lvl) return this.sendSession(s, 'error', { code: 'level', message: `Unlocks at level ${house.lvl}.` }); + if (p.money < house.price) return this.sendSession(s, 'error', { code: 'poor', message: 'Not enough TownCoins.' }); + p.money -= house.price; + p.housesOwned.push(house.id); + p.houseTier = house.id; + if (!p.achievements.includes('first_home')) p.achievements.push('first_home'); + this.sendSession(s, 'profile', { profile: this.clientProfile(p) }); + this.sendSession(s, 'money', { amount: p.money }); + this.sendSession(s, 'notify', { title: 'New home!', text: `You bought the ${house.name}! 🏡`, icon: '🏡' }); + this.checkAchievements(s); + saveProfile(p); + } + + placeHome(s, msg) { + const p = s.profile; + if (!Array.isArray(msg.layout)) return; + // validate: each placed item must exist in catalog; cap count; positions sane + const layout = msg.layout.slice(0, 120).filter(it => + CATALOG_BY_ID[it?.itemId] && + Number.isFinite(it.x) && Number.isFinite(it.z) && + Math.abs(it.x) < 30 && Math.abs(it.z) < 30 + ).map(it => ({ itemId: it.itemId, x: +it.x.toFixed(2), z: +it.z.toFixed(2), rot: (+it.rot || 0) })); + p.homeLayout = layout; + this.sendSession(s, 'layoutSaved', { count: layout.length }); + this.saveLayoutDebounced(s); + } + + saveLayoutDebounced(s) { + clearTimeout(s._layoutSaveTimer); + s._layoutSaveTimer = setTimeout(() => { try { saveProfile(s.profile); } catch {} }, 2000); + this.checkAchievements(s); + } + + needsSync(s, msg) { + const p = s.profile; + if (msg.needs && typeof msg.needs === 'object') { + p.needs = Object.fromEntries(NEEDS.map(k => [k, clamp(Number(msg.needs[k]) || 50, 0, 100)])); + } + if (Array.isArray(msg.moodlets)) p.moodlets = msg.moodlets.slice(0, 12); + if (Date.now() - s.lastNeedsSync > 25000) { s.lastNeedsSync = Date.now(); saveProfile(p); } + } + + // ---------------- achievements --------------------------- + checkAchievements(s) { + const p = s.profile; + const grant = (id, title, text) => { + if (p.achievements.includes(id)) return; + p.achievements.push(id); + this.sendSession(s, 'achievement', { id, name: title, desc: text }); + this.sendSession(s, 'profile', { profile: this.clientProfile(p) }); + }; + const defs = { + first_home: ['First Home', 'Buy your first house.'], + social_butterfly: ['Social Butterfly', 'Make 20 friends.'], + millionaire: ['Millionaire', 'Hold 1,000,000 TownCoins.'], + heartbreaker: ['Heartbreaker', 'Have 5 partners.'], + interior_designer: ['Interior Designer', 'Place 25 furniture items.'], + employed: ['Day One', 'Get your first job.'], + promoted: ['Moving On Up', 'Earn any promotion.'], + party_animal: ['Party Animal', 'Dance at 3 events.'], + }; + if (p.housesOwned.length >= 1 && p.houseTier !== 'studio') grant('first_home', ...defs.first_home); + if (Object.values(p.relationships).filter(r => r.f >= 25).length >= 20) grant('social_butterfly', ...defs.social_butterfly); + if (p.money >= 1000000) grant('millionaire', ...defs.millionaire); + if ((p.stats.partners || 0) >= 5) grant('heartbreaker', ...defs.heartbreaker); + if ((p.homeLayout || []).length >= 25) grant('interior_designer', ...defs.interior_designer); + if (!!p.job) grant('employed', ...defs.employed); + if (p.job && p.job.rank >= 1) grant('promoted', ...defs.promoted); + if ((p.stats.dances || 0) >= 3) grant('party_animal', ...defs.party_animal); + } + + // ---------------- main loops ----------------------------- + tick() { + // NPC brains + const now = Date.now(); + const { minutes } = this.gameTime(); + const hour = minutes / 60; + for (const n of this.npcs) { + const def = NPCS.find(d => d.id === n.id); + // choose schedule stop for current hour + let stopId = def.schedule[0][1]; + for (const [startH, poi] of def.schedule) if (hour >= startH) stopId = poi; + const anchor = AREA_POINTS[stopId] || { x: 0, z: -26 }; + // deterministic per-npc offset so they don't stack + const seed = (n.id.charCodeAt(0) * 7 + n.id.length * 13) % 100 / 100; + const ox = Math.sin(now / 9000 + seed * 6.28) * 3.2; + const oz = Math.cos(now / 11000 + seed * 6.28) * 3.2; + const tx = anchor.x + ox, tz = anchor.z + oz; + + if (!n.target || Math.hypot(n.tx - tx, n.tz - tz) > 2) { n.tx = tx; n.tz = tz; n.target = true; } + const dx = n.tx - n.x, dz = n.tz - n.z; + const d = Math.hypot(dx, dz); + if (d > 0.35) { + const sp = 1.9; + n.x += dx / d * sp * (TICK_MS / 1000); + n.z += dz / d * sp * (TICK_MS / 1000); + n.ry = Math.atan2(dx, dz); + n.anim = 'walk'; + } else { + n.anim = now > n.waitUntil ? (Math.random() < 0.25 ? 'talk' : 'idle') : n.anim; + if (now > n.waitUntil) n.waitUntil = now + 4000 + Math.random() * 6000; + } + // ambient chatter + if (now > n.lineAt && n.anim !== 'walk') { + n.lineAt = now + 24000 + Math.random() * 26000; + const lines = [ + 'Lovely day in LifeTown!', 'Have you tried the taco truck?', 'I should redecorate my place…', + 'The fountain sparkles at night.', 'Anyone up for dancing later?', 'Work, work, work…', + 'I love the smell of café roast.', 'Rain soon, I can feel it.', + ]; + this.broadcastToZone('town', 'bubble', { id: n.id, text: lines[Math.floor(Math.random() * lines.length)] }); + } + } + + // snapshots per zone + const npcSnap = this.npcs.map(n => ({ id: n.id, x: +n.x.toFixed(2), z: +n.z.toFixed(2), ry: +n.ry.toFixed(2), anim: n.anim })); + for (const [zoneId, set] of this.byZone) { + const players = [...set].map(pl => ({ id: pl.id, x: +pl.x.toFixed(2), z: +pl.z.toFixed(2), ry: +(pl.ry || 0).toFixed(2), anim: pl.anim })); + const payload = JSON.stringify({ + t: 'snapshot', zone: zoneId, + players, + npcs: zoneId === 'town' ? npcSnap : [], + }); + for (const pl of set) if (pl.ws.readyState === WebSocket.OPEN) pl.ws.send(payload); + } + } + + slowTick() { + const time = this.gameTime(); + // weather machine + if (Date.now() > this.nextWeatherRoll) { + this.nextWeatherRoll = Date.now() + 45000 + Math.random() * 75000; + if (Math.random() < 0.55) { + const weights = { sunny: 0.42, cloudy: 0.24, rain: 0.16, storm: 0.06, fog: 0.12 }; + const roll = Math.random(); + let acc = 0, next = this.weather; + for (const w of WEATHER_STATES) { acc += weights[w]; if (roll <= acc) { next = w; break; } } + if (next !== this.weather) { + this.weather = next; + this.broadcast('weather', { weather: next }); + const labels = { sunny: 'The sun breaks through over LifeTown!', cloudy: 'Clouds drift in…', rain: 'Rain patters across Maple Court ☔', storm: 'A storm rolls in — stay cozy!', fog: 'A soft fog wraps the town.' }; + this.broadcast('notify', { title: 'Weather', text: labels[next], icon: '🌤️' }); + } + } + } + for (const s of this.sessions) this.sendSession(s, 'time', { ...time, weather: this.weather }); + } +} diff --git a/server/index.mjs b/server/index.mjs new file mode 100644 index 0000000..9aa6f2b --- /dev/null +++ b/server/index.mjs @@ -0,0 +1,55 @@ +// LifeTown Online — entry point: static file server + WebSocket upgrade. +import http from 'node:http'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { WebSocketServer } from 'ws'; +import { GameServer } from './game.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const DIST = path.join(__dirname, '..', 'client', 'dist'); +const PORT = Number(process.env.GAME_PORT || 7788); + +const MIME = { + '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.mjs': 'text/javascript', + '.css': 'text/css', '.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg', + '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.wasm': 'application/wasm', + '.woff2': 'font/woff2', '.mp3': 'audio/mpeg', '.ogg': 'audio/ogg', '.webp': 'image/webp', +}; + +function serveStatic(req, res) { + let urlPath = decodeURIComponent(new URL(req.url, 'http://x').pathname); + if (urlPath === '/') urlPath = '/index.html'; + let filePath = path.normalize(path.join(DIST, urlPath)); + if (!filePath.startsWith(DIST)) { res.writeHead(403); res.end('Forbidden'); return; } + if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) { + // SPA fallback + filePath = path.join(DIST, 'index.html'); + if (!fs.existsSync(filePath)) { res.writeHead(503); res.end('LifeTown client is not built yet. Run: npm run build'); return; } + } + const ext = path.extname(filePath).toLowerCase(); + res.writeHead(200, { + 'Content-Type': MIME[ext] || 'application/octet-stream', + 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=3600', + }); + fs.createReadStream(filePath).pipe(res); +} + +const server = http.createServer(serveStatic); + +// Single WebSocket endpoint: /ws +const wss = new WebSocketServer({ noServer: true }); +server.on('upgrade', (req, socket, head) => { + const { pathname } = new URL(req.url, 'http://x'); + if (pathname === '/ws') { + wss.handleUpgrade(req, socket, head, (ws) => game.onConnection(ws)); + } else { + socket.destroy(); + } +}); + +const game = new GameServer(wss); + +server.listen(PORT, () => { + console.log(`[LifeTown] server listening on http://127.0.0.1:${PORT}`); +}); diff --git a/server/persist.mjs b/server/persist.mjs new file mode 100644 index 0000000..e2d0729 --- /dev/null +++ b/server/persist.mjs @@ -0,0 +1,97 @@ +// LifeTown Online — JSON-file persistence for player profiles. +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { START_MONEY, HOUSES } from '../shared/data.mjs'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const SAVE_DIR = path.join(__dirname, '..', 'saves'); +fs.mkdirSync(SAVE_DIR, { recursive: true }); + +function sanitizeName(name) { + return String(name || '').trim().replace(/[^A-Za-z0-9_\- ]/g, '').slice(0, 20); +} + +export function newProfile(name, appearance) { + const safe = sanitizeName(name); + return { + name: safe, + token: crypto.randomBytes(16).toString('hex'), + createdAt: Date.now(), + appearance, // character creator output (client-defined schema) + money: START_MONEY, + level: 1, xp: 0, // player level + job: null, // { id, rank, xp } + workLastAt: 0, + houseTier: 'studio', // owned house tier id + housesOwned: ['studio'], + homeLayout: [], // [{itemId,x,z,rot}] + inventory: [], // [{itemId}] purchased, not yet placed + relationships: {}, // npcId|playerName -> {f,r,status} + achievements: [], + stats: { shifts: 0, dances: 0, giftsSent: 0 }, + needs: null, // client-simulated; stored for continuity + moodlets: [], + }; +} + +export function loadProfile(name) { + const safe = sanitizeName(name); + const file = path.join(SAVE_DIR, `${safe.toLowerCase()}.json`); + try { + if (!fs.existsSync(file)) return null; + const raw = JSON.parse(fs.readFileSync(file, 'utf8')); + if (!raw || raw.name !== safe) return null; + // migrate defaults + const base = newProfile(safe, raw.appearance || defaultAppearance()); + const merged = { ...base, ...raw, name: safe }; + if (!Array.isArray(merged.homeLayout)) merged.homeLayout = []; + if (!Array.isArray(merged.inventory)) merged.inventory = []; + if (!Array.isArray(merged.achievements)) merged.achievements = []; + if (typeof merged.relationships !== 'object' || !merged.relationships) merged.relationships = {}; + if (typeof merged.stats !== 'object' || !merged.stats) merged.stats = { shifts: 0, dances: 0, giftsSent: 0 }; + return merged; + } catch { + return null; + } +} + +export function saveProfile(profile) { + const file = path.join(SAVE_DIR, `${profile.name.toLowerCase()}.json`); + const tmp = file + '.tmp'; + fs.writeFileSync(tmp, JSON.stringify(profile)); + fs.renameSync(tmp, file); +} + +export function defaultAppearance() { + return { + bodyType: 'average', height: 1, skin: '#eab98f', faceShape: 'round', + hair: 'short', hairColor: '#4a2f1d', eyes: 'round', eyeColor: '#3a5a40', + brows: 'natural', nose: 'button', mouth: 'smile', + outfit: { top: 'tee', bottom: 'jeans', dress: null, shoes: 'sneakers', accessory: 'none', topColor: '#4f8fd9', bottomColor: '#33415c' }, + personality: ['Friendly'], voice: 'warm', age: 25, + }; +} + +export function findProfileByToken(token) { + if (!token || typeof token !== 'string') return null; + for (const f of fs.readdirSync(SAVE_DIR)) { + if (!f.endsWith('.json')) continue; + try { + const p = JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8')); + if (p.token === token) return p; + } catch { /* skip corrupt */ } + } + return null; +} + +export function listProfileNames() { + try { + return fs.readdirSync(SAVE_DIR).filter(f => f.endsWith('.json')).map(f => { + try { return JSON.parse(fs.readFileSync(path.join(SAVE_DIR, f), 'utf8')).name; } catch { return null; } + }).filter(Boolean); + } catch { return []; } +} + +export { sanitizeName }; diff --git a/shared/data.mjs b/shared/data.mjs new file mode 100644 index 0000000..07924a8 --- /dev/null +++ b/shared/data.mjs @@ -0,0 +1,387 @@ +// ============================================================ +// LifeTown Online — shared game data (single source of truth) +// Imported by both the Node server and the browser client. +// All content is original to LifeTown Online. +// ============================================================ + +export const GAME_NAME = 'LifeTown Online'; +export const DISTRICT_NAME = 'Maple Court'; +export const TIME_SCALE = 60; // 1 real second = 1 game minute -> full day = 24 min +export const DAY_MINUTES = 1440; +export const START_MONEY = 1500; + +// ---------------- World layout (metres) --------------------- +export const WORLD = { + size: 150, // playable half-extent reference + roadX: { from: -70, to: 70, z: 0, width: 8 }, // main street along X + sidewalkZ: 6, + plaza: { x: 0, z: -26, r: 14 }, // central plaza + fountain + park: { x: 42, z: -30, w: 44, d: 40 }, // park with pond + spawn: { x: 0, z: 12 }, +}; + +// Points of interest: where players/NPCs can do things. +// kind: sit | eat | work | exercise | shop | fish | stage | jobs | fountain | swim +export const POIS = [ + { id: 'fountain', name: 'Copper Fountain', x: 0, z: -26, kind: 'fountain', radius: 3 }, + { id: 'stage', name: 'Plaza Stage', x: -18, z: -34, kind: 'stage', radius: 4 }, + { id: 'bench_park', name: 'Park Bench', x: 36, z: -22, kind: 'sit', radius: 2 }, + { id: 'bench_2', name: 'Street Bench', x: -30, z: 9, kind: 'sit', radius: 2 }, + { id: 'cafe', name: 'Daily Grind Café', x: -48, z: -14, kind: 'eat', radius: 3, jobWorkspot: true }, + { id: 'cityhall', name: 'City Hall Desk', x: 24, z: -16, kind: 'jobs', radius: 3 }, + { id: 'gym', name: 'Flex Den Gym', x: 54, z: -14, kind: 'exercise', radius: 3, jobWorkspot: true }, + { id: 'store', name: 'MapleMart Home Store',x: -66, z: -16, kind: 'shop', radius: 3 }, + { id: 'foodtruck', name: 'Taco Comet Truck', x: 12, z: 10, kind: 'eat', radius: 3 }, + { id: 'pond', name: 'Willow Pond', x: 46, z: -40, kind: 'fish', radius: 4 }, +]; + +// Buildings placed along the street (visual shells on the client). +export const BUILDINGS = [ + { id: 'cafe', name: 'Daily Grind Café', x: -48, z: -26, w: 16, d: 14, color: 0xf2b06b, roof: 0xd96c4f, sign: 'CAFÉ' }, + { id: 'cityhall', name: 'LifeTown City Hall', x: 24, z: -28, w: 22, d: 16, color: 0xe8e3d5, roof: 0x7fa88b, sign: 'CITY HALL' }, + { id: 'gym', name: 'Flex Den Gym', x: 54, z: -26, w: 16, d: 14, color: 0xc9d7f2, roof: 0x5b74a8, sign: 'GYM' }, + { id: 'store', name: 'MapleMart', x: -66, z: -28, w: 18, d: 14, color: 0xf7cdd3, roof: 0xb85c6e, sign: 'MAPLEMART' }, + { id: 'apt_a', name: 'Birch Apartments', x: -20, z: 22, w: 18, d: 14, color: 0xd9c9a8, roof: 0x8a6f56, sign: 'APARTMENTS' }, + { id: 'apt_b', name: 'Rowan Lofts', x: 6, z: 22, w: 18, d: 14, color: 0xcfd8c2, roof: 0x6f7f5a, sign: 'LOFTS' }, + { id: 'arcade', name: 'Pixel Porcupine Arcade', x: 38, z: 22, w: 16, d: 14, color: 0xd7c4ef, roof: 0x8a63b8, sign: 'ARCADE' }, +]; + +// ---------------- Building doors & enterable venues ---------------- +// Doors sit just outside each building's street-facing entrance. +export const DOORS = [ + { id: 'door_cafe', x: -48, z: -18.3, label: 'Enter Daily Grind Café', zone: 'venue:cafe' }, + { id: 'door_cityhall', x: 24, z: -19.2, label: 'Enter City Hall', zone: 'venue:cityhall' }, + { id: 'door_gym', x: 54, z: -18.3, label: 'Enter Flex Den Gym', zone: 'venue:gym' }, + { id: 'door_store', x: -66, z: -18.3, label: 'Enter MapleMart', zone: 'venue:store' }, + { id: 'door_arcade', x: 38, z: 13.9, label: 'Enter Pixel Porcupine Arcade', zone: 'venue:arcade' }, + { id: 'door_apt_a', x: -20, z: 13.9, label: 'Enter Your Apartment', zone: 'home:self' }, + { id: 'door_apt_b', x: 6, z: 13.9, label: 'Enter Your Apartment', zone: 'home:self' }, +]; + +// Public venue interiors: shared rooms every resident can walk into. +export const VENUES = { + 'venue:cafe': { + name: 'Daily Grind Café', size: [14, 10], + items: [ + { itemId: 'counter_kitchen', x: -2.5, z: -3.6, rot: 0 }, + { itemId: 'counter_kitchen', x: -0.5, z: -3.6, rot: 0 }, + { itemId: 'coffeemaker', x: 0.6, z: -3.7, rot: 0 }, + { itemId: 'microwave', x: -3.4, z: -3.7, rot: 0 }, + { itemId: 'table_dining', x: -4, z: 0.5, rot: 0 }, { itemId: 'stool', x: -4, z: 1.6, rot: 0 }, { itemId: 'stool', x: -4, z: -0.6, rot: 0 }, + { itemId: 'table_dining', x: 0.5, z: 0.5, rot: 0 }, { itemId: 'stool', x: 0.5, z: 1.6, rot: 0 }, { itemId: 'stool', x: 0.5, z: -0.6, rot: 0 }, + { itemId: 'loveseat', x: 5, z: 2.5, rot: Math.PI / 2 }, + { itemId: 'plant_monstera', x: 6, z: -3.5, rot: 0 }, + { itemId: 'paint_meadow', x: 0, z: -4.85, rot: 0 }, + { itemId: 'lamp_floor', x: 6.2, z: 3.8, rot: 0 }, + { itemId: 'rug_round', x: 0.25, z: 0.5, rot: 0 }, + ], + spots: [ + { x: -1.5, z: -2.6, label: 'Order a coffee ☕', act: 'eat' }, + { x: 5.5, z: 1.6, label: 'Lounge on the Loveseat', act: 'sit' }, + ], + }, + 'venue:cityhall': { + name: 'LifeTown City Hall', size: [16, 11], + items: [ + { itemId: 'table_desk', x: -3, z: -3, rot: 0 }, { itemId: 'chair_office', x: -3, z: -2.2, rot: Math.PI }, + { itemId: 'table_desk', x: 3, z: -3, rot: 0 }, { itemId: 'chair_office', x: 3, z: -2.2, rot: Math.PI }, + { itemId: 'shelf_books', x: -6.5, z: -4.6, rot: 0 }, + { itemId: 'shelf_cube', x: 6.5, z: -4.6, rot: 0 }, + { itemId: 'plant_tree', x: 0, z: -4.4, rot: 0 }, + { itemId: 'decor_clock', x: 0, z: -5.35, rot: 0 }, + { itemId: 'rug_long', x: 0, z: 1.5, rot: 0 }, + { itemId: 'lamp_arc', x: 0, z: 3.4, rot: 0 }, + ], + spots: [ + { x: -3, z: -1.6, label: 'Speak to the Career Desk 💼', act: 'jobs' }, + { x: 3, z: -1.6, label: 'Browse Property Records 🏡', act: 'property' }, + ], + }, + 'venue:gym': { + name: 'Flex Den Gym', size: [14, 10], + items: [ + { itemId: 'rug_shag', x: -4, z: -2.5, rot: 0 }, + { itemId: 'rug_shag', x: 4, z: -2.5, rot: 0 }, + { itemId: 'speaker', x: 0, z: -4.3, rot: 0 }, + { itemId: 'fridge_dual', x: -6.2, z: -4, rot: 0 }, + { itemId: 'plant_fern', x: 6.2, z: -4.2, rot: 0 }, + { itemId: 'tv_oled', x: 0, z: -4.75, rot: 0 }, + { itemId: 'sofa_modern', x: 5.5, z: 3.4, rot: Math.PI }, + ], + spots: [ + { x: -4, z: -1.4, label: 'Hit the Mats 🏋️', act: 'exercise' }, + { x: 4, z: -1.4, label: 'Cardio Corner 🏃', act: 'exercise' }, + ], + }, + 'venue:store': { + name: 'MapleMart Home Store', size: [16, 11], + items: [ + { itemId: 'shelf_cube', x: -5, z: -3.8, rot: 0 }, { itemId: 'shelf_cube', x: -3, z: -3.8, rot: 0 }, + { itemId: 'shelf_books', x: 5, z: -3.8, rot: 0 }, + { itemId: 'wardrobe_pine', x: 6.4, z: 0.5, rot: -Math.PI / 2 }, + { itemId: 'bed_cozy', x: -6, z: 1.5, rot: 0 }, + { itemId: 'table_coffee', x: 0.5, z: 0.8, rot: 0 }, + { itemId: 'sofa_basic', x: 2.5, z: 2.8, rot: Math.PI }, + { itemId: 'light_neon', x: 0, z: -5.2, rot: 0 }, + { itemId: 'plant_cactus', x: -6.8, z: -4.4, rot: 0 }, + { itemId: 'rug_round', x: 0.5, z: 0.8, rot: 0 }, + ], + spots: [ + { x: 0, z: -2.6, label: 'Browse the Catalogue 🛒', act: 'shop' }, + ], + }, + 'venue:arcade': { + name: 'Pixel Porcupine Arcade', size: [13, 10], + items: [ + { itemId: 'pc_tower', x: -4, z: -3.6, rot: 0 }, + { itemId: 'console', x: 0, z: -3.6, rot: 0 }, + { itemId: 'tv_flat', x: 0, z: -4.2, rot: 0 }, + { itemId: 'keyboard_elec', x: 4, z: -3.6, rot: 0 }, + { itemId: 'light_neon', x: -2.5, z: -5.05, rot: 0 }, + { itemId: 'light_neon', x: 2.5, z: -5.05, rot: 0 }, + { itemId: 'speaker', x: -6, z: -4.2, rot: 0 }, + { itemId: 'stool', x: -4, z: -2.6, rot: 0 }, { itemId: 'stool', x: 0, z: -2.6, rot: 0 }, { itemId: 'stool', x: 4, z: -2.6, rot: 0 }, + { itemId: 'rug_long', x: 0, z: 0.5, rot: 0 }, + ], + spots: [ + { x: 0, z: -2, label: 'Play the Cabinets 🕹️', act: 'arcade' }, + { x: 4.5, z: -2, label: 'Dance Machine 💃', act: 'stage' }, + ], + }, +}; + +// ---------------- Personality traits ------------------------ +export const TRAITS = ['Friendly','Romantic','Ambitious','Lazy','Creative','Athletic','Intelligent','Funny','Introverted','Extroverted','Generous','Competitive']; + +// ---------------- Jobs / careers ---------------------------- +// pay is TownCoins per work shift; xp accumulates toward promotion. +export const JOBS = [ + { id:'barista', title:'Barista', poi:'cafe', basePay: 55, hours:[6,14], + ranks:['Trainee Barista','Barista','Head Barista','Café Manager','Coffee Boss'] }, + { id:'chef', title:'Chef', poi:'foodtruck', basePay: 70, hours:[10,19], + ranks:['Kitchen Hand','Line Cook','Sous Chef','Head Chef','Star Chef'] }, + { id:'programmer', title:'Programmer', poi:'cityhall',basePay: 90, hours:[9,17], + ranks:['Junior Developer','Software Engineer','Senior Engineer','Lead Architect','Tech Executive'] }, + { id:'designer', title:'Designer', poi:'store', basePay: 75, hours:[10,18], + ranks:['Design Intern','Junior Designer','Product Designer','Art Director','Creative Director'] }, + { id:'doctor', title:'Doctor', poi:'gym', basePay: 110, hours:[8,16], + ranks:['Resident','Physician','Surgeon','Chief of Staff','Medical Director'] }, + { id:'teacher', title:'Teacher', poi:'cityhall',basePay: 65, hours:[8,15], + ranks:['Substitute','Teacher','Senior Teacher','Dean','Principal'] }, + { id:'police', title:'Police Officer', poi:'cityhall',basePay: 80, hours:[7,15], + ranks:['Cadet','Officer','Sergeant','Lieutenant','Commissioner'] }, + { id:'engineer', title:'Engineer', poi:'gym', basePay: 95, hours:[9,17], + ranks:['Assistant Engineer','Engineer','Project Engineer','Principal Engineer','Director of Works'] }, + { id:'musician', title:'Musician', poi:'stage', basePay: 50, hours:[17,23], + ranks:['Street Performer','Gig Musician','Band Leader','Recording Artist','Headliner'] }, + { id:'influencer', title:'Influencer', poi:'plaza', basePay: 45, hours:[11,19], + ranks:['Newcomer','Micro-Influencer','Trendsetter','Star Creator','Town Icon'] }, + { id:'athlete', title:'Athlete', poi:'gym', basePay: 60, hours:[7,13], + ranks:['Rookie','Amateur','Pro','Champion','Legend'] }, + { id:'artist', title:'Artist', poi:'pond', basePay: 40, hours:[10,16], + ranks:['Sketcher','Painter','Gallery Artist','Master Artist','Living Legend'] }, +]; +export const XP_PER_RANK = [0, 100, 260, 520, 900]; // cumulative xp needed to reach rank i +export const WORK_SECONDS = 6; // real seconds per shift +export const WORK_COOLDOWN = 25; // real seconds between shifts (server-enforced) + +// ---------------- Needs ------------------------------------ +export const NEEDS = ['hunger','energy','hygiene','fun','social','comfort']; // 0..100 +export const NEED_DECAY_PER_GAME_HOUR = { + hunger: 4.2, energy: 3.6, hygiene: 2.4, fun: 3.0, social: 2.6, comfort: 2.0, +}; +export const MOOD_NEEDS = [ // weight of each need into mood (-100..100 scale) + ['hunger',1.0],['energy',0.8],['hygiene',0.5],['fun',1.0],['social',0.8],['comfort',0.6] +]; + +// ---------------- Furniture catalogue ----------------------- +// fx: multipliers/capacities used by interactable objects at home. +// cat: bed sofa table chair tv pc fridge stove shower toilet sink wardrobe shelf plant decor light rug paint elec +export const CATALOG = [ + // Beds + { id:'bed_basic', cat:'bed', name:'Dreamer Single Bed', price:180, lvl:1, w:2, d:3, fx:{ energyRate:1.0 } }, + { id:'bed_cozy', cat:'bed', name:'Cloudline Double Bed', price:420, lvl:2, w:2.4, d:3, fx:{ energyRate:1.2 } }, + { id:'bed_lux', cat:'bed', name:'Aurora Velvet Bed', price:980, lvl:4, w:2.6, d:3.2, fx:{ energyRate:1.45 } }, + { id:'bed_kids', cat:'bed', name:'Rocket Bunk Bed', price:260, lvl:1, w:2.2, d:3, fx:{ energyRate:1.05 } }, + // Sofas + { id:'sofa_basic', cat:'sofa', name:'Comfi Couch', price:240, lvl:1, w:3, d:1.4, fx:{ comfortRate:1.1 } }, + { id:'sofa_modern',cat:'sofa', name:'Pebble Modular Sofa', price:520, lvl:3, w:3.4, d:1.6, fx:{ comfortRate:1.25 } }, + { id:'loveseat', cat:'sofa', name:'Sweetheart Loveseat', price:300, lvl:2, w:2.2, d:1.3, fx:{ comfortRate:1.15, romanceAura:true } }, + { id:'sofa_lux', cat:'sofa', name:'Velvet Cloud Sectional', price:1100, lvl:5, w:3.8, d:1.8, fx:{ comfortRate:1.4 } }, + // Tables + { id:'table_dining',cat:'table', name:'Oakmeet Dining Table', price:220, lvl:1, w:2.6, d:1.6, fx:{} }, + { id:'table_coffee',cat:'table',name:'Roundabout Coffee Table', price:130, lvl:1, w:1.4, d:1.4, fx:{ comfortRate:1.05 } }, + { id:'table_desk', cat:'table', name:'Focus Desk', price:190, lvl:1, w:2, d:1, fx:{ productivity:1.1 } }, + // Chairs + { id:'chair_wood', cat:'chair', name:'Simple Chair', price:60, lvl:1, w:0.9, d:0.9, fx:{} }, + { id:'chair_office',cat:'chair',name:'Ergo Glide Chair', price:170, lvl:2, w:1, d:1, fx:{ productivity:1.15 } }, + { id:'stool', cat:'chair', name:'Pop Stool', price:45, lvl:1, w:0.7, d:0.7, fx:{} }, + // TV & electronics + { id:'tv_flat', cat:'tv', name:'SlimView 40"', price:400, lvl:2, w:1.6, d:0.4, fx:{ funRate:1.2 } }, + { id:'tv_oled', cat:'tv', name:'CinemaWall 65"', price:1400, lvl:5, w:2.2, d:0.5, fx:{ funRate:1.5 } }, + { id:'pc_tower', cat:'pc', name:'Quanta PC Setup', price:800, lvl:3, w:1.8, d:0.8, fx:{ funRate:1.25, productivity:1.3 } }, + { id:'console', cat:'elec', name:'PlayBox Console', price:350, lvl:2, w:0.6, d:0.4, fx:{ funRate:1.3 } }, + { id:'speaker', cat:'elec', name:'BoomBerry Speaker', price:150, lvl:1, w:0.5, d:0.5, fx:{ funRate:1.1 } }, + { id:'fridge_retro',cat:'fridge',name:'Mint Fridge', price:320, lvl:1, w:1.2, d:1, fx:{ hungerRate:1.1 } }, + { id:'fridge_dual',cat:'fridge',name:'TwinFrost Deluxe', price:700, lvl:3, w:1.4, d:1, fx:{ hungerRate:1.25 } }, + { id:'stove_basic',cat:'stove',name: 'HeatWhirl Oven', price:290, lvl:1, w:1.2, d:0.9, fx:{ cookSpeed:1.0 } }, + { id:'stove_pro', cat:'stove',name:'ChefBlaze Range', price:650, lvl:3, w:1.4, d:0.9, fx:{ cookSpeed:1.35 } }, + // Bathroom + { id:'shower_std', cat:'shower',name:'RainRing Shower', price:260, lvl:1, w:1.2, d:1.2, fx:{ hygieneRate:1.0 } }, + { id:'shower_lux', cat:'shower',name:'Falls Spa Shower', price:600, lvl:4, w:1.4, d:1.4, fx:{ hygieneRate:1.35 } }, + { id:'toilet_std', cat:'toilet',name:'Porcelain Throne', price:140, lvl:1, w:0.9, d:1.1, fx:{} }, + { id:'sink_std', cat:'sink', name:'Bubble Basin', price:110, lvl:1, w:1, d:0.6, fx:{ hygieneRate:1.08 } }, + { id:'bath_tub', cat:'shower',name:'Soakstar Tub', price:480, lvl:3, w:1.8, d:1, fx:{ hygieneRate:1.2, comfortRate:1.05 } }, + // Storage + { id:'wardrobe_pine',cat:'wardrobe',name:'Pinewood Wardrobe', price:210, lvl:1, w:1.6, d:0.8, fx:{} }, + { id:'wardrobe_walk',cat:'wardrobe',name:'Walk-In Closet Set', price:850, lvl:5, w:2.6, d:1.2, fx:{ comfortRate:1.1 } }, + { id:'shelf_books',cat:'shelf', name:'Storyline Bookshelf', price:160, lvl:1, w:1.6, d:0.5, fx:{ funRate:1.05 } }, + { id:'shelf_cube', cat:'shelf', name:'CubeGrid Shelving', price:190, lvl:2, w:1.8, d:0.5, fx:{} }, + { id:'dresser', cat:'wardrobe',name:'Morningview Dresser', price:180, lvl:1, w:1.4, d:0.7, fx:{} }, + // Plants & decor + { id:'plant_fern', cat:'plant', name:'Feather Fern', price:40, lvl:1, w:0.8, d:0.8, fx:{ comfortRate:1.03 } }, + { id:'plant_monstera',cat:'plant',name:'Monstera Magnum', price:90, lvl:2, w:1, d:1, fx:{ comfortRate:1.06 } }, + { id:'plant_cactus',cat:'plant',name:'Spike Buddy Cactus', price:30, lvl:1, w:0.5, d:0.5, fx:{} }, + { id:'plant_tree', cat:'plant', name:'Indoor Fig Tree', price:140, lvl:2, w:1.2, d:1.2, fx:{ comfortRate:1.08 } }, + { id:'decor_vase', cat:'decor', name:'Sunset Vase', price:55, lvl:1, w:0.5, d:0.5, fx:{} }, + { id:'decor_clock',cat:'decor', name:'Ticktock Wall Clock', price:70, lvl:1, w:0.6, d:0.2, fx:{} }, + { id:'decor_mirror',cat:'decor',name:'Glimmer Mirror', price:120, lvl:2, w:1, d:0.2, fx:{ hygieneRate:1.05 } }, + { id:'decor_aquarium',cat:'decor',name:'Ripple Aquarium', price:380, lvl:3, w:1.2, d:0.6, fx:{ funRate:1.15, comfortRate:1.05 } }, + { id:'rug_round', cat:'rug', name:'Circle Breeze Rug', price:80, lvl:1, w:2.4, d:2.4, fx:{ comfortRate:1.04 } }, + { id:'rug_long', cat:'rug', name:'Hallway Runner', price:65, lvl:1, w:3, d:1.2, fx:{} }, + { id:'rug_shag', cat:'rug', name:'Cloudpile Shag Rug', price:150, lvl:2, w:2.8, d:2, fx:{ comfortRate:1.08 } }, + // Lighting + { id:'lamp_floor', cat:'light',name:'Halo Floor Lamp', price:95, lvl:1, w:0.7, d:0.7, fx:{ comfortRate:1.04 } }, + { id:'lamp_table', cat:'light',name:'Dusk Table Lamp', price:60, lvl:1, w:0.5, d:0.5, fx:{} }, + { id:'lamp_arc', cat:'light',name:'Arcline Lamp', price:230, lvl:3, w:1.2, d:0.6, fx:{ comfortRate:1.08 } }, + { id:'light_neon', cat:'light',name:'NeonWave Strip', price:180, lvl:2, w:2, d:0.2, fx:{ funRate:1.1 } }, + // Paintings + { id:'paint_meadow',cat:'paint',name:'Meadow Morning Print', price:85, lvl:1, w:1.2, d:0.1, fx:{ comfortRate:1.04 } }, + { id:'paint_abstract',cat:'paint',name:'Confetti Abstract', price:130, lvl:2, w:1.4, d:0.1, fx:{ funRate:1.05 } }, + { id:'paint_portrait',cat:'paint',name:'Ancestral Portrait', price:200, lvl:3, w:1.2, d:0.1, fx:{ comfortRate:1.06 } }, + // Kitchen extras / misc + { id:'counter_kitchen',cat:'table',name:'Prep Counter', price:240, lvl:2, w:1.8, d:0.9, fx:{ cookSpeed:1.1 } }, + { id:'microwave', cat:'elec', name:'ZapWave Microwave', price:130, lvl:1, w:0.8, d:0.6, fx:{ cookSpeed:1.08 } }, + { id:'coffeemaker',cat:'elec', name:'BrewBuddy Machine', price:110, lvl:1, w:0.6, d:0.5, fx:{ energyRate:1.05 } }, + { id:'guitar', cat:'decor',name:'Starwood Guitar', price:220, lvl:2, w:0.6, d:0.4, fx:{ funRate:1.15 } }, + { id:'keyboard_elec',cat:'elec',name:'MelodyKeys Synth', price:260, lvl:2, w:1.4, d:0.5, fx:{ funRate:1.15 } }, + { id:'easel', cat:'decor',name:'Vision Easel', price:150, lvl:2, w:1, d:0.8, fx:{ funRate:1.1 } }, + { id:'teddy', cat:'decor',name:'Mr. Snugglesworth', price:45, lvl:1, w:0.5, d:0.5, fx:{ comfortRate:1.05, romanceAura:true } }, +]; +export const CATALOG_BY_ID = Object.fromEntries(CATALOG.map(c => [c.id, c])); + +// ---------------- Housing tiers ----------------------------- +export const HOUSES = [ + { id:'studio', name:'Willow Studio Apartment', price:0, lvl:1, size:[11,9] }, + { id:'small', name:'Clover Small House', price:4500, lvl:3, size:[13,11] }, + { id:'medium', name:'Brookside Medium House', price:12000, lvl:6, size:[16,12] }, + { id:'large', name:'Hillcrest Large House', price:28000, lvl:10, size:[19,14] }, + { id:'luxury', name:'Lakeside Luxury Villa', price:60000, lvl:15, size:[22,16] }, + { id:'mansion',name:'Starfall Mansion', price:125000,lvl:22, size:[26,20] }, +]; + +// ---------------- NPCs (all original) ----------------------- +// schedule entries: [startHour, poiId] — walks to POIs through the day. +export const NPCS = [ + { id:'maya', name:'Maya Bloom', role:'Barista', color:0xe8875f, hairColor:0x3b2417, traits:['Friendly','Extroverted'], + schedule:[[6,'cafe'],[12,'plaza'],[14,'cafe'],[18,'stage'],[21,'fountain']], home:'cafe' }, + { id:'felix', name:'Felix Grant', role:'Programmer', color:0x5f86e8, hairColor:0x20180f, traits:['Intelligent','Introverted'], + schedule:[[8,'cityhall'],[12,'foodtruck'],[13,'cityhall'],[18,'arcade'],[22,'apt_b']], home:'apt_b' }, + { id:'rosa', name:'Rosa Vega', role:'Chef', color:0xd95f8a, hairColor:0x141414, traits:['Creative','Generous'], + schedule:[[9,'foodtruck'],[14,'foodtruck'],[18,'cafe'],[21,'plaza']], home:'apt_a' }, + { id:'kenji', name:'Kenji Sato', role:'Personal Coach', color:0x67b56a, hairColor:0x101010, traits:['Athletic','Competitive'], + schedule:[[6,'gym'],[12,'park'],[14,'gym'],[19,'stage'],[22,'apt_a']], home:'apt_a' }, + { id:'nadia', name:'Nadia Sol', role:'Artist', color:0xefcf62, hairColor:0x7a3d1e, traits:['Creative','Lazy'], + schedule:[[9,'pond'],[13,'park'],[16,'store'],[19,'plaza'],[22,'apt_b']], home:'apt_b' }, + { id:'theo', name:'Theo Marsh', role:'Shop Clerk', color:0x8fb7d9, hairColor:0x59391f, traits:['Funny','Generous'], + schedule:[[8,'store'],[13,'foodtruck'],[14,'store'],[19,'arcade'],[22,'apt_a']], home:'apt_a' }, + { id:'ivy', name:'Ivy Lane', role:'Musician', color:0xb58fd9, hairColor:0x2e1e47, traits:['Romantic','Creative'], + schedule:[[10,'stage'],[14,'cafe'],[17,'pond'],[20,'stage'],[23,'apt_b']], home:'apt_b' }, + { id:'bruno', name:'Bruno Steel', role:'Engineer', color:0xd97f4f, hairColor:0x2a2a2a, traits:['Ambitious','Competitive'], + schedule:[[7,'gym'],[9,'cityhall'],[12,'foodtruck'],[13,'cityhall'],[19,'arcade'],[22,'apt_a']], home:'apt_a' }, + { id:'cleo', name:'Cleo Brooks', role:'Reporter', color:0x7fd9c2, hairColor:0x8a2f2f, traits:['Extroverted','Funny'], + schedule:[[8,'plaza'],[11,'cafe'],[14,'cityhall'],[18,'stage'],[21,'fountain']], home:'apt_b' }, + { id:'oliver',name:'Oliver Finch', role:'Doctor', color:0xa8d97f, hairColor:0x4a3018, traits:['Intelligent','Generous'], + schedule:[[7,'gym'],[9,'cityhall'],[12,'cafe'],[13,'cityhall'],[19,'pond'],[22,'apt_a']], home:'apt_a' }, +]; +// POI anchor points for NPC schedule targets that are areas rather than POIs: +export const AREA_POINTS = { plaza:{x:0,z:-26}, park:{x:42,z:-26} }; + +// ---------------- Social interactions ----------------------- +// req: minimum friendship(f)/romance(r) level. fx: relationship deltas & mood. +// anim: animation key both parties play. line templates may include {name}. +export const INTERACTIONS = [ + { id:'hello', group:'Friendly', label:'Say Hello', req:{f:0}, df:2, dr:0, anim:'wave', + lines:['Hi there, {name}!','Hello hello!','Hey {name}, good to see you!'] }, + { id:'chat', group:'Friendly', label:'Chat', req:{f:0}, df:4, dr:0, anim:'talk', + lines:['Did you see the plaza fountain? Gorgeous.','I heard the café has a new roast.','Weather has been lovely lately.'] }, + { id:'joke', group:'Friendly', label:'Tell Joke', req:{f:5}, df:5, dr:0, anim:'laugh', moodFx:{fun:+8}, + lines:['Why did the scarecrow move to LifeTown? He heard the fields were outstanding!','I told my fridge a joke… it didn\'t get it, cold shoulder.'] }, + { id:'compliment',group:'Friendly',label:'Compliment', req:{f:5}, df:6, dr:1, anim:'talk', moodFx:{social:+6}, + lines:['{name}, that outfit really suits you!','You always brighten my day, {name}.'] }, + { id:'highfive', group:'Friendly', label:'High Five', req:{f:10}, df:5, dr:0, anim:'highfive', moodFx:{fun:+5}, + lines:['High five!','Up top!'] }, + { id:'story', group:'Friendly', label:'Tell Story', req:{f:15}, df:7, dr:0, anim:'talk', moodFx:{fun:+6}, + lines:['So yesterday, a pigeon stole my sandwich…','Let me tell you about the great taco incident.'] }, + { id:'hug', group:'Friendly', label:'Hug', req:{f:25}, df:8, dr:1, anim:'hug', moodFx:{social:+10,comfort:+6}, + lines:['Come here, you!','Group hug… well, duo hug.'] }, + { id:'flirt', group:'Romantic', label:'Flirt', req:{r:0}, df:1, dr:4, anim:'flirt', + lines:['Is your name Wi-Fi? Because I feel a connection.','Do you believe in love at first sight, {name}?'] }, + { id:'romantic_compliment',group:'Romantic',label:'Romantic Compliment',req:{r:10}, df:1, dr:6, anim:'flirt', moodFx:{social:+8}, + lines:['Your smile could light up City Hall, {name}.','Every day with you feels like a festival.'] }, + { id:'gift_small',group:'Romantic',label:'Give Flowers', req:{r:10}, cost:40, df:2, dr:8, anim:'gift', moodFx:{social:+8}, + lines:['These made me think of you!','A little something beautiful, for someone beautiful.'] }, + { id:'holdhands',group:'Romantic', label:'Hold Hands', req:{r:25}, df:2, dr:7, anim:'holdhands', moodFx:{social:+9,comfort:+5}, + lines:['Shall we?','Just like this, {name}.'] }, + { id:'date', group:'Romantic', label:'Ask on Date', req:{r:35}, df:3, dr:9, anim:'celebrate', moodFx:{fun:+12,social:+12}, + lines:['Dinner at the café, just us two?','It\'s a date, {name}!'] }, + { id:'kiss', group:'Romantic', label:'Kiss', req:{r:50}, df:2, dr:10, anim:'kiss', moodFx:{social:+14,happiness:10}, + lines:['❤️','Mwah!'] }, + { id:'dance', group:'Funny', label:'Dance Together', req:{f:10}, df:4, dr:1, anim:'dance', moodFx:{fun:+14}, + lines:['Show me your moves!','DJ, drop the beat!'] }, + { id:'funnyface',group:'Funny', label:'Make Funny Face', req:{f:5}, df:4, dr:0, anim:'laugh', moodFx:{fun:+8}, + lines:['>:P','Boing! Look at my face!'] }, + { id:'insult', group:'Negative', label:'Insult', req:{f:0}, df:-12,dr:-6, anim:'argue', moodFx:{happiness:-10}, + lines:['Nice going, clumsy!','Did you even try today?'] }, + { id:'argue', group:'Negative', label:'Argue', req:{f:10}, df:-15,dr:-10,anim:'argue', moodFx:{happiness:-14}, + lines:['That\'s not what we agreed!','You always say that!'] }, + { id:'apologize',group:'Negative', label:'Apologize', req:{f:0}, df:10, dr:2, anim:'talk', moodFx:{happiness:6}, + lines:['I\'m sorry, truly.','Can we start over, {name}?'] }, +]; +export const INTERACTION_BY_ID = Object.fromEntries(INTERACTIONS.map(i=>[i.id,i])); + +// Friendship thresholds (spec): 0 stranger, 10 acquaintance, 25 friend, 50 close friend, 75 best friend. +export const FRIEND_TIERS = [ + { at:0, label:'Stranger' }, { at:10, label:'Acquaintance' }, { at:25, label:'Friend' }, + { at:50, label:'Close Friend' }, { at:75, label:'Best Friend' }, +]; +export const ROMANCE_PARTNER_AT = 100; +export const REL_MAX = 100; + +// ---------------- Moods ------------------------------------- +export const MOODS = ['Happy','Excited','Relaxed','Confident','Inspired','Romantic','Sad','Angry','Bored','Embarrassed','Lonely','Tired','Stressed']; +export const MOOD_EMOJI = { Happy:'😊', Excited:'🤩', Relaxed:'😌', Confident:'😎', Inspired:'💡', Romantic:'😍', + Sad:'😢', Angry:'😠', Bored:'😐', Embarrassed:'😳', Lonely:'🥺', Tired:'🥱', Stressed:'😖', Neutral:'🙂' }; + +// ---------------- Weather ---------------------------------- +export const WEATHER_STATES = ['sunny','cloudy','rain','storm','fog']; +export const WEATHER_LABEL = { sunny:'Sunny', cloudy:'Cloudy', rain:'Rain', storm:'Storm', fog:'Fog' }; +export const WEATHER_EMOJI = { sunny:'☀️', cloudy:'⛅', rain:'🌧️', storm:'⛈️', fog:'🌫️' }; + +// ---------------- Achievements ------------------------------ +export const ACHIEVEMENTS = [ + { id:'first_home', name:'First Home', desc:'Buy your first house.' }, + { id:'social_butterfly', name:'Social Butterfly', desc:'Make 20 friends (friendship ≥ 25).' }, + { id:'millionaire', name:'Millionaire', desc:'Hold 1,000,000 TownCoins.' }, + { id:'heartbreaker', name:'Heartbreaker', desc:'Reach partner status 5 times.' }, + { id:'interior_designer', name:'Interior Designer', desc:'Place 25 furniture items.' }, + { id:'employed', name:'Day One', desc:'Get your first job.' }, + { id:'promoted', name:'Moving On Up', desc:'Earn any promotion.' }, + { id:'party_animal', name:'Party Animal', desc:'Dance at 3 events.' }, +]; + +// ---------------- Emotes ----------------------------------- +export const EMOTES = ['wave','dance','laugh','sad','angry','sleep','sit']; + +// Chat moderation: basic length cap. +export const MAX_CHAT_LEN = 240; diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..b325549 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "strict": true, + "noUnusedLocals": false, + "noUnusedParameters": false, + "skipLibCheck": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["client/src"] +} diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 0000000..87208b0 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,21 @@ +import { defineConfig } from 'vite'; + +export default defineConfig({ + root: 'client', + base: './', + build: { + target: 'es2020', + chunkSizeWarningLimit: 1600, + rollupOptions: { + output: { + manualChunks: { three: ['three'] } + } + } + }, + server: { + port: 5199, + proxy: { + '/ws': { target: 'ws://127.0.0.1:7788', ws: true } + } + } +});