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