@plasius/gpu-shared 0.1.4 → 0.1.7

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/dist/index.cjs CHANGED
@@ -20,11 +20,32 @@ var __copyProps = (to, from, except, desc) => {
20
20
  var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
21
21
 
22
22
  // src/asset-url.js
23
+ function createInlineShowcaseAssetUrl() {
24
+ return new URL(INLINE_BRIGANTINE_GLTF_URL);
25
+ }
26
+ function getBrowserBaseUrl() {
27
+ if (typeof document !== "undefined" && typeof document.baseURI === "string" && document.baseURI.length > 0) {
28
+ return document.baseURI;
29
+ }
30
+ if (typeof window !== "undefined" && typeof window.location?.href === "string" && window.location.href.length > 0) {
31
+ return window.location.href;
32
+ }
33
+ return null;
34
+ }
23
35
  function resolveShowcaseAssetUrl(baseUrl2 = import_meta.url) {
24
36
  try {
25
37
  return new URL("../assets/brigantine.gltf", baseUrl2);
26
38
  } catch {
27
- return new URL(INLINE_BRIGANTINE_GLTF_URL);
39
+ const browserBaseUrl = getBrowserBaseUrl();
40
+ if (browserBaseUrl) {
41
+ try {
42
+ const normalizedBaseUrl = new URL(baseUrl2, browserBaseUrl);
43
+ return new URL("../assets/brigantine.gltf", normalizedBaseUrl);
44
+ } catch {
45
+ return createInlineShowcaseAssetUrl();
46
+ }
47
+ }
48
+ return createInlineShowcaseAssetUrl();
28
49
  }
29
50
  }
30
51
  var import_meta, INLINE_BRIGANTINE_GLTF_URL;
@@ -110,12 +131,39 @@ function computeBounds(positions) {
110
131
  }
111
132
  return { min, max };
112
133
  }
