let-them-talk 4.3.0 → 5.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,221 @@
1
+ import * as THREE from 'three';
2
+ import { S } from './state.js';
3
+
4
+ // ============================================================
5
+ // DAY/NIGHT CYCLE — dynamic lighting, sky colors, atmosphere
6
+ // Phase 4: Persistent city feels alive 24/7
7
+ // Target: minimal performance impact, 120fps compatible
8
+ // ============================================================
9
+
10
+ var GAME_HOUR_SECONDS = 60; // 1 game hour = 60 real seconds (24 min full cycle)
11
+ var gameTime = 8.0; // start at 8 AM
12
+
13
+ var sunLight = null;
14
+ var moonLight = null;
15
+ var ambientLight = null;
16
+ var streetLights = [];
17
+ var windowMeshes = [];
18
+
19
+ // Sky color palette (lerp between these based on time)
20
+ var SKY_COLORS = {
21
+ dawn: new THREE.Color(0x2a1a3a), // 5-7
22
+ morning: new THREE.Color(0x4488cc), // 7-10
23
+ midday: new THREE.Color(0x5599dd), // 10-14
24
+ afternoon: new THREE.Color(0x6688bb), // 14-17
25
+ sunset: new THREE.Color(0xcc6633), // 17-19
26
+ dusk: new THREE.Color(0x1a1a3a), // 19-21
27
+ night: new THREE.Color(0x0a0e14), // 21-5
28
+ };
29
+
30
+ var FOG_COLORS = {
31
+ day: new THREE.Color(0x88aacc),
32
+ night: new THREE.Color(0x0a0e14),
33
+ };
34
+
35
+ // ============================================================
36
+ // INITIALIZATION
37
+ // ============================================================
38
+
39
+ export function initDayNight(options) {
40
+ options = options || {};
41
+ GAME_HOUR_SECONDS = options.hourSeconds || 60;
42
+ gameTime = options.startHour || 8.0;
43
+
44
+ // Find or create sun directional light
45
+ S.furnitureGroup.traverse(function(child) {
46
+ if (child.isDirectionalLight && child.castShadow && !sunLight) {
47
+ sunLight = child;
48
+ }
49
+ if (child.isDirectionalLight && !child.castShadow && !moonLight) {
50
+ moonLight = child;
51
+ }
52
+ if (child.isAmbientLight && !ambientLight) {
53
+ ambientLight = child;
54
+ }
55
+ // Collect street light bulbs (emissive meshes near light poles)
56
+ if (child.isPointLight) {
57
+ streetLights.push(child);
58
+ }
59
+ // Collect window meshes (identified by window material color)
60
+ if (child.isMesh && child.material && child.material.emissive &&
61
+ child.material.color && child.material.color.getHex() === 0x88bbee) {
62
+ windowMeshes.push(child);
63
+ }
64
+ });
65
+ }
66
+
67
+ // ============================================================
68
+ // TIME HELPERS
69
+ // ============================================================
70
+
71
+ export function getGameTime() { return gameTime; }
72
+ export function getGameHour() { return Math.floor(gameTime) % 24; }
73
+
74
+ export function getTimeOfDay() {
75
+ var h = gameTime % 24;
76
+ if (h >= 5 && h < 7) return 'dawn';
77
+ if (h >= 7 && h < 10) return 'morning';
78
+ if (h >= 10 && h < 14) return 'midday';
79
+ if (h >= 14 && h < 17) return 'afternoon';
80
+ if (h >= 17 && h < 19) return 'sunset';
81
+ if (h >= 19 && h < 21) return 'dusk';
82
+ return 'night';
83
+ }
84
+
85
+ export function isNightTime() {
86
+ var h = gameTime % 24;
87
+ return h >= 21 || h < 5;
88
+ }
89
+
90
+ function getTimeFactor() {
91
+ // Returns 0.0 (midnight) to 1.0 (noon) for smooth interpolation
92
+ var h = gameTime % 24;
93
+ if (h <= 12) return h / 12;
94
+ return (24 - h) / 12;
95
+ }
96
+
97
+ // ============================================================
98
+ // SKY COLOR INTERPOLATION
99
+ // ============================================================
100
+
101
+ function getSkyColor() {
102
+ var tod = getTimeOfDay();
103
+ return SKY_COLORS[tod] || SKY_COLORS.night;
104
+ }
105
+
106
+ function getFogColor() {
107
+ var factor = getTimeFactor();
108
+ var color = new THREE.Color();
109
+ color.lerpColors(FOG_COLORS.night, FOG_COLORS.day, factor);
110
+ return color;
111
+ }
112
+
113
+ // ============================================================
114
+ // MAIN UPDATE — called every frame
115
+ // ============================================================
116
+
117
+ export function updateDayNight(dt) {
118
+ // Advance game time
119
+ gameTime += dt / GAME_HOUR_SECONDS;
120
+ if (gameTime >= 24) gameTime -= 24;
121
+
122
+ var factor = getTimeFactor(); // 0=midnight, 1=noon
123
+ var night = isNightTime();
124
+ var h = gameTime % 24;
125
+
126
+ // === SKY COLOR ===
127
+ var skyColor = getSkyColor();
128
+ if (S.scene.background) {
129
+ S.scene.background.lerp(skyColor, 0.02);
130
+ }
131
+
132
+ // === FOG ===
133
+ if (S.scene.fog) {
134
+ var fogColor = getFogColor();
135
+ S.scene.fog.color.lerp(fogColor, 0.02);
136
+ // Fog distance: closer at night (atmospheric), farther at day
137
+ S.scene.fog.near = THREE.MathUtils.lerp(30, 60, factor);
138
+ S.scene.fog.far = THREE.MathUtils.lerp(120, 250, factor);
139
+ }
140
+
141
+ // === SUN POSITION (orbits overhead) ===
142
+ if (sunLight) {
143
+ var sunAngle = ((h - 6) / 12) * Math.PI; // 6AM=horizon, 12PM=zenith, 6PM=horizon
144
+ sunLight.position.set(
145
+ Math.cos(sunAngle) * 80,
146
+ Math.max(Math.sin(sunAngle) * 60, -10),
147
+ 50
148
+ );
149
+ // Sun intensity: bright during day, off at night
150
+ sunLight.intensity = Math.max(0, Math.sin(sunAngle)) * 0.8;
151
+ // Warm sunrise/sunset color
152
+ if (h >= 5 && h < 8) {
153
+ sunLight.color.setHex(0xffaa66); // warm sunrise
154
+ } else if (h >= 16 && h < 19) {
155
+ sunLight.color.setHex(0xff8844); // warm sunset
156
+ } else {
157
+ sunLight.color.setHex(0xffeedd); // neutral day
158
+ }
159
+ }
160
+
161
+ // === MOON ===
162
+ if (moonLight) {
163
+ moonLight.intensity = night ? 0.2 : 0;
164
+ }
165
+
166
+ // === AMBIENT LIGHT ===
167
+ if (ambientLight) {
168
+ ambientLight.intensity = THREE.MathUtils.lerp(0.15, 0.5, factor);
169
+ if (night) {
170
+ ambientLight.color.setHex(0x334466); // cool blue ambient at night
171
+ } else {
172
+ ambientLight.color.setHex(0xffffff);
173
+ }
174
+ }
175
+
176
+ // === STREET LIGHTS (on at night) ===
177
+ var targetIntensity = night ? 0.6 : 0;
178
+ streetLights.forEach(function(light) {
179
+ light.intensity = THREE.MathUtils.lerp(light.intensity, targetIntensity, 0.05);
180
+ });
181
+
182
+ // === WINDOW GLOW (emissive at night — boosted for bloom) ===
183
+ var windowEmissive = night ? 1.5 : 0.2;
184
+ windowMeshes.forEach(function(mesh) {
185
+ if (mesh.material.emissiveIntensity !== undefined) {
186
+ mesh.material.emissiveIntensity = THREE.MathUtils.lerp(
187
+ mesh.material.emissiveIntensity, windowEmissive, 0.03
188
+ );
189
+ }
190
+ });
191
+
192
+ // Bloom disabled for performance
193
+ }
194
+
195
+ // ============================================================
196
+ // TIME CONTROL — manual set / speed adjustment
197
+ // ============================================================
198
+
199
+ export function setGameTime(hour) {
200
+ gameTime = hour % 24;
201
+ }
202
+
203
+ export function setTimeSpeed(hourSeconds) {
204
+ GAME_HOUR_SECONDS = Math.max(1, hourSeconds);
205
+ }
206
+
207
+ export function getTimeSpeed() {
208
+ return GAME_HOUR_SECONDS;
209
+ }
210
+
211
+ // ============================================================
212
+ // CLEANUP
213
+ // ============================================================
214
+
215
+ export function disposeDayNight() {
216
+ sunLight = null;
217
+ moonLight = null;
218
+ ambientLight = null;
219
+ streetLights = [];
220
+ windowMeshes = [];
221
+ }
@@ -0,0 +1,432 @@
1
+ /**
2
+ * Economy HUD — Credit balance, upgrade popup, transaction history.
3
+ * HTML overlay on top of the Three.js canvas.
4
+ * Always visible in city mode. Polls /api/city/economy for data.
5
+ * Target: zero canvas impact (pure HTML/CSS).
6
+ */
7
+
8
+ let hudEl = null;
9
+ let popupEl = null;
10
+ let historyEl = null;
11
+ let pollInterval = null;
12
+ let visible = false;
13
+ let currentBalance = 0;
14
+ let historyVisible = false;
15
+
16
+ const ECON_STYLES = `
17
+ .econ-hud {
18
+ position: fixed;
19
+ top: 12px;
20
+ right: 12px;
21
+ z-index: 100;
22
+ pointer-events: auto;
23
+ font-family: 'Segoe UI', sans-serif;
24
+ opacity: 0;
25
+ transition: opacity 0.3s ease;
26
+ }
27
+ .econ-hud.visible { opacity: 1; }
28
+
29
+ .econ-balance {
30
+ background: rgba(0,0,0,0.7);
31
+ border: 1px solid rgba(212,175,55,0.4);
32
+ border-radius: 8px;
33
+ padding: 8px 16px;
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 8px;
37
+ cursor: pointer;
38
+ user-select: none;
39
+ backdrop-filter: blur(8px);
40
+ }
41
+ .econ-balance:hover { border-color: rgba(212,175,55,0.8); }
42
+ .econ-coin {
43
+ width: 20px;
44
+ height: 20px;
45
+ border-radius: 50%;
46
+ background: linear-gradient(135deg, #FFD700 0%, #D4AF37 50%, #B8860B 100%);
47
+ box-shadow: 0 0 6px rgba(212,175,55,0.5);
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ font-size: 11px;
52
+ font-weight: 700;
53
+ color: #000;
54
+ }
55
+ .econ-amount {
56
+ font-size: 18px;
57
+ font-weight: 700;
58
+ font-variant-numeric: tabular-nums;
59
+ color: #FFD700;
60
+ text-shadow: 0 0 6px rgba(212,175,55,0.4);
61
+ }
62
+ .econ-label {
63
+ font-size: 10px;
64
+ color: #aaa;
65
+ text-transform: uppercase;
66
+ letter-spacing: 1px;
67
+ }
68
+
69
+ .econ-popup {
70
+ position: fixed;
71
+ top: 50%;
72
+ left: 50%;
73
+ transform: translate(-50%, -50%);
74
+ background: rgba(20,20,20,0.95);
75
+ border: 1px solid rgba(212,175,55,0.5);
76
+ border-radius: 12px;
77
+ padding: 24px;
78
+ min-width: 320px;
79
+ max-width: 400px;
80
+ z-index: 200;
81
+ pointer-events: auto;
82
+ backdrop-filter: blur(12px);
83
+ font-family: 'Segoe UI', sans-serif;
84
+ color: #fff;
85
+ display: none;
86
+ }
87
+ .econ-popup.open { display: block; }
88
+ .econ-popup-title {
89
+ font-size: 16px;
90
+ font-weight: 700;
91
+ color: #FFD700;
92
+ margin-bottom: 16px;
93
+ display: flex;
94
+ justify-content: space-between;
95
+ align-items: center;
96
+ }
97
+ .econ-popup-close {
98
+ cursor: pointer;
99
+ font-size: 18px;
100
+ color: #888;
101
+ background: none;
102
+ border: none;
103
+ padding: 4px 8px;
104
+ }
105
+ .econ-popup-close:hover { color: #fff; }
106
+
107
+ .econ-upgrade-item {
108
+ display: flex;
109
+ justify-content: space-between;
110
+ align-items: center;
111
+ padding: 10px 0;
112
+ border-bottom: 1px solid rgba(255,255,255,0.08);
113
+ }
114
+ .econ-upgrade-item:last-child { border-bottom: none; }
115
+ .econ-upgrade-name {
116
+ font-size: 13px;
117
+ color: #eee;
118
+ }
119
+ .econ-upgrade-desc {
120
+ font-size: 11px;
121
+ color: #888;
122
+ margin-top: 2px;
123
+ }
124
+ .econ-upgrade-btn {
125
+ background: linear-gradient(135deg, #D4AF37, #B8860B);
126
+ border: none;
127
+ border-radius: 6px;
128
+ padding: 6px 14px;
129
+ color: #000;
130
+ font-weight: 700;
131
+ font-size: 12px;
132
+ cursor: pointer;
133
+ white-space: nowrap;
134
+ }
135
+ .econ-upgrade-btn:hover { filter: brightness(1.2); }
136
+ .econ-upgrade-btn:disabled {
137
+ opacity: 0.4;
138
+ cursor: not-allowed;
139
+ filter: none;
140
+ }
141
+
142
+ .econ-history {
143
+ position: fixed;
144
+ top: 60px;
145
+ right: 12px;
146
+ width: 280px;
147
+ max-height: 300px;
148
+ overflow-y: auto;
149
+ background: rgba(0,0,0,0.85);
150
+ border: 1px solid rgba(212,175,55,0.3);
151
+ border-radius: 8px;
152
+ padding: 12px;
153
+ z-index: 150;
154
+ pointer-events: auto;
155
+ font-family: 'Segoe UI', sans-serif;
156
+ display: none;
157
+ backdrop-filter: blur(8px);
158
+ }
159
+ .econ-history.open { display: block; }
160
+ .econ-history-title {
161
+ font-size: 11px;
162
+ color: #D4AF37;
163
+ text-transform: uppercase;
164
+ letter-spacing: 1px;
165
+ margin-bottom: 8px;
166
+ }
167
+ .econ-tx {
168
+ display: flex;
169
+ justify-content: space-between;
170
+ padding: 4px 0;
171
+ font-size: 11px;
172
+ border-bottom: 1px solid rgba(255,255,255,0.05);
173
+ }
174
+ .econ-tx-desc { color: #ccc; }
175
+ .econ-tx-amount.credit { color: #3fb950; }
176
+ .econ-tx-amount.debit { color: #f85149; }
177
+ .econ-tx-time { color: #666; font-size: 10px; }
178
+
179
+ .econ-credit-pop {
180
+ position: fixed;
181
+ top: 20%;
182
+ left: 50%;
183
+ transform: translateX(-50%);
184
+ font-size: 24px;
185
+ font-weight: 700;
186
+ color: #3fb950;
187
+ text-shadow: 0 0 10px rgba(63,185,80,0.6);
188
+ pointer-events: none;
189
+ z-index: 300;
190
+ animation: creditPop 1.5s ease-out forwards;
191
+ }
192
+ @keyframes creditPop {
193
+ 0% { opacity: 1; transform: translateX(-50%) translateY(0); }
194
+ 100% { opacity: 0; transform: translateX(-50%) translateY(-40px); }
195
+ }
196
+ `;
197
+
198
+ const UPGRADES = [
199
+ { id: 'speed_boost', name: 'Speed Boost', desc: 'Increase car max speed by 20%', cost: 50 },
200
+ { id: 'building_glow', name: 'Building Neon', desc: 'Add neon glow to your buildings', cost: 100 },
201
+ { id: 'extra_car', name: 'Extra Vehicle', desc: 'Spawn an additional car', cost: 75 },
202
+ { id: 'night_vision', name: 'Night Mode', desc: 'Toggle day/night cycle', cost: 30 },
203
+ ];
204
+
205
+ /**
206
+ * Initialize the economy HUD. Call once.
207
+ */
208
+ export function initEconomyHUD() {
209
+ if (hudEl) return;
210
+
211
+ const style = document.createElement('style');
212
+ style.textContent = ECON_STYLES;
213
+ document.head.appendChild(style);
214
+
215
+ // Balance display
216
+ hudEl = document.createElement('div');
217
+ hudEl.className = 'econ-hud';
218
+ hudEl.innerHTML = `
219
+ <div class="econ-balance" id="econ-balance-btn">
220
+ <div class="econ-coin">C</div>
221
+ <div>
222
+ <div class="econ-amount" id="econ-amount">0</div>
223
+ <div class="econ-label">Credits</div>
224
+ </div>
225
+ </div>
226
+ `;
227
+ document.body.appendChild(hudEl);
228
+
229
+ // Transaction history panel
230
+ historyEl = document.createElement('div');
231
+ historyEl.className = 'econ-history';
232
+ historyEl.id = 'econ-history';
233
+ historyEl.innerHTML = `
234
+ <div class="econ-history-title">Transaction History</div>
235
+ <div id="econ-tx-list"></div>
236
+ `;
237
+ document.body.appendChild(historyEl);
238
+
239
+ // Upgrade popup
240
+ popupEl = document.createElement('div');
241
+ popupEl.className = 'econ-popup';
242
+ popupEl.id = 'econ-popup';
243
+ popupEl.innerHTML = `
244
+ <div class="econ-popup-title">
245
+ <span>Upgrade Shop</span>
246
+ <button class="econ-popup-close" id="econ-popup-close">&times;</button>
247
+ </div>
248
+ <div id="econ-upgrade-list"></div>
249
+ `;
250
+ document.body.appendChild(popupEl);
251
+
252
+ // Click handlers
253
+ document.getElementById('econ-balance-btn').addEventListener('click', toggleHistory);
254
+ document.getElementById('econ-popup-close').addEventListener('click', closeUpgradePopup);
255
+ }
256
+
257
+ /**
258
+ * Show the economy HUD (when entering city environment).
259
+ */
260
+ export function showEconomyHUD() {
261
+ if (!hudEl) initEconomyHUD();
262
+ hudEl.classList.add('visible');
263
+ visible = true;
264
+ startPolling();
265
+ }
266
+
267
+ /**
268
+ * Hide the economy HUD.
269
+ */
270
+ export function hideEconomyHUD() {
271
+ if (hudEl) hudEl.classList.remove('visible');
272
+ if (historyEl) historyEl.classList.remove('open');
273
+ if (popupEl) popupEl.classList.remove('open');
274
+ visible = false;
275
+ historyVisible = false;
276
+ stopPolling();
277
+ }
278
+
279
+ /**
280
+ * Update the balance display.
281
+ * @param {number} balance
282
+ */
283
+ export function updateBalance(balance) {
284
+ currentBalance = balance;
285
+ const el = document.getElementById('econ-amount');
286
+ if (el) el.textContent = balance.toLocaleString();
287
+ }
288
+
289
+ /**
290
+ * Show a credit earned popup animation.
291
+ * @param {number} amount
292
+ * @param {string} reason
293
+ */
294
+ export function showCreditEarned(amount, reason) {
295
+ const pop = document.createElement('div');
296
+ pop.className = 'econ-credit-pop';
297
+ pop.textContent = '+' + amount + ' ' + (reason || '');
298
+ document.body.appendChild(pop);
299
+ setTimeout(function() { pop.remove(); }, 1500);
300
+ }
301
+
302
+ /**
303
+ * Open the upgrade shop popup.
304
+ */
305
+ export function openUpgradePopup() {
306
+ if (!popupEl) return;
307
+ renderUpgrades();
308
+ popupEl.classList.add('open');
309
+ }
310
+
311
+ /**
312
+ * Close the upgrade shop popup.
313
+ */
314
+ export function closeUpgradePopup() {
315
+ if (popupEl) popupEl.classList.remove('open');
316
+ }
317
+
318
+ function toggleHistory() {
319
+ historyVisible = !historyVisible;
320
+ if (historyEl) {
321
+ historyEl.classList.toggle('open', historyVisible);
322
+ if (historyVisible) fetchEconomy();
323
+ }
324
+ }
325
+
326
+ function renderUpgrades() {
327
+ const list = document.getElementById('econ-upgrade-list');
328
+ if (!list) return;
329
+ list.innerHTML = UPGRADES.map(function(u) {
330
+ const canAfford = currentBalance >= u.cost;
331
+ return '<div class="econ-upgrade-item">' +
332
+ '<div>' +
333
+ '<div class="econ-upgrade-name">' + escapeHtml(u.name) + '</div>' +
334
+ '<div class="econ-upgrade-desc">' + escapeHtml(u.desc) + '</div>' +
335
+ '</div>' +
336
+ '<button class="econ-upgrade-btn" ' + (canAfford ? '' : 'disabled') +
337
+ ' onclick="window._econBuy(\'' + u.id + '\',' + u.cost + ')">' +
338
+ u.cost + ' C</button>' +
339
+ '</div>';
340
+ }).join('');
341
+ }
342
+
343
+ function renderTransactions(transactions) {
344
+ const list = document.getElementById('econ-tx-list');
345
+ if (!list) return;
346
+ if (!transactions || !transactions.length) {
347
+ list.innerHTML = '<div style="color:#666;font-size:11px">No transactions yet</div>';
348
+ return;
349
+ }
350
+ list.innerHTML = transactions.slice(-20).reverse().map(function(tx) {
351
+ const isCredit = tx.amount > 0;
352
+ const timeStr = tx.timestamp ? new Date(tx.timestamp).toLocaleTimeString() : '';
353
+ return '<div class="econ-tx">' +
354
+ '<div>' +
355
+ '<div class="econ-tx-desc">' + escapeHtml(tx.reason || tx.type || 'transaction') + '</div>' +
356
+ '<div class="econ-tx-time">' + timeStr + '</div>' +
357
+ '</div>' +
358
+ '<div class="econ-tx-amount ' + (isCredit ? 'credit' : 'debit') + '">' +
359
+ (isCredit ? '+' : '') + tx.amount + '</div>' +
360
+ '</div>';
361
+ }).join('');
362
+ }
363
+
364
+ /**
365
+ * Purchase an upgrade via the API.
366
+ */
367
+ window._econBuy = function(upgradeId, cost) {
368
+ if (currentBalance < cost) return;
369
+ fetch('/api/city/economy/spend', {
370
+ method: 'POST',
371
+ headers: { 'Content-Type': 'application/json' },
372
+ body: JSON.stringify({ upgrade: upgradeId, cost: cost })
373
+ })
374
+ .then(function(r) { return r.json(); })
375
+ .then(function(data) {
376
+ if (data.success) {
377
+ updateBalance(data.balance);
378
+ renderUpgrades();
379
+ fetchEconomy();
380
+ }
381
+ })
382
+ .catch(function() {});
383
+ };
384
+
385
+ function startPolling() {
386
+ if (pollInterval) return;
387
+ fetchEconomy();
388
+ pollInterval = setInterval(fetchEconomy, 5000);
389
+ }
390
+
391
+ function stopPolling() {
392
+ if (pollInterval) {
393
+ clearInterval(pollInterval);
394
+ pollInterval = null;
395
+ }
396
+ }
397
+
398
+ function fetchEconomy() {
399
+ fetch('/api/city/economy')
400
+ .then(function(r) { return r.ok ? r.json() : null; })
401
+ .then(function(data) {
402
+ if (!data) return;
403
+ if (typeof data.total_credits === 'number') updateBalance(data.total_credits);
404
+ if (data.recent_transactions) renderTransactions(data.recent_transactions);
405
+ })
406
+ .catch(function() {});
407
+ }
408
+
409
+ /**
410
+ * Get current balance.
411
+ * @returns {number}
412
+ */
413
+ export function getBalance() {
414
+ return currentBalance;
415
+ }
416
+
417
+ /**
418
+ * Dispose the economy HUD.
419
+ */
420
+ export function disposeEconomyHUD() {
421
+ hideEconomyHUD();
422
+ if (hudEl) { hudEl.remove(); hudEl = null; }
423
+ if (popupEl) { popupEl.remove(); popupEl = null; }
424
+ if (historyEl) { historyEl.remove(); historyEl = null; }
425
+ delete window._econBuy;
426
+ }
427
+
428
+ function escapeHtml(str) {
429
+ const div = document.createElement('div');
430
+ div.textContent = str;
431
+ return div.innerHTML;
432
+ }