turbometro 0.1.0
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.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/bin/turbometro.js +2 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +107 -0
- package/dist/find-root.d.ts +2 -0
- package/dist/find-root.js +14 -0
- package/dist/graph.d.ts +14 -0
- package/dist/graph.js +74 -0
- package/dist/layout.d.ts +19 -0
- package/dist/layout.js +15 -0
- package/dist/parse-turbo.d.ts +5 -0
- package/dist/parse-turbo.js +29 -0
- package/dist/parse-workspace.d.ts +4 -0
- package/dist/parse-workspace.js +100 -0
- package/dist/render-html.d.ts +8 -0
- package/dist/render-html.js +699 -0
- package/dist/render-map.d.ts +29 -0
- package/dist/render-map.js +287 -0
- package/dist/replay.d.ts +9 -0
- package/dist/replay.js +74 -0
- package/dist/scale.d.ts +7 -0
- package/dist/scale.js +19 -0
- package/dist/scene-data.d.ts +40 -0
- package/dist/scene-data.js +146 -0
- package/dist/theme.d.ts +15 -0
- package/dist/theme.js +15 -0
- package/dist/types.d.ts +30 -0
- package/dist/types.js +1 -0
- package/fixtures/mini-mono/apps/web/package.json +11 -0
- package/fixtures/mini-mono/package.json +5 -0
- package/fixtures/mini-mono/packages/ui/package.json +11 -0
- package/fixtures/mini-mono/packages/utils/package.json +8 -0
- package/fixtures/mini-mono/pnpm-workspace.yaml +3 -0
- package/fixtures/mini-mono/turbo.json +10 -0
- package/package.json +44 -0
- package/vendor/dag-map/LICENSE +201 -0
- package/vendor/dag-map/NOTICE +6 -0
- package/vendor/dag-map/src/color-scales.js +61 -0
- package/vendor/dag-map/src/dag-map.css +63 -0
- package/vendor/dag-map/src/events.js +108 -0
- package/vendor/dag-map/src/graph-utils.js +185 -0
- package/vendor/dag-map/src/hasse.css +61 -0
- package/vendor/dag-map/src/index.js +55 -0
- package/vendor/dag-map/src/layout-flow.js +1066 -0
- package/vendor/dag-map/src/layout-hasse.js +485 -0
- package/vendor/dag-map/src/layout-metro.js +542 -0
- package/vendor/dag-map/src/occupancy.js +138 -0
- package/vendor/dag-map/src/render-flow-station.js +132 -0
- package/vendor/dag-map/src/render.js +360 -0
- package/vendor/dag-map/src/route-angular.js +137 -0
- package/vendor/dag-map/src/route-bezier.js +51 -0
- package/vendor/dag-map/src/route-metro.js +122 -0
- package/vendor/dag-map/src/themes.js +49 -0
|
@@ -0,0 +1,699 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { pathToFileURL, fileURLToPath } from 'node:url';
|
|
3
|
+
import { toDagMapInput } from './layout.js';
|
|
4
|
+
import { buildSceneData } from './scene-data.js';
|
|
5
|
+
import { TRANSIT_LAYOUT_THEME } from './theme.js';
|
|
6
|
+
import { taskColor } from './replay.js';
|
|
7
|
+
async function loadVendor() {
|
|
8
|
+
const pkgRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
9
|
+
const metroUrl = pathToFileURL(path.join(pkgRoot, 'vendor/dag-map/src/layout-metro.js')).href;
|
|
10
|
+
const { layoutMetro } = (await import(metroUrl));
|
|
11
|
+
return { layoutMetro };
|
|
12
|
+
}
|
|
13
|
+
function esc(s) {
|
|
14
|
+
return s
|
|
15
|
+
.replace(/&/g, '&')
|
|
16
|
+
.replace(/</g, '<')
|
|
17
|
+
.replace(/>/g, '>')
|
|
18
|
+
.replace(/"/g, '"');
|
|
19
|
+
}
|
|
20
|
+
function taskChips(turbo) {
|
|
21
|
+
const tasks = Object.keys(turbo);
|
|
22
|
+
return tasks
|
|
23
|
+
.map((t) => {
|
|
24
|
+
const c = taskColor(t, tasks);
|
|
25
|
+
return `<span class="chip"><i style="background:${c}"></i>${esc(t)}</span>`;
|
|
26
|
+
})
|
|
27
|
+
.join('');
|
|
28
|
+
}
|
|
29
|
+
export async function renderMetroHtml(opts) {
|
|
30
|
+
const { layoutMetro } = await loadVendor();
|
|
31
|
+
const dag = toDagMapInput(opts.graph);
|
|
32
|
+
const layout = layoutMetro(dag, {
|
|
33
|
+
routing: 'angular',
|
|
34
|
+
theme: TRANSIT_LAYOUT_THEME,
|
|
35
|
+
scale: 2.15,
|
|
36
|
+
layerSpacing: 48,
|
|
37
|
+
mainSpacing: 46,
|
|
38
|
+
subSpacing: 22,
|
|
39
|
+
});
|
|
40
|
+
const scene = buildSceneData({
|
|
41
|
+
title: opts.title,
|
|
42
|
+
graph: opts.graph,
|
|
43
|
+
layout,
|
|
44
|
+
turbo: opts.turbo,
|
|
45
|
+
replay: opts.replay,
|
|
46
|
+
});
|
|
47
|
+
const sceneJson = JSON.stringify(scene).replace(/</g, '\\u003c');
|
|
48
|
+
const stationCount = opts.graph.nodes.length;
|
|
49
|
+
const railCount = opts.graph.edges.length;
|
|
50
|
+
return `<!DOCTYPE html>
|
|
51
|
+
<html lang="en">
|
|
52
|
+
<head>
|
|
53
|
+
<meta charset="utf-8" />
|
|
54
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
55
|
+
<title>turbometro — ${esc(opts.title)}</title>
|
|
56
|
+
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;600;700&family=Syne:wght@700;800&display=swap" rel="stylesheet" />
|
|
57
|
+
<script type="importmap">
|
|
58
|
+
{"imports":{"three":"https://cdn.jsdelivr.net/npm/three@0.170.0/build/three.module.js","three/addons/":"https://cdn.jsdelivr.net/npm/three@0.170.0/examples/jsm/"}}
|
|
59
|
+
</script>
|
|
60
|
+
<style>
|
|
61
|
+
:root{--bg:#0c0e12;--panel:rgba(14,16,20,.92);--ink:#e6e9ef;--muted:#8b93a1;--border:#2a303b;--accent:#5b8def;--accent-2:#8fa3bf}
|
|
62
|
+
*{box-sizing:border-box} html,body{height:100%;margin:0;background:var(--bg);color:var(--ink);font-family:Syne,system-ui,sans-serif;overflow:hidden}
|
|
63
|
+
.app{height:100%;display:grid;grid-template-rows:auto 1fr auto}
|
|
64
|
+
.top,.bottom{display:flex;align-items:center;justify-content:space-between;gap:1rem;padding:.7rem 1rem;background:var(--panel);backdrop-filter:blur(12px);z-index:2}
|
|
65
|
+
.top{border-bottom:1px solid var(--border)}.bottom{border-top:1px solid var(--border);flex-wrap:wrap}
|
|
66
|
+
.logo{font-weight:800;font-size:1.2rem;letter-spacing:-.04em;background:linear-gradient(110deg,var(--accent),var(--accent-2));-webkit-background-clip:text;background-clip:text;color:transparent}
|
|
67
|
+
.repo{font-family:"IBM Plex Mono",monospace;font-size:.72rem;color:var(--muted)}
|
|
68
|
+
.toolbar{display:flex;gap:.35rem}
|
|
69
|
+
.toolbar button{font-family:"IBM Plex Mono",monospace;font-size:.68rem;text-transform:uppercase;letter-spacing:.05em;color:var(--ink);background:transparent;border:1px solid var(--border);padding:.35rem .6rem;cursor:pointer}
|
|
70
|
+
.toolbar button:hover{border-color:var(--accent);color:var(--accent)}
|
|
71
|
+
#view{position:relative;min-height:0}
|
|
72
|
+
#view canvas{display:block;width:100%!important;height:100%!important}
|
|
73
|
+
.hud{position:absolute;left:1rem;top:1rem;z-index:1;pointer-events:none;max-width:min(340px,80vw)}
|
|
74
|
+
.hud h1{margin:0;font-size:clamp(1.15rem,2.2vw,1.55rem);letter-spacing:-.03em;text-shadow:0 2px 12px rgba(0,0,0,.25)}
|
|
75
|
+
.hud p{margin:.35rem 0 0;font-family:"IBM Plex Mono",monospace;font-size:.7rem;color:var(--muted);line-height:1.45}
|
|
76
|
+
.stats{position:absolute;right:1rem;top:1rem;z-index:1;display:flex;gap:.8rem;font-family:"IBM Plex Mono",monospace;font-size:.65rem;color:var(--muted);pointer-events:none}
|
|
77
|
+
.stats strong{display:block;color:var(--ink);font-family:Syne,sans-serif;font-size:.95rem}
|
|
78
|
+
.hint{position:absolute;left:50%;bottom:1rem;transform:translateX(-50%);font-family:"IBM Plex Mono",monospace;font-size:.65rem;color:var(--muted);background:var(--panel);border:1px solid var(--border);padding:.3rem .65rem;z-index:1;pointer-events:none}
|
|
79
|
+
.chips{display:flex;flex-wrap:wrap;gap:.35rem}
|
|
80
|
+
.chip{display:inline-flex;align-items:center;gap:.3rem;font-family:"IBM Plex Mono",monospace;font-size:.68rem;border:1px solid var(--border);padding:.18rem .45rem;color:var(--muted)}
|
|
81
|
+
.chip i{width:.9rem;height:.22rem;display:inline-block}
|
|
82
|
+
.note{font-family:"IBM Plex Mono",monospace;font-size:.62rem;color:var(--muted);max-width:26rem;line-height:1.4}
|
|
83
|
+
#boot{position:absolute;inset:0;display:grid;place-items:center;background:#0c0e12;z-index:3;font-family:"IBM Plex Mono",monospace;font-size:.78rem;color:#8b93a1}
|
|
84
|
+
</style>
|
|
85
|
+
</head>
|
|
86
|
+
<body>
|
|
87
|
+
<div class="app">
|
|
88
|
+
<header class="top">
|
|
89
|
+
<div style="display:flex;gap:.7rem;align-items:baseline;min-width:0">
|
|
90
|
+
<div class="logo">turbometro</div>
|
|
91
|
+
<div class="repo">${esc(opts.title)}</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div class="toolbar">
|
|
94
|
+
<button type="button" id="btn-play" aria-pressed="true">Pause</button>
|
|
95
|
+
<button type="button" id="btn-clear">Clear train</button>
|
|
96
|
+
<button type="button" id="btn-reset">Reset cam</button>
|
|
97
|
+
</div>
|
|
98
|
+
</header>
|
|
99
|
+
<div id="view">
|
|
100
|
+
<div id="boot">loading model…</div>
|
|
101
|
+
<div class="hud">
|
|
102
|
+
<h1 id="hud-title">Select a station</h1>
|
|
103
|
+
<p id="hud-sub">Click a stop · pieces assemble once, then the train rides</p>
|
|
104
|
+
</div>
|
|
105
|
+
<div class="stats">
|
|
106
|
+
<div><strong>${stationCount}</strong>stations</div>
|
|
107
|
+
<div><strong>${railCount}</strong>rails</div>
|
|
108
|
+
<div><strong id="stat-ride">0</strong>active</div>
|
|
109
|
+
</div>
|
|
110
|
+
<div class="hint">click station · drag orbit · scroll zoom</div>
|
|
111
|
+
</div>
|
|
112
|
+
<footer class="bottom">
|
|
113
|
+
<div class="chips">${taskChips(opts.turbo)}</div>
|
|
114
|
+
<p class="note">${esc(opts.disclaimer)}</p>
|
|
115
|
+
</footer>
|
|
116
|
+
</div>
|
|
117
|
+
<script type="module">
|
|
118
|
+
import * as THREE from 'three';
|
|
119
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
120
|
+
import { RoundedBoxGeometry } from 'three/addons/geometries/RoundedBoxGeometry.js';
|
|
121
|
+
|
|
122
|
+
const SCENE = ${sceneJson};
|
|
123
|
+
const view = document.getElementById('view');
|
|
124
|
+
document.getElementById('boot').remove();
|
|
125
|
+
let playing = true;
|
|
126
|
+
|
|
127
|
+
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
128
|
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
|
129
|
+
renderer.setSize(view.clientWidth, view.clientHeight);
|
|
130
|
+
renderer.shadowMap.enabled = true;
|
|
131
|
+
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
|
|
132
|
+
renderer.toneMapping = THREE.ACESFilmicToneMapping;
|
|
133
|
+
renderer.toneMappingExposure = 1.02;
|
|
134
|
+
view.appendChild(renderer.domElement);
|
|
135
|
+
|
|
136
|
+
const scene = new THREE.Scene();
|
|
137
|
+
scene.background = new THREE.Color(0x12151a);
|
|
138
|
+
scene.fog = new THREE.Fog(0x12151a, 28, 70);
|
|
139
|
+
|
|
140
|
+
const camera = new THREE.PerspectiveCamera(36, view.clientWidth / view.clientHeight, 0.1, 200);
|
|
141
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
142
|
+
controls.enableDamping = true;
|
|
143
|
+
controls.dampingFactor = 0.05;
|
|
144
|
+
controls.maxPolarAngle = Math.PI * 0.45;
|
|
145
|
+
controls.minDistance = 8;
|
|
146
|
+
controls.maxDistance = 40;
|
|
147
|
+
|
|
148
|
+
scene.add(new THREE.HemisphereLight(0xb8c4d4, 0x2a2e36, 0.55));
|
|
149
|
+
const sun = new THREE.DirectionalLight(0xf0f2f5, 1.35);
|
|
150
|
+
sun.position.set(14, 24, 12);
|
|
151
|
+
sun.castShadow = true;
|
|
152
|
+
sun.shadow.mapSize.set(2048, 2048);
|
|
153
|
+
sun.shadow.camera.near = 2; sun.shadow.camera.far = 80;
|
|
154
|
+
sun.shadow.camera.left = sun.shadow.camera.bottom = -30;
|
|
155
|
+
sun.shadow.camera.right = sun.shadow.camera.top = 30;
|
|
156
|
+
sun.shadow.bias = -0.0002;
|
|
157
|
+
scene.add(sun);
|
|
158
|
+
const fill = new THREE.DirectionalLight(0x6a7a90, 0.45);
|
|
159
|
+
fill.position.set(-14, 10, -8); scene.add(fill);
|
|
160
|
+
|
|
161
|
+
const allX = SCENE.stations.map(s => s.x);
|
|
162
|
+
const allZ = SCENE.stations.map(s => s.z);
|
|
163
|
+
const cx = (Math.min(...allX) + Math.max(...allX)) / 2 || 0;
|
|
164
|
+
const cz = (Math.min(...allZ) + Math.max(...allZ)) / 2 || 0;
|
|
165
|
+
const span = Math.max(Math.max(...allX) - Math.min(...allX), Math.max(...allZ) - Math.min(...allZ), 8);
|
|
166
|
+
const islandR = span * 0.055 * 0.55 + 6.8;
|
|
167
|
+
function world(p) { return new THREE.Vector3(p.x - cx, p.y, p.z - cz); }
|
|
168
|
+
function mulberry32(a){return function(){let t=a+=0x6D2B79F5;t=Math.imul(t^t>>>15,t|1);t^=t+Math.imul(t^t>>>7,t|61);return((t^t>>>14)>>>0)/4294967296}}
|
|
169
|
+
const rnd = mulberry32(SCENE.stations.length * 9973 + SCENE.rails.length * 17);
|
|
170
|
+
|
|
171
|
+
const mats = {
|
|
172
|
+
slab: new THREE.MeshStandardMaterial({ color: 0x2c3138, roughness: 0.82, metalness: 0.12 }),
|
|
173
|
+
edge: new THREE.MeshStandardMaterial({ color: 0x1a1d22, roughness: 0.75, metalness: 0.2 }),
|
|
174
|
+
asphalt: new THREE.MeshStandardMaterial({ color: 0x3a4048, roughness: 0.9, metalness: 0.05 }),
|
|
175
|
+
plaza: new THREE.MeshStandardMaterial({ color: 0x4a5058, roughness: 0.7, metalness: 0.15 }),
|
|
176
|
+
concrete: new THREE.MeshStandardMaterial({ color: 0x6a7078, roughness: 0.65, metalness: 0.18 }),
|
|
177
|
+
steel: new THREE.MeshStandardMaterial({ color: 0x8a929c, roughness: 0.35, metalness: 0.75 }),
|
|
178
|
+
glass: new THREE.MeshStandardMaterial({ color: 0x1c2836, roughness: 0.15, metalness: 0.85, transparent: true, opacity: 0.85 }),
|
|
179
|
+
dark: new THREE.MeshStandardMaterial({ color: 0x14171c, roughness: 0.6, metalness: 0.3 }),
|
|
180
|
+
road: new THREE.MeshStandardMaterial({ color: 0x32383f, roughness: 0.92, metalness: 0.08 }),
|
|
181
|
+
buildings: [0x3d434c,0x2f353d,0x454c56,0x252a31,0x505862].map(c =>
|
|
182
|
+
new THREE.MeshStandardMaterial({ color: c, roughness: 0.55, metalness: 0.25 }))
|
|
183
|
+
};
|
|
184
|
+
const rideByPkg = new Map(SCENE.trains.map(t => [t.package, t]));
|
|
185
|
+
const stationMeshes = [];
|
|
186
|
+
let activeTrain = null;
|
|
187
|
+
let selectedId = null;
|
|
188
|
+
const hudTitle = document.getElementById('hud-title');
|
|
189
|
+
const hudSub = document.getElementById('hud-sub');
|
|
190
|
+
const statRide = document.getElementById('stat-ride');
|
|
191
|
+
|
|
192
|
+
const worldRoot = new THREE.Group();
|
|
193
|
+
scene.add(worldRoot);
|
|
194
|
+
|
|
195
|
+
// Architectural maquette base — concrete slab, not toy island
|
|
196
|
+
const cliff = new THREE.Mesh(new THREE.BoxGeometry(islandR*2.05, 1.1, islandR*2.05), mats.edge);
|
|
197
|
+
cliff.position.y = -0.55; cliff.castShadow = true; cliff.receiveShadow = true; worldRoot.add(cliff);
|
|
198
|
+
const top = new THREE.Mesh(new THREE.BoxGeometry(islandR*2, 0.22, islandR*2), mats.slab);
|
|
199
|
+
top.position.y = 0.05; top.receiveShadow = true; top.castShadow = true; worldRoot.add(top);
|
|
200
|
+
const pedestal = new THREE.Mesh(new THREE.BoxGeometry(islandR*1.15, 0.45, islandR*1.15), mats.dark);
|
|
201
|
+
pedestal.position.y = -1.25; pedestal.castShadow = true; worldRoot.add(pedestal);
|
|
202
|
+
|
|
203
|
+
// sparse volume blocks (massing study, not candy houses)
|
|
204
|
+
function building(x,z,w,d,h,mi){
|
|
205
|
+
const mesh=new THREE.Mesh(new THREE.BoxGeometry(w,h,d), mats.buildings[mi%mats.buildings.length]);
|
|
206
|
+
mesh.position.set(x,h/2+0.16,z); mesh.castShadow=true; mesh.receiveShadow=true;
|
|
207
|
+
const win=new THREE.Mesh(new THREE.BoxGeometry(w*0.72,h*0.55,0.03), mats.glass);
|
|
208
|
+
win.position.set(0,0.02,d/2+0.02); mesh.add(win); return mesh;
|
|
209
|
+
}
|
|
210
|
+
for(let i=0;i<18;i++){
|
|
211
|
+
const a=rnd()*Math.PI*2, r=islandR*(0.35+rnd()*0.5);
|
|
212
|
+
const x=Math.cos(a)*r, z=Math.sin(a)*r;
|
|
213
|
+
let ok=true;
|
|
214
|
+
for(const st of SCENE.stations){ const p=world(st); if(Math.hypot(p.x-x,p.z-z)<1.8){ok=false;break;} }
|
|
215
|
+
if(!ok) continue;
|
|
216
|
+
worldRoot.add(building(x,z, 0.45+rnd()*0.7, 0.4+rnd()*0.55, 0.7+rnd()*2.2, Math.floor(rnd()*5)));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const railY = 1.05;
|
|
220
|
+
for (const rail of SCENE.rails) {
|
|
221
|
+
const pts = rail.points.map(p => { const v=world(p); v.y=railY; return v; });
|
|
222
|
+
if (pts.length<2) continue;
|
|
223
|
+
const curve = new THREE.CatmullRomCurve3(pts, false, 'catmullrom', 0.22);
|
|
224
|
+
const segs = Math.max(36, pts.length*16);
|
|
225
|
+
const radius = Math.max(0.055, rail.width*0.5);
|
|
226
|
+
const tube = new THREE.Mesh(new THREE.TubeGeometry(curve, segs, radius, 12, false),
|
|
227
|
+
new THREE.MeshStandardMaterial({ color: new THREE.Color(rail.color), metalness:0.3, roughness:0.35, emissive:new THREE.Color(rail.color), emissiveIntensity:0.2 }));
|
|
228
|
+
tube.castShadow=true; tube.receiveShadow=true; worldRoot.add(tube);
|
|
229
|
+
const deck = new THREE.Mesh(new THREE.TubeGeometry(curve, segs, radius*1.75, 8, false), mats.steel);
|
|
230
|
+
deck.position.y=-0.05; deck.castShadow=true; worldRoot.add(deck);
|
|
231
|
+
const n = Math.max(3, Math.floor(curve.getLength()/1.55));
|
|
232
|
+
for(let i=0;i<=n;i++){
|
|
233
|
+
const p=curve.getPointAt(i/n);
|
|
234
|
+
const pillar=new THREE.Mesh(new THREE.BoxGeometry(0.1,Math.max(0.2,p.y-0.19),0.1), mats.concrete);
|
|
235
|
+
pillar.position.set(p.x,(p.y-0.19)/2+0.19,p.z); pillar.castShadow=true; worldRoot.add(pillar);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
for (const st of SCENE.stations) {
|
|
240
|
+
const neon = new THREE.Color(st.color);
|
|
241
|
+
const g=new THREE.Group(); const p=world(st); g.position.set(p.x,0.19,p.z);
|
|
242
|
+
g.userData.stationId = st.id; g.userData.label = st.label; g.userData.color = st.color;
|
|
243
|
+
const plaza=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?2.0:1.55, 0.08, st.interchange?2.0:1.55), mats.asphalt);
|
|
244
|
+
plaza.receiveShadow=true; plaza.castShadow=true; plaza.userData.pick=true; g.add(plaza);
|
|
245
|
+
// neon ground ring
|
|
246
|
+
const ring=new THREE.Mesh(new THREE.TorusGeometry(st.interchange?0.95:0.72, 0.035, 10, 48),
|
|
247
|
+
new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:1.35, roughness:0.25, metalness:0.4 }));
|
|
248
|
+
ring.rotation.x=Math.PI/2; ring.position.y=0.06; ring.userData.pick=true; g.add(ring);
|
|
249
|
+
const ringInner=new THREE.Mesh(new THREE.TorusGeometry(st.interchange?0.55:0.42, 0.018, 8, 40),
|
|
250
|
+
new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:2.1, roughness:0.2, metalness:0.35 }));
|
|
251
|
+
ringInner.rotation.x=Math.PI/2; ringInner.position.y=0.07; g.add(ringInner);
|
|
252
|
+
const platform=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?1.5:1.15, 0.12, 0.5), mats.concrete);
|
|
253
|
+
platform.position.y=railY-0.35; platform.castShadow=true; platform.userData.pick=true; g.add(platform);
|
|
254
|
+
const canopy=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?1.3:1.0, 0.05, 0.48), mats.steel);
|
|
255
|
+
canopy.position.y=railY+0.2; canopy.castShadow=true; g.add(canopy);
|
|
256
|
+
const accent=new THREE.Mesh(new THREE.BoxGeometry(st.interchange?1.3:1.0, 0.04, 0.05),
|
|
257
|
+
new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:1.8, roughness:0.25, metalness:0.45 }));
|
|
258
|
+
accent.position.y=railY+0.16; g.add(accent);
|
|
259
|
+
// neon beacon mast
|
|
260
|
+
const mast=new THREE.Mesh(new THREE.CylinderGeometry(0.035,0.045,1.35,10),
|
|
261
|
+
new THREE.MeshStandardMaterial({ color:0x1a1e24, roughness:0.4, metalness:0.7 }));
|
|
262
|
+
mast.position.set(st.interchange?0.55:0.42, 0.75, 0.35); g.add(mast);
|
|
263
|
+
const beacon=new THREE.Mesh(new THREE.SphereGeometry(0.09,12,12),
|
|
264
|
+
new THREE.MeshStandardMaterial({ color:neon, emissive:neon, emissiveIntensity:2.4, roughness:0.15 }));
|
|
265
|
+
beacon.position.set(st.interchange?0.55:0.42, 1.45, 0.35); beacon.userData.pick=true; g.add(beacon);
|
|
266
|
+
const glow=new THREE.PointLight(neon, 0.55, 4.5, 2);
|
|
267
|
+
glow.position.copy(beacon.position); g.add(glow);
|
|
268
|
+
g.userData.neonMats=[ring.material, ringInner.material, accent.material, beacon.material];
|
|
269
|
+
g.userData.glow=glow;
|
|
270
|
+
const nBuild=st.interchange?3:2;
|
|
271
|
+
const props=[];
|
|
272
|
+
for(let i=0;i<nBuild;i++){
|
|
273
|
+
const ang=(i/nBuild)*Math.PI*2+0.4, rr=1.55+rnd()*0.35;
|
|
274
|
+
const b=building(Math.cos(ang)*rr, Math.sin(ang)*rr, 0.4+rnd()*0.35, 0.35+rnd()*0.25, 0.9+rnd()*1.6, Math.floor(rnd()*5));
|
|
275
|
+
b.userData.baseY=b.position.y;
|
|
276
|
+
b.userData.baseScale=1;
|
|
277
|
+
b.scale.setScalar(0);
|
|
278
|
+
b.visible=false;
|
|
279
|
+
props.push(b); g.add(b);
|
|
280
|
+
}
|
|
281
|
+
// platform kit pieces also stagger in on first visit
|
|
282
|
+
[accent, canopy, mast, beacon].forEach(piece=>{
|
|
283
|
+
piece.userData.baseY=piece.position.y;
|
|
284
|
+
piece.userData.baseScale=1;
|
|
285
|
+
piece.scale.setScalar(0);
|
|
286
|
+
piece.visible=false;
|
|
287
|
+
props.push(piece);
|
|
288
|
+
});
|
|
289
|
+
g.userData.props=props;
|
|
290
|
+
g.userData.revealed=false;
|
|
291
|
+
const canvas=document.createElement('canvas'); canvas.width=256; canvas.height=64;
|
|
292
|
+
const ctx=canvas.getContext('2d');
|
|
293
|
+
ctx.fillStyle='rgba(10,12,16,0.9)';
|
|
294
|
+
if(ctx.roundRect){ctx.roundRect(10,14,236,36,8);ctx.fill()} else ctx.fillRect(10,14,236,36);
|
|
295
|
+
ctx.strokeStyle=st.color; ctx.lineWidth=3;
|
|
296
|
+
if(ctx.roundRect){ctx.beginPath();ctx.roundRect(10,14,236,36,8);ctx.stroke()}
|
|
297
|
+
ctx.font='700 22px IBM Plex Mono, monospace'; ctx.fillStyle=st.color; ctx.textAlign='center'; ctx.textBaseline='middle';
|
|
298
|
+
ctx.fillText(st.label,128,34);
|
|
299
|
+
const sprite=new THREE.Sprite(new THREE.SpriteMaterial({map:new THREE.CanvasTexture(canvas), transparent:true, depthTest:false}));
|
|
300
|
+
sprite.scale.set(2.15,0.54,1); sprite.position.set(0,2.1,0); g.add(sprite);
|
|
301
|
+
stationMeshes.push(g); worldRoot.add(g);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
for(let i=0;i<SCENE.stations.length-1;i++){
|
|
305
|
+
if(rnd()>0.5) continue;
|
|
306
|
+
const a=world(SCENE.stations[i]), b=world(SCENE.stations[i+1]);
|
|
307
|
+
const mid=a.clone().lerp(b,0.5), len=a.distanceTo(b);
|
|
308
|
+
const road=new THREE.Mesh(new THREE.BoxGeometry(0.26,0.03,len), mats.road);
|
|
309
|
+
road.position.set(mid.x,0.205,mid.z); road.lookAt(b.x,0.205,b.z); road.receiveShadow=true; worldRoot.add(road);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function easeOutCubic(x){ return 1-Math.pow(1-x,3); }
|
|
313
|
+
function easeOutBack(x){
|
|
314
|
+
const c1=1.70158, c3=c1+1;
|
|
315
|
+
return 1+c3*Math.pow(x-1,3)+c1*Math.pow(x-1,2);
|
|
316
|
+
}
|
|
317
|
+
function easeInBack(x){
|
|
318
|
+
const c1=1.70158, c3=c1+1;
|
|
319
|
+
return c3*x*x*x-c1*x*x;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// bokoko-style: each piece pops in on its own timeline (scale/Y only — never mutate shared material opacity)
|
|
323
|
+
function updateStaggerParts(parts, elapsed, partDur, stagger){
|
|
324
|
+
let allDone=true;
|
|
325
|
+
for(let i=0;i<parts.length;i++){
|
|
326
|
+
const part=parts[i];
|
|
327
|
+
const local=Math.min(1, Math.max(0, (elapsed - i*stagger)/partDur));
|
|
328
|
+
if(local<1) allDone=false;
|
|
329
|
+
if(local<=0){
|
|
330
|
+
part.visible=false;
|
|
331
|
+
part.scale.setScalar(0);
|
|
332
|
+
continue;
|
|
333
|
+
}
|
|
334
|
+
part.visible=true;
|
|
335
|
+
const s=Math.max(0.001, easeOutBack(local));
|
|
336
|
+
part.scale.setScalar(s * (part.userData.baseScale||1));
|
|
337
|
+
if(part.userData.baseY!=null){
|
|
338
|
+
part.position.y = part.userData.baseY + (1-easeOutCubic(local))*0.55;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
return allDone;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function snapPartsIn(parts){
|
|
345
|
+
for(const part of parts){
|
|
346
|
+
part.visible=true;
|
|
347
|
+
part.scale.setScalar(part.userData.baseScale||1);
|
|
348
|
+
if(part.userData.baseY!=null) part.position.y=part.userData.baseY;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function makeTrain(colorHex){
|
|
353
|
+
const color=new THREE.Color(colorHex); const g=new THREE.Group();
|
|
354
|
+
const parts=[];
|
|
355
|
+
function part(){
|
|
356
|
+
const pg=new THREE.Group();
|
|
357
|
+
pg.userData.baseScale=1;
|
|
358
|
+
pg.userData.baseY=0;
|
|
359
|
+
pg.position.set(0,0,0);
|
|
360
|
+
parts.push(pg); g.add(pg); return pg;
|
|
361
|
+
}
|
|
362
|
+
function windowPane(x,y,z){
|
|
363
|
+
// BasicMaterial + depthWrite false: never get buried by body / lighting
|
|
364
|
+
const mat=new THREE.MeshBasicMaterial({
|
|
365
|
+
color:0x7ecbff, transparent:false, depthWrite:false
|
|
366
|
+
});
|
|
367
|
+
const w=new THREE.Mesh(new THREE.BoxGeometry(0.2,0.14,0.05), mat);
|
|
368
|
+
w.position.set(x,y,z);
|
|
369
|
+
w.renderOrder=10;
|
|
370
|
+
return w;
|
|
371
|
+
}
|
|
372
|
+
// bokoko reveal order: rear → body → windows → headlight
|
|
373
|
+
const rear=part();
|
|
374
|
+
const car2=new THREE.Mesh(new RoundedBoxGeometry(0.55,0.34,0.4,2,0.06),
|
|
375
|
+
new THREE.MeshStandardMaterial({color:color.clone().offsetHSL(0,-0.05,0.06), roughness:0.35, metalness:0.2, emissive:color, emissiveIntensity:0.15}));
|
|
376
|
+
car2.position.x=-0.78; car2.castShadow=true; car2.name='car2'; rear.add(car2);
|
|
377
|
+
|
|
378
|
+
const mid=part();
|
|
379
|
+
const body=new THREE.Mesh(new RoundedBoxGeometry(0.95,0.36,0.42,2,0.07),
|
|
380
|
+
new THREE.MeshStandardMaterial({color, metalness:0.25, roughness:0.3, emissive:color, emissiveIntensity:0.35}));
|
|
381
|
+
body.castShadow=true; body.name='body'; mid.add(body);
|
|
382
|
+
|
|
383
|
+
const glass=part();
|
|
384
|
+
glass.name='windows';
|
|
385
|
+
// both flanks, clearly outside body half-depth (0.21)
|
|
386
|
+
[[0.18,0.05,0.28],[-0.18,0.05,0.28],[0.18,0.05,-0.28],[-0.18,0.05,-0.28],
|
|
387
|
+
[-0.78,0.05,0.26],[-0.78,0.05,-0.26]].forEach(([x,y,z])=> glass.add(windowPane(x,y,z)));
|
|
388
|
+
|
|
389
|
+
const nose=part();
|
|
390
|
+
const light=new THREE.Mesh(new THREE.SphereGeometry(0.05,10,10),
|
|
391
|
+
new THREE.MeshStandardMaterial({color:0xfff2c4, emissive:0xffe08a, emissiveIntensity:1.1}));
|
|
392
|
+
light.position.set(0.52,0,0); nose.add(light);
|
|
393
|
+
|
|
394
|
+
g.userData.parts=parts;
|
|
395
|
+
g.userData.windows=glass;
|
|
396
|
+
return g;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function recolorTrain(mesh, colorHex){
|
|
400
|
+
const color=new THREE.Color(colorHex);
|
|
401
|
+
const body=mesh.getObjectByName('body');
|
|
402
|
+
const car2=mesh.getObjectByName('car2');
|
|
403
|
+
if(body && body.material){ body.material.color.copy(color); body.material.emissive.copy(color); }
|
|
404
|
+
if(car2 && car2.material){
|
|
405
|
+
const c2=color.clone().offsetHSL(0,-0.05,0.06);
|
|
406
|
+
car2.material.color.copy(c2); car2.material.emissive.copy(color);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const clock=new THREE.Clock();
|
|
411
|
+
const revealedStations=new Set();
|
|
412
|
+
let sharedTrain=null;
|
|
413
|
+
let trainEverDeparted=false;
|
|
414
|
+
|
|
415
|
+
function ensureTrain(colorHex){
|
|
416
|
+
if(!sharedTrain){
|
|
417
|
+
sharedTrain=makeTrain(colorHex);
|
|
418
|
+
sharedTrain.visible=false;
|
|
419
|
+
worldRoot.add(sharedTrain);
|
|
420
|
+
} else {
|
|
421
|
+
recolorTrain(sharedTrain, colorHex);
|
|
422
|
+
}
|
|
423
|
+
return sharedTrain;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function disposeObject(obj){
|
|
427
|
+
worldRoot.remove(obj);
|
|
428
|
+
obj.traverse(o=>{ if(o.geometry) o.geometry.dispose(); if(o.material){
|
|
429
|
+
if(Array.isArray(o.material)) o.material.forEach(m=>m.dispose()); else o.material.dispose();
|
|
430
|
+
}});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
function finishPropsIfNeeded(tr){
|
|
434
|
+
if(!tr || tr.phase!=='props' || !tr.stGroup) return;
|
|
435
|
+
snapPartsIn(tr.stGroup.userData.props||[]);
|
|
436
|
+
revealedStations.add(tr.pkgId);
|
|
437
|
+
tr.stGroup.userData.revealed=true;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function disposeTrainNow(){
|
|
441
|
+
if(!activeTrain) return;
|
|
442
|
+
finishPropsIfNeeded(activeTrain);
|
|
443
|
+
// only Clear removes the mesh; station switches reuse sharedTrain
|
|
444
|
+
if(sharedTrain && activeTrain.mesh===sharedTrain){
|
|
445
|
+
sharedTrain.visible=false;
|
|
446
|
+
} else if(activeTrain.mesh && activeTrain.mesh!==sharedTrain){
|
|
447
|
+
disposeObject(activeTrain.mesh);
|
|
448
|
+
}
|
|
449
|
+
activeTrain=null; statRide.textContent='0';
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function clearTrain(animated){
|
|
453
|
+
if(!activeTrain && !sharedTrain) return;
|
|
454
|
+
if(sharedTrain){
|
|
455
|
+
disposeObject(sharedTrain);
|
|
456
|
+
sharedTrain=null;
|
|
457
|
+
trainEverDeparted=false;
|
|
458
|
+
}
|
|
459
|
+
activeTrain=null;
|
|
460
|
+
statRide.textContent='0';
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function setSelection(stGroup){
|
|
464
|
+
for(const s of stationMeshes){
|
|
465
|
+
const on=s===stGroup;
|
|
466
|
+
if(s.userData.neonMats[0]) s.userData.neonMats[0].emissiveIntensity=on?2.8:1.35;
|
|
467
|
+
if(s.userData.neonMats[1]) s.userData.neonMats[1].emissiveIntensity=on?3.4:2.1;
|
|
468
|
+
if(s.userData.neonMats[2]) s.userData.neonMats[2].emissiveIntensity=on?3.0:1.8;
|
|
469
|
+
if(s.userData.neonMats[3]) s.userData.neonMats[3].emissiveIntensity=on?4.2:2.4;
|
|
470
|
+
if(s.userData.glow) s.userData.glow.intensity=on?1.35:0.55;
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const _fwd=new THREE.Vector3(1,0,0);
|
|
475
|
+
const _tan=new THREE.Vector3();
|
|
476
|
+
const _q=new THREE.Quaternion();
|
|
477
|
+
function placeTrainOnCurve(mesh, curve, u){
|
|
478
|
+
// stay slightly before the true end so tangent stays stable (no 90° flip)
|
|
479
|
+
const uu=Math.min(Math.max(u,0), 0.995);
|
|
480
|
+
const p=curve.getPointAt(uu);
|
|
481
|
+
curve.getTangentAt(uu, _tan);
|
|
482
|
+
if(_tan.lengthSq()<1e-10) _tan.set(1,0,0); else _tan.normalize();
|
|
483
|
+
mesh.position.copy(p);
|
|
484
|
+
// train mesh faces +X — align +X to path tangent (no lookAt+rotateY hack)
|
|
485
|
+
_q.setFromUnitVectors(_fwd, _tan);
|
|
486
|
+
mesh.quaternion.copy(_q);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function beginRide(tr){
|
|
490
|
+
placeTrainOnCurve(tr.mesh, tr.curve, 0);
|
|
491
|
+
tr.mesh.visible=true;
|
|
492
|
+
tr.mesh.scale.setScalar(1);
|
|
493
|
+
playing=true;
|
|
494
|
+
const playBtn=document.getElementById('btn-play');
|
|
495
|
+
playBtn.textContent='Pause'; playBtn.setAttribute('aria-pressed','true');
|
|
496
|
+
statRide.textContent='1';
|
|
497
|
+
// first appearance only — bokoko: parts assemble one-by-one
|
|
498
|
+
if(!trainEverDeparted){
|
|
499
|
+
const parts=tr.mesh.userData.parts||[];
|
|
500
|
+
for(const part of parts){
|
|
501
|
+
part.visible=false;
|
|
502
|
+
part.scale.setScalar(0);
|
|
503
|
+
part.position.y=part.userData.baseY||0;
|
|
504
|
+
}
|
|
505
|
+
tr.phase='intro';
|
|
506
|
+
tr.t0=clock.getElapsedTime();
|
|
507
|
+
tr.partDur=0.52;
|
|
508
|
+
tr.partStagger=0.12;
|
|
509
|
+
hudSub.textContent='task · '+tr.task+' · train assembling…';
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
snapPartsIn(tr.mesh.userData.parts||[]);
|
|
513
|
+
tr.phase='ride';
|
|
514
|
+
tr.t0=clock.getElapsedTime();
|
|
515
|
+
hudSub.textContent='task · '+tr.task+' · riding dep path';
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function spawnRide(pkgId, label){
|
|
519
|
+
selectedId=pkgId;
|
|
520
|
+
const stGroup=stationMeshes.find(s=>s.userData.stationId===pkgId) || null;
|
|
521
|
+
setSelection(stGroup);
|
|
522
|
+
hudTitle.textContent=label;
|
|
523
|
+
|
|
524
|
+
// same station — leave train exactly as-is (incl. first intro)
|
|
525
|
+
if(activeTrain && activeTrain.pkgId===pkgId) return;
|
|
526
|
+
|
|
527
|
+
// switching stations: finish previous assemble, but NEVER dispose/hide the shared train
|
|
528
|
+
if(activeTrain) finishPropsIfNeeded(activeTrain);
|
|
529
|
+
|
|
530
|
+
const ride=rideByPkg.get(pkgId);
|
|
531
|
+
if(!ride){
|
|
532
|
+
hudSub.textContent='Terminal stop · no dep ride path';
|
|
533
|
+
if(stGroup && !revealedStations.has(pkgId)){
|
|
534
|
+
activeTrain={
|
|
535
|
+
pkgId, mesh:sharedTrain || new THREE.Group(), curve:null, speed:0, task:'',
|
|
536
|
+
phase:'props', t0:clock.getElapsedTime(), stGroup,
|
|
537
|
+
propDur:0.55, propStagger:0.13
|
|
538
|
+
};
|
|
539
|
+
if(activeTrain.mesh!==sharedTrain) worldRoot.add(activeTrain.mesh);
|
|
540
|
+
} else if(activeTrain){
|
|
541
|
+
activeTrain.pkgId=pkgId;
|
|
542
|
+
activeTrain.stGroup=stGroup;
|
|
543
|
+
activeTrain.curve=null;
|
|
544
|
+
activeTrain.phase='arrived';
|
|
545
|
+
}
|
|
546
|
+
return;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
const firstVisit=!revealedStations.has(pkgId);
|
|
550
|
+
const pts=ride.points.map(p=>{ const v=world(p); v.y=railY+0.24; return v; });
|
|
551
|
+
const curve=new THREE.CatmullRomCurve3(pts,false,'catmullrom',0.25);
|
|
552
|
+
const mesh=ensureTrain(ride.color);
|
|
553
|
+
|
|
554
|
+
activeTrain={
|
|
555
|
+
pkgId, curve, mesh, speed:ride.speed, task:ride.task, stGroup,
|
|
556
|
+
phase: firstVisit ? 'props' : 'ride',
|
|
557
|
+
t0:clock.getElapsedTime(),
|
|
558
|
+
propDur:0.55, propStagger:0.13,
|
|
559
|
+
outroDur:0.35
|
|
560
|
+
};
|
|
561
|
+
hudTitle.textContent=label;
|
|
562
|
+
statRide.textContent=trainEverDeparted || mesh.visible ? '1' : '0';
|
|
563
|
+
|
|
564
|
+
if(firstVisit){
|
|
565
|
+
// hide ONLY before the very first departure in the session
|
|
566
|
+
if(!trainEverDeparted) mesh.visible=false;
|
|
567
|
+
hudSub.textContent='task · '+ride.task+' · assembling stop…';
|
|
568
|
+
playing=true;
|
|
569
|
+
} else {
|
|
570
|
+
// other stop / revisit — retarget path, stay visible, no pop
|
|
571
|
+
beginRide(activeTrain);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
const box=new THREE.Box3().setFromObject(worldRoot);
|
|
576
|
+
const center=box.getCenter(new THREE.Vector3());
|
|
577
|
+
const size=box.getSize(new THREE.Vector3());
|
|
578
|
+
controls.target.copy(center);
|
|
579
|
+
const dist=Math.max(size.x,size.z,10)*1.2;
|
|
580
|
+
camera.position.set(center.x+dist*0.9, center.y+dist*0.78, center.z+dist*0.72);
|
|
581
|
+
controls.update();
|
|
582
|
+
const homeCam=camera.position.clone(), homeTarget=controls.target.clone();
|
|
583
|
+
|
|
584
|
+
const raycaster=new THREE.Raycaster();
|
|
585
|
+
const pointer=new THREE.Vector2();
|
|
586
|
+
let down={x:0,y:0};
|
|
587
|
+
|
|
588
|
+
renderer.domElement.addEventListener('pointerdown', e=>{
|
|
589
|
+
down={x:e.clientX,y:e.clientY};
|
|
590
|
+
});
|
|
591
|
+
renderer.domElement.addEventListener('pointerup', e=>{
|
|
592
|
+
if(Math.hypot(e.clientX-down.x, e.clientY-down.y)>6) return;
|
|
593
|
+
const rect=renderer.domElement.getBoundingClientRect();
|
|
594
|
+
pointer.x=((e.clientX-rect.left)/rect.width)*2-1;
|
|
595
|
+
pointer.y=-((e.clientY-rect.top)/rect.height)*2+1;
|
|
596
|
+
raycaster.setFromCamera(pointer, camera);
|
|
597
|
+
const hits=raycaster.intersectObjects(stationMeshes, true);
|
|
598
|
+
if(!hits.length) return;
|
|
599
|
+
let obj=hits[0].object;
|
|
600
|
+
while(obj && !obj.userData.stationId) obj=obj.parent;
|
|
601
|
+
if(!obj) return;
|
|
602
|
+
spawnRide(obj.userData.stationId, obj.userData.label);
|
|
603
|
+
});
|
|
604
|
+
renderer.domElement.style.cursor='pointer';
|
|
605
|
+
|
|
606
|
+
document.getElementById('btn-play').addEventListener('click', e=>{
|
|
607
|
+
playing=!playing; e.currentTarget.textContent=playing?'Pause':'Play';
|
|
608
|
+
e.currentTarget.setAttribute('aria-pressed', playing?'true':'false');
|
|
609
|
+
});
|
|
610
|
+
document.getElementById('btn-clear').addEventListener('click', ()=>{
|
|
611
|
+
clearTrain(true); selectedId=null; setSelection(null);
|
|
612
|
+
hudTitle.textContent='Select a station';
|
|
613
|
+
hudSub.textContent='Click a neon stop · buildings assemble once, then ride';
|
|
614
|
+
});
|
|
615
|
+
document.getElementById('btn-reset').addEventListener('click', ()=>{
|
|
616
|
+
camera.position.copy(homeCam); controls.target.copy(homeTarget);
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
(function tick(){
|
|
620
|
+
const t=clock.getElapsedTime();
|
|
621
|
+
controls.update();
|
|
622
|
+
for(const s of stationMeshes){
|
|
623
|
+
const pulse=0.85+0.15*Math.sin(t*2.2 + s.position.x);
|
|
624
|
+
const sel=s.userData.stationId===selectedId;
|
|
625
|
+
if(s.userData.neonMats[1]) s.userData.neonMats[1].emissiveIntensity=(sel?3.4:2.1)*pulse;
|
|
626
|
+
if(s.userData.glow) s.userData.glow.intensity=(sel?1.35:0.55)*(0.9+0.1*Math.sin(t*3+s.position.z));
|
|
627
|
+
}
|
|
628
|
+
if(activeTrain){
|
|
629
|
+
const tr=activeTrain;
|
|
630
|
+
if(tr.phase==='props'){
|
|
631
|
+
const props=(tr.stGroup && tr.stGroup.userData.props) || [];
|
|
632
|
+
const done=updateStaggerParts(props, t-tr.t0, tr.propDur, tr.propStagger);
|
|
633
|
+
if(done){
|
|
634
|
+
revealedStations.add(tr.pkgId);
|
|
635
|
+
if(tr.stGroup) tr.stGroup.userData.revealed=true;
|
|
636
|
+
if(!tr.curve){
|
|
637
|
+
tr.phase='arrived';
|
|
638
|
+
hudSub.textContent='Terminal stop · assembled';
|
|
639
|
+
} else {
|
|
640
|
+
// buildings done — first show only if never departed; else stay visible
|
|
641
|
+
beginRide(tr);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
} else if(tr.phase==='intro'){
|
|
645
|
+
placeTrainOnCurve(tr.mesh, tr.curve, 0);
|
|
646
|
+
const parts=tr.mesh.userData.parts||[];
|
|
647
|
+
const done=updateStaggerParts(parts, t-tr.t0, tr.partDur, tr.partStagger);
|
|
648
|
+
if(done){
|
|
649
|
+
snapPartsIn(parts);
|
|
650
|
+
if(tr.mesh.userData.windows){
|
|
651
|
+
tr.mesh.userData.windows.visible=true;
|
|
652
|
+
tr.mesh.userData.windows.scale.setScalar(1);
|
|
653
|
+
tr.mesh.userData.windows.position.y=0;
|
|
654
|
+
}
|
|
655
|
+
trainEverDeparted=true;
|
|
656
|
+
tr.phase='ride';
|
|
657
|
+
tr.t0=t;
|
|
658
|
+
hudSub.textContent='task · '+tr.task+' · riding dep path';
|
|
659
|
+
}
|
|
660
|
+
} else if(tr.phase==='outro'){
|
|
661
|
+
const raw=Math.min(1, (t-tr.t0)/tr.outroDur);
|
|
662
|
+
const s=Math.max(0.001, 1-easeInBack(raw));
|
|
663
|
+
tr.mesh.scale.setScalar(s);
|
|
664
|
+
if(raw>=1) disposeTrainNow();
|
|
665
|
+
} else if(tr.phase==='arrived'){
|
|
666
|
+
// parked — no animation
|
|
667
|
+
} else if(playing && tr.phase==='ride'){
|
|
668
|
+
if(tr.mesh.userData.windows && !tr.mesh.userData.windows.visible){
|
|
669
|
+
snapPartsIn(tr.mesh.userData.parts||[]);
|
|
670
|
+
}
|
|
671
|
+
const u=Math.min(1, (t-tr.t0)*tr.speed);
|
|
672
|
+
placeTrainOnCurve(tr.mesh, tr.curve, u);
|
|
673
|
+
if(u>=1){
|
|
674
|
+
placeTrainOnCurve(tr.mesh, tr.curve, 0.995);
|
|
675
|
+
tr.phase='arrived';
|
|
676
|
+
hudSub.textContent='task · '+tr.task+' · arrived';
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
renderer.render(scene,camera);
|
|
681
|
+
requestAnimationFrame(tick);
|
|
682
|
+
})();
|
|
683
|
+
window.addEventListener('resize', ()=>{
|
|
684
|
+
const w=view.clientWidth, h=view.clientHeight;
|
|
685
|
+
camera.aspect=w/h; camera.updateProjectionMatrix(); renderer.setSize(w,h);
|
|
686
|
+
});
|
|
687
|
+
|
|
688
|
+
// optional capture hook: metro.html?capture=1
|
|
689
|
+
if(location.search.includes('capture')){
|
|
690
|
+
window.__turbometro = {
|
|
691
|
+
stations: SCENE.stations.map(s=>({id:s.id,label:s.label})),
|
|
692
|
+
spawn:(id,label)=>spawnRide(id,label||id),
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
</script>
|
|
696
|
+
</body>
|
|
697
|
+
</html>
|
|
698
|
+
`;
|
|
699
|
+
}
|