134
+ function resolveBrowserRequestBaseUrl() {
135
+ if (typeof document !== "undefined" && typeof document.baseURI === "string" && document.baseURI.length > 0) {
136
+ return document.baseURI;
137
+ }
138
+ if (typeof window !== "undefined" && typeof window.location?.href === "string" && window.location.href.length > 0) {
139
+ return window.location.href;
140
+ }
141
+ return null;
142
+ }
143
+ function resolveFetchBaseUrl(requestUrl, responseUrl) {
144
+ if (typeof responseUrl === "string" && responseUrl.length > 0) {
145
+ try {
146
+ return new URL(responseUrl);
147
+ } catch {
148
+ }
149
+ }
150
+ try {
151
+ return new URL(requestUrl);
152
+ } catch {
153
+ const browserBaseUrl = resolveBrowserRequestBaseUrl();
154
+ if (browserBaseUrl) {
155
+ return new URL(requestUrl, browserBaseUrl);
156
+ }
157
+ throw new Error(`Unable to resolve a stable base URL for glTF asset loading: ${String(requestUrl)}`);
158
+ }
159
+ }
113
160
  async function loadGltfModel(url) {
114
161
  const response = await fetch(url);
115
162
  if (!response.ok) {
116
163
  throw new Error(`Failed to load glTF asset: ${response.status} ${response.statusText}`);
117
164
  }
118
165
  const document2 = await response.json();
166
+ const baseUrl2 = resolveFetchBaseUrl(url, response.url);
119
167
  const buffers = await Promise.all(
120
168
  (document2.buffers ?? []).map(async (buffer) => {
121
169
  if (typeof buffer.uri !== "string") {
@@ -124,7 +172,7 @@ async function loadGltfModel(url) {
124
172
  if (buffer.uri.startsWith("data:")) {
125
173
  return decodeDataUri(buffer.uri);
126
174
  }
127
- const nested = await fetch(new URL(buffer.uri, url));
175
+ const nested = await fetch(new URL(buffer.uri, baseUrl2));
128
176
  if (!nested.ok) {
129
177
  throw new Error(`Failed to load glTF buffer: ${nested.status} ${nested.statusText}`);
130
178
  }
@@ -2172,6 +2220,9 @@ var init_dist2 = __esm({
2172
2220
  });
2173
2221
 
2174
2222
  // node_modules/@plasius/gpu-lighting/dist/index.js
2223
+ function createModuleBaseUrl(metaUrl) {
2224
+ return Reflect.construct(URL, [String(metaUrl)]);
2225
+ }
2175
2226
  function buildTechnique(name, spec) {
2176
2227
  const preludeUrl = new URL(`./techniques/${name}/${spec.prelude}`, baseUrl);
2177
2228
  const jobs = Object.entries(spec.jobs).map(([key, file]) => {
@@ -2480,7 +2531,7 @@ var init_dist3 = __esm({
2480
2531
  });
2481
2532
  baseUrl = (() => {
2482
2533
  if (typeof import_meta2.url !== "undefined") {
2483
- return new URL("./index.js", import_meta2.url);
2534
+ return createModuleBaseUrl(import_meta2.url);
2484
2535
  }
2485
2536
  if (typeof __filename !== "undefined" && typeof __require !== "undefined") {
2486
2537
  const { pathToFileURL } = __require("url");
@@ -5107,7 +5158,7 @@ var init_dist5 = __esm({
5107
5158
  }
5108
5159
  });
5109
5160
 
5110
- // node_modules/@plasius/gpu-physics/dist/chunk-CNTXT5QJ.js
5161
+ // node_modules/@plasius/gpu-physics/dist/chunk-PFUNZLNF.js
5111
5162
  function assertIdentifier5(name, value) {
5112
5163
  if (typeof value !== "string" || value.trim().length === 0) {
5113
5164
  throw new Error(`${name} must be a non-empty string.`);
@@ -5256,7 +5307,7 @@ function getPhysicsWorkerManifest(profile = defaultPhysicsWorkerProfile) {
5256
5307
  return manifest;
5257
5308
  }
5258
5309
  function createPhysicsSimulationPlan(profile = defaultPhysicsWorkerProfile) {
5259
- const plan = physicsSimulationPlanSpecs[profile];
5310
+ const plan = physicsSimulationPlans[profile];
5260
5311
  if (!plan) {
5261
5312
  const available = physicsWorkerProfileNames.join(", ");
5262
5313
  throw new Error(
@@ -5320,9 +5371,9 @@ function createPhysicsWorldSnapshot(input) {
5320
5371
  metadata: normalizeMetadata(input.metadata)
5321
5372
  });
5322
5373
  }
5323
- var physicsDebugOwner, physicsWorkerQueueClasses, defaultPhysicsWorkerProfile, physicsSimulationStageOrder, physicsWorkerProfileSpecs, physicsWorkerDagSpecs, physicsSimulationPlanSpecs, physicsWorkerManifests, physicsWorkerProfileNames;
5324
- var init_chunk_CNTXT5QJ = __esm({
5325
- "node_modules/@plasius/gpu-physics/dist/chunk-CNTXT5QJ.js"() {
5374
+ var physicsDebugOwner, physicsWorkerQueueClasses, defaultPhysicsWorkerProfile, physicsSimulationStageOrder, physicsWorkerProfileSpecs, physicsWorkerDagSpecs, physicsSimulationPlans, physicsWorkerManifests, physicsWorkerProfileNames;
5375
+ var init_chunk_PFUNZLNF = __esm({
5376
+ "node_modules/@plasius/gpu-physics/dist/chunk-PFUNZLNF.js"() {
5326
5377
  physicsDebugOwner = "physics";
5327
5378
  physicsWorkerQueueClasses = Object.freeze({
5328
5379
  simulation: "simulation",
@@ -6068,7 +6119,7 @@ var init_chunk_CNTXT5QJ = __esm({
6068
6119
  contactVisuals: { priority: 1, dependencies: ["worldSnapshot"] }
6069
6120
  }
6070
6121
  };
6071
- physicsSimulationPlanSpecs = Object.freeze({
6122
+ physicsSimulationPlans = Object.freeze({
6072
6123
  gameplay: Object.freeze({
6073
6124
  description: "Gameplay simulation plan that hands off a stable post-commit world snapshot to visual preparation.",
6074
6125
  snapshotStageId: "worldSnapshot",
@@ -6284,7 +6335,7 @@ var init_chunk_CNTXT5QJ = __esm({
6284
6335
  // node_modules/@plasius/gpu-physics/dist/browser.js
6285
6336
  var init_browser = __esm({
6286
6337
  "node_modules/@plasius/gpu-physics/dist/browser.js"() {
6287
- init_chunk_CNTXT5QJ();
6338
+ init_chunk_PFUNZLNF();
6288
6339
  }
6289
6340
  });
6290
6341
 
@@ -6301,27 +6352,27 @@ function injectStyles() {
6301
6352
  const style = document.createElement("style");
6302
6353
  style.id = STYLE_ID;
6303
6354
  style.textContent = `
6304
- :root {
6305
- color-scheme: light;
6306
- --plasius-paper: #f4f7f8;
6307
- --plasius-ink: #152028;
6308
- --plasius-muted: #5c6f7b;
6309
- --plasius-accent: #8f5634;
6310
- --plasius-panel: rgba(255, 255, 255, 0.82);
6311
- --plasius-border: rgba(21, 32, 40, 0.12);
6312
- --plasius-shadow: 0 20px 48px rgba(15, 24, 31, 0.16);
6313
- }
6314
- * {
6315
- box-sizing: border-box;
6316
- }
6317
- body {
6355
+ .${ROOT_CLASS} {
6356
+ color-scheme: dark;
6357
+ --plasius-paper: #081321;
6358
+ --plasius-ink: #edf4ff;
6359
+ --plasius-muted: #b6c5dd;
6360
+ --plasius-accent: #f3b16a;
6361
+ --plasius-panel: rgba(8, 19, 33, 0.72);
6362
+ --plasius-border: rgba(159, 185, 223, 0.18);
6363
+ --plasius-shadow: 0 24px 56px rgba(1, 6, 14, 0.44);
6318
6364
  margin: 0;
6319
- min-height: 100vh;
6365
+ min-height: 100%;
6320
6366
  font-family: "Fraunces", "Iowan Old Style", serif;
6321
6367
  color: var(--plasius-ink);
6322
6368
  background:
6323
- radial-gradient(circle at top left, rgba(255, 247, 238, 0.92), transparent 34%),
6324
- linear-gradient(180deg, #f6f8fb 0%, #d2dee6 48%, #b6c4ce 100%);
6369
+ radial-gradient(circle at 18% 12%, rgba(73, 101, 170, 0.28), transparent 30%),
6370
+ radial-gradient(circle at 82% 18%, rgba(240, 188, 103, 0.08), transparent 18%),
6371
+ linear-gradient(180deg, #04101d 0%, #0b1930 42%, #081321 100%);
6372
+ }
6373
+ .${ROOT_CLASS},
6374
+ .${ROOT_CLASS} * {
6375
+ box-sizing: border-box;
6325
6376
  }
6326
6377
  .plasius-demo {
6327
6378
  width: min(1560px, calc(100vw - 32px));
@@ -6355,7 +6406,7 @@ function injectStyles() {
6355
6406
  text-transform: uppercase;
6356
6407
  letter-spacing: 0.18em;
6357
6408
  font-size: 12px;
6358
- color: rgba(21, 32, 40, 0.56);
6409
+ color: rgba(226, 236, 255, 0.58);
6359
6410
  }
6360
6411
  .plasius-demo h1,
6361
6412
  .plasius-demo h2,
@@ -6373,7 +6424,7 @@ function injectStyles() {
6373
6424
  margin: 0;
6374
6425
  padding: 8px 12px;
6375
6426
  border-radius: 999px;
6376
- background: rgba(143, 86, 52, 0.12);
6427
+ background: rgba(243, 177, 106, 0.14);
6377
6428
  color: var(--plasius-accent);
6378
6429
  font-weight: 700;
6379
6430
  }
@@ -6395,8 +6446,8 @@ function injectStyles() {
6395
6446
  aspect-ratio: 16 / 9;
6396
6447
  display: block;
6397
6448
  border-radius: 20px;
6398
- border: 1px solid rgba(21, 32, 40, 0.08);
6399
- background: linear-gradient(180deg, #dce8ef 0%, #a9bfd0 42%, #0f5168 42%, #092433 100%);
6449
+ border: 1px solid rgba(159, 185, 223, 0.12);
6450
+ background: linear-gradient(180deg, #071220 0%, #132440 42%, #10344b 42%, #05111d 100%);
6400
6451
  }
6401
6452
  .plasius-demo__toolbar {
6402
6453
  position: absolute;
@@ -6416,9 +6467,9 @@ function injectStyles() {
6416
6467
  .plasius-demo button,
6417
6468
  .plasius-demo .plasius-toggle,
6418
6469
  .plasius-demo select {
6419
- border: 1px solid rgba(21, 32, 40, 0.12);
6470
+ border: 1px solid rgba(159, 185, 223, 0.18);
6420
6471
  border-radius: 999px;
6421
- background: rgba(255, 255, 255, 0.84);
6472
+ background: rgba(9, 20, 34, 0.84);
6422
6473
  color: var(--plasius-ink);
6423
6474
  padding: 10px 14px;
6424
6475
  }
@@ -6457,8 +6508,8 @@ function injectStyles() {
6457
6508
  bottom: 24px;
6458
6509
  padding: 10px 14px;
6459
6510
  border-radius: 16px;
6460
- background: rgba(255, 255, 255, 0.84);
6461
- border: 1px solid rgba(21, 32, 40, 0.1);
6511
+ background: rgba(9, 20, 34, 0.82);
6512
+ border: 1px solid rgba(159, 185, 223, 0.16);
6462
6513
  color: var(--plasius-muted);
6463
6514
  font-size: 12px;
6464
6515
  line-height: 1.45;
@@ -6470,7 +6521,7 @@ function injectStyles() {
6470
6521
  }
6471
6522
  .plasius-demo__footer {
6472
6523
  margin-top: 4px;
6473
- color: rgba(21, 32, 40, 0.66);
6524
+ color: rgba(226, 236, 255, 0.68);
6474
6525
  font-size: 13px;
6475
6526
  line-height: 1.6;
6476
6527
  }
@@ -6489,6 +6540,10 @@ function clamp(value, min, max) {
6489
6540
  function mix(a, b, t) {
6490
6541
  return a + (b - a) * t;
6491
6542
  }
6543
+ function pseudoRandom(seed) {
6544
+ const value = Math.sin(seed * 12.9898 + seed * seed * 17e-4) * 43758.5453;
6545
+ return value - Math.floor(value);
6546
+ }
6492
6547
  function vec3(x = 0, y = 0, z = 0) {
6493
6548
  return { x, y, z };
6494
6549
  }
@@ -6522,6 +6577,12 @@ function reflectVec3(vector, normal) {
6522
6577
  const unitNormal = normalizeVec3(normal);
6523
6578
  return subVec3(vector, scaleVec3(unitNormal, 2 * dotVec3(vector, unitNormal)));
6524
6579
  }
6580
+ function directionFromYaw(yaw) {
6581
+ return normalizeVec3(vec3(Math.sin(yaw), 0, Math.cos(yaw)));
6582
+ }
6583
+ function perpendicularOnWater(direction) {
6584
+ return vec3(-direction.z, 0, direction.x);
6585
+ }
6525
6586
  function rotateY(point, angle) {
6526
6587
  const cosine = Math.cos(angle);
6527
6588
  const sine = Math.sin(angle);
@@ -6704,7 +6765,7 @@ function buildDemoDom(root, options) {
6704
6765
  <section class="plasius-panel plasius-demo__status">
6705
6766
  <p id="demoStatus" class="plasius-demo__status-badge">Booting 3D scene\u2026</p>
6706
6767
  <p id="demoDetails" class="plasius-demo__status-text">
6707
- Preparing GLTF assets, cloth and fluid continuity plans, and adaptive quality metadata.
6768
+ Preparing a moonlit harbor scene, GLTF hull data, cloth and fluid continuity plans, and adaptive quality metadata.
6708
6769
  </p>
6709
6770
  </section>
6710
6771
  </section>
@@ -6732,9 +6793,9 @@ function buildDemoDom(root, options) {
6732
6793
  </div>
6733
6794
  <div class="plasius-demo__legend">
6734
6795
  <strong>Scene</strong>
6735
- GLTF ships carry collision metadata.<br />
6736
- Flag cloth and ocean waves scale by distance band.<br />
6737
- Ray-traced shadow and reflection style is preserved near the camera.
6796
+ GLTF ships carry hull mass and damping metadata.<br />
6797
+ Lanterns and torches warm the moonlit harbor.<br />
6798
+ Mass-aware collisions stay authoritative near the camera.
6738
6799
  </div>
6739
6800
  </section>
6740
6801
  <aside class="plasius-demo__sidebar">
@@ -6815,6 +6876,7 @@ function buildSceneSnapshot(state, shipModel) {
6815
6876
  })
6816
6877
  )
6817
6878
  ),
6879
+ shipPhysics: shipModel?.physics ?? null,
6818
6880
  physics: Object.freeze({
6819
6881
  profile: state.physics.profile,
6820
6882
  plan: state.physics.plan,
@@ -6858,28 +6920,39 @@ function readVisualNumber(value, fallback) {
6858
6920
  function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {}) {
6859
6921
  const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
6860
6922
  const defaults = {
6861
- skyTop: premiumShadows ? "#f0f7fb" : "#e8f1f7",
6862
- skyMid: premiumShadows ? "#c7d9e5" : "#b9ceda",
6863
- skyBottom: premiumShadows ? "#84a7bd" : "#7b9bb0",
6864
- seaTop: premiumShadows ? "#235064" : "#264c5f",
6865
- seaMid: premiumShadows ? "#153e53" : "#173d4f",
6866
- seaBottom: "#0b2433",
6867
- sunCore: "rgba(255, 244, 210, 0.9)",
6923
+ skyTop: premiumShadows ? "#040c1a" : "#06101f",
6924
+ skyMid: premiumShadows ? "#11203b" : "#152643",
6925
+ skyBottom: premiumShadows ? "#2f4468" : "#364d73",
6926
+ duskGlow: premiumShadows ? "rgba(116, 142, 201, 0.26)" : "rgba(104, 128, 188, 0.22)",
6927
+ seaTop: premiumShadows ? "#102946" : "#153050",
6928
+ seaMid: premiumShadows ? "#0a1d33" : "#0d2138",
6929
+ seaBottom: "#04101d",
6930
+ moonCore: "rgba(241, 246, 255, 0.98)",
6931
+ moonHalo: "rgba(167, 191, 255, 0.24)",
6932
+ moonReflection: "rgba(192, 214, 255, 0.22)",
6933
+ starColor: "rgba(232, 239, 255, 0.82)",
6934
+ ambientMist: "rgba(41, 63, 97, 0.16)",
6868
6935
  reflectionStrength: lightingSnapshot.currentLevel.config.reflectionStrength,
6869
6936
  shadowAccent: lightingSnapshot.currentLevel.config.shadowStrength,
6870
- waveAmplitude: 1,
6871
- waveDirection: { x: 0.86, z: 0.34 },
6872
- wavePhaseSpeed: 1,
6873
- wakeStrength: 0.24,
6874
- wakeLength: 15,
6875
- collisionRippleStrength: 0.34,
6876
- waterNear: { r: 0.12, g: 0.36, b: 0.46 },
6877
- waterFar: { r: 0.28, g: 0.56, b: 0.68 },
6878
- harborWall: { r: 0.48, g: 0.4, b: 0.32 },
6879
- harborDeck: { r: 0.5, g: 0.34, b: 0.22 },
6880
- harborTower: { r: 0.34, g: 0.32, b: 0.36 },
6881
- flagColor: { r: 0.76, g: 0.24, b: 0.18 },
6882
- flagMotion: 1
6937
+ waveAmplitude: 0.94,
6938
+ waveDirection: { x: 0.88, z: 0.28 },
6939
+ wavePhaseSpeed: 0.88,
6940
+ wakeStrength: 0.31,
6941
+ wakeLength: 18,
6942
+ collisionRippleStrength: 0.42,
6943
+ waterNear: { r: 0.08, g: 0.23, b: 0.33 },
6944
+ waterFar: { r: 0.18, g: 0.35, b: 0.49 },
6945
+ harborWall: { r: 0.26, g: 0.24, b: 0.28 },
6946
+ harborDeck: { r: 0.33, g: 0.22, b: 0.16 },
6947
+ harborTower: { r: 0.23, g: 0.24, b: 0.29 },
6948
+ flagColor: { r: 0.66, g: 0.16, b: 0.13 },
6949
+ flagMotion: 0.92,
6950
+ lanternCore: { r: 0.98, g: 0.8, b: 0.48 },
6951
+ lanternGlow: { r: 1, g: 0.56, b: 0.2 },
6952
+ lanternReflectionStrength: 0.42,
6953
+ torchCore: { r: 0.99, g: 0.72, b: 0.36 },
6954
+ torchGlow: { r: 0.98, g: 0.38, b: 0.15 },
6955
+ collisionFlash: "rgba(255, 212, 168, 0.16)"
6883
6956
  };
6884
6957
  return {
6885
6958
  skyTop: typeof customVisuals.skyTop === "string" ? customVisuals.skyTop : defaults.skyTop,
@@ -6888,7 +6961,12 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
6888
6961
  seaTop: typeof customVisuals.seaTop === "string" ? customVisuals.seaTop : defaults.seaTop,
6889
6962
  seaMid: typeof customVisuals.seaMid === "string" ? customVisuals.seaMid : defaults.seaMid,
6890
6963
  seaBottom: typeof customVisuals.seaBottom === "string" ? customVisuals.seaBottom : defaults.seaBottom,
6891
- sunCore: typeof customVisuals.sunCore === "string" ? customVisuals.sunCore : defaults.sunCore,
6964
+ duskGlow: typeof customVisuals.duskGlow === "string" ? customVisuals.duskGlow : defaults.duskGlow,
6965
+ moonCore: typeof customVisuals.moonCore === "string" ? customVisuals.moonCore : typeof customVisuals.sunCore === "string" ? customVisuals.sunCore : defaults.moonCore,
6966
+ moonHalo: typeof customVisuals.moonHalo === "string" ? customVisuals.moonHalo : defaults.moonHalo,
6967
+ moonReflection: typeof customVisuals.moonReflection === "string" ? customVisuals.moonReflection : defaults.moonReflection,
6968
+ starColor: typeof customVisuals.starColor === "string" ? customVisuals.starColor : defaults.starColor,
6969
+ ambientMist: typeof customVisuals.ambientMist === "string" ? customVisuals.ambientMist : defaults.ambientMist,
6892
6970
  reflectionStrength: readVisualNumber(
6893
6971
  customVisuals.reflectionStrength,
6894
6972
  defaults.reflectionStrength
@@ -6909,7 +6987,16 @@ function resolveVisualConfig(nearLighting, lightingSnapshot, customVisuals = {})
6909
6987
  harborDeck: normalizeColorOverride(customVisuals.harborDeck, defaults.harborDeck),
6910
6988
  harborTower: normalizeColorOverride(customVisuals.harborTower, defaults.harborTower),
6911
6989
  flagColor: normalizeColorOverride(customVisuals.flagColor, defaults.flagColor),
6912
- flagMotion: readVisualNumber(customVisuals.flagMotion, defaults.flagMotion)
6990
+ flagMotion: readVisualNumber(customVisuals.flagMotion, defaults.flagMotion),
6991
+ lanternCore: normalizeColorOverride(customVisuals.lanternCore, defaults.lanternCore),
6992
+ lanternGlow: normalizeColorOverride(customVisuals.lanternGlow, defaults.lanternGlow),
6993
+ lanternReflectionStrength: readVisualNumber(
6994
+ customVisuals.lanternReflectionStrength,
6995
+ defaults.lanternReflectionStrength
6996
+ ),
6997
+ torchCore: normalizeColorOverride(customVisuals.torchCore, defaults.torchCore),
6998
+ torchGlow: normalizeColorOverride(customVisuals.torchGlow, defaults.torchGlow),
6999
+ collisionFlash: typeof customVisuals.collisionFlash === "string" ? customVisuals.collisionFlash : defaults.collisionFlash
6913
7000
  };
6914
7001
  }
6915
7002
  function buildClothSurface(model, state, meshDetail, visuals) {
@@ -7144,18 +7231,34 @@ function createSceneState(options) {
7144
7231
  {
7145
7232
  id: "northwind",
7146
7233
  position: vec3(-5.2, 0, 7.2),
7147
- velocity: vec3(2.1, 0, -1.6),
7148
- rotationY: 0.42,
7149
- angularVelocity: 0.18,
7150
- tint: { r: 0.62, g: 0.39, b: 0.23 }
7234
+ velocity: vec3(2.35, 0, -1.08),
7235
+ rotationY: 0.58,
7236
+ angularVelocity: 0.09,
7237
+ tint: { r: 0.62, g: 0.39, b: 0.23 },
7238
+ massScale: 1.42,
7239
+ cruiseSpeed: 2.25,
7240
+ throttleResponse: 0.46,
7241
+ rudderResponse: 0.54,
7242
+ wanderPhase: 0.35,
7243
+ lanterns: SHIP_LANTERNS,
7244
+ lanternStrength: 1.06,
7245
+ collisionRadiusScale: 1.04
7151
7246
  },
7152
7247
  {
7153
7248
  id: "tidecaller",
7154
7249
  position: vec3(4.8, 0, 4.4),
7155
- velocity: vec3(-1.85, 0, 1.25),
7156
- rotationY: -2.62,
7157
- angularVelocity: -0.14,
7158
- tint: { r: 0.48, g: 0.28, b: 0.19 }
7250
+ velocity: vec3(-2.15, 0, 1.74),
7251
+ rotationY: -2.48,
7252
+ angularVelocity: -0.2,
7253
+ tint: { r: 0.48, g: 0.28, b: 0.19 },
7254
+ massScale: 0.84,
7255
+ cruiseSpeed: 2.68,
7256
+ throttleResponse: 0.7,
7257
+ rudderResponse: 0.78,
7258
+ wanderPhase: 1.6,
7259
+ lanterns: SHIP_LANTERNS,
7260
+ lanternStrength: 1.18,
7261
+ collisionRadiusScale: 0.94
7159
7262
  }
7160
7263
  ],
7161
7264
  sprays: [],
@@ -7177,41 +7280,71 @@ function setListContent(element, values) {
7177
7280
  element.innerHTML = values.map((value) => `<li>${value}</li>`).join("");
7178
7281
  }
7179
7282
  function drawSkyAndShore(ctx, canvas, state, nearLighting, reflectionStrength, shadowStrength, visuals) {
7180
- const premiumShadows = nearLighting.primaryShadowSource === "ray-traced-primary";
7181
7283
  const sky = ctx.createLinearGradient(0, 0, 0, canvas.height * 0.5);
7182
7284
  sky.addColorStop(0, visuals.skyTop);
7183
- sky.addColorStop(0.6, visuals.skyMid);
7285
+ sky.addColorStop(0.54, visuals.skyMid);
7184
7286
  sky.addColorStop(1, visuals.skyBottom);
7185
7287
  ctx.fillStyle = sky;
7186
7288
  ctx.fillRect(0, 0, canvas.width, canvas.height);
7289
+ for (let index = 0; index < 70; index += 1) {
7290
+ const x = pseudoRandom(index + 13) * canvas.width;
7291
+ const y = pseudoRandom(index * 7 + 5) * canvas.height * 0.42;
7292
+ const twinkle = 0.45 + Math.sin(state.time * 1.4 + index * 0.73) * 0.25;
7293
+ const radius = 0.6 + pseudoRandom(index * 11 + 2) * 1.9;
7294
+ ctx.fillStyle = visuals.starColor.replace(/[\d.]+\)$/u, `${clamp(twinkle, 0.16, 0.92)})`);
7295
+ ctx.beginPath();
7296
+ ctx.arc(x, y, radius, 0, Math.PI * 2);
7297
+ ctx.fill();
7298
+ }
7299
+ const horizonGlow = ctx.createLinearGradient(0, canvas.height * 0.22, 0, canvas.height * 0.62);
7300
+ horizonGlow.addColorStop(0, "rgba(0, 0, 0, 0)");
7301
+ horizonGlow.addColorStop(1, visuals.duskGlow);
7302
+ ctx.fillStyle = horizonGlow;
7303
+ ctx.fillRect(0, canvas.height * 0.2, canvas.width, canvas.height * 0.45);
7187
7304
  const shoreline = ctx.createLinearGradient(0, canvas.height * 0.45, 0, canvas.height);
7188
7305
  shoreline.addColorStop(0, visuals.seaTop);
7189
7306
  shoreline.addColorStop(0.52, visuals.seaMid);
7190
7307
  shoreline.addColorStop(1, visuals.seaBottom);
7191
7308
  ctx.fillStyle = shoreline;
7192
7309
  ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
7193
- const sunX = mix(canvas.width * 0.16, canvas.width * 0.84, (Math.sin(state.time * 0.12) + 1) * 0.5);
7194
- const sunY = canvas.height * 0.14 + Math.cos(state.time * 0.12) * 22;
7195
- const sun = ctx.createRadialGradient(sunX, sunY, 10, sunX, sunY, 90);
7196
- sun.addColorStop(0, visuals.sunCore);
7197
- sun.addColorStop(1, "rgba(255, 244, 210, 0)");
7198
- ctx.fillStyle = sun;
7310
+ const moonX = canvas.width * 0.76 + Math.sin(state.time * 0.045) * 18;
7311
+ const moonY = canvas.height * 0.17 + Math.cos(state.time * 0.05) * 10;
7312
+ const moon = ctx.createRadialGradient(moonX, moonY, 14, moonX, moonY, 126);
7313
+ moon.addColorStop(0, visuals.moonCore);
7314
+ moon.addColorStop(0.46, visuals.moonHalo);
7315
+ moon.addColorStop(1, "rgba(167, 191, 255, 0)");
7316
+ ctx.fillStyle = moon;
7317
+ ctx.beginPath();
7318
+ ctx.arc(moonX, moonY, 94, 0, Math.PI * 2);
7319
+ ctx.fill();
7320
+ const moonCore = ctx.createRadialGradient(moonX, moonY, 4, moonX, moonY, 28);
7321
+ moonCore.addColorStop(0, "rgba(255, 255, 255, 0.98)");
7322
+ moonCore.addColorStop(1, visuals.moonCore);
7323
+ ctx.fillStyle = moonCore;
7199
7324
  ctx.beginPath();
7200
- ctx.arc(sunX, sunY, 90, 0, Math.PI * 2);
7325
+ ctx.arc(moonX, moonY, 24, 0, Math.PI * 2);
7201
7326
  ctx.fill();
7202
- const track = ctx.createLinearGradient(sunX, canvas.height * 0.46, sunX, canvas.height * 0.96);
7203
- track.addColorStop(0, `rgba(255, 243, 214, ${0.08 + reflectionStrength * 0.18})`);
7204
- track.addColorStop(0.42, `rgba(224, 242, 255, ${0.04 + reflectionStrength * 0.2})`);
7205
- track.addColorStop(1, "rgba(224, 242, 255, 0)");
7327
+ const track = ctx.createLinearGradient(moonX, canvas.height * 0.44, moonX, canvas.height * 0.98);
7328
+ track.addColorStop(0, visuals.moonReflection.replace(/[\d.]+\)$/u, `${0.08 + reflectionStrength * 0.12})`));
7329
+ track.addColorStop(0.42, visuals.moonReflection.replace(/[\d.]+\)$/u, `${0.04 + reflectionStrength * 0.18})`));
7330
+ track.addColorStop(1, "rgba(192, 214, 255, 0)");
7206
7331
  ctx.save();
7207
7332
  ctx.globalCompositeOperation = "screen";
7208
7333
  ctx.fillStyle = track;
7209
7334
  ctx.beginPath();
7210
- ctx.ellipse(sunX, canvas.height * 0.72, 46 + shadowStrength * 60, canvas.height * 0.26, 0, 0, Math.PI * 2);
7335
+ ctx.ellipse(moonX, canvas.height * 0.75, 38 + shadowStrength * 42, canvas.height * 0.24, 0, 0, Math.PI * 2);
7211
7336
  ctx.fill();
7212
7337
  ctx.restore();
7338
+ const mist = ctx.createLinearGradient(0, canvas.height * 0.5, 0, canvas.height);
7339
+ mist.addColorStop(0, "rgba(0, 0, 0, 0)");
7340
+ mist.addColorStop(1, visuals.ambientMist);
7341
+ ctx.fillStyle = mist;
7342
+ ctx.fillRect(0, canvas.height * 0.45, canvas.width, canvas.height * 0.55);
7213
7343
  if (state.collisionFlash > 0.01) {
7214
- ctx.fillStyle = `rgba(255, 243, 228, ${state.collisionFlash * 0.14})`;
7344
+ ctx.fillStyle = visuals.collisionFlash.replace(
7345
+ /[\d.]+\)$/u,
7346
+ `${clamp(state.collisionFlash * 0.22, 0, 0.26)})`
7347
+ );
7215
7348
  ctx.fillRect(0, 0, canvas.width, canvas.height);
7216
7349
  }
7217
7350
  }
@@ -7310,7 +7443,7 @@ function pushHarborGeometry(camera, viewport, triangles, visuals) {
7310
7443
  }
7311
7444
  }
7312
7445
  function renderShipRigging(ctx, ship, camera, viewport) {
7313
- const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
7446
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
7314
7447
  const mastBase = transformPoint(vec3(0, 0.38, -0.4), transform);
7315
7448
  const mastTop = transformPoint(vec3(0, 3.8, -0.2), transform);
7316
7449
  const aftBase = transformPoint(vec3(-0.25, 0.32, -1.9), transform);
@@ -7414,6 +7547,35 @@ function renderWaterHighlights(ctx, waterBands, camera, viewport) {
7414
7547
  }
7415
7548
  }
7416
7549
  }
7550
+ function readPhysicsNumber(physics, key, fallback) {
7551
+ const value = physics?.[key];
7552
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
7553
+ }
7554
+ function getShipMass(ship, shipModel) {
7555
+ const baseMass = readPhysicsNumber(shipModel.physics, "mass", 3200);
7556
+ return baseMass * readVisualNumber(ship.massScale, 1);
7557
+ }
7558
+ function getShipHalfExtents(ship, shipModel) {
7559
+ const physicsHalfExtents = Array.isArray(shipModel.physics.halfExtents) ? shipModel.physics.halfExtents : [1.35, 0.95, 3.9];
7560
+ const scale = SHIP_SCALE * readVisualNumber(ship.collisionRadiusScale, 1);
7561
+ return {
7562
+ x: physicsHalfExtents[0] * scale,
7563
+ y: physicsHalfExtents[1] * scale,
7564
+ z: physicsHalfExtents[2] * scale
7565
+ };
7566
+ }
7567
+ function getShipCollisionRadius(ship, shipModel) {
7568
+ const halfExtents = getShipHalfExtents(ship, shipModel);
7569
+ return Math.max(halfExtents.x * 1.08, halfExtents.z * 0.62);
7570
+ }
7571
+ function getShipInverseMass(ship, shipModel) {
7572
+ return 1 / Math.max(1, getShipMass(ship, shipModel));
7573
+ }
7574
+ function getShipInverseInertia(ship, shipModel) {
7575
+ const radius = getShipCollisionRadius(ship, shipModel);
7576
+ const inertia = getShipMass(ship, shipModel) * radius * radius * 0.72;
7577
+ return 1 / Math.max(1, inertia);
7578
+ }
7417
7579
  function spawnSpray(state, point, intensity) {
7418
7580
  const count = state.fluidDetail.getSnapshot().currentLevel.config.splashCount;
7419
7581
  for (let index = 0; index < count; index += 1) {
@@ -7426,55 +7588,182 @@ function spawnSpray(state, point, intensity) {
7426
7588
  });
7427
7589
  }
7428
7590
  }
7429
- function updateShips(state, dt, shipModel) {
7591
+ function resolveShipRoute(ship, state, radius) {
7592
+ if (typeof ship.routeDirection !== "number") {
7593
+ ship.routeDirection = ship.velocity.x >= 0 ? 1 : -1;
7594
+ }
7595
+ if (ship.position.x > HARBOR_BOUNDS.maxX - radius * 1.1) {
7596
+ ship.routeDirection = -1;
7597
+ } else if (ship.position.x < HARBOR_BOUNDS.minX + radius * 1.1) {
7598
+ ship.routeDirection = 1;
7599
+ }
7600
+ const wander = Math.sin(state.time * 0.22 + readVisualNumber(ship.wanderPhase, 0));
7601
+ const crossCurrent = Math.cos(state.time * 0.31 + readVisualNumber(ship.wanderPhase, 0));
7602
+ const laneCenter = ship.id === "northwind" ? 10.2 + wander * 2.1 + crossCurrent * 0.6 : 7 + wander * 3.3 - crossCurrent * 1.1;
7603
+ const targetX = ship.routeDirection > 0 ? HARBOR_BOUNDS.maxX - radius * 1.7 : HARBOR_BOUNDS.minX + radius * 1.7;
7604
+ return vec3(targetX, 0, clamp(laneCenter, HARBOR_BOUNDS.minZ + 1.8, HARBOR_BOUNDS.maxZ - 1.8));
7605
+ }
7606
+ function updateShipMotion(state, ship, dt, shipModel) {
7430
7607
  const physics = shipModel.physics;
7431
- const halfExtents = physics.halfExtents ?? [1.35, 0.95, 3.9];
7608
+ const massScale = Math.max(0.55, readVisualNumber(ship.massScale, 1));
7609
+ const radius = getShipCollisionRadius(ship, shipModel);
7610
+ const waterline = readPhysicsNumber(physics, "waterline", 0.42);
7611
+ const linearDamping = readPhysicsNumber(physics, "linearDamping", 0.04);
7612
+ const angularDamping = readPhysicsNumber(physics, "angularDamping", 0.08);
7613
+ const throttleResponse = readVisualNumber(ship.throttleResponse, 0.58);
7614
+ const rudderResponse = readVisualNumber(ship.rudderResponse, 0.62);
7615
+ const cruiseSpeed = readVisualNumber(ship.cruiseSpeed, 2.4);
7616
+ ship.collisionCooldown = Math.max(0, readVisualNumber(ship.collisionCooldown, 0) - dt);
7617
+ const forward = directionFromYaw(ship.rotationY);
7618
+ const lateral = perpendicularOnWater(forward);
7619
+ const routeTarget = resolveShipRoute(ship, state, radius);
7620
+ const desiredHeading = Math.atan2(routeTarget.x - ship.position.x, routeTarget.z - ship.position.z);
7621
+ const headingError = Math.atan2(
7622
+ Math.sin(desiredHeading - ship.rotationY),
7623
+ Math.cos(desiredHeading - ship.rotationY)
7624
+ );
7625
+ ship.angularVelocity += headingError * rudderResponse * dt * (1.18 / Math.sqrt(massScale)) + Math.sin(state.time * 0.9 + readVisualNumber(ship.wanderPhase, 0)) * dt * 0.04;
7626
+ const waveDirection = resolveWaveDirection(state);
7627
+ const forwardSpeed = dotVec3(ship.velocity, forward);
7628
+ const lateralSpeed = dotVec3(ship.velocity, lateral);
7629
+ const thrust = (cruiseSpeed - forwardSpeed) * throttleResponse;
7630
+ const currentDrift = sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.016;
7631
+ const acceleration = addVec3(
7632
+ scaleVec3(forward, thrust),
7633
+ addVec3(
7634
+ scaleVec3(lateral, -lateralSpeed * (1.28 + rudderResponse * 0.4)),
7635
+ scaleVec3(waveDirection, currentDrift / Math.sqrt(massScale))
7636
+ )
7637
+ );
7638
+ ship.velocity = addVec3(ship.velocity, scaleVec3(acceleration, dt));
7639
+ ship.velocity = scaleVec3(
7640
+ ship.velocity,
7641
+ Math.max(0, 1 - linearDamping / Math.pow(massScale, 0.22) * dt)
7642
+ );
7643
+ ship.angularVelocity *= Math.max(
7644
+ 0,
7645
+ 1 - angularDamping / Math.pow(massScale, 0.15) * dt
7646
+ );
7647
+ ship.rotationY += ship.angularVelocity * dt;
7648
+ ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
7649
+ ship.position.y = sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.24 + waterline;
7650
+ }
7651
+ function resolveBoundaryCollision(ship, state, shipModel) {
7652
+ const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.56;
7653
+ const radius = getShipCollisionRadius(ship, shipModel);
7654
+ const boundaries = [
7655
+ { axis: "x", min: HARBOR_BOUNDS.minX + radius, max: HARBOR_BOUNDS.maxX - radius, normalMin: vec3(1, 0, 0), normalMax: vec3(-1, 0, 0) },
7656
+ { axis: "z", min: HARBOR_BOUNDS.minZ + radius, max: HARBOR_BOUNDS.maxZ - radius, normalMin: vec3(0, 0, 1), normalMax: vec3(0, 0, -1) }
7657
+ ];
7658
+ for (const boundary of boundaries) {
7659
+ if (ship.position[boundary.axis] < boundary.min) {
7660
+ ship.position[boundary.axis] = boundary.min;
7661
+ const normal = boundary.normalMin;
7662
+ const speedIntoWall = dotVec3(ship.velocity, normal);
7663
+ if (speedIntoWall < 0) {
7664
+ ship.velocity = subVec3(
7665
+ ship.velocity,
7666
+ scaleVec3(normal, (1 + restitution) * speedIntoWall)
7667
+ );
7668
+ const tangent = vec3(-normal.z, 0, normal.x);
7669
+ const tangentSpeed = dotVec3(ship.velocity, tangent);
7670
+ ship.velocity = subVec3(ship.velocity, scaleVec3(tangent, tangentSpeed * 0.12));
7671
+ ship.angularVelocity += tangentSpeed * 4e-3;
7672
+ }
7673
+ } else if (ship.position[boundary.axis] > boundary.max) {
7674
+ ship.position[boundary.axis] = boundary.max;
7675
+ const normal = boundary.normalMax;
7676
+ const speedIntoWall = dotVec3(ship.velocity, normal);
7677
+ if (speedIntoWall < 0) {
7678
+ ship.velocity = subVec3(
7679
+ ship.velocity,
7680
+ scaleVec3(normal, (1 + restitution) * speedIntoWall)
7681
+ );
7682
+ const tangent = vec3(-normal.z, 0, normal.x);
7683
+ const tangentSpeed = dotVec3(ship.velocity, tangent);
7684
+ ship.velocity = subVec3(ship.velocity, scaleVec3(tangent, tangentSpeed * 0.12));
7685
+ ship.angularVelocity += tangentSpeed * 4e-3;
7686
+ }
7687
+ }
7688
+ }
7689
+ }
7690
+ function resolveShipCollision(state, a, b, shipModel) {
7691
+ const delta = subVec3(b.position, a.position);
7692
+ const radiusA = getShipCollisionRadius(a, shipModel);
7693
+ const radiusB = getShipCollisionRadius(b, shipModel);
7694
+ const distance = Math.hypot(delta.x, delta.z);
7695
+ const minDistance = radiusA + radiusB;
7696
+ if (distance >= minDistance) {
7697
+ return false;
7698
+ }
7699
+ const normal = distance > 1e-4 ? normalizeVec3(vec3(delta.x / distance, 0, delta.z / distance)) : normalizeVec3(vec3(Math.cos(state.time * 5.2), 0, Math.sin(state.time * 4.8)));
7700
+ const tangent = vec3(-normal.z, 0, normal.x);
7701
+ const penetration = minDistance - distance;
7702
+ const invMassA = getShipInverseMass(a, shipModel);
7703
+ const invMassB = getShipInverseMass(b, shipModel);
7704
+ const invMassSum = invMassA + invMassB;
7705
+ const correction = scaleVec3(normal, penetration / Math.max(1e-4, invMassSum) * 0.72);
7706
+ a.position = subVec3(a.position, scaleVec3(correction, invMassA));
7707
+ b.position = addVec3(b.position, scaleVec3(correction, invMassB));
7708
+ const relativeVelocity = subVec3(b.velocity, a.velocity);
7709
+ const velocityAlongNormal = dotVec3(relativeVelocity, normal);
7710
+ const restitution = readPhysicsNumber(shipModel.physics, "restitution", 0.22) * 0.88;
7711
+ if (velocityAlongNormal < 0) {
7712
+ const impulseMagnitude = -(1 + restitution) * velocityAlongNormal / Math.max(1e-4, invMassSum);
7713
+ const impulse = scaleVec3(normal, impulseMagnitude);
7714
+ a.velocity = subVec3(a.velocity, scaleVec3(impulse, invMassA));
7715
+ b.velocity = addVec3(b.velocity, scaleVec3(impulse, invMassB));
7716
+ const tangentSpeed = dotVec3(relativeVelocity, tangent);
7717
+ const frictionMagnitude = clamp(
7718
+ -tangentSpeed / Math.max(1e-4, invMassSum),
7719
+ -impulseMagnitude * 0.16,
7720
+ impulseMagnitude * 0.16
7721
+ );
7722
+ const frictionImpulse = scaleVec3(tangent, frictionMagnitude);
7723
+ a.velocity = subVec3(a.velocity, scaleVec3(frictionImpulse, invMassA));
7724
+ b.velocity = addVec3(b.velocity, scaleVec3(frictionImpulse, invMassB));
7725
+ a.angularVelocity -= tangentSpeed * radiusA * getShipInverseInertia(a, shipModel) * 0.2 + impulseMagnitude * 24e-5;
7726
+ b.angularVelocity += tangentSpeed * radiusB * getShipInverseInertia(b, shipModel) * 0.2 + impulseMagnitude * 24e-5;
7727
+ const impactSpeed = Math.abs(velocityAlongNormal);
7728
+ if (impactSpeed > 0.18 && Math.max(readVisualNumber(a.collisionCooldown, 0), readVisualNumber(b.collisionCooldown, 0)) <= 0) {
7729
+ const contactPoint = vec3(
7730
+ (a.position.x + b.position.x) * 0.5,
7731
+ (a.position.y + b.position.y) * 0.5 + 0.14,
7732
+ (a.position.z + b.position.z) * 0.5
7733
+ );
7734
+ spawnSpray(state, contactPoint, impactSpeed * 2.4 + penetration * 8);
7735
+ state.waveImpulses.push({
7736
+ x: contactPoint.x,
7737
+ z: contactPoint.z,
7738
+ strength: clamp(0.24 + impactSpeed * 0.46 + penetration * 0.9, 0.2, 1.7),
7739
+ radius: 0.9 + penetration * 1.4,
7740
+ life: 1
7741
+ });
7742
+ state.collisionCount += 1;
7743
+ state.collisionFlash = Math.max(
7744
+ state.collisionFlash,
7745
+ clamp(impactSpeed * 0.55 + penetration * 1.8, 0.16, 1)
7746
+ );
7747
+ a.collisionCooldown = 0.2;
7748
+ b.collisionCooldown = 0.2;
7749
+ }
7750
+ }
7751
+ state.contactCount += 1;
7752
+ return true;
7753
+ }
7754
+ function updateShips(state, dt, shipModel) {
7432
7755
  let collided = false;
7433
7756
  state.contactCount = 0;
7434
7757
  for (const ship of state.ships) {
7435
- ship.position = addVec3(ship.position, scaleVec3(ship.velocity, dt));
7436
- ship.rotationY += ship.angularVelocity * dt;
7437
- ship.velocity = scaleVec3(ship.velocity, 1 - (physics.linearDamping ?? 0.04) * dt);
7438
- ship.angularVelocity *= 1 - (physics.angularDamping ?? 0.08) * dt;
7439
- ship.position.y = sampleWave(state, ship.position.x, ship.position.z, state.time) * 0.22 + (physics.waterline ?? 0.42);
7440
- if (Math.abs(ship.position.x) > 10) {
7441
- ship.velocity.x *= -1;
7442
- ship.angularVelocity *= -1;
7443
- }
7444
- if (ship.position.z < 2 || ship.position.z > 16) {
7445
- ship.velocity.z *= -1;
7446
- ship.angularVelocity *= -1;
7447
- }
7758
+ updateShipMotion(state, ship, dt, shipModel);
7759
+ resolveBoundaryCollision(ship, state, shipModel);
7448
7760
  }
7449
- const [a, b] = state.ships;
7450
- const dx = b.position.x - a.position.x;
7451
- const dz = b.position.z - a.position.z;
7452
- const overlapX = Math.abs(dx) < halfExtents[0] * 1.7;
7453
- const overlapZ = Math.abs(dz) < halfExtents[2] * 0.8;
7454
- if (overlapX && overlapZ) {
7455
- const restitution = physics.restitution ?? 0.22;
7456
- const swapX = a.velocity.x;
7457
- const swapZ = a.velocity.z;
7458
- a.velocity.x = -b.velocity.x * (0.86 + restitution);
7459
- a.velocity.z = -b.velocity.z * (0.82 + restitution);
7460
- b.velocity.x = -swapX * (0.86 + restitution);
7461
- b.velocity.z = -swapZ * (0.82 + restitution);
7462
- a.angularVelocity += 0.55;
7463
- b.angularVelocity -= 0.55;
7464
- const contactPoint = vec3((a.position.x + b.position.x) * 0.5, (a.position.y + b.position.y) * 0.5 + 0.1, (a.position.z + b.position.z) * 0.5);
7465
- spawnSpray(state, contactPoint, Math.abs(dx) + Math.abs(dz));
7466
- state.waveImpulses.push({
7467
- x: contactPoint.x,
7468
- z: contactPoint.z,
7469
- strength: Math.min(1.4, 0.2 + (Math.abs(dx) + Math.abs(dz)) * 0.18),
7470
- radius: 0.8,
7471
- life: 1
7472
- });
7473
- state.collisionCount += 1;
7474
- state.contactCount = 1;
7475
- collided = true;
7761
+ for (let index = 0; index < state.ships.length; index += 1) {
7762
+ for (let otherIndex = index + 1; otherIndex < state.ships.length; otherIndex += 1) {
7763
+ collided = resolveShipCollision(state, state.ships[index], state.ships[otherIndex], shipModel) || collided;
7764
+ }
7476
7765
  }
7477
- state.collisionFlash = collided ? 1 : Math.max(0, state.collisionFlash - dt * 1.8);
7766
+ state.collisionFlash = collided ? Math.max(0.12, state.collisionFlash) : Math.max(0, state.collisionFlash - dt * 1.3);
7478
7767
  }
7479
7768
  function updateWaveImpulses(state, dt) {
7480
7769
  state.waveImpulses = state.waveImpulses.map((impulse) => ({
@@ -7587,7 +7876,7 @@ function renderFlagPole(ctx, camera, viewport) {
7587
7876
  function renderShipShadow(ctx, shipModel, ship, state, camera, viewport, lightDir, shadowStrength) {
7588
7877
  const bounds = shipModel.bounds;
7589
7878
  const keelY = (shipModel.physics.waterline ?? 0.42) - 0.28;
7590
- const transform = { position: ship.position, rotationY: ship.rotationY, scale: 1.1 };
7879
+ const transform = { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE };
7591
7880
  const hullCorners = [
7592
7881
  vec3(bounds.min[0], keelY, bounds.min[2]),
7593
7882
  vec3(bounds.max[0], keelY, bounds.min[2]),
@@ -7613,6 +7902,97 @@ function renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength
7613
7902
  blur: 12 + shadowStrength * 20
7614
7903
  });
7615
7904
  }
7905
+ function renderGlowLight(ctx, point, camera, viewport, coreColor, glowColor, glowScale, reflectionStrength = 0, state = null) {
7906
+ const projected = projectPoint(point, camera, viewport);
7907
+ if (!projected) {
7908
+ return;
7909
+ }
7910
+ const radius = clamp(1 / projected.depth * 420 * glowScale, 4, 34);
7911
+ const halo = ctx.createRadialGradient(projected.x, projected.y, radius * 0.12, projected.x, projected.y, radius);
7912
+ halo.addColorStop(0, colorToRgba(coreColor, 0.98));
7913
+ halo.addColorStop(0.5, colorToRgba(glowColor, 0.42));
7914
+ halo.addColorStop(1, colorToRgba(glowColor, 0));
7915
+ ctx.save();
7916
+ ctx.globalCompositeOperation = "screen";
7917
+ ctx.fillStyle = halo;
7918
+ ctx.beginPath();
7919
+ ctx.arc(projected.x, projected.y, radius, 0, Math.PI * 2);
7920
+ ctx.fill();
7921
+ ctx.restore();
7922
+ ctx.fillStyle = colorToRgba(coreColor, 0.98);
7923
+ ctx.beginPath();
7924
+ ctx.arc(projected.x, projected.y, Math.max(1.2, radius * 0.16), 0, Math.PI * 2);
7925
+ ctx.fill();
7926
+ if (state && reflectionStrength > 0) {
7927
+ const waterline = sampleWave(state, point.x, point.z, state.time) * 0.22;
7928
+ const reflectedPoint = vec3(point.x, waterline - (point.y - waterline) * 0.58, point.z + 0.08);
7929
+ const reflected = projectPoint(reflectedPoint, camera, viewport);
7930
+ if (reflected) {
7931
+ const reflectionRadius = radius * 0.72;
7932
+ const glow = ctx.createRadialGradient(
7933
+ reflected.x,
7934
+ reflected.y,
7935
+ reflectionRadius * 0.1,
7936
+ reflected.x,
7937
+ reflected.y,
7938
+ reflectionRadius
7939
+ );
7940
+ glow.addColorStop(0, colorToRgba(coreColor, reflectionStrength * 0.34));
7941
+ glow.addColorStop(1, colorToRgba(glowColor, 0));
7942
+ ctx.save();
7943
+ ctx.globalCompositeOperation = "screen";
7944
+ ctx.fillStyle = glow;
7945
+ ctx.beginPath();
7946
+ ctx.ellipse(
7947
+ reflected.x,
7948
+ reflected.y,
7949
+ reflectionRadius * 0.34,
7950
+ reflectionRadius,
7951
+ 0,
7952
+ 0,
7953
+ Math.PI * 2
7954
+ );
7955
+ ctx.fill();
7956
+ ctx.restore();
7957
+ }
7958
+ }
7959
+ }
7960
+ function renderShipLanterns(ctx, ship, state, camera, viewport, visuals) {
7961
+ const lanterns = Array.isArray(ship.lanterns) ? ship.lanterns : SHIP_LANTERNS;
7962
+ const strength = readVisualNumber(ship.lanternStrength, 1);
7963
+ for (const lantern of lanterns) {
7964
+ const position = transformPoint(
7965
+ vec3(lantern.x, lantern.y, lantern.z),
7966
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE }
7967
+ );
7968
+ renderGlowLight(
7969
+ ctx,
7970
+ position,
7971
+ camera,
7972
+ viewport,
7973
+ visuals.lanternCore,
7974
+ visuals.lanternGlow,
7975
+ lantern.glow * strength,
7976
+ visuals.lanternReflectionStrength,
7977
+ state
7978
+ );
7979
+ }
7980
+ }
7981
+ function renderHarborTorches(ctx, state, camera, viewport, visuals) {
7982
+ for (const torch of HARBOR_TORCHES) {
7983
+ renderGlowLight(
7984
+ ctx,
7985
+ vec3(torch.x, torch.y, torch.z),
7986
+ camera,
7987
+ viewport,
7988
+ visuals.torchCore,
7989
+ visuals.torchGlow,
7990
+ torch.glow,
7991
+ visuals.lanternReflectionStrength * 0.55,
7992
+ state
7993
+ );
7994
+ }
7995
+ }
7616
7996
  function renderScene(ctx, canvas, state, shipModel, dom) {
7617
7997
  const viewport = { width: canvas.width, height: canvas.height };
7618
7998
  const camera = buildCamera(state, canvas);
@@ -7622,7 +8002,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
7622
8002
  importance: state.focus === "lighting" ? "critical" : "high"
7623
8003
  });
7624
8004
  const nearLighting = lightingPlan.bands.find((entry) => entry.band === "near") ?? lightingPlan.bands[0];
7625
- const lightDir = normalizeVec3(vec3(-0.45, 0.85, -0.24));
8005
+ const lightDir = normalizeVec3(vec3(-0.22, 0.94, -0.31));
7626
8006
  const lightingSnapshot = state.lightingDetail.getSnapshot();
7627
8007
  const visuals = resolveVisualConfig(
7628
8008
  nearLighting,
@@ -7696,7 +8076,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
7696
8076
  for (const ship of state.ships) {
7697
8077
  buildTrianglesFromMesh(
7698
8078
  shipModel,
7699
- { position: ship.position, rotationY: ship.rotationY, scale: 1.1 },
8079
+ { position: ship.position, rotationY: ship.rotationY, scale: SHIP_SCALE },
7700
8080
  ship.tint,
7701
8081
  camera,
7702
8082
  viewport,
@@ -7710,10 +8090,12 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
7710
8090
  renderFlagShadow(ctx, cloth, camera, viewport, lightDir, shadowStrength);
7711
8091
  drawTriangles(ctx, triangles, lightDir, reflectionStrength, camera, shadowStrength);
7712
8092
  renderWaterHighlights(ctx, water.bandMeshes, camera, viewport);
8093
+ renderHarborTorches(ctx, state, camera, viewport, visuals);
7713
8094
  renderFlagPole(ctx, camera, viewport);
7714
8095
  renderClothAccent(ctx, cloth, camera, viewport);
7715
8096
  for (const ship of state.ships) {
7716
8097
  renderShipRigging(ctx, ship, camera, viewport);
8098
+ renderShipLanterns(ctx, ship, state, camera, viewport, visuals);
7717
8099
  }
7718
8100
  renderSprays(ctx, state.sprays, camera, viewport);
7719
8101
  const debugSnapshot = state.debugSession.getSnapshot();
@@ -7725,8 +8107,10 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
7725
8107
  const sceneMetrics = [
7726
8108
  `focus: ${state.focus}`,
7727
8109
  `ships: ${state.ships.length} active GLTF hulls`,
8110
+ `moonlight: cold overhead key + ${HARBOR_TORCHES.length + state.ships.length * SHIP_LANTERNS.length} warm deck and harbor lights`,
7728
8111
  `physics snapshot: ${state.physics.snapshot.stage} (${state.physics.snapshot.stability})`,
7729
- `physics contacts: ${state.physics.snapshot.contactCount ?? 0}`,
8112
+ `physics contacts: ${state.contactCount}`,
8113
+ `mass split: ${state.ships.map((ship) => `${ship.id} ${(getShipMass(ship, shipModel) / 1e3).toFixed(1)}t`).join(" \xB7 ")}`,
7730
8114
  `cloth band: ${cloth.band} -> ${cloth.representation.output}`,
7731
8115
  `fluid near band: ${water.bandMeshes[0].representation.output}`,
7732
8116
  `lighting profile: ${lightingPlan.profile} (${lightingDistanceBands.length} bands)`
@@ -7749,8 +8133,8 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
7749
8133
  ];
7750
8134
  const sceneNotes = state.focus === "physics" ? [
7751
8135
  "Stable world snapshots are taken after the authoritative rigid-body commit and before visual follow-up work.",
7752
- "The ships collide on GLTF-derived hull volumes while cloth and fluid remain downstream visual consumers.",
7753
- "Near-field lighting keeps the ray-traced-primary shadow impression so the collision read stays crisp."
8136
+ "The ships collide with mass-weighted impulses and positional correction, so the heavier hull keeps more of its line.",
8137
+ "Moonlight keeps the overall read legible while lanterns and torches make collision moments easy to track against the water."
7754
8138
  ] : SCENE_NOTES;
7755
8139
  const custom = state.demoDescription ?? null;
7756
8140
  setListContent(
@@ -7767,7 +8151,7 @@ function renderScene(ctx, canvas, state, shipModel, dom) {
7767
8151
  );
7768
8152
  setListContent(dom.sceneNotes, Array.isArray(custom?.notes) ? custom.notes : sceneNotes);
7769
8153
  dom.status.textContent = typeof custom?.status === "string" ? custom.status : `3D scene live \xB7 ${state.lastDecision.metrics.fps.toFixed(1)} FPS`;
7770
- dom.details.textContent = typeof custom?.details === "string" ? custom.details : state.focus === "physics" ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; GLTF ships collide on ${shipModel.physics.shape ?? "box"} volumes while visual follow-up remains downstream.` : `GLTF ships are colliding with ${shipModel.physics.shape ?? "box"} physics volumes; cloth and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
8154
+ dom.details.textContent = typeof custom?.details === "string" ? custom.details : state.focus === "physics" ? `Stable world snapshots are emitted from ${state.physics.plan.snapshotStageId} after the authoritative solver; the heavier hull now carries momentum through mass-aware collision impulses while cloth and fluid remain downstream.` : `Moonlit GLTF ships collide on ${shipModel.physics.shape ?? "box"} physics volumes; lantern reflections, cloth, and fluid remain continuous while the governor pressure is ${state.lastDecision.pressureLevel}.`;
7771
8155
  }
7772
8156
  function updateSceneState(state, dt, shipModel) {
7773
8157
  updateShips(state, dt, shipModel);
@@ -7786,7 +8170,9 @@ function syncTextState(state, shipModel) {
7786
8170
  y: Number(ship.position.y.toFixed(2)),
7787
8171
  z: Number(ship.position.z.toFixed(2)),
7788
8172
  vx: Number(ship.velocity.x.toFixed(2)),
7789
- vz: Number(ship.velocity.z.toFixed(2))
8173
+ vz: Number(ship.velocity.z.toFixed(2)),
8174
+ massKg: Math.round(getShipMass(ship, shipModel)),
8175
+ lanterns: Array.isArray(ship.lanterns) ? ship.lanterns.length : 0
7790
8176
  })),
7791
8177
  shipPhysics: shipModel.physics,
7792
8178
  sprays: state.sprays.length,
@@ -7814,6 +8200,7 @@ function syncTextState(state, shipModel) {
7814
8200
  async function mountGpuShowcase(options = {}) {
7815
8201
  injectStyles();
7816
8202
  const root = options.root ?? document.body;
8203
+ root.classList?.add?.(ROOT_CLASS);
7817
8204
  const previousMarkup = root.innerHTML;
7818
8205
  const previousRenderGameToText = window.render_game_to_text;
7819
8206
  const previousAdvanceTime = window.advanceTime;
@@ -7833,8 +8220,11 @@ async function mountGpuShowcase(options = {}) {
7833
8220
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
7834
8221
  syncTextState(state, shipModel);
7835
8222
  const ctx = dom.canvas.getContext("2d");
8223
+ if (!ctx) {
8224
+ throw new Error("2D canvas context is required for the shared showcase.");
8225
+ }
8226
+ let animationFrameId = 0;
7836
8227
  let destroyed = false;
7837
- let frameHandle = null;
7838
8228
  const renderFrame = (nowMs) => {
7839
8229
  if (destroyed) {
7840
8230
  return;
@@ -7855,7 +8245,7 @@ async function mountGpuShowcase(options = {}) {
7855
8245
  state.demoDescription = resolveSceneDescription(state, options, shipModel).description;
7856
8246
  renderScene(ctx, dom.canvas, state, shipModel, dom);
7857
8247
  syncTextState(state, shipModel);
7858
- frameHandle = requestAnimationFrame(renderFrame);
8248
+ animationFrameId = requestAnimationFrame(renderFrame);
7859
8249
  };
7860
8250
  const handlePauseClick = () => {
7861
8251
  state.paused = !state.paused;
@@ -7874,34 +8264,43 @@ async function mountGpuShowcase(options = {}) {
7874
8264
  dom.pauseButton.addEventListener("click", handlePauseClick);
7875
8265
  dom.stressToggle.addEventListener("change", handleStressChange);
7876
8266
  dom.focusMode.addEventListener("change", handleFocusChange);
7877
- frameHandle = requestAnimationFrame(renderFrame);
8267
+ animationFrameId = requestAnimationFrame(renderFrame);
8268
+ const destroy = () => {
8269
+ if (destroyed) {
8270
+ return;
8271
+ }
8272
+ destroyed = true;
8273
+ if (animationFrameId) {
8274
+ cancelAnimationFrame(animationFrameId);
8275
+ }
8276
+ dom.pauseButton.removeEventListener("click", handlePauseClick);
8277
+ dom.stressToggle.removeEventListener("change", handleStressChange);
8278
+ dom.focusMode.removeEventListener("change", handleFocusChange);
8279
+ try {
8280
+ if (typeof options.destroyState === "function") {
8281
+ options.destroyState(state.packageState);
8282
+ }
8283
+ } finally {
8284
+ state.packageState = void 0;
8285
+ }
8286
+ root.classList?.remove?.(ROOT_CLASS);
8287
+ root.innerHTML = previousMarkup;
8288
+ if (typeof previousRenderGameToText === "function") {
8289
+ window.render_game_to_text = previousRenderGameToText;
8290
+ } else {
8291
+ delete window.render_game_to_text;
8292
+ }
8293
+ if (typeof previousAdvanceTime === "function") {
8294
+ window.advanceTime = previousAdvanceTime;
8295
+ } else {
8296
+ delete window.advanceTime;
8297
+ }
8298
+ };
7878
8299
  return {
7879
8300
  state,
7880
8301
  shipModel,
7881
8302
  canvas: dom.canvas,
7882
- destroy() {
7883
- if (destroyed) {
7884
- return;
7885
- }
7886
- destroyed = true;
7887
- if (frameHandle != null) {
7888
- cancelAnimationFrame(frameHandle);
7889
- }
7890
- dom.pauseButton.removeEventListener("click", handlePauseClick);
7891
- dom.stressToggle.removeEventListener("change", handleStressChange);
7892
- dom.focusMode.removeEventListener("change", handleFocusChange);
7893
- root.innerHTML = previousMarkup;
7894
- if (typeof previousRenderGameToText === "function") {
7895
- window.render_game_to_text = previousRenderGameToText;
7896
- } else {
7897
- delete window.render_game_to_text;
7898
- }
7899
- if (typeof previousAdvanceTime === "function") {
7900
- window.advanceTime = previousAdvanceTime;
7901
- } else {
7902
- delete window.advanceTime;
7903
- }
7904
- }
8303
+ destroy
7905
8304
  };
7906
8305
  }
7907
8306
  function updatePhysicsSnapshot(state, shipModel) {
@@ -7915,7 +8314,7 @@ function updatePhysicsSnapshot(state, shipModel) {
7915
8314
  animationInputRevision: state.frame,
7916
8315
  bodyCount: state.ships.length + 2,
7917
8316
  dynamicBodyCount: state.ships.length,
7918
- contactCount: state.collisionFlash > 0.02 ? 1 : 0,
8317
+ contactCount: state.contactCount,
7919
8318
  metadata: {
7920
8319
  collisionCount: state.collisionCount,
7921
8320
  contactCount: state.contactCount,
@@ -7924,7 +8323,7 @@ function updatePhysicsSnapshot(state, shipModel) {
7924
8323
  }
7925
8324
  });
7926
8325
  }
7927
- var STYLE_ID, DEFAULT_TITLE, DEFAULT_SUBTITLE, CAMERA_PRESETS, showcaseFocusModes, SCENE_NOTES, UNIT_BOX_MESH;
8326
+ var STYLE_ID, ROOT_CLASS, DEFAULT_TITLE, DEFAULT_SUBTITLE, SHIP_SCALE, HARBOR_BOUNDS, CAMERA_PRESETS, showcaseFocusModes, SCENE_NOTES, UNIT_BOX_MESH, SHIP_LANTERNS, HARBOR_TORCHES;
7928
8327
  var init_showcase_runtime = __esm({
7929
8328
  "src/showcase-runtime.js"() {
7930
8329
  init_dist();
@@ -7936,8 +8335,16 @@ var init_showcase_runtime = __esm({
7936
8335
  init_asset_url();
7937
8336
  init_gltf_loader();
7938
8337
  STYLE_ID = "plasius-shared-3d-showcase-style";
8338
+ ROOT_CLASS = "plasius-showcase-root";
7939
8339
  DEFAULT_TITLE = "Flag by the Sea";
7940
8340
  DEFAULT_SUBTITLE = "Shared 3D validation scene using GLTF ships, cloth, fluid continuity, adaptive performance, and telemetry.";
8341
+ SHIP_SCALE = 1.1;
8342
+ HARBOR_BOUNDS = Object.freeze({
8343
+ minX: -11.2,
8344
+ maxX: 11.2,
8345
+ minZ: 1.8,
8346
+ maxZ: 17.2
8347
+ });
7941
8348
  CAMERA_PRESETS = Object.freeze({
7942
8349
  integrated: Object.freeze({ yaw: -0.55, pitch: 0.34, distance: 27, target: [0, 2.2, 0] }),
7943
8350
  lighting: Object.freeze({ yaw: -0.28, pitch: 0.28, distance: 23, target: [0, 2.8, 0] }),
@@ -7949,10 +8356,10 @@ var init_showcase_runtime = __esm({
7949
8356
  });
7950
8357
  showcaseFocusModes = Object.freeze(Object.keys(CAMERA_PRESETS));
7951
8358
  SCENE_NOTES = Object.freeze([
7952
- "Ships are loaded from a GLTF asset and carry physics metadata from node extras.",
7953
- "Near-field lighting uses the ray-traced-primary shadow and reflection path before stepping down by distance band.",
7954
- "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands.",
7955
- "Performance pressure reduces visual detail before authoritative collision motion is touched."
8359
+ "Ships are loaded from a GLTF asset and carry mass, damping, restitution, and hull extents from node extras.",
8360
+ "Moonlight sets the cold ambient read while deck lanterns and harbor torches provide warm local contrast.",
8361
+ "Cloth and fluid continuity stay coherent across near, mid, far, and horizon bands even in the darker night palette.",
8362
+ "Performance pressure reduces visual detail before mass-weighted authoritative collision motion is touched."
7956
8363
  ]);
7957
8364
  UNIT_BOX_MESH = Object.freeze({
7958
8365
  positions: Object.freeze([
@@ -8020,6 +8427,17 @@ var init_showcase_runtime = __esm({
8020
8427
  0
8021
8428
  ])
8022
8429
  });
8430
+ SHIP_LANTERNS = Object.freeze([
8431
+ Object.freeze({ x: 0.94, y: 1.54, z: 2.52, glow: 1 }),
8432
+ Object.freeze({ x: -0.9, y: 1.58, z: 2.44, glow: 0.92 }),
8433
+ Object.freeze({ x: 0.62, y: 1.42, z: -2.18, glow: 0.88 }),
8434
+ Object.freeze({ x: -0.58, y: 1.46, z: -2.04, glow: 0.84 })
8435
+ ]);
8436
+ HARBOR_TORCHES = Object.freeze([
8437
+ Object.freeze({ x: -5.2, y: 1.25, z: 1.36, glow: 1.1 }),
8438
+ Object.freeze({ x: -8.6, y: 2.48, z: -0.72, glow: 1 }),
8439
+ Object.freeze({ x: -10.4, y: 1.28, z: 0.82, glow: 0.92 })
8440
+ ]);
8023
8441
  }
8024
8442
  });
8025
8443