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
This commit is contained in:
deepseek
2026-08-23 07:01:24 +00:00
commit 93b018e42b
35 changed files with 7785 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
node_modules/
dist/
saves/
*.log
.DS_Store
+123
View File
@@ -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.
+197
View File
@@ -0,0 +1,197 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover" />
<meta name="theme-color" content="#141826" />
<title>LifeTown Online</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ccircle cx='50' cy='50' r='46' fill='%23ff7e5f'/%3E%3Cpath d='M28 62V44l22-18 22 18v18a4 4 0 0 1-4 4H32a4 4 0 0 1-4-4z' fill='%23fff'/%3E%3C/svg%3E" />
</head>
<body>
<div id="app">
<canvas id="gl"></canvas>
<!-- ===== Title / login screen ===== -->
<div id="screen-title" class="screen">
<div class="title-sky"></div>
<div class="title-card glass">
<div class="wordmark">Life<span>Town</span></div>
<div class="tagline">your town · your people · your story</div>
<div class="field-row">
<input id="inp-name" maxlength="20" placeholder="Choose your name…" autocomplete="off" spellcheck="false"/>
</div>
<button id="btn-play" class="btn btn-primary btn-big">Enter LifeTown</button>
<button id="btn-newchar" class="btn btn-ghost">Create New Character</button>
<div class="hint" id="title-hint"></div>
</div>
<div class="title-foot">An original multiplayer life simulation · v0.1 MVP</div>
</div>
<!-- ===== Character creator ===== -->
<div id="screen-creator" class="screen hidden">
<div class="creator-layout">
<div id="creator-preview"></div>
<div class="creator-panel glass">
<div class="wordmark small">Design your resident</div>
<div class="creator-scroll">
<section>
<label>Name</label>
<input id="cr-name" maxlength="20" placeholder="Your name" autocomplete="off"/>
<label>Age · <span id="cr-age-val">25</span></label>
<input type="range" id="cr-age" min="18" max="70" value="25"/>
</section>
<section>
<label>Body</label>
<div class="chips" data-cr="bodyType"></div>
<label>Height</label>
<input type="range" id="cr-height" min="85" max="112" value="97"/>
</section>
<section><label>Skin tone</label><div class="swatches" data-cr="skin"></div></section>
<section>
<label>Hair style</label><div class="chips" data-cr="hair"></div>
<label>Hair colour</label><div class="swatches" data-cr="hairColor"></div>
</section>
<section><label>Eyes</label><div class="chips" data-cr="eyes"></div>
<label>Eye colour</label><div class="swatches" data-cr="eyeColor"></div></section>
<section>
<label>Brows</label><div class="chips" data-cr="brows"></div>
<label>Nose</label><div class="chips" data-cr="nose"></div>
<label>Mouth</label><div class="chips" data-cr="mouth"></div>
</section>
<section>
<label>Outfit top</label><div class="chips" data-cr="outfitTop"></div>
<label>Top colour</label><div class="swatches" data-cr="topColor"></div>
<label>Bottom</label><div class="chips" data-cr="outfitBottom"></div>
<label>Bottom colour</label><div class="swatches" data-cr="bottomColor"></div>
<label>Shoes</label><div class="chips" data-cr="shoes"></div>
</section>
<section><label>Accessory</label><div class="chips" data-cr="accessory"></div></section>
<section>
<label>Personality <span class="dim">(pick up to 3)</span></label>
<div class="chips multi" data-cr="personality"></div>
</section>
<section>
<label>Voice</label><div class="chips" data-cr="voice"></div>
</section>
</div>
<button id="btn-startlife" class="btn btn-primary btn-big">Start My Life ✨</button>
</div>
</div>
</div>
<!-- ===== In-game HUD ===== -->
<div id="hud" class="hidden">
<!-- top bar -->
<div id="topbar">
<div class="pill money-pill" title="TownCoins"><span class="coin"></span><span id="tb-money">0</span></div>
<div class="pill clock-pill"><span id="tb-clock">07:30</span> · <span id="tb-day">Day 1</span></div>
<div class="pill weather-pill"><span id="tb-weather">☀️</span><span id="tb-zone">Maple Court</span></div>
<div class="pill online-pill" title="Residents online"><span>👥</span><span id="tb-online">1</span></div>
<div class="top-actions">
<button class="iconbtn" id="btn-audio" title="Sound">🔊</button>
<button class="iconbtn" id="btn-fullscreen" title="Fullscreen"></button>
</div>
</div>
<!-- notifications -->
<div id="toasts"></div>
<!-- right side: quests / context -->
<div id="context-panel" class="glass">
<div class="cp-title">✦ Now</div>
<div id="cp-content"></div>
</div>
<!-- bottom bar -->
<div id="bottombar">
<div id="need-rings"></div>
<div id="mood-chip" class="glass"><span id="mood-emoji">🙂</span><span id="mood-label">Okay</span></div>
<div id="action-bar">
<button class="abtn" data-act="interact">👋<span>Act</span></button>
<button class="abtn" data-act="emote">💃<span>Dance</span></button>
<button class="abtn" data-act="phone">📱<span>Phone</span></button>
<button class="abtn" data-act="map">🗺️<span>Map</span></button>
<button class="abtn" data-act="bag">🎒<span>Bag</span></button>
<button class="abtn" data-act="home">🏠<span>Home</span></button>
<button class="abtn" data-act="build">🔨<span>Build</span></button>
</div>
</div>
<!-- interaction prompt -->
<div id="prompt" class="hidden"><kbd id="prompt-key">E</kbd><span id="prompt-text"></span></div>
<!-- speech bubble layer anchor -->
<div id="bubble-layer"></div>
<!-- chat -->
<div id="chat-panel">
<div id="chat-log"></div>
<div class="chat-input-row">
<input id="chat-input" placeholder="Say something… (Enter)" maxlength="240" autocomplete="off"/>
<select id="chat-channel"><option value="say">Say</option><option value="global">Global</option></select>
</div>
</div>
<!-- mobile controls -->
<div id="touch-ui" class="hidden">
<div id="joy-base"><div id="joy-knob"></div></div>
<div id="touch-buttons">
<button id="tb-interact"></button>
<button id="tb-jump-run">🏃</button>
</div>
</div>
<!-- interaction wheel -->
<div id="wheel" class="hidden glass"></div>
<!-- phone -->
<div id="phone" class="hidden">
<div class="phone-frame">
<div class="phone-top"><span id="phone-clock">07:30</span><span>LTE ◉◉◉</span></div>
<div id="phone-screen"></div>
<div class="phone-dock" id="phone-dock"></div>
</div>
</div>
<!-- build mode UI -->
<div id="buildbar" class="hidden">
<div class="bb-title">🔨 Build Mode</div>
<div class="bb-tools">
<button class="tool active" data-tool="place">🛋 Place</button>
<button class="tool" data-tool="move">✥ Move</button>
<button class="tool" data-tool="rotate">⟳ Rotate</button>
<button class="tool" data-tool="duplicate">⧉ Copy</button>
<button class="tool" data-tool="delete">🗑 Sell</button>
</div>
<button id="btn-shop" class="btn btn-accent">🛒 Shop Furniture</button>
<button id="btn-exit-build" class="btn btn-ghost">✓ Live Mode</button>
</div>
<!-- catalog modal -->
<div id="catalog" class="modal hidden"><div class="modal-card glass">
<div class="modal-head"><span>🛒 MapleMart Catalogue</span><button class="iconbtn" id="catalog-close"></button></div>
<div class="cat-tabs" id="cat-tabs"></div>
<div id="cat-grid" class="cat-grid"></div>
</div></div>
<!-- inventory modal -->
<div id="inventory" class="modal hidden"><div class="modal-card glass">
<div class="modal-head"><span>🎒 Inventory</span><button class="iconbtn" id="inv-close"></button></div>
<div id="inv-grid" class="cat-grid"></div>
</div></div>
<!-- generic dialog -->
<div id="dialog" class="modal hidden"><div class="modal-card glass small">
<div class="modal-head"><span id="dialog-title"></span><button class="iconbtn" id="dialog-close"></button></div>
<div id="dialog-body"></div>
</div></div>
<!-- minimap -->
<canvas id="minimap" width="180" height="180"></canvas>
</div>
<div id="loading" class="hidden"><div class="loader-ring"></div><div id="loading-text">Warming up LifeTown…</div></div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+162
View File
@@ -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<TrackId, number> = { 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 });
+175
View File
@@ -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<string>();
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;
}
}
+463
View File
@@ -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<string, string> = {
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,
};
}
+107
View File
@@ -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); }
}
+351
View File
@@ -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 [];
}
}
+166
View File
@@ -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); }
+369
View File
@@ -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<string, THREE.Mesh>();
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);
}
+89
View File
@@ -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'));
}
}
}
}
+2
View File
@@ -0,0 +1,2 @@
// Ambient declarations for JS modules shared with the Node server.
declare module '*.mjs';
+666
View File
@@ -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<string, number> = { 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', '', `<b>Career desk</b><br><span class="dim">${p?.job ? 'You can work shifts at your workplace (see Phone → Jobs).' : 'Choose your calling below — you can change careers anytime.'}</span>`)).style.marginBottom = '12px';
for (const j of JOBS.slice(0, 12)) {
const active = p?.job?.id === j.id;
const row = el('div', 'stat-row', `<span><b>${j.title}</b> <span class="dim">· ${j.ranks[0]}</span></span>
<span style="display:flex;gap:8px;align-items:center"><span class="dim">${j.basePay} ◉</span>${active ? '<span class="tx-in">Current ✓</span>' : '<button class="btn btn-accent" style="padding:6px 13px;font-size:.78rem">Apply</button>'}</span>`);
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); }
});
+88
View File
@@ -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'); });
+76
View File
@@ -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<string, RelEntry>;
achievements: string[];
stats: { shifts?: number; dances?: number; giftsSent?: number; partners?: number };
appearance: any; needs: Record<Need, number> | 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<string, Set<Handler>>();
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:<name>'
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<Need, number>,
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()}`; }
+376
View File
@@ -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; }
}
+100
View File
@@ -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<string, number>,
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<Record<Need | 'happiness' | 'energy' | 'fun', number>>) {
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);
});
+281
View File
@@ -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<string>(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' : ''), `
<div class="cat-ico">${CAT_EMOJI[item.cat] || '📦'}</div>
<div class="ci-name">${item.name}</div>
<div class="fx-note">${fxNotes.slice(0, 2).join(' · ')}</div>
<div class="ci-meta"><span class="ci-price">${item.price.toLocaleString()} ◉</span><span>Lv ${item.lvl}${ownedCount ? ` · own ×${ownedCount}` : ''}</span></div>`);
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', `
<div class="cat-ico">${CAT_EMOJI[item.cat] || '📦'}</div>
<div class="ci-name">${item.name}</div>
<div class="ci-meta"><span>Sell ${Math.round(item.price * .6)} ◉</span></div>`);
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', `<span><b>${h.name}</b><br><span class="dim" style="font-weight:600">${h.size[0]}×${h.size[1]} m · Level ${h.lvl}+</span></span>
${owned ? '<span class="tx-in">Owned ✓</span>' : `<span style="display:flex;gap:10px;align-items:center"><b>${h.price.toLocaleString()} ◉</b><button class="btn btn-accent" style="padding:7px 14px">Buy</button></span>`}`);
if (!owned && !locked) {
(row.querySelector('button') as HTMLElement).addEventListener('click', () => net.send({ t: 'buy_house', houseId: h.id }));
}
body.appendChild(row);
}
});
}
+44
View File
@@ -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(`<b>${m.from}${m.channel === 'global' ? ' 🌐' : ''}:</b> ${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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c] as string));
}
+61
View File
@@ -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 = `<span class="t-icon">${icon}</span><div><b>${title}</b><p>${text}</p></div>`;
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<string, string> = {
bed: '🛏️', sofa: '🛋️', table: '🪵', chair: '🪑', tv: '📺', pc: '🖥️',
fridge: '🧊', stove: '🍳', shower: '🚿', toilet: '🚽', sink: '🚰',
wardrobe: '🗄️', shelf: '📚', plant: '🪴', decor: '🕯️', light: '💡',
rug: '🧶', paint: '🖼️', elec: '🎮',
};
+218
View File
@@ -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());
}
+192
View File
@@ -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 = `<div style="font-weight:900;margin-bottom:6px">${target.name} <span class="dim" style="font-size:.78rem">· ${relLabel(rel.f)}${rel.partner ? ' 💞' : ''}</span></div>`;
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' : ''),
`<span>${it.label}${it.cost ? ` <small>◉${it.cost}</small>` : ''}</span><small>${locked ? needsLabel(it) : ''}</small>`);
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 = `
<div class="glass" style="padding:26px 40px;text-align:center">
<div style="font-size:2.4rem">${jobEmoji(job.id)}</div>
<b style="font-size:1.1rem">Working as ${job.ranks[p.job.rank]}…</b>
<div style="width:260px;height:10px;border-radius:99px;background:rgba(255,255,255,.14);margin-top:12px;overflow:hidden">
<i id="work-fill" style="display:block;height:100%;width:0%;background:linear-gradient(90deg,#35c4b5,#7fe3d7);transition:width .1s linear"></i>
</div>
<div class="dim" id="work-tip" style="margin-top:8px;font-size:.85rem">Stay focused!</div>
</div>`;
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 = `
<p class="dim" style="margin-bottom:10px;font-size:.9rem">Tap STOP when the marker is in the green zone! 3 catches to win big.</p>
<div id="fish-bar" style="position:relative;height:34px;border-radius:99px;background:rgba(255,255,255,.08);overflow:hidden;margin-bottom:14px">
<div id="fish-zone" style="position:absolute;top:0;bottom:0;background:rgba(103,181,106,.5)"></div>
<div id="fish-mark" style="position:absolute;top:-3px;width:7px;height:40px;border-radius:4px;background:#ffcf6b"></div>
</div>
<button class="btn btn-primary" id="fish-stop" style="width:100%">STOP!</button>
<div class="dim" id="fish-score" style="text-align:center;margin-top:10px">Catches: 0 / 3</div>`;
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 = `
<p class="dim" style="margin-bottom:10px;font-size:.9rem">Memorise the recipe order, then repeat it!</p>
<div id="cook-show" style="display:flex;gap:8px;justify-content:center;font-size:2rem;margin-bottom:14px;min-height:56px"></div>
<div id="cook-pads" style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px"></div>
<div class="dim" id="cook-msg" style="text-align:center;margin-top:10px">Watch closely…</div>`;
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;
+169
View File
@@ -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<string, { fg: SVGCircleElement; val: number }> = {};
for (const [id, emoji, color] of NEED_META) {
const d = el('div', 'ring');
d.title = id;
d.innerHTML = `
<svg width="44" height="44" viewBox="0 0 44 44">
<circle class="r-bg" cx="22" cy="22" r="18" fill="rgba(16,20,36,.65)" stroke-width="5"></circle>
<circle class="r-fg" cx="22" cy="22" r="18" fill="none" stroke="${color}" stroke-width="5"
stroke-dasharray="${2 * Math.PI * 18}" stroke-dashoffset="0"></circle>
</svg>
<span class="r-emoji">${emoji}</span>`;
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', `<span class="ci-ico">${ico}</span><div>${t}<small>${sub}</small></div>`));
}
};
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);
}
+334
View File
@@ -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 = `<div class="papp-title">💬 Messages</div>`;
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',
`<div class="contact-face" style="background:#31405e">${m.from.slice(0, 1).toUpperCase()}</div>
<div><b>${escapeHtml(m.from)}</b> <span class="dim">${m.channel === 'global' ? '🌐' : ''}</span><br>${escapeHtml(m.text)}</div>`));
}
box.appendChild(list);
}
function relEntryOf(key: string) { return S.profile?.relationships[key]; }
function renderContacts() {
const box = screen();
box.innerHTML = `<div class="papp-title">👥 Contacts</div>`;
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', `
<div class="contact-face" style="background:${face}">${npc.name.slice(0, 1)}</div>
<div style="flex:1">
<b>${npc.name}</b> <span class="dim">· ${npc.role}</span><br>
<span class="dim">${relLabel(r.f)}${r.partner ? ' 💞 Partner' : ''}</span>
<div class="rel-bar"><i style="width:${r.f}%"></i></div>
${r.r > 5 ? `<div class="rel-bar"><i class="rom" style="width:${r.r}%"></i></div>` : ''}
</div>`));
}
// 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', `
<div class="contact-face" style="background:#4a5a8a">${nm.slice(0, 1).toUpperCase()}</div>
<div style="flex:1"><b>${escapeHtml(nm)}</b><br><span class="dim">${relLabel(v.f)}${v.partner ? ' 💞' : ''}</span>
<div class="rel-bar"><i style="width:${v.f}%"></i></div></div>
<button class="btn btn-ghost" data-invite="${nm}" style="padding:6px 12px;font-size:.75rem">Invite</button>`));
}
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 = `<div class="papp-title">🗺️ Town Map</div>`;
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('<br>');
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 = `<div class="papp-title">💼 Careers</div>`;
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', `
<b>${def.ranks[job.rank]}</b> <span class="dim">· ${def.title}</span><br>
<span class="dim">Pay ≈ ${Math.round(def.basePay * (1 + job.rank * .55))} TC / shift · Hours ${def.hours[0]}:00${def.hours[1]}:00</span>
<div class="rel-bar"><i style="width:${pct}%"></i></div>
<div class="dim" style="font-size:.72rem;margin-top:3px">Next: ${def.ranks[Math.min(job.rank + 1, def.ranks.length - 1)]}</div>`);
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', `
<div class="contact-face" style="background:#31405e">${j.title.slice(0, 1)}</div>
<div style="flex:1"><b>${j.title}</b><br><span class="dim">${j.ranks[0]}${j.ranks[j.ranks.length - 1]} · base ${j.basePay} TC</span></div>
${active ? '<span class="tx-in">Current</span>' : `<button class="btn btn-ghost" style="padding:6px 12px;font-size:.75rem">Apply</button>`}`);
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 = `
<div class="papp-title">🏦 TownBank</div>
<div class="post-card" style="text-align:center">
<div class="dim" style="font-size:.78rem">BALANCE</div>
<div style="font-size:1.9rem;font-weight:900;color:#ffcf6b" id="bank-balance">${S.money.toLocaleString()} ◉</div>
<div class="dim" style="font-size:.76rem">Level ${S.profile?.level ?? 1} resident</div>
</div>`;
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',
`<span>${t.label}<br><span class="dim" style="font-size:.72rem">${new Date(t.ts).toLocaleTimeString()}</span></span>
<span class="${t.amt >= 0 ? 'tx-in' : 'tx-out'}">${t.amt >= 0 ? '+' : ''}${t.amt} ◉</span>`));
}
box.appendChild(list);
bus.once('eco:money', () => { if (phoneState.open && phoneState.app === 'bank') renderBank(); });
}
function renderSocial() {
const box = screen();
box.innerHTML = `<div class="papp-title">📸 LifeFeed</div>`;
const composer = el('div', 'post-card');
composer.innerHTML = `<input id="post-text" placeholder="Share something about your life…" maxlength="140"/>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="btn btn-accent" id="btn-post" style="flex:1;padding:9px">Post</button>
<button class="btn btn-ghost" id="btn-snap" style="padding:9px">📷 Screenshot</button>
</div>`;
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', `
<div class="post-head"><div class="contact-face" style="background:#7a5a8a">${escapeHtml((p.name || '?').slice(0, 1))}</div>
<div>${escapeHtml(p.name)}<br><span class="dim" style="font-weight:600;font-size:.7rem">${timeAgo(p.ts)}</span></div></div>
<div style="font-size:.9rem">${escapeHtml(p.text || '')}</div>
${p.img ? `<img class="post-img" src="${p.img}"/>` : ''}
<div class="post-actions"><span>❤️ ${p.likes}</span><span>💬 Reply</span><span>↗ Share</span></div>`);
(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 = `<div class="papp-title">📅 Town Calendar</div>`;
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 ? '' : ''), `
<div class="contact-face" style="background:${active ? '#35c4b5' : '#31405e'};opacity:${active ? 1 : .65}">${ico}</div>
<div style="flex:1"><b>${title}</b> <span class="dim">@ ${String(Math.floor(startHm / 60)).padStart(2, '0')}:00</span><br><span class="dim">${desc}</span></div>
${active ? '<span class="tx-in">NOW</span>' : ''}`));
}
box.appendChild(el('div', 'dim', `Day ${S.time.day} in LifeTown · seasons & festivals coming soon`));
}
function renderHomeApp() {
const box = screen();
box.innerHTML = `<div class="papp-title">🏠 Home</div>`;
const tier = HOUSES.find(h => h.id === S.profile?.houseTier) || HOUSES[0];
box.appendChild(el('div', 'post-card', `<b>${tier.name}</b><br><span class="dim">Your current home · ${tier.size[0]}×${tier.size[1]} m</span>`));
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', `
<div class="contact-face" style="background:#31405e">🏘️</div>
<div style="flex:1"><b>${h.name}</b><br><span class="dim">${h.price === 0 ? 'Starter home' : h.price.toLocaleString() + ' ◉ · Level ' + h.lvl + '+'}</span></div>
${owned ? '<span class="tx-in">Owned ✓</span>' : locked ? '<span class="dim">🔒</span>' : `<button class="btn btn-accent" style="padding:7px 13px;font-size:.78rem">Buy</button>`}`);
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;
+301
View File
@@ -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<string, Actor>();
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<string, string> = { 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();
}
+1165
View File
File diff suppressed because it is too large Load Diff
+22
View File
@@ -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"
}
}
+171
View File
@@ -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); });
+99
View File
@@ -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');
})();
+636
View File
@@ -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<session>
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 });
}
}
+55
View File
@@ -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}`);
});
+97
View File
@@ -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 };
+387
View File
@@ -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;
+17
View File
@@ -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"]
}
+21
View File
@@ -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 }
}
}
});