@pascal-app/viewer 1.0.3 → 1.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/capture/capture-runtime.js +1 -1
- package/dist/components/error-boundary.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.d.ts +1 -1
- package/dist/components/renderers/node-renderer.d.ts.map +1 -1
- package/dist/components/renderers/node-renderer.js +12 -4
- package/dist/components/renderers/parametric-node-renderer.d.ts.map +1 -1
- package/dist/components/viewer/bvh-ecctrl.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.d.ts +1 -0
- package/dist/components/viewer/frame-limiter.d.ts.map +1 -1
- package/dist/components/viewer/frame-limiter.js +22 -3
- package/dist/components/viewer/glb-interactive.d.ts +25 -1
- package/dist/components/viewer/glb-interactive.d.ts.map +1 -1
- package/dist/components/viewer/glb-interactive.js +253 -86
- package/dist/components/viewer/glb-mechanisms.d.ts +10 -0
- package/dist/components/viewer/glb-mechanisms.d.ts.map +1 -0
- package/dist/components/viewer/glb-mechanisms.js +19 -0
- package/dist/components/viewer/glb-reference-nodes.js +1 -1
- package/dist/components/viewer/glb-replace-instances.js +1 -1
- package/dist/components/viewer/glb-scene.d.ts +45 -27
- package/dist/components/viewer/glb-scene.d.ts.map +1 -1
- package/dist/components/viewer/glb-scene.js +421 -508
- package/dist/components/viewer/glb-walkthrough-controller.d.ts +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.d.ts.map +1 -1
- package/dist/components/viewer/glb-walkthrough-controller.js +13 -2
- package/dist/components/viewer/index.d.ts +24 -1
- package/dist/components/viewer/index.d.ts.map +1 -1
- package/dist/components/viewer/index.js +111 -37
- package/dist/components/viewer/lights.d.ts.map +1 -1
- package/dist/components/viewer/lights.js +113 -22
- package/dist/components/viewer/perf-monitor.d.ts.map +1 -1
- package/dist/components/viewer/perf-monitor.js +6 -0
- package/dist/components/viewer/perf-panel.d.ts.map +1 -1
- package/dist/components/viewer/perf-panel.js +1 -1
- package/dist/components/viewer/post-processing.d.ts.map +1 -1
- package/dist/components/viewer/post-processing.js +104 -36
- package/dist/components/viewer/registered-systems.d.ts.map +1 -1
- package/dist/components/viewer/registered-systems.js +17 -3
- package/dist/components/viewer/render-error.d.ts +18 -0
- package/dist/components/viewer/render-error.d.ts.map +1 -0
- package/dist/components/viewer/render-error.js +29 -0
- package/dist/components/viewer/scene-bvh.d.ts.map +1 -1
- package/dist/components/viewer/scene-bvh.js +1 -1
- package/dist/components/viewer/scene-environment.d.ts.map +1 -1
- package/dist/components/viewer/scene-environment.js +31 -11
- package/dist/components/viewer/scene-ground-replacement.d.ts +6 -0
- package/dist/components/viewer/scene-ground-replacement.d.ts.map +1 -1
- package/dist/components/viewer/scene-ground-replacement.js +15 -0
- package/dist/components/viewer/selection-manager.d.ts.map +1 -1
- package/dist/components/viewer/selection-manager.js +25 -7
- package/dist/components/viewer/underground-ambiance.d.ts +9 -0
- package/dist/components/viewer/underground-ambiance.d.ts.map +1 -0
- package/dist/components/viewer/underground-ambiance.js +105 -0
- package/dist/components/viewer/viewer-camera.d.ts +4 -0
- package/dist/components/viewer/viewer-camera.d.ts.map +1 -1
- package/dist/components/viewer/viewer-camera.js +12 -2
- package/dist/components/viewer/viewer-presentations.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.d.ts.map +1 -1
- package/dist/hooks/use-gltf-ktx2.js +2 -0
- package/dist/hooks/use-node-events.d.ts.map +1 -1
- package/dist/index.d.ts +36 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +34 -19
- package/dist/lib/asset-url.d.ts.map +1 -1
- package/dist/lib/asset-url.js +7 -1
- package/dist/lib/backdrop.d.ts +17 -0
- package/dist/lib/backdrop.d.ts.map +1 -1
- package/dist/lib/backdrop.js +18 -1
- package/dist/lib/construction-dust.d.ts +57 -0
- package/dist/lib/construction-dust.d.ts.map +1 -0
- package/dist/lib/construction-dust.js +187 -0
- package/dist/lib/csg-utils.js +1 -1
- package/dist/lib/display-state.d.ts +25 -0
- package/dist/lib/display-state.d.ts.map +1 -0
- package/dist/lib/display-state.js +1 -0
- package/dist/lib/foundation-materials.d.ts +26 -0
- package/dist/lib/foundation-materials.d.ts.map +1 -0
- package/dist/lib/foundation-materials.js +65 -0
- package/dist/lib/gltf-unlit.d.ts +10 -0
- package/dist/lib/gltf-unlit.d.ts.map +1 -0
- package/dist/lib/gltf-unlit.js +23 -0
- package/dist/lib/ground-window.d.ts +33 -0
- package/dist/lib/ground-window.d.ts.map +1 -0
- package/dist/lib/ground-window.js +114 -0
- package/dist/lib/hero-pose.d.ts +13 -3
- package/dist/lib/hero-pose.d.ts.map +1 -1
- package/dist/lib/hero-pose.js +72 -5
- package/dist/lib/ink-edges.d.ts +6 -3
- package/dist/lib/ink-edges.d.ts.map +1 -1
- package/dist/lib/ink-edges.js +17 -7
- package/dist/lib/key-light-override.d.ts +11 -0
- package/dist/lib/key-light-override.d.ts.map +1 -0
- package/dist/lib/key-light-override.js +14 -0
- package/dist/lib/ktx2-loader.d.ts +7 -0
- package/dist/lib/ktx2-loader.d.ts.map +1 -1
- package/dist/lib/ktx2-loader.js +35 -0
- package/dist/lib/layer-pass.d.ts.map +1 -1
- package/dist/lib/layers.d.ts +11 -7
- package/dist/lib/layers.d.ts.map +1 -1
- package/dist/lib/layers.js +16 -6
- package/dist/lib/live-frame-hold.d.ts +14 -0
- package/dist/lib/live-frame-hold.d.ts.map +1 -0
- package/dist/lib/live-frame-hold.js +28 -0
- package/dist/lib/materials.d.ts +19 -7
- package/dist/lib/materials.d.ts.map +1 -1
- package/dist/lib/materials.js +65 -9
- package/dist/lib/merged-outline-node.d.ts +5 -0
- package/dist/lib/merged-outline-node.d.ts.map +1 -1
- package/dist/lib/merged-outline-node.js +80 -2
- package/dist/lib/node-top-surface-height.d.ts +6 -0
- package/dist/lib/node-top-surface-height.d.ts.map +1 -0
- package/dist/lib/node-top-surface-height.js +74 -0
- package/dist/lib/perf-probe-state.d.ts +39 -0
- package/dist/lib/perf-probe-state.d.ts.map +1 -0
- package/dist/lib/perf-probe-state.js +17 -0
- package/dist/lib/pointer-events.d.ts +26 -2
- package/dist/lib/pointer-events.d.ts.map +1 -1
- package/dist/lib/pointer-events.js +99 -12
- package/dist/lib/post-processing-resources.d.ts.map +1 -1
- package/dist/lib/procedural-emission.d.ts +7 -0
- package/dist/lib/procedural-emission.d.ts.map +1 -0
- package/dist/lib/procedural-emission.js +54 -0
- package/dist/lib/reveal-candidates.d.ts +80 -0
- package/dist/lib/reveal-candidates.d.ts.map +1 -0
- package/dist/lib/reveal-candidates.js +524 -0
- package/dist/lib/reveal-ghost.d.ts +9 -0
- package/dist/lib/reveal-ghost.d.ts.map +1 -0
- package/dist/lib/reveal-ghost.js +62 -0
- package/dist/lib/reveal-pending.d.ts +12 -0
- package/dist/lib/reveal-pending.d.ts.map +1 -0
- package/dist/lib/reveal-pending.js +12 -0
- package/dist/lib/reveal-pose.d.ts +70 -0
- package/dist/lib/reveal-pose.d.ts.map +1 -0
- package/dist/lib/reveal-pose.js +248 -0
- package/dist/lib/reveal-schedule.d.ts +208 -0
- package/dist/lib/reveal-schedule.d.ts.map +1 -0
- package/dist/lib/reveal-schedule.js +410 -0
- package/dist/lib/reveal-texture-scale.d.ts +5 -0
- package/dist/lib/reveal-texture-scale.d.ts.map +1 -0
- package/dist/lib/reveal-texture-scale.js +113 -0
- package/dist/lib/reveal-windows.d.ts +23 -0
- package/dist/lib/reveal-windows.d.ts.map +1 -0
- package/dist/lib/reveal-windows.js +31 -0
- package/dist/lib/reveal-wipe.d.ts +71 -0
- package/dist/lib/reveal-wipe.d.ts.map +1 -0
- package/dist/lib/reveal-wipe.js +105 -0
- package/dist/lib/scene-pass-mrt.d.ts +12 -0
- package/dist/lib/scene-pass-mrt.d.ts.map +1 -0
- package/dist/lib/scene-pass-mrt.js +23 -0
- package/dist/lib/scene-support-height.d.ts +4 -0
- package/dist/lib/scene-support-height.d.ts.map +1 -0
- package/dist/lib/scene-support-height.js +34 -0
- package/dist/lib/scene-themes.d.ts +30 -8
- package/dist/lib/scene-themes.d.ts.map +1 -1
- package/dist/lib/scene-themes.js +36 -0
- package/dist/lib/scene-visibility.d.ts +17 -1
- package/dist/lib/scene-visibility.d.ts.map +1 -1
- package/dist/lib/scene-visibility.js +53 -3
- package/dist/lib/scripted-opening.d.ts +11 -0
- package/dist/lib/scripted-opening.d.ts.map +1 -0
- package/dist/lib/scripted-opening.js +20 -0
- package/dist/lib/site-surfaces.d.ts +32 -0
- package/dist/lib/site-surfaces.d.ts.map +1 -0
- package/dist/lib/site-surfaces.js +70 -0
- package/dist/lib/snapshot-pipeline.d.ts +34 -3
- package/dist/lib/snapshot-pipeline.d.ts.map +1 -1
- package/dist/lib/snapshot-pipeline.js +247 -117
- package/dist/lib/terrain-perimeter-fill.d.ts.map +1 -1
- package/dist/lib/terrain-perimeter-fill.js +22 -14
- package/dist/lib/tsl-compat.d.ts +2 -2
- package/dist/lib/tsl-compat.d.ts.map +1 -1
- package/dist/lib/underground-ambiance.d.ts +168 -0
- package/dist/lib/underground-ambiance.d.ts.map +1 -0
- package/dist/lib/underground-ambiance.js +334 -0
- package/dist/lib/zone-geometry.d.ts +11 -0
- package/dist/lib/zone-geometry.d.ts.map +1 -0
- package/dist/lib/zone-geometry.js +46 -0
- package/dist/store/use-item-light-pool.d.ts +14 -10
- package/dist/store/use-item-light-pool.d.ts.map +1 -1
- package/dist/store/use-item-light-pool.js +52 -26
- package/dist/store/use-viewer.d.ts +194 -26
- package/dist/store/use-viewer.d.ts.map +1 -1
- package/dist/store/use-viewer.js +164 -21
- package/dist/systems/ceiling/ceiling-system.d.ts +15 -1
- package/dist/systems/ceiling/ceiling-system.d.ts.map +1 -1
- package/dist/systems/ceiling/ceiling-system.js +88 -6
- package/dist/systems/construction-reveal/construction-dust.d.ts +16 -0
- package/dist/systems/construction-reveal/construction-dust.d.ts.map +1 -0
- package/dist/systems/construction-reveal/construction-dust.js +92 -0
- package/dist/systems/construction-reveal/construction-reveal.d.ts +276 -0
- package/dist/systems/construction-reveal/construction-reveal.d.ts.map +1 -0
- package/dist/systems/construction-reveal/construction-reveal.js +2383 -0
- package/dist/systems/construction-reveal/dust-material.d.ts +10 -0
- package/dist/systems/construction-reveal/dust-material.d.ts.map +1 -0
- package/dist/systems/construction-reveal/dust-material.js +17 -0
- package/dist/systems/construction-reveal/ghost-dress.d.ts +10 -0
- package/dist/systems/construction-reveal/ghost-dress.d.ts.map +1 -0
- package/dist/systems/construction-reveal/ghost-dress.js +24 -0
- package/dist/systems/door/door-system.d.ts.map +1 -1
- package/dist/systems/door/door-system.js +55 -17
- package/dist/systems/elevator/elevator-interaction-system.d.ts +7 -0
- package/dist/systems/elevator/elevator-interaction-system.d.ts.map +1 -1
- package/dist/systems/elevator/elevator-interaction-system.js +18 -1
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts +3 -1
- package/dist/systems/floor-elevation/floor-elevation-system.d.ts.map +1 -1
- package/dist/systems/floor-elevation/floor-elevation-system.js +33 -5
- package/dist/systems/geometry/geometry-system.d.ts.map +1 -1
- package/dist/systems/geometry/geometry-system.js +71 -11
- package/dist/systems/interactive/control-widget.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.d.ts.map +1 -1
- package/dist/systems/interactive/interactive-system.js +52 -17
- package/dist/systems/interactive/procedural-controls.d.ts +13 -0
- package/dist/systems/interactive/procedural-controls.d.ts.map +1 -0
- package/dist/systems/interactive/procedural-controls.js +17 -0
- package/dist/systems/interactive/scripted-clips.d.ts +19 -0
- package/dist/systems/interactive/scripted-clips.d.ts.map +1 -0
- package/dist/systems/interactive/scripted-clips.js +134 -0
- package/dist/systems/item/item-system.d.ts.map +1 -1
- package/dist/systems/item/item-system.js +9 -3
- package/dist/systems/item/object-cut-invalidation.d.ts +3 -0
- package/dist/systems/item/object-cut-invalidation.d.ts.map +1 -0
- package/dist/systems/item/object-cut-invalidation.js +68 -0
- package/dist/systems/item-light/item-light-system.d.ts +1 -1
- package/dist/systems/item-light/item-light-system.d.ts.map +1 -1
- package/dist/systems/item-light/item-light-system.js +48 -53
- package/dist/systems/level/level-hold.d.ts +4 -0
- package/dist/systems/level/level-hold.d.ts.map +1 -0
- package/dist/systems/level/level-hold.js +14 -0
- package/dist/systems/level/level-system.d.ts.map +1 -1
- package/dist/systems/level/level-system.js +19 -6
- package/dist/systems/level/level-utils.d.ts +19 -6
- package/dist/systems/level/level-utils.d.ts.map +1 -1
- package/dist/systems/level/level-utils.js +68 -9
- package/dist/systems/level/shadow-only-levels.d.ts +9 -0
- package/dist/systems/level/shadow-only-levels.d.ts.map +1 -0
- package/dist/systems/level/shadow-only-levels.js +7 -0
- package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts +103 -0
- package/dist/systems/roof/__fixtures__/roof-plane-cases.d.ts.map +1 -0
- package/dist/systems/roof/__fixtures__/roof-plane-cases.js +317 -0
- package/dist/systems/roof/roof-materials.d.ts +16 -2
- package/dist/systems/roof/roof-materials.d.ts.map +1 -1
- package/dist/systems/roof/roof-materials.js +64 -31
- package/dist/systems/roof/roof-system.d.ts +21 -4
- package/dist/systems/roof/roof-system.d.ts.map +1 -1
- package/dist/systems/roof/roof-system.js +374 -49
- package/dist/systems/slab/slab-system.d.ts +1 -1
- package/dist/systems/slab/slab-system.d.ts.map +1 -1
- package/dist/systems/slab/slab-system.js +49 -19
- package/dist/systems/stair/stair-system.d.ts.map +1 -1
- package/dist/systems/stair/stair-system.js +133 -354
- package/dist/systems/wall/wall-cutout-cache.d.ts +39 -3
- package/dist/systems/wall/wall-cutout-cache.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout-cache.js +141 -19
- package/dist/systems/wall/wall-cutout.d.ts +6 -0
- package/dist/systems/wall/wall-cutout.d.ts.map +1 -1
- package/dist/systems/wall/wall-cutout.js +42 -28
- package/dist/systems/wall/wall-finish-data.d.ts +22 -0
- package/dist/systems/wall/wall-finish-data.d.ts.map +1 -0
- package/dist/systems/wall/wall-finish-data.js +17 -0
- package/dist/systems/wall/wall-material-variant.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.d.ts +9 -4
- package/dist/systems/wall/wall-materials.d.ts.map +1 -1
- package/dist/systems/wall/wall-materials.js +135 -153
- package/dist/systems/wall/wall-placeholder-sweep.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.d.ts +33 -2
- package/dist/systems/wall/wall-system.d.ts.map +1 -1
- package/dist/systems/wall/wall-system.js +621 -159
- package/dist/systems/window/corner-frame.d.ts +10 -0
- package/dist/systems/window/corner-frame.d.ts.map +1 -0
- package/dist/systems/window/corner-frame.js +17 -0
- package/dist/systems/window/window-system.d.ts +1 -1
- package/dist/systems/window/window-system.d.ts.map +1 -1
- package/dist/systems/window/window-system.js +71 -27
- package/package.json +10 -10
- package/dist/systems/fence/fence-system.d.ts +0 -22
- package/dist/systems/fence/fence-system.d.ts.map +0 -1
- package/dist/systems/fence/fence-system.js +0 -357
|
@@ -0,0 +1,2383 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { criticallyDamped, emitter, getLevelElevations, isRestoringSceneHistory, nodeRegistry, sceneRegistry, subscribeSceneCommits, useScene, } from '@pascal-app/core';
|
|
4
|
+
import { useFrame } from '@react-three/fiber';
|
|
5
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
6
|
+
import { Box3, Vector3 } from 'three';
|
|
7
|
+
import { DustPool, emitAlongBase, emitAroundFoot } from '../../lib/construction-dust';
|
|
8
|
+
import { SCENE_LAYER } from '../../lib/layers';
|
|
9
|
+
import { CONTAINERS, commitChangeCandidates, commitDeleteCandidates, commitEditCandidates, commitRevealCandidates, nodeRevealCandidates, PART_LEAD, } from '../../lib/reveal-candidates';
|
|
10
|
+
import { clearGhost, setGhost } from '../../lib/reveal-ghost';
|
|
11
|
+
import { isRevealWaiting, revealPending } from '../../lib/reveal-pending';
|
|
12
|
+
import { clearRevealPose, hasRevealPose, REVEAL_ARRIVAL, retractPoseAt, revealHasLanded, revealLocalBounds, revealPivot, revealPoseAt, setGlidePose, setRevealPose, } from '../../lib/reveal-pose';
|
|
13
|
+
import { coverRangeAt, LAZY_REVEAL_TIMING, lazyRetractTimingAt, lazyTimingAt, normalizeAnimationSpeed, planLazyRetract, planLazyReveal, planRetract, planReveal, retractTimingAt, revealGroupKey, revealTimingAt, styleMs, } from '../../lib/reveal-schedule';
|
|
14
|
+
import { clearRiseTextureScale, setRiseTextureScale } from '../../lib/reveal-texture-scale';
|
|
15
|
+
import { coverDurationMs, coverShareAt, coverSweepOf, setRevealWipe, } from '../../lib/reveal-wipe';
|
|
16
|
+
import useViewer from '../../store/use-viewer';
|
|
17
|
+
import { releaseLevel, setHeldLevelY } from '../level/level-hold';
|
|
18
|
+
import { getLevelPresentationY } from '../level/level-utils';
|
|
19
|
+
import { ConstructionDust } from './construction-dust';
|
|
20
|
+
import { dressDeletionGhost } from './ghost-dress';
|
|
21
|
+
export { isRevealWaiting };
|
|
22
|
+
const coverBox = new Box3();
|
|
23
|
+
/** The sweep over what is drawn under `root`, and the time it takes in `range`. */
|
|
24
|
+
function planCover(root, range) {
|
|
25
|
+
root.updateWorldMatrix(true, true);
|
|
26
|
+
const sweep = coverSweepOf(coverBox.setFromObject(root));
|
|
27
|
+
return { sweep, durationMs: coverDurationMs(sweep, range) };
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* The world coordinate the covering has reached when `share` of its sweep is closed: a hair under
|
|
31
|
+
* the lowest edge at the start and over the highest at the end, so it is whole where it ends.
|
|
32
|
+
*/
|
|
33
|
+
const coverLevel = (sweep, share) => sweep.from - 0.01 + (sweep.to - sweep.from + 0.02) * share;
|
|
34
|
+
/** Lays the covering `elapsedMs` into its time: it moves with every frame, easing in and out. */
|
|
35
|
+
function layCover(root, cover, elapsedMs) {
|
|
36
|
+
const share = coverShareAt(elapsedMs, cover.durationMs);
|
|
37
|
+
setRevealWipe(root, cover.sweep, coverLevel(cover.sweep, share));
|
|
38
|
+
}
|
|
39
|
+
/** Lifts the covering off: the same sweep played from the highest edge to the lowest. */
|
|
40
|
+
function liftCover(root, cover, progress) {
|
|
41
|
+
const left = (1 - Math.min(1, Math.max(0, progress))) * cover.durationMs;
|
|
42
|
+
setRevealWipe(root, cover.sweep, coverLevel(cover.sweep, coverShareAt(left, cover.durationMs)));
|
|
43
|
+
}
|
|
44
|
+
/** A started node waits this long for its renderer to register (its kind may never mount). */
|
|
45
|
+
const MOUNT_TIMEOUT_MS = 2000;
|
|
46
|
+
/**
|
|
47
|
+
* And this long for its first build (an item's model) before it animates anyway, counted from
|
|
48
|
+
* the last build that landed: a wall builds once mounted, a few a frame, so a big agent build
|
|
49
|
+
* queues for longer than this while every build lands in turn (Victor run 11: 1,545 walls).
|
|
50
|
+
*/
|
|
51
|
+
const BUILD_TIMEOUT_MS = 2500;
|
|
52
|
+
/** The longest step the reveal clock takes: a stalled tab resumes the build, it does not start it all at once. */
|
|
53
|
+
const MAX_STEP_MS = 50;
|
|
54
|
+
const MIN_SCALE = 1e-3;
|
|
55
|
+
/** A full-height drop (a column from 4 m) raises the most dust. */
|
|
56
|
+
const FULL_DUST_DROP_M = 4;
|
|
57
|
+
/** The last piece of a build lands this much heavier: a bigger squash, a bigger puff. */
|
|
58
|
+
const FINALE_WEIGHT = 1.7;
|
|
59
|
+
/** On the lazy schedule a drop lifts this share of its height before it falls (anticipation). */
|
|
60
|
+
const LAZY_DROP_LEAD = 0.08;
|
|
61
|
+
/** A lifted node rises over this many seconds, and comes back a little quicker. */
|
|
62
|
+
const LIFT_RESPONSE_UP_S = 0.55;
|
|
63
|
+
const LIFT_RESPONSE_DOWN_S = 0.45;
|
|
64
|
+
/**
|
|
65
|
+
* It waits this long after the last piece of the phase has landed, in case more is coming: one
|
|
66
|
+
* furnishing is often several writes, a model's turn between them takes seconds, and a roof that
|
|
67
|
+
* bobs down and up between them is a distraction. A host that knows the turn is over brings it down
|
|
68
|
+
* at once (`seatLiftedNow`).
|
|
69
|
+
*/
|
|
70
|
+
const LIFT_HOLD_MS = 10_000;
|
|
71
|
+
/** The beat the room is seen whole under the open sky before the roof comes down on a host's say. */
|
|
72
|
+
const LIFT_BEAT_MS = 700;
|
|
73
|
+
/** At the top it is this faint (a ghost), never gone: the walls under it keep their shape. */
|
|
74
|
+
const LIFT_GHOST_MIN = 0.14;
|
|
75
|
+
/**
|
|
76
|
+
* A ghost's first draw (the transparent variant of every roof material) costs its shaders: a lift warms
|
|
77
|
+
* with the roof at this opacity, indistinguishable from whole, before it begins to rise.
|
|
78
|
+
*/
|
|
79
|
+
const LIFT_WARM_OPACITY = 0.995;
|
|
80
|
+
/** A lift stays put this many quiet frames after its first ghost draw. */
|
|
81
|
+
const LIFT_WARM_TICKS = 3;
|
|
82
|
+
/**
|
|
83
|
+
* A kind's first appearance costs its shaders and pipelines (a first wall, a roof, an item's model are
|
|
84
|
+
* 100 to 400 ms), and the frame that pays freezes the picture. So the first node of each kind not yet
|
|
85
|
+
* on screen mounts up front, hidden, and the plan waits for it to have drawn and for the frames to be
|
|
86
|
+
* quiet again: the stall lands while nothing moves.
|
|
87
|
+
*/
|
|
88
|
+
const WARM_QUIET_FRAMES = 2;
|
|
89
|
+
const WARM_QUIET_FRAME_MS = 34;
|
|
90
|
+
const WARM_MIN_TICKS = 2;
|
|
91
|
+
const WARM_MAX_MS = 2500;
|
|
92
|
+
/** A node whose renderer has not registered a root by then never will (a kind with no 3D renderer). */
|
|
93
|
+
const WARM_MOUNT_MS = 600;
|
|
94
|
+
const UNIT = new Vector3(1, 1, 1);
|
|
95
|
+
const ORIGIN = new Vector3();
|
|
96
|
+
const HIDDEN = new Vector3(MIN_SCALE, MIN_SCALE, MIN_SCALE);
|
|
97
|
+
const sample = { lift: 0, scale: new Vector3() };
|
|
98
|
+
const lineFrom = new Vector3();
|
|
99
|
+
const lineTo = new Vector3();
|
|
100
|
+
const foot = [new Vector3(), new Vector3(), new Vector3(), new Vector3()];
|
|
101
|
+
const pending = revealPending;
|
|
102
|
+
let queue = [];
|
|
103
|
+
/** The plan's entries still to start, by id: when each moves in, and its preroll before. */
|
|
104
|
+
const planned = new Map();
|
|
105
|
+
/** How long a plugin plays before a node of the lazy schedule moves in (its preroll), if it plays any. */
|
|
106
|
+
let prerollOf = null;
|
|
107
|
+
let unplanned = false;
|
|
108
|
+
/** The plan is to be made again, once what mounts up front has drawn. */
|
|
109
|
+
let replanning = false;
|
|
110
|
+
const warming = new Map();
|
|
111
|
+
/** Roofs ghosted ahead of the furniture, with the frames they have drawn so. */
|
|
112
|
+
const ghostWarming = new Map();
|
|
113
|
+
let warmWaiting = false;
|
|
114
|
+
let warmStartedMs = 0;
|
|
115
|
+
let warmQuiet = 0;
|
|
116
|
+
let dustWarmed = false;
|
|
117
|
+
/** Whether the last frame took no longer than a steady one (unknown: it did not). */
|
|
118
|
+
let frameQuiet = true;
|
|
119
|
+
const active = new Map();
|
|
120
|
+
const announced = new Set();
|
|
121
|
+
/** An assembled node's parts still on their way, and each part's host. */
|
|
122
|
+
const parts = new Map();
|
|
123
|
+
const hostOf = new Map();
|
|
124
|
+
let arrivals = 0;
|
|
125
|
+
let lastStartMs = Number.NEGATIVE_INFINITY;
|
|
126
|
+
let clockMs = 0;
|
|
127
|
+
/** When a started node's first build last landed. */
|
|
128
|
+
let buildLandedMs = Number.NEGATIVE_INFINITY;
|
|
129
|
+
let tickedBusy = false;
|
|
130
|
+
let revealLevel = () => 'full';
|
|
131
|
+
/** The person's animation speed, asked when a plan is made (never mid-animation). */
|
|
132
|
+
let revealSpeed = () => 1;
|
|
133
|
+
/** The speed the plan in force was made at: what waits for its turn plays at it. */
|
|
134
|
+
let planSpeed = 1;
|
|
135
|
+
const currentSpeed = () => normalizeAnimationSpeed(revealSpeed());
|
|
136
|
+
/** The animation speed the plan in force was made at, for a plugin that stages its own pieces. */
|
|
137
|
+
export function getRevealPlanSpeed() {
|
|
138
|
+
return planSpeed;
|
|
139
|
+
}
|
|
140
|
+
let dust = null;
|
|
141
|
+
/** The groups still open, each (phase, level) once; and the group each staged node counts in. */
|
|
142
|
+
const groups = new Map();
|
|
143
|
+
const groupOfNode = new Map();
|
|
144
|
+
/** Moves waiting for the reveal to be quiet: they hold at their old place meanwhile. */
|
|
145
|
+
let changeQueue = [];
|
|
146
|
+
/** The ids waiting in `changeQueue`. */
|
|
147
|
+
const queuedChanges = new Set();
|
|
148
|
+
/** Pieces on their way (or waiting their turn to start). */
|
|
149
|
+
const glides = new Map();
|
|
150
|
+
const changeGroups = new Map();
|
|
151
|
+
const changeGroupOf = new Map();
|
|
152
|
+
/** The moves since the reveal was last quiet: what a change's `complete` reports. */
|
|
153
|
+
let changeRun = null;
|
|
154
|
+
let glideClockMs = Number.NEGATIVE_INFINITY;
|
|
155
|
+
/** A move lifted the roof for the furniture: it is let go of at this time on the clock, a beat after the last. */
|
|
156
|
+
let changeLiftReleaseAt = null;
|
|
157
|
+
/** How long after the last move the roof is let go of (it then waits out `LIFT_HOLD_MS` for more). */
|
|
158
|
+
const CHANGE_LIFT_BEAT_MS = 1200;
|
|
159
|
+
/** A glide is a short spring: it is home about three quarters of a second after it starts. */
|
|
160
|
+
const GLIDE_RESPONSE_S = 0.7;
|
|
161
|
+
/** A piece is carried, not slid: it rises this far (metres) half way. */
|
|
162
|
+
const GLIDE_HOP_M = 0.05;
|
|
163
|
+
/** Pieces of a rearranged room start this far apart, up to a cap however many move. */
|
|
164
|
+
const GLIDE_STAGGER_MS = 80;
|
|
165
|
+
const GLIDE_STAGGER_CAP_MS = 1200;
|
|
166
|
+
/** The build since the reveal was last idle: what `complete` reports. */
|
|
167
|
+
let build = null;
|
|
168
|
+
/** What is up, and what has asked to be but is still being built or revealed. */
|
|
169
|
+
const lifts = new Map();
|
|
170
|
+
const liftWanted = new Map();
|
|
171
|
+
/** The build's finale, said when what was lifted seats again instead of at the last landing. */
|
|
172
|
+
let deferredFinale = null;
|
|
173
|
+
let liftClockMs = Number.NEGATIVE_INFINITY;
|
|
174
|
+
const pendingListeners = new Map();
|
|
175
|
+
const phaseListeners = new Set();
|
|
176
|
+
const eventListeners = new Set();
|
|
177
|
+
const assemblyListeners = new Map();
|
|
178
|
+
const tickListeners = new Set();
|
|
179
|
+
function notifyPending(id) {
|
|
180
|
+
const listeners = pendingListeners.get(id);
|
|
181
|
+
if (listeners)
|
|
182
|
+
for (const listener of [...listeners])
|
|
183
|
+
listener();
|
|
184
|
+
}
|
|
185
|
+
function notifyAssembly(hostId, assembling) {
|
|
186
|
+
const listeners = assemblyListeners.get(hostId);
|
|
187
|
+
if (listeners)
|
|
188
|
+
for (const listener of [...listeners])
|
|
189
|
+
listener(assembling);
|
|
190
|
+
}
|
|
191
|
+
function notifyTicks() {
|
|
192
|
+
for (const listener of [...tickListeners]) {
|
|
193
|
+
try {
|
|
194
|
+
listener();
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
console.error('[viewer] reveal tick listener failed', error);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Whether the node waits for its turn (unmounted), is still moving in, is going out (a reverse play,
|
|
203
|
+
* and held gone until the host takes it out of the scene), or is being carried to a new place. A batch
|
|
204
|
+
* hands it back to itself and keeps it out.
|
|
205
|
+
*/
|
|
206
|
+
export function isNodeRevealing(id) {
|
|
207
|
+
return (pending.has(id) ||
|
|
208
|
+
active.has(id) ||
|
|
209
|
+
retracts.has(id) ||
|
|
210
|
+
held.has(id) ||
|
|
211
|
+
glides.has(id) ||
|
|
212
|
+
queuedChanges.has(id));
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* A `cut` node leaves its host: the host, built whole while it waited, rebuilds with the opening.
|
|
216
|
+
*/
|
|
217
|
+
function released(candidate) {
|
|
218
|
+
notifyPending(candidate.id);
|
|
219
|
+
if (candidate.style !== 'cut')
|
|
220
|
+
return;
|
|
221
|
+
const hostId = useScene.getState().nodes[candidate.id]?.parentId;
|
|
222
|
+
if (hostId)
|
|
223
|
+
useScene.getState().markDirty(hostId);
|
|
224
|
+
}
|
|
225
|
+
function subscribePending(id, listener) {
|
|
226
|
+
let listeners = pendingListeners.get(id);
|
|
227
|
+
if (!listeners) {
|
|
228
|
+
listeners = new Set();
|
|
229
|
+
pendingListeners.set(id, listeners);
|
|
230
|
+
}
|
|
231
|
+
const own = listeners;
|
|
232
|
+
own.add(listener);
|
|
233
|
+
return () => {
|
|
234
|
+
own.delete(listener);
|
|
235
|
+
if (own.size === 0 && pendingListeners.get(id) === own)
|
|
236
|
+
pendingListeners.delete(id);
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* True while the node waits for its turn: its renderer stays unmounted. A node that mounts up front,
|
|
241
|
+
* hidden, to draw its shaders before the build moves (see `chooseWarm`) is mounted: not pending.
|
|
242
|
+
*/
|
|
243
|
+
export function useRevealPending(id) {
|
|
244
|
+
const subscribe = useCallback((listener) => subscribePending(id, listener), [id]);
|
|
245
|
+
const read = useCallback(() => pending.has(id) && !warming.has(id), [id]);
|
|
246
|
+
return useSyncExternalStore(subscribe, read, read);
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* True while the plan has something to play for the node: it waits for its turn, moves in, goes (an
|
|
250
|
+
* undo's reverse play) or is held gone. A plan entry that draws the node itself subscribes to the
|
|
251
|
+
* reveal's ticks only while this is true.
|
|
252
|
+
*/
|
|
253
|
+
export function useRevealPlanActive(id) {
|
|
254
|
+
const subscribe = useCallback((listener) => subscribePending(id, listener), [id]);
|
|
255
|
+
const read = useCallback(() => pending.has(id) || active.has(id) || retracts.has(id) || held.has(id), [id]);
|
|
256
|
+
return useSyncExternalStore(subscribe, read, read);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* True while the node has not had its turn, mounted up front or not: what a view that draws the node
|
|
260
|
+
* itself (the 2D plan) leaves out until it does.
|
|
261
|
+
*/
|
|
262
|
+
export function useRevealWaiting(id) {
|
|
263
|
+
const subscribe = useCallback((listener) => subscribePending(id, listener), [id]);
|
|
264
|
+
const read = useCallback(() => pending.has(id), [id]);
|
|
265
|
+
return useSyncExternalStore(subscribe, read, read);
|
|
266
|
+
}
|
|
267
|
+
/** True while an `assemble` node's parts are still on their way: its renderer draws them apart. */
|
|
268
|
+
export function isRevealAssembling(id) {
|
|
269
|
+
return (parts.get(id)?.size ?? 0) > 0;
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* Calls `listener` each time `id` starts or stops assembling. It fires
|
|
273
|
+
* synchronously, even when a capture ends the reveal, so a renderer that
|
|
274
|
+
* swaps its parts for a merged shell can do so before the frame is cloned.
|
|
275
|
+
*/
|
|
276
|
+
export function subscribeRevealAssembling(id, listener) {
|
|
277
|
+
let listeners = assemblyListeners.get(id);
|
|
278
|
+
if (!listeners) {
|
|
279
|
+
listeners = new Set();
|
|
280
|
+
assemblyListeners.set(id, listeners);
|
|
281
|
+
}
|
|
282
|
+
const own = listeners;
|
|
283
|
+
own.add(listener);
|
|
284
|
+
return () => {
|
|
285
|
+
own.delete(listener);
|
|
286
|
+
if (own.size === 0 && assemblyListeners.get(id) === own)
|
|
287
|
+
assemblyListeners.delete(id);
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* The node's reveal for a plan view, on the reveal's own clock: `progress` is
|
|
292
|
+
* 0 while it waits and runs to 1 over its style's duration from its start
|
|
293
|
+
* (not from its first 3D build, which a paused canvas never runs). Null once
|
|
294
|
+
* it is done, or if it never revealed.
|
|
295
|
+
*/
|
|
296
|
+
export function getRevealPlanState(id) {
|
|
297
|
+
const waiting = pending.get(id);
|
|
298
|
+
if (waiting)
|
|
299
|
+
return { style: waiting.style, progress: 0 };
|
|
300
|
+
// The build played in reverse is the same state read backwards: whole until its turn, then going,
|
|
301
|
+
// then gone while it is held.
|
|
302
|
+
const retract = retracts.get(id);
|
|
303
|
+
if (retract) {
|
|
304
|
+
if (clockMs < retract.startMs)
|
|
305
|
+
return null;
|
|
306
|
+
const gone = (clockMs - retract.startMs) / Math.max(1, retract.durationMs);
|
|
307
|
+
return { style: retract.style, progress: Math.min(1, Math.max(0, 1 - gone)) };
|
|
308
|
+
}
|
|
309
|
+
const gone = held.get(id);
|
|
310
|
+
if (gone)
|
|
311
|
+
return { style: gone.style, progress: 0 };
|
|
312
|
+
const reveal = active.get(id);
|
|
313
|
+
if (!reveal)
|
|
314
|
+
return null;
|
|
315
|
+
const durationMs = reveal.style === 'assemble' ? reveal.assembleMs : reveal.durationMs;
|
|
316
|
+
const progress = (clockMs - reveal.startedAt) / Math.max(1, durationMs);
|
|
317
|
+
return { style: reveal.style, progress: Math.min(1, Math.max(0, progress)) };
|
|
318
|
+
}
|
|
319
|
+
/**
|
|
320
|
+
* When a waiting node of the plan moves in, on the reveal's clock (`revealClockMs`), and how long its
|
|
321
|
+
* preroll plays before: a plugin that stages its own pieces for it (a wall's frame and boards) plays
|
|
322
|
+
* them in `[startMs - prerollMs, startMs]`. Null once it has started, or when it is not planned.
|
|
323
|
+
*/
|
|
324
|
+
export function getRevealTurn(id) {
|
|
325
|
+
const entry = planned.get(id);
|
|
326
|
+
return entry ? { startMs: entry.startMs, prerollMs: entry.prerollMs ?? 0 } : null;
|
|
327
|
+
}
|
|
328
|
+
/** The reveal's own clock: what `getRevealTurn` counts in. */
|
|
329
|
+
export function revealClockMs() {
|
|
330
|
+
return clockMs;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Lets a plugin ask for a preroll before nodes of the lazy schedule (the `framing` level) move in:
|
|
334
|
+
* the plan leaves `prerollOf(candidate)` ms before each, and the plugin plays its own pieces then.
|
|
335
|
+
* Asked once per node when the plan is made; 0 for none. Null takes it back.
|
|
336
|
+
*/
|
|
337
|
+
export function setRevealPreroll(next) {
|
|
338
|
+
prerollOf = next;
|
|
339
|
+
}
|
|
340
|
+
const puffFoot = [new Vector3(), new Vector3(), new Vector3(), new Vector3()];
|
|
341
|
+
/**
|
|
342
|
+
* A puff of the reveal's dust around `centre` (world), `radius` metres out, `strength` in (0, 1]:
|
|
343
|
+
* for a plugin's own pieces landing. Nothing when no dust plays (the simple level, no reveal).
|
|
344
|
+
*/
|
|
345
|
+
export function emitRevealPuff(centre, radius, strength) {
|
|
346
|
+
if (!(dust && dustOn()))
|
|
347
|
+
return;
|
|
348
|
+
puffFoot[0].set(centre.x - radius, centre.y, centre.z - radius);
|
|
349
|
+
puffFoot[1].set(centre.x + radius, centre.y, centre.z - radius);
|
|
350
|
+
puffFoot[2].set(centre.x + radius, centre.y, centre.z + radius);
|
|
351
|
+
puffFoot[3].set(centre.x - radius, centre.y, centre.z + radius);
|
|
352
|
+
emitAroundFoot(dust, puffFoot, Math.min(1, Math.max(0.05, strength)), clockMs);
|
|
353
|
+
}
|
|
354
|
+
/** Calls `listener` after every reveal step while anything reveals, and once when it all ends. */
|
|
355
|
+
export function subscribeRevealTicks(listener) {
|
|
356
|
+
tickListeners.add(listener);
|
|
357
|
+
return () => {
|
|
358
|
+
tickListeners.delete(listener);
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
/**
|
|
362
|
+
* Everything a reveal says, in order and on its clock: the phase groups starting, landing and
|
|
363
|
+
* settling, each node starting, and the build completing. The chat's step cards, the sounds and the
|
|
364
|
+
* camera that follows the build listen here, so they share one rhythm.
|
|
365
|
+
*/
|
|
366
|
+
export function subscribeRevealEvents(listener) {
|
|
367
|
+
eventListeners.add(listener);
|
|
368
|
+
return () => {
|
|
369
|
+
eventListeners.delete(listener);
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
function emit(event) {
|
|
373
|
+
for (const listener of [...eventListeners]) {
|
|
374
|
+
try {
|
|
375
|
+
listener(event);
|
|
376
|
+
}
|
|
377
|
+
catch (error) {
|
|
378
|
+
console.error('[viewer] reveal event listener failed', error);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
function emitGroup(type, group, atMs) {
|
|
383
|
+
emit({
|
|
384
|
+
type,
|
|
385
|
+
phase: group.phase,
|
|
386
|
+
levelId: group.levelId,
|
|
387
|
+
nodeIds: [...group.ids],
|
|
388
|
+
source: group.source,
|
|
389
|
+
instant: !group.animated || group.forced,
|
|
390
|
+
atMs,
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
/** Counts each staged node in its (phase, level) group, and the build they belong to. */
|
|
394
|
+
function registerCandidates(candidates, atMs) {
|
|
395
|
+
build ??= { startedAt: atMs, count: 0, source: null, animated: false };
|
|
396
|
+
for (const candidate of candidates) {
|
|
397
|
+
const key = revealGroupKey(candidate);
|
|
398
|
+
let group = groups.get(key);
|
|
399
|
+
if (!group) {
|
|
400
|
+
group = {
|
|
401
|
+
phase: candidate.phase,
|
|
402
|
+
levelId: candidate.levelId,
|
|
403
|
+
ids: new Set(),
|
|
404
|
+
remaining: 0,
|
|
405
|
+
started: false,
|
|
406
|
+
lifted: false,
|
|
407
|
+
landed: false,
|
|
408
|
+
animated: false,
|
|
409
|
+
forced: false,
|
|
410
|
+
source: candidate.source ?? null,
|
|
411
|
+
};
|
|
412
|
+
groups.set(key, group);
|
|
413
|
+
}
|
|
414
|
+
if (!group.ids.has(candidate.id))
|
|
415
|
+
group.remaining += 1;
|
|
416
|
+
group.ids.add(candidate.id);
|
|
417
|
+
groupOfNode.set(candidate.id, key);
|
|
418
|
+
build.count += 1;
|
|
419
|
+
build.source = candidate.source ?? build.source;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* A node is let in to play. What lifts out of the way of its phase starts to, so the roof is on its way
|
|
424
|
+
* as the first piece of furniture begins; the group itself is not said to have started until a node moves.
|
|
425
|
+
*/
|
|
426
|
+
function groupReleased(candidate) {
|
|
427
|
+
const group = groups.get(revealGroupKey(candidate));
|
|
428
|
+
if (!group)
|
|
429
|
+
return;
|
|
430
|
+
group.animated = true;
|
|
431
|
+
if (build)
|
|
432
|
+
build.animated = true;
|
|
433
|
+
if (!group.lifted) {
|
|
434
|
+
group.lifted = true;
|
|
435
|
+
wantLifts(group.phase, group.levelId);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* A node's first move: a group's first node begins to move. That is when a step card lands and the
|
|
440
|
+
* camera goes there, not when the node is let in (it may wait for a slow build, and nothing is seen).
|
|
441
|
+
*/
|
|
442
|
+
function groupStarted(candidate, atMs) {
|
|
443
|
+
const group = groups.get(revealGroupKey(candidate));
|
|
444
|
+
if (!group)
|
|
445
|
+
return;
|
|
446
|
+
if (!group.started) {
|
|
447
|
+
group.started = true;
|
|
448
|
+
emitGroup('start', group, atMs);
|
|
449
|
+
}
|
|
450
|
+
emit({
|
|
451
|
+
type: 'node-start',
|
|
452
|
+
id: candidate.id,
|
|
453
|
+
phase: candidate.phase,
|
|
454
|
+
levelId: candidate.levelId,
|
|
455
|
+
style: candidate.style,
|
|
456
|
+
source: candidate.source ?? null,
|
|
457
|
+
atMs,
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
/** A group's first node makes contact: the frame its sound belongs on. */
|
|
461
|
+
function groupLanded(id, atMs) {
|
|
462
|
+
const group = groups.get(groupOfNode.get(id) ?? '');
|
|
463
|
+
if (!group || group.landed)
|
|
464
|
+
return;
|
|
465
|
+
if (!group.started) {
|
|
466
|
+
group.started = true;
|
|
467
|
+
emitGroup('start', group, atMs);
|
|
468
|
+
}
|
|
469
|
+
group.landed = true;
|
|
470
|
+
emitGroup('land', group, atMs);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* `id` is at rest (or was never staged, or was cut short): when its group has no node left, the
|
|
474
|
+
* group settles, saying the start and the land first if it never had occasion to.
|
|
475
|
+
*/
|
|
476
|
+
function closeNode(id, atMs, forced) {
|
|
477
|
+
const key = groupOfNode.get(id);
|
|
478
|
+
if (key === undefined)
|
|
479
|
+
return;
|
|
480
|
+
groupOfNode.delete(id);
|
|
481
|
+
const group = groups.get(key);
|
|
482
|
+
if (!group)
|
|
483
|
+
return;
|
|
484
|
+
if (forced)
|
|
485
|
+
group.forced = true;
|
|
486
|
+
group.remaining -= 1;
|
|
487
|
+
if (group.remaining > 0)
|
|
488
|
+
return;
|
|
489
|
+
groups.delete(key);
|
|
490
|
+
if (!group.started) {
|
|
491
|
+
group.started = true;
|
|
492
|
+
emitGroup('start', group, atMs);
|
|
493
|
+
}
|
|
494
|
+
if (!group.landed) {
|
|
495
|
+
group.landed = true;
|
|
496
|
+
emitGroup('land', group, atMs);
|
|
497
|
+
}
|
|
498
|
+
emitGroup('settle', group, atMs);
|
|
499
|
+
releaseLifts(group.phase, atMs);
|
|
500
|
+
}
|
|
501
|
+
/** The build is over: nothing waits and nothing moves. */
|
|
502
|
+
function completeBuild(atMs, forced) {
|
|
503
|
+
const finished = build;
|
|
504
|
+
build = null;
|
|
505
|
+
if (!finished)
|
|
506
|
+
return;
|
|
507
|
+
emit({
|
|
508
|
+
type: 'complete',
|
|
509
|
+
nodeCount: finished.count,
|
|
510
|
+
durationMs: Math.max(0, atMs - finished.startedAt),
|
|
511
|
+
source: finished.source,
|
|
512
|
+
instant: forced || !finished.animated,
|
|
513
|
+
atMs,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
function emitChangeGroup(type, group, atMs) {
|
|
517
|
+
emit({
|
|
518
|
+
type,
|
|
519
|
+
phase: group.phase,
|
|
520
|
+
levelId: group.levelId,
|
|
521
|
+
nodeIds: [...group.ids],
|
|
522
|
+
source: group.source,
|
|
523
|
+
instant: !group.animated || group.forced,
|
|
524
|
+
atMs,
|
|
525
|
+
changed: true,
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
/** Counts each moved piece in its (phase, level) group, and the run they belong to. */
|
|
529
|
+
function registerChanges(changes, atMs) {
|
|
530
|
+
changeRun ??= { startedAt: atMs, count: 0, source: null, animated: false };
|
|
531
|
+
for (const change of changes) {
|
|
532
|
+
const key = revealGroupKey(change);
|
|
533
|
+
let group = changeGroups.get(key);
|
|
534
|
+
if (!group) {
|
|
535
|
+
group = {
|
|
536
|
+
phase: change.phase,
|
|
537
|
+
levelId: change.levelId,
|
|
538
|
+
ids: new Set(),
|
|
539
|
+
remaining: 0,
|
|
540
|
+
started: false,
|
|
541
|
+
landed: false,
|
|
542
|
+
animated: false,
|
|
543
|
+
forced: false,
|
|
544
|
+
source: change.source ?? null,
|
|
545
|
+
};
|
|
546
|
+
changeGroups.set(key, group);
|
|
547
|
+
}
|
|
548
|
+
if (!group.ids.has(change.id))
|
|
549
|
+
group.remaining += 1;
|
|
550
|
+
group.ids.add(change.id);
|
|
551
|
+
changeGroupOf.set(change.id, key);
|
|
552
|
+
changeRun.count += 1;
|
|
553
|
+
changeRun.source = change.source ?? changeRun.source;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
/** A piece's move begins: its group starts with its first, and the roof lifts out of the way as for furniture. */
|
|
557
|
+
function beginChange(change, atMs) {
|
|
558
|
+
const group = changeGroups.get(changeGroupOf.get(change.id) ?? '');
|
|
559
|
+
if (!group)
|
|
560
|
+
return;
|
|
561
|
+
if (!group.started) {
|
|
562
|
+
group.started = true;
|
|
563
|
+
emitChangeGroup('start', group, atMs);
|
|
564
|
+
}
|
|
565
|
+
emit({
|
|
566
|
+
type: 'node-start',
|
|
567
|
+
id: change.id,
|
|
568
|
+
phase: change.phase,
|
|
569
|
+
levelId: change.levelId,
|
|
570
|
+
style: change.edited ? change.style : 'glide',
|
|
571
|
+
source: change.source ?? null,
|
|
572
|
+
atMs,
|
|
573
|
+
changed: true,
|
|
574
|
+
});
|
|
575
|
+
wantLifts(change.phase, change.levelId);
|
|
576
|
+
changeLiftReleaseAt = null;
|
|
577
|
+
}
|
|
578
|
+
/** A piece is home (or its move was cut short): its group settles with its last, and the run with its last group. */
|
|
579
|
+
function endChange(id, atMs, forced) {
|
|
580
|
+
const key = changeGroupOf.get(id);
|
|
581
|
+
if (key === undefined)
|
|
582
|
+
return;
|
|
583
|
+
changeGroupOf.delete(id);
|
|
584
|
+
const group = changeGroups.get(key);
|
|
585
|
+
if (!group)
|
|
586
|
+
return;
|
|
587
|
+
if (forced)
|
|
588
|
+
group.forced = true;
|
|
589
|
+
if (!group.started) {
|
|
590
|
+
group.started = true;
|
|
591
|
+
emitChangeGroup('start', group, atMs);
|
|
592
|
+
}
|
|
593
|
+
if (!group.landed) {
|
|
594
|
+
group.landed = true;
|
|
595
|
+
emitChangeGroup('land', group, atMs);
|
|
596
|
+
}
|
|
597
|
+
group.remaining -= 1;
|
|
598
|
+
if (group.remaining > 0)
|
|
599
|
+
return;
|
|
600
|
+
changeGroups.delete(key);
|
|
601
|
+
emitChangeGroup('settle', group, atMs);
|
|
602
|
+
if (changeGroups.size > 0)
|
|
603
|
+
return;
|
|
604
|
+
const run = changeRun;
|
|
605
|
+
changeRun = null;
|
|
606
|
+
if (!run)
|
|
607
|
+
return;
|
|
608
|
+
emit({
|
|
609
|
+
type: 'complete',
|
|
610
|
+
nodeCount: run.count,
|
|
611
|
+
durationMs: Math.max(0, atMs - run.startedAt),
|
|
612
|
+
source: run.source,
|
|
613
|
+
instant: forced || !run.animated,
|
|
614
|
+
atMs,
|
|
615
|
+
changed: true,
|
|
616
|
+
});
|
|
617
|
+
changeLiftReleaseAt = atMs + CHANGE_LIFT_BEAT_MS;
|
|
618
|
+
}
|
|
619
|
+
const glideFrom = new Vector3();
|
|
620
|
+
/** Shows `change` where it was, with `left` of the way still to go. */
|
|
621
|
+
function poseGlide(change, root, left) {
|
|
622
|
+
const from = change.from;
|
|
623
|
+
if (!from)
|
|
624
|
+
return;
|
|
625
|
+
glideFrom.set(...from.position);
|
|
626
|
+
setGlidePose(root, glideFrom, from.yaw, left, GLIDE_HOP_M * 4 * left * (1 - left));
|
|
627
|
+
}
|
|
628
|
+
/** Whether a move glides now: it has somewhere to glide from, and the animation plays. */
|
|
629
|
+
function glidePlays(change) {
|
|
630
|
+
return ((change.from !== null || change.edited === true) &&
|
|
631
|
+
!showsAtOnce() &&
|
|
632
|
+
!useViewer.getState().renderPaused);
|
|
633
|
+
}
|
|
634
|
+
/** The agent's moves wait for the reveal to be quiet; until then each piece holds at its old place. */
|
|
635
|
+
function queueChanges(changes) {
|
|
636
|
+
for (const change of changes) {
|
|
637
|
+
if (queuedChanges.has(change.id) || glides.has(change.id))
|
|
638
|
+
continue;
|
|
639
|
+
queuedChanges.add(change.id);
|
|
640
|
+
changeQueue.push(change);
|
|
641
|
+
const root = sceneRegistry.nodes.get(change.id);
|
|
642
|
+
if (root && glidePlays(change))
|
|
643
|
+
poseGlide(change, root, 1);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
/** Takes the glide pose off a piece that will not glide (it has gone, or the animation has been turned off). */
|
|
647
|
+
function dropGlidePose(id) {
|
|
648
|
+
const root = sceneRegistry.nodes.get(id);
|
|
649
|
+
if (root && hasRevealPose(root))
|
|
650
|
+
clearRevealPose(root);
|
|
651
|
+
}
|
|
652
|
+
/** The reveal is quiet: the queued moves begin, a beat apart, or are said at once where they cannot glide. */
|
|
653
|
+
function flushChanges(now) {
|
|
654
|
+
const nodes = useScene.getState().nodes;
|
|
655
|
+
const waiting = changeQueue.filter((change) => nodes[change.id]);
|
|
656
|
+
for (const change of changeQueue)
|
|
657
|
+
if (!nodes[change.id])
|
|
658
|
+
dropGlidePose(change.id);
|
|
659
|
+
changeQueue = [];
|
|
660
|
+
queuedChanges.clear();
|
|
661
|
+
if (waiting.length === 0)
|
|
662
|
+
return;
|
|
663
|
+
registerChanges(waiting, now);
|
|
664
|
+
const atOnce = [];
|
|
665
|
+
// However many move, they begin spread over the same time, never piled on the last frame.
|
|
666
|
+
const stagger = Math.min(GLIDE_STAGGER_MS, GLIDE_STAGGER_CAP_MS / waiting.length);
|
|
667
|
+
let delay = 0;
|
|
668
|
+
for (const change of waiting) {
|
|
669
|
+
const root = sceneRegistry.nodes.get(change.id);
|
|
670
|
+
if (root && glidePlays(change)) {
|
|
671
|
+
poseGlide(change, root, 1);
|
|
672
|
+
glides.set(change.id, {
|
|
673
|
+
change,
|
|
674
|
+
startAt: now + delay,
|
|
675
|
+
state: { x: 1, v: 0 },
|
|
676
|
+
began: false,
|
|
677
|
+
});
|
|
678
|
+
delay += stagger;
|
|
679
|
+
const group = changeGroups.get(changeGroupOf.get(change.id) ?? '');
|
|
680
|
+
if (group)
|
|
681
|
+
group.animated = true;
|
|
682
|
+
if (changeRun)
|
|
683
|
+
changeRun.animated = true;
|
|
684
|
+
}
|
|
685
|
+
else {
|
|
686
|
+
dropGlidePose(change.id);
|
|
687
|
+
atOnce.push(change);
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
for (const change of atOnce)
|
|
691
|
+
beginChange(change, now);
|
|
692
|
+
for (const change of atOnce)
|
|
693
|
+
endChange(change.id, now, false);
|
|
694
|
+
}
|
|
695
|
+
/** An edited node plays again in this share of the time its first appearance took. */
|
|
696
|
+
const EDIT_SHARE = 0.55;
|
|
697
|
+
/** An edited node begins its quick re-rise, at the person's animation speed. */
|
|
698
|
+
function startEdit(change, now) {
|
|
699
|
+
beginChange(change, now);
|
|
700
|
+
const speed = currentSpeed();
|
|
701
|
+
const lazy = lazyOn();
|
|
702
|
+
const timing = lazy ? lazyTimingAt(speed) : revealTimingAt(speed);
|
|
703
|
+
active.set(change.id, {
|
|
704
|
+
id: change.id,
|
|
705
|
+
candidate: change,
|
|
706
|
+
style: change.style,
|
|
707
|
+
height: change.height,
|
|
708
|
+
levelId: change.levelId,
|
|
709
|
+
startedAt: now,
|
|
710
|
+
durationMs: styleMs(timing.durationMs, change.style) * EDIT_SHARE,
|
|
711
|
+
assembleMs: 0,
|
|
712
|
+
clockStart: null,
|
|
713
|
+
pivot: null,
|
|
714
|
+
base: null,
|
|
715
|
+
landed: false,
|
|
716
|
+
root: null,
|
|
717
|
+
lead: 0,
|
|
718
|
+
weight: 1,
|
|
719
|
+
overshoot: lazy && change.style in LAZY_REVEAL_TIMING.overshoot
|
|
720
|
+
? LAZY_REVEAL_TIMING.overshoot[change.style]
|
|
721
|
+
: 0,
|
|
722
|
+
finale: false,
|
|
723
|
+
finaleSaid: false,
|
|
724
|
+
edit: true,
|
|
725
|
+
});
|
|
726
|
+
}
|
|
727
|
+
function stepGlides(now) {
|
|
728
|
+
const dt = Math.min(0.05, Math.max(0, (now - glideClockMs) / 1000));
|
|
729
|
+
glideClockMs = now;
|
|
730
|
+
const nodes = useScene.getState().nodes;
|
|
731
|
+
for (const glide of [...glides.values()]) {
|
|
732
|
+
const { change } = glide;
|
|
733
|
+
const root = sceneRegistry.nodes.get(change.id);
|
|
734
|
+
if (!nodes[change.id]) {
|
|
735
|
+
glides.delete(change.id);
|
|
736
|
+
endChange(change.id, now, true);
|
|
737
|
+
continue;
|
|
738
|
+
}
|
|
739
|
+
if (change.edited) {
|
|
740
|
+
if (now < glide.startAt)
|
|
741
|
+
continue;
|
|
742
|
+
glides.delete(change.id);
|
|
743
|
+
startEdit(change, now);
|
|
744
|
+
continue;
|
|
745
|
+
}
|
|
746
|
+
if (!glide.began) {
|
|
747
|
+
if (now < glide.startAt) {
|
|
748
|
+
if (root)
|
|
749
|
+
poseGlide(change, root, 1);
|
|
750
|
+
continue;
|
|
751
|
+
}
|
|
752
|
+
glide.began = true;
|
|
753
|
+
beginChange(change, now);
|
|
754
|
+
}
|
|
755
|
+
criticallyDamped(glide.state, 0, GLIDE_RESPONSE_S, dt);
|
|
756
|
+
const left = Math.max(0, glide.state.x);
|
|
757
|
+
if (left < 0.003 && Math.abs(glide.state.v) < 0.05) {
|
|
758
|
+
glides.delete(change.id);
|
|
759
|
+
dropGlidePose(change.id);
|
|
760
|
+
endChange(change.id, now, false);
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
if (root)
|
|
764
|
+
poseGlide(change, root, left);
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
/** A glide is cut short (selected, retracted, the reveal stopped): the piece is home at once. */
|
|
768
|
+
function finishGlide(id) {
|
|
769
|
+
if (queuedChanges.delete(id)) {
|
|
770
|
+
changeQueue = changeQueue.filter((change) => change.id !== id);
|
|
771
|
+
dropGlidePose(id);
|
|
772
|
+
return;
|
|
773
|
+
}
|
|
774
|
+
if (!glides.delete(id))
|
|
775
|
+
return;
|
|
776
|
+
dropGlidePose(id);
|
|
777
|
+
endChange(id, clockMs, true);
|
|
778
|
+
}
|
|
779
|
+
function finishChanges() {
|
|
780
|
+
for (const id of [...queuedChanges])
|
|
781
|
+
finishGlide(id);
|
|
782
|
+
for (const id of [...glides.keys()])
|
|
783
|
+
finishGlide(id);
|
|
784
|
+
changeLiftReleaseAt = null;
|
|
785
|
+
}
|
|
786
|
+
/** One event when each (phase, level) group of a reveal starts — at most one sound cue each. */
|
|
787
|
+
export function subscribeRevealPhases(listener) {
|
|
788
|
+
phaseListeners.add(listener);
|
|
789
|
+
return () => {
|
|
790
|
+
phaseListeners.delete(listener);
|
|
791
|
+
};
|
|
792
|
+
}
|
|
793
|
+
function announce(candidate) {
|
|
794
|
+
const group = revealGroupKey(candidate);
|
|
795
|
+
if (announced.has(group))
|
|
796
|
+
return;
|
|
797
|
+
announced.add(group);
|
|
798
|
+
const event = { phase: candidate.phase, levelId: candidate.levelId };
|
|
799
|
+
for (const listener of [...phaseListeners]) {
|
|
800
|
+
try {
|
|
801
|
+
listener(event);
|
|
802
|
+
}
|
|
803
|
+
catch (error) {
|
|
804
|
+
console.error('[viewer] reveal phase listener failed', error);
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
/** What the commit stages, and the parts it adds to a node still assembling from an earlier one. */
|
|
809
|
+
function revealCandidates(commit, at) {
|
|
810
|
+
const candidates = commitRevealCandidates(commit, at, arrivals);
|
|
811
|
+
arrivals += candidates.length;
|
|
812
|
+
// A kind that declares no reveal may have come in on its own (a default reveal): on a host still
|
|
813
|
+
// assembling, it is one of its parts instead; on a host still coming in, it comes in with it.
|
|
814
|
+
const staged = new Set(candidates.map((candidate) => candidate.id));
|
|
815
|
+
const replaced = new Set();
|
|
816
|
+
const { before, current } = commit;
|
|
817
|
+
for (const id in current.nodes) {
|
|
818
|
+
if (before.nodes[id])
|
|
819
|
+
continue;
|
|
820
|
+
const node = current.nodes[id];
|
|
821
|
+
if (!node || nodeRegistry.get(node.type)?.capabilities?.reveal)
|
|
822
|
+
continue;
|
|
823
|
+
const hostId = node.parentId;
|
|
824
|
+
const host = hostId ? (pending.get(hostId) ?? active.get(hostId)?.candidate) : undefined;
|
|
825
|
+
// Under a host still waiting or moving in, it is drawn inside it and comes in with it.
|
|
826
|
+
if (staged.has(id) && hostId && (pending.has(hostId) || active.has(hostId)))
|
|
827
|
+
replaced.add(id);
|
|
828
|
+
if (host?.style === 'assemble') {
|
|
829
|
+
candidates.push({
|
|
830
|
+
...host,
|
|
831
|
+
id,
|
|
832
|
+
style: 'drop',
|
|
833
|
+
depth: host.depth + 1,
|
|
834
|
+
seq: arrivals++,
|
|
835
|
+
host: host.id,
|
|
836
|
+
lead: PART_LEAD,
|
|
837
|
+
});
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
if (replaced.size === 0)
|
|
841
|
+
return candidates;
|
|
842
|
+
return candidates.filter((candidate) => !(replaced.has(candidate.id) && candidate.host === undefined));
|
|
843
|
+
}
|
|
844
|
+
/** The author's own eye on a level (the sidebar's) is the one thing that keeps its work out of sight. */
|
|
845
|
+
function levelHiddenByAuthor(levelId) {
|
|
846
|
+
if (!levelId)
|
|
847
|
+
return false;
|
|
848
|
+
const level = useScene.getState().nodes[levelId];
|
|
849
|
+
return level?.type === 'level' && level.visible === false;
|
|
850
|
+
}
|
|
851
|
+
/** The levels the reveal has work on: what waits, moves, goes out or is carried to a new place. */
|
|
852
|
+
function levelsAtWork() {
|
|
853
|
+
const levels = new Set();
|
|
854
|
+
const add = (levelId) => {
|
|
855
|
+
if (levelId)
|
|
856
|
+
levels.add(levelId);
|
|
857
|
+
};
|
|
858
|
+
for (const candidate of pending.values())
|
|
859
|
+
add(candidate.levelId);
|
|
860
|
+
for (const reveal of active.values())
|
|
861
|
+
add(reveal.levelId);
|
|
862
|
+
for (const retract of retracts.values())
|
|
863
|
+
add(retract.group.levelId);
|
|
864
|
+
for (const glide of glides.values())
|
|
865
|
+
add(glide.change.levelId);
|
|
866
|
+
for (const change of changeQueue)
|
|
867
|
+
add(change.levelId);
|
|
868
|
+
for (const ghost of ghosts.values())
|
|
869
|
+
add(ghost.levelId);
|
|
870
|
+
for (const levelId of levelHolds.keys())
|
|
871
|
+
add(levelId);
|
|
872
|
+
return [...levels];
|
|
873
|
+
}
|
|
874
|
+
/**
|
|
875
|
+
* The nodes the agent changed that the reveal has not finished with: pieces moved or edited (not
|
|
876
|
+
* built, not going out), waiting for their turn, carried or replaying. What a commit that edits and
|
|
877
|
+
* moves is made of, read before its first piece plays.
|
|
878
|
+
*/
|
|
879
|
+
export function revealChangedNodeIds() {
|
|
880
|
+
const ids = new Set(queuedChanges);
|
|
881
|
+
for (const change of changeQueue)
|
|
882
|
+
ids.add(change.id);
|
|
883
|
+
for (const id of glides.keys())
|
|
884
|
+
ids.add(id);
|
|
885
|
+
for (const reveal of active.values())
|
|
886
|
+
if (reveal.edit)
|
|
887
|
+
ids.add(reveal.id);
|
|
888
|
+
return [...ids];
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Tells the viewer which levels the reveal is working on, so each shows while its work plays: a build
|
|
892
|
+
* on the storey above the one the person has selected, or in the solo view of another, is seen, not
|
|
893
|
+
* played to nobody. The person's own choice of level is left as it is; the level steps aside again
|
|
894
|
+
* when its work is done.
|
|
895
|
+
*/
|
|
896
|
+
function publishLevelsAtWork() {
|
|
897
|
+
publishNodesAtPlay();
|
|
898
|
+
const next = levelsAtWork();
|
|
899
|
+
const current = useViewer.getState().levelsRevealing;
|
|
900
|
+
if (next.length === current.length && next.every((levelId) => current.includes(levelId)))
|
|
901
|
+
return;
|
|
902
|
+
useViewer.setState({ levelsRevealing: next });
|
|
903
|
+
}
|
|
904
|
+
/**
|
|
905
|
+
* The nodes playing now: moving in, going out, carried to a new place, or an edit waiting its turn
|
|
906
|
+
* with its new picture held. The cutaway leaves these, and what they host, in view
|
|
907
|
+
* (`nodesRevealing`); a node waiting for its turn is not drawn yet, so it is not among them. A
|
|
908
|
+
* deleted node's picture plays from a snapshot, out of the scene: what it leaves stands for it.
|
|
909
|
+
*/
|
|
910
|
+
function nodesAtPlay() {
|
|
911
|
+
const ids = new Set();
|
|
912
|
+
for (const id of active.keys())
|
|
913
|
+
ids.add(id);
|
|
914
|
+
for (const id of glides.keys())
|
|
915
|
+
ids.add(id);
|
|
916
|
+
for (const id of retracts.keys())
|
|
917
|
+
ids.add(id);
|
|
918
|
+
for (const id of queuedChanges)
|
|
919
|
+
ids.add(id);
|
|
920
|
+
for (const ghost of ghosts.values())
|
|
921
|
+
if (ghost.host)
|
|
922
|
+
ids.add(ghost.host);
|
|
923
|
+
return [...ids];
|
|
924
|
+
}
|
|
925
|
+
function publishNodesAtPlay() {
|
|
926
|
+
const next = nodesAtPlay();
|
|
927
|
+
const current = useViewer.getState().nodesRevealing;
|
|
928
|
+
if (next.length === current.length && next.every((id) => current.includes(id)))
|
|
929
|
+
return;
|
|
930
|
+
useViewer.setState({ nodesRevealing: next });
|
|
931
|
+
}
|
|
932
|
+
function endReveal(reveal, forced = false) {
|
|
933
|
+
if (reveal.root) {
|
|
934
|
+
clearRevealPose(reveal.root);
|
|
935
|
+
clearRiseTextureScale(reveal.root);
|
|
936
|
+
}
|
|
937
|
+
active.delete(reveal.id);
|
|
938
|
+
if (reveal.edit)
|
|
939
|
+
return endChange(reveal.id, clockMs, forced);
|
|
940
|
+
settle(reveal.id);
|
|
941
|
+
closeNode(reveal.id, clockMs, forced);
|
|
942
|
+
}
|
|
943
|
+
/** `id` no longer reveals: its host may now be whole, and done. */
|
|
944
|
+
function settle(id) {
|
|
945
|
+
const hostId = hostOf.get(id);
|
|
946
|
+
if (hostId === undefined)
|
|
947
|
+
return;
|
|
948
|
+
hostOf.delete(id);
|
|
949
|
+
const own = parts.get(hostId);
|
|
950
|
+
if (!own?.delete(id) || own.size > 0)
|
|
951
|
+
return;
|
|
952
|
+
parts.delete(hostId);
|
|
953
|
+
const host = active.get(hostId);
|
|
954
|
+
if (host)
|
|
955
|
+
endReveal(host);
|
|
956
|
+
notifyAssembly(hostId, false);
|
|
957
|
+
}
|
|
958
|
+
function startReveal(candidate, now, animate) {
|
|
959
|
+
if (!pending.delete(candidate.id))
|
|
960
|
+
return;
|
|
961
|
+
planned.delete(candidate.id);
|
|
962
|
+
// A node shows only inside its host: a host still waiting starts with it.
|
|
963
|
+
const nodes = useScene.getState().nodes;
|
|
964
|
+
let parentId = nodes[candidate.id]?.parentId;
|
|
965
|
+
for (let guard = 0; parentId && guard < 32; guard += 1) {
|
|
966
|
+
const host = pending.get(parentId);
|
|
967
|
+
if (host)
|
|
968
|
+
startReveal(host, now, animate);
|
|
969
|
+
parentId = nodes[parentId]?.parentId;
|
|
970
|
+
}
|
|
971
|
+
released(candidate);
|
|
972
|
+
const wasWarm = restoreWarm(candidate.id);
|
|
973
|
+
if (wasWarm && !(animate && candidate.style !== 'assemble')) {
|
|
974
|
+
// Shown at once, or held still while its parts drop in: neither sets the pose the warm start left.
|
|
975
|
+
const root = sceneRegistry.nodes.get(candidate.id);
|
|
976
|
+
if (root)
|
|
977
|
+
clearRevealPose(root);
|
|
978
|
+
}
|
|
979
|
+
if (!animate) {
|
|
980
|
+
settle(candidate.id);
|
|
981
|
+
return closeNode(candidate.id, now, false);
|
|
982
|
+
}
|
|
983
|
+
lastStartMs = now;
|
|
984
|
+
announce(candidate);
|
|
985
|
+
groupReleased(candidate);
|
|
986
|
+
// The lazy schedule overdoes it a little: higher falls, an anticipation, an overshoot.
|
|
987
|
+
const lazy = lazyOn();
|
|
988
|
+
const timing = lazy ? lazyTimingAt(planSpeed) : revealTimingAt(planSpeed);
|
|
989
|
+
active.set(candidate.id, {
|
|
990
|
+
id: candidate.id,
|
|
991
|
+
candidate,
|
|
992
|
+
style: candidate.style,
|
|
993
|
+
height: lazy
|
|
994
|
+
? Math.max(candidate.height, LAZY_REVEAL_TIMING.minHeightM[candidate.style] ?? 0)
|
|
995
|
+
: candidate.height,
|
|
996
|
+
levelId: candidate.levelId,
|
|
997
|
+
startedAt: now,
|
|
998
|
+
durationMs: styleMs(timing.durationMs, candidate.style),
|
|
999
|
+
assembleMs: revealTimingAt(planSpeed).durationMs.drop,
|
|
1000
|
+
...(candidate.style === 'cover' ? { coverRange: coverRangeAt(planSpeed) } : {}),
|
|
1001
|
+
clockStart: null,
|
|
1002
|
+
pivot: null,
|
|
1003
|
+
base: null,
|
|
1004
|
+
landed: false,
|
|
1005
|
+
root: null,
|
|
1006
|
+
lead: lazy && candidate.style === 'drop'
|
|
1007
|
+
? Math.max(candidate.lead ?? 0, LAZY_DROP_LEAD)
|
|
1008
|
+
: (candidate.lead ?? 0),
|
|
1009
|
+
overshoot: lazy && candidate.style in LAZY_REVEAL_TIMING.overshoot
|
|
1010
|
+
? LAZY_REVEAL_TIMING.overshoot[candidate.style]
|
|
1011
|
+
: 0,
|
|
1012
|
+
weight: 'finale' in candidate && candidate.finale ? FINALE_WEIGHT : 1,
|
|
1013
|
+
finale: 'finale' in candidate && candidate.finale === true,
|
|
1014
|
+
finaleSaid: false,
|
|
1015
|
+
});
|
|
1016
|
+
}
|
|
1017
|
+
const dustOn = () => dust !== null && revealLevel() !== 'simple';
|
|
1018
|
+
/** A rising wall's puff, along the longer side of its base. */
|
|
1019
|
+
function puffAlongBase(reveal, root, now) {
|
|
1020
|
+
const box = reveal.base;
|
|
1021
|
+
if (!(dust && dustOn() && box) || box.isEmpty())
|
|
1022
|
+
return;
|
|
1023
|
+
const midX = (box.min.x + box.max.x) / 2;
|
|
1024
|
+
const midZ = (box.min.z + box.max.z) / 2;
|
|
1025
|
+
if (box.max.x - box.min.x >= box.max.z - box.min.z) {
|
|
1026
|
+
lineFrom.set(box.min.x, box.min.y, midZ);
|
|
1027
|
+
lineTo.set(box.max.x, box.min.y, midZ);
|
|
1028
|
+
}
|
|
1029
|
+
else {
|
|
1030
|
+
lineFrom.set(midX, box.min.y, box.min.z);
|
|
1031
|
+
lineTo.set(midX, box.min.y, box.max.z);
|
|
1032
|
+
}
|
|
1033
|
+
root.updateWorldMatrix(true, false);
|
|
1034
|
+
emitAlongBase(dust, lineFrom.applyMatrix4(root.matrixWorld), lineTo.applyMatrix4(root.matrixWorld), now);
|
|
1035
|
+
}
|
|
1036
|
+
/** A landing's puff, around its foot. */
|
|
1037
|
+
function puffAroundFoot(reveal, root, now) {
|
|
1038
|
+
const box = reveal.base;
|
|
1039
|
+
if (!(dust && dustOn() && box) || box.isEmpty())
|
|
1040
|
+
return;
|
|
1041
|
+
root.updateWorldMatrix(true, false);
|
|
1042
|
+
foot[0].set(box.min.x, box.min.y, box.min.z).applyMatrix4(root.matrixWorld);
|
|
1043
|
+
foot[1].set(box.max.x, box.min.y, box.min.z).applyMatrix4(root.matrixWorld);
|
|
1044
|
+
foot[2].set(box.max.x, box.min.y, box.max.z).applyMatrix4(root.matrixWorld);
|
|
1045
|
+
foot[3].set(box.min.x, box.min.y, box.max.z).applyMatrix4(root.matrixWorld);
|
|
1046
|
+
const strength = Math.min(1, Math.max(0.25, reveal.height / FULL_DUST_DROP_M)) * reveal.weight;
|
|
1047
|
+
emitAroundFoot(dust, foot, strength, now);
|
|
1048
|
+
}
|
|
1049
|
+
function stepReveal(reveal, now) {
|
|
1050
|
+
const { nodes, dirtyNodes } = useScene.getState();
|
|
1051
|
+
if (!nodes[reveal.id] || levelHiddenByAuthor(reveal.levelId))
|
|
1052
|
+
return endReveal(reveal);
|
|
1053
|
+
const root = sceneRegistry.nodes.get(reveal.id);
|
|
1054
|
+
if (!root) {
|
|
1055
|
+
if (now - reveal.startedAt > MOUNT_TIMEOUT_MS)
|
|
1056
|
+
endReveal(reveal);
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (reveal.root !== root) {
|
|
1060
|
+
if (reveal.root) {
|
|
1061
|
+
clearRevealPose(reveal.root);
|
|
1062
|
+
clearRiseTextureScale(reveal.root);
|
|
1063
|
+
}
|
|
1064
|
+
reveal.root = root;
|
|
1065
|
+
}
|
|
1066
|
+
// An assembled node holds still; it is done once its last part has landed.
|
|
1067
|
+
if (reveal.style === 'assemble') {
|
|
1068
|
+
if (!isRevealAssembling(reveal.id))
|
|
1069
|
+
endReveal(reveal);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
if (reveal.clockStart === null) {
|
|
1073
|
+
// Hidden until its first build lands, so it moves in from nothing rather
|
|
1074
|
+
// than popping in at full size mid-animation.
|
|
1075
|
+
if (dirtyNodes.has(reveal.id)) {
|
|
1076
|
+
if (now - Math.max(reveal.startedAt, buildLandedMs) < BUILD_TIMEOUT_MS) {
|
|
1077
|
+
setRevealPose(root, ORIGIN, HIDDEN);
|
|
1078
|
+
return;
|
|
1079
|
+
}
|
|
1080
|
+
}
|
|
1081
|
+
else
|
|
1082
|
+
buildLandedMs = now;
|
|
1083
|
+
reveal.clockStart = now;
|
|
1084
|
+
reveal.base = revealLocalBounds(root);
|
|
1085
|
+
reveal.pivot = revealPivot(reveal.base, reveal.style);
|
|
1086
|
+
revealPoseAt(reveal.style, 0, reveal.height, sample, reveal);
|
|
1087
|
+
setRevealPose(root, reveal.pivot, sample.scale, sample.lift);
|
|
1088
|
+
if (reveal.style === 'cover') {
|
|
1089
|
+
// The covering takes as long as what it covers is big, now that the roof is drawn.
|
|
1090
|
+
reveal.cover = planCover(root, reveal.coverRange ?? coverRangeAt(planSpeed));
|
|
1091
|
+
reveal.durationMs = reveal.cover.durationMs;
|
|
1092
|
+
layCover(root, reveal.cover, 0);
|
|
1093
|
+
}
|
|
1094
|
+
if (!reveal.edit)
|
|
1095
|
+
groupStarted(reveal.candidate, now);
|
|
1096
|
+
if (reveal.style === 'rise')
|
|
1097
|
+
puffAlongBase(reveal, root, now);
|
|
1098
|
+
}
|
|
1099
|
+
const t = (now - reveal.clockStart) / reveal.durationMs;
|
|
1100
|
+
if (t < 1) {
|
|
1101
|
+
revealPoseAt(reveal.style, t, reveal.height, sample, reveal);
|
|
1102
|
+
setRevealPose(root, reveal.pivot ?? ORIGIN, sample.scale, sample.lift);
|
|
1103
|
+
if (reveal.style === 'rise')
|
|
1104
|
+
setRiseTextureScale(root, sample.scale.y, reveal.pivot?.y ?? 0);
|
|
1105
|
+
if (reveal.cover)
|
|
1106
|
+
layCover(root, reveal.cover, now - reveal.clockStart);
|
|
1107
|
+
}
|
|
1108
|
+
if (!reveal.landed && revealHasLanded(reveal.style, t)) {
|
|
1109
|
+
reveal.landed = true;
|
|
1110
|
+
if (!reveal.edit)
|
|
1111
|
+
groupLanded(reveal.id, now);
|
|
1112
|
+
if (reveal.finale && !reveal.finaleSaid && t >= REVEAL_ARRIVAL[reveal.style])
|
|
1113
|
+
sayFinale(reveal, now);
|
|
1114
|
+
if (reveal.style === 'drop')
|
|
1115
|
+
puffAroundFoot(reveal, root, now);
|
|
1116
|
+
}
|
|
1117
|
+
if (reveal.finale && !reveal.finaleSaid && t >= REVEAL_ARRIVAL[reveal.style])
|
|
1118
|
+
sayFinale(reveal, now);
|
|
1119
|
+
if (t >= 1)
|
|
1120
|
+
endReveal(reveal);
|
|
1121
|
+
}
|
|
1122
|
+
const climbToBuilding = (id) => {
|
|
1123
|
+
const nodes = useScene.getState().nodes;
|
|
1124
|
+
let node = nodes[id];
|
|
1125
|
+
for (let guard = 0; node && guard < 32; guard += 1) {
|
|
1126
|
+
if (node.type === 'building')
|
|
1127
|
+
return node.id;
|
|
1128
|
+
node = node.parentId ? nodes[node.parentId] : undefined;
|
|
1129
|
+
}
|
|
1130
|
+
return null;
|
|
1131
|
+
};
|
|
1132
|
+
/** Whether the plan is the lazy, nested one: the framing level. */
|
|
1133
|
+
const lazyOn = () => revealLevel() === 'framing';
|
|
1134
|
+
/** Whether lifts play at the person's level: the full construction only. */
|
|
1135
|
+
const liftsPlay = () => revealLevel() === 'full' || revealLevel() === 'framing';
|
|
1136
|
+
/**
|
|
1137
|
+
* A phase starts: what declares it clears for it (a roof, for the furnishing) in the same building
|
|
1138
|
+
* asks to lift. It lifts as soon as it is built and done revealing.
|
|
1139
|
+
*/
|
|
1140
|
+
function wantLifts(phase, levelId, skip) {
|
|
1141
|
+
if (!liftsPlay())
|
|
1142
|
+
return;
|
|
1143
|
+
const nodes = useScene.getState().nodes;
|
|
1144
|
+
const building = levelId ? climbToBuilding(levelId) : null;
|
|
1145
|
+
for (const id in nodes) {
|
|
1146
|
+
if (skip?.has(id))
|
|
1147
|
+
continue;
|
|
1148
|
+
const node = nodes[id];
|
|
1149
|
+
const clears = node && nodeRegistry.get(node.type)?.capabilities?.reveal?.clears;
|
|
1150
|
+
if (!clears || clears.for !== phase)
|
|
1151
|
+
continue;
|
|
1152
|
+
if (building && climbToBuilding(id) !== building)
|
|
1153
|
+
continue;
|
|
1154
|
+
const held = lifts.get(id);
|
|
1155
|
+
if (held) {
|
|
1156
|
+
// More of the phase is coming: it stays where it is.
|
|
1157
|
+
held.releaseAt = null;
|
|
1158
|
+
held.target = 1;
|
|
1159
|
+
continue;
|
|
1160
|
+
}
|
|
1161
|
+
liftWanted.set(id, { phase, levelId, height: clears.height });
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* The last group of `phase` has settled (or been taken back): what was lifted for it comes back after
|
|
1166
|
+
* `holdMs`, in case more of the phase is coming.
|
|
1167
|
+
*/
|
|
1168
|
+
function releaseLifts(phase, atMs, holdMs = LIFT_HOLD_MS) {
|
|
1169
|
+
for (const group of groups.values())
|
|
1170
|
+
if (group.phase === phase)
|
|
1171
|
+
return;
|
|
1172
|
+
for (const retract of retracts.values())
|
|
1173
|
+
if (retract.group.phase === phase)
|
|
1174
|
+
return;
|
|
1175
|
+
for (const [id, want] of [...liftWanted])
|
|
1176
|
+
if (want.phase === phase)
|
|
1177
|
+
liftWanted.delete(id);
|
|
1178
|
+
for (const lift of lifts.values()) {
|
|
1179
|
+
if (lift.phase === phase && lift.releaseAt === null)
|
|
1180
|
+
lift.releaseAt = atMs + holdMs;
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
/**
|
|
1184
|
+
* The build is over (the agent's turn has ended): what lifted out of the way comes back down after
|
|
1185
|
+
* a beat, without waiting out the hold. Nothing happens when nothing is up.
|
|
1186
|
+
*/
|
|
1187
|
+
export function seatLiftedNow() {
|
|
1188
|
+
liftWanted.clear();
|
|
1189
|
+
changeLiftReleaseAt = null;
|
|
1190
|
+
for (const lift of lifts.values()) {
|
|
1191
|
+
lift.releaseAt = Math.min(lift.releaseAt ?? Number.POSITIVE_INFINITY, liftClockMs + LIFT_BEAT_MS);
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
/** Seats `lift` exactly where it was, whole again. */
|
|
1195
|
+
function seatLift(lift, atMs, say) {
|
|
1196
|
+
if (lift.root) {
|
|
1197
|
+
clearRevealPose(lift.root);
|
|
1198
|
+
clearGhost(lift.root);
|
|
1199
|
+
}
|
|
1200
|
+
lifts.delete(lift.id);
|
|
1201
|
+
if (!(say && deferredFinale))
|
|
1202
|
+
return;
|
|
1203
|
+
const finale = deferredFinale;
|
|
1204
|
+
deferredFinale = null;
|
|
1205
|
+
emit({ ...finale, id: lift.id, atMs });
|
|
1206
|
+
}
|
|
1207
|
+
function stepLifts(now) {
|
|
1208
|
+
const dt = Math.min(0.05, Math.max(0, (now - liftClockMs) / 1000));
|
|
1209
|
+
liftClockMs = now;
|
|
1210
|
+
if (changeLiftReleaseAt !== null && now >= changeLiftReleaseAt && glides.size === 0) {
|
|
1211
|
+
changeLiftReleaseAt = null;
|
|
1212
|
+
releaseLifts('furnishing', now);
|
|
1213
|
+
}
|
|
1214
|
+
const nodes = useScene.getState().nodes;
|
|
1215
|
+
for (const [id, want] of [...liftWanted]) {
|
|
1216
|
+
if (!nodes[id]) {
|
|
1217
|
+
liftWanted.delete(id);
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
const root = sceneRegistry.nodes.get(id);
|
|
1221
|
+
if (!root || isNodeRevealing(id))
|
|
1222
|
+
continue;
|
|
1223
|
+
liftWanted.delete(id);
|
|
1224
|
+
lifts.set(id, {
|
|
1225
|
+
id,
|
|
1226
|
+
phase: want.phase,
|
|
1227
|
+
levelId: want.levelId,
|
|
1228
|
+
height: want.height,
|
|
1229
|
+
state: { x: 0, v: 0 },
|
|
1230
|
+
target: 1,
|
|
1231
|
+
releaseAt: null,
|
|
1232
|
+
root,
|
|
1233
|
+
// Ghosted ahead of the furniture already: its shaders have drawn.
|
|
1234
|
+
warm: ghostWarming.delete(id) ? 0 : LIFT_WARM_TICKS,
|
|
1235
|
+
});
|
|
1236
|
+
}
|
|
1237
|
+
for (const lift of [...lifts.values()]) {
|
|
1238
|
+
if (!nodes[lift.id]) {
|
|
1239
|
+
lifts.delete(lift.id);
|
|
1240
|
+
continue;
|
|
1241
|
+
}
|
|
1242
|
+
lift.root = sceneRegistry.nodes.get(lift.id) ?? lift.root;
|
|
1243
|
+
if (lift.warm > 0 && lift.root) {
|
|
1244
|
+
// The ghost's first draw pays for its shaders: it holds still until the frames are steady again.
|
|
1245
|
+
setGhost(lift.root, LIFT_WARM_OPACITY);
|
|
1246
|
+
if (frameQuiet)
|
|
1247
|
+
lift.warm -= 1;
|
|
1248
|
+
continue;
|
|
1249
|
+
}
|
|
1250
|
+
if (lift.releaseAt !== null && now >= lift.releaseAt)
|
|
1251
|
+
lift.target = 0;
|
|
1252
|
+
criticallyDamped(lift.state, lift.target, lift.target === 1 ? LIFT_RESPONSE_UP_S : LIFT_RESPONSE_DOWN_S, dt);
|
|
1253
|
+
const root = lift.root;
|
|
1254
|
+
if (!root)
|
|
1255
|
+
continue;
|
|
1256
|
+
if (lift.target === 0 && lift.state.x < 1e-3 && Math.abs(lift.state.v) < 1e-2) {
|
|
1257
|
+
seatLift(lift, now, true);
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
const x = Math.max(0, lift.state.x);
|
|
1261
|
+
setRevealPose(root, ORIGIN, UNIT, x * lift.height);
|
|
1262
|
+
// Never quite whole while it is up: whole would give the ghost's materials back, and their shaders with them.
|
|
1263
|
+
setGhost(root, Math.min(LIFT_WARM_OPACITY, 1 - (1 - LIFT_GHOST_MIN) * Math.min(1, x)));
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
/** Everything that is up comes down at once: a load, an undo, a capture. */
|
|
1267
|
+
function seatAllLifts() {
|
|
1268
|
+
for (const lift of [...lifts.values()])
|
|
1269
|
+
seatLift(lift, liftClockMs, false);
|
|
1270
|
+
liftWanted.clear();
|
|
1271
|
+
deferredFinale = null;
|
|
1272
|
+
}
|
|
1273
|
+
// ---------------------------------------------------------------------------------------------
|
|
1274
|
+
// The build in reverse. An undo of what an agent built plays the construction backwards: the host
|
|
1275
|
+
// asks `retractNodes` to take the nodes back, waits for it, and then removes them from the scene.
|
|
1276
|
+
// The nodes stay hidden (held) until they leave the scene, so no frame shows them whole between.
|
|
1277
|
+
// ---------------------------------------------------------------------------------------------
|
|
1278
|
+
/** A node that has gone and is not yet out of the scene is held gone this long, then comes back. */
|
|
1279
|
+
const RETRACT_HOLD_MS = 2000;
|
|
1280
|
+
/** A roof that lifts out of the way of the furniture going has this long to be most of the way up first. */
|
|
1281
|
+
const RETRACT_LIFT_LEAD_MS = 450;
|
|
1282
|
+
const retracts = new Map();
|
|
1283
|
+
const held = new Map();
|
|
1284
|
+
const retractSample = { lift: 0, scale: new Vector3(), opacity: 1 };
|
|
1285
|
+
/** Whether the host shows a build at once (animation off, reduced motion, the read-only viewer, an export). */
|
|
1286
|
+
let showsAtOnce = () => true;
|
|
1287
|
+
/** What lifts away as it goes fades with it; what shrinks does not. */
|
|
1288
|
+
const fadesAway = (style) => style === 'settle' || style === 'drop' || style === 'assemble';
|
|
1289
|
+
function emitRetract(type, group, atMs) {
|
|
1290
|
+
emit({
|
|
1291
|
+
type,
|
|
1292
|
+
phase: group.phase,
|
|
1293
|
+
levelId: group.levelId,
|
|
1294
|
+
nodeIds: [...group.ids],
|
|
1295
|
+
source: null,
|
|
1296
|
+
atMs,
|
|
1297
|
+
});
|
|
1298
|
+
}
|
|
1299
|
+
function dressHeld(hold, root) {
|
|
1300
|
+
hold.root = root;
|
|
1301
|
+
setRevealPose(root, hold.pivot, hold.scale, hold.lift);
|
|
1302
|
+
if (hold.fades)
|
|
1303
|
+
setGhost(root, 0);
|
|
1304
|
+
}
|
|
1305
|
+
function releaseHold(id, hold) {
|
|
1306
|
+
held.delete(id);
|
|
1307
|
+
notifyPending(id);
|
|
1308
|
+
if (!hold.root)
|
|
1309
|
+
return;
|
|
1310
|
+
clearRevealPose(hold.root);
|
|
1311
|
+
clearGhost(hold.root);
|
|
1312
|
+
}
|
|
1313
|
+
/** One piece has gone (or was cut short): its group ends with its last, and the run with its last group. */
|
|
1314
|
+
function retractedAway(retract, atMs) {
|
|
1315
|
+
if (!retracts.delete(retract.id))
|
|
1316
|
+
return;
|
|
1317
|
+
const { group, run } = retract;
|
|
1318
|
+
group.remaining -= 1;
|
|
1319
|
+
if (group.remaining === 0) {
|
|
1320
|
+
if (!group.started) {
|
|
1321
|
+
group.started = true;
|
|
1322
|
+
emitRetract('retract-start', group, atMs);
|
|
1323
|
+
}
|
|
1324
|
+
emitRetract('retract-end', group, atMs);
|
|
1325
|
+
// What lifted out of the way of this phase comes back down after a beat: there is no more of it.
|
|
1326
|
+
releaseLifts(group.phase, atMs, LIFT_BEAT_MS);
|
|
1327
|
+
}
|
|
1328
|
+
run.remaining -= 1;
|
|
1329
|
+
if (run.remaining === 0)
|
|
1330
|
+
run.resolve({ retracted: run.retracted, skipped: run.skipped, instant: false });
|
|
1331
|
+
}
|
|
1332
|
+
/** Holds `retract`'s node gone, at the pose it ends in, until the host takes it out of the scene. */
|
|
1333
|
+
function holdGone(retract, root, atMs) {
|
|
1334
|
+
const pivot = retract.pivot ?? revealPivot(revealLocalBounds(root), retract.style);
|
|
1335
|
+
const end = retractPoseAt(retract.style, 1, retract.height, retractSample);
|
|
1336
|
+
const hold = {
|
|
1337
|
+
root: null,
|
|
1338
|
+
style: retract.style,
|
|
1339
|
+
pivot,
|
|
1340
|
+
scale: end.scale.clone(),
|
|
1341
|
+
lift: retract.from.lift + end.lift,
|
|
1342
|
+
fades: fadesAway(retract.style),
|
|
1343
|
+
until: atMs + RETRACT_HOLD_MS,
|
|
1344
|
+
};
|
|
1345
|
+
held.set(retract.id, hold);
|
|
1346
|
+
dressHeld(hold, root);
|
|
1347
|
+
notifyPending(retract.id);
|
|
1348
|
+
}
|
|
1349
|
+
const ghosts = new Map();
|
|
1350
|
+
/**
|
|
1351
|
+
* Where a deleted node's snapshot stands: its own parent's object while that node stays in the
|
|
1352
|
+
* scene, else the nearest object above it that does (a deleted floor's pictures stand in their
|
|
1353
|
+
* building), so they outlive the object that held them.
|
|
1354
|
+
*/
|
|
1355
|
+
function ghostStand(id, commit, own) {
|
|
1356
|
+
const above = survivingAbove(id, commit);
|
|
1357
|
+
return (above ? sceneRegistry.nodes.get(above) : undefined) ?? own;
|
|
1358
|
+
}
|
|
1359
|
+
/** The nearest node above a deleted one that the commit leaves in the scene. */
|
|
1360
|
+
function survivingAbove(id, commit) {
|
|
1361
|
+
let above = commit.before.nodes[id]?.parentId;
|
|
1362
|
+
for (let guard = 0; above && !commit.current.nodes[above] && guard < 32; guard += 1)
|
|
1363
|
+
above = commit.before.nodes[above]?.parentId;
|
|
1364
|
+
return above ?? null;
|
|
1365
|
+
}
|
|
1366
|
+
/** The node a departing one hung on, when it stays: a storey or a building is not a host. */
|
|
1367
|
+
function ghostHost(id, commit) {
|
|
1368
|
+
const above = survivingAbove(id, commit);
|
|
1369
|
+
const type = above && commit.current.nodes[above]?.type;
|
|
1370
|
+
return above && type && !CONTAINERS.has(type) ? above : null;
|
|
1371
|
+
}
|
|
1372
|
+
/**
|
|
1373
|
+
* When a node that is going out (undone, or deleted) plays, on the reveal clock: its own going
|
|
1374
|
+
* starts at `startMs` and takes `durationMs`; a plugin that takes it apart plays `prerollMs` after.
|
|
1375
|
+
* Null when the node is not going.
|
|
1376
|
+
*/
|
|
1377
|
+
export function getRetractTurn(id) {
|
|
1378
|
+
const ghost = ghosts.get(id);
|
|
1379
|
+
if (ghost) {
|
|
1380
|
+
return { startMs: ghost.startMs, durationMs: ghost.durationMs, prerollMs: ghost.prerollMs ?? 0 };
|
|
1381
|
+
}
|
|
1382
|
+
const retract = retracts.get(id);
|
|
1383
|
+
if (!retract)
|
|
1384
|
+
return null;
|
|
1385
|
+
return {
|
|
1386
|
+
startMs: retract.startMs,
|
|
1387
|
+
durationMs: retract.durationMs,
|
|
1388
|
+
prerollMs: retract.prerollMs ?? 0,
|
|
1389
|
+
};
|
|
1390
|
+
}
|
|
1391
|
+
/** Draws the clone as the scene pass does: a batched source keeps no layer of its own. */
|
|
1392
|
+
function drawnInScenePass(root) {
|
|
1393
|
+
root.traverse((object) => {
|
|
1394
|
+
object.layers.set(SCENE_LAYER);
|
|
1395
|
+
});
|
|
1396
|
+
}
|
|
1397
|
+
/**
|
|
1398
|
+
* The reverse plan of `candidates` from `at`: at the Framing level the build's own plan read from its
|
|
1399
|
+
* end (the rooms come apart as they went up, a plugin's prerolls kept), else the Full rewind.
|
|
1400
|
+
*/
|
|
1401
|
+
function planBack(candidates, at, speed, nodes) {
|
|
1402
|
+
if (!lazyOn())
|
|
1403
|
+
return planRetract(candidates, at, retractTimingAt(speed), revealTimingAt(speed));
|
|
1404
|
+
const asked = candidates.map((candidate) => {
|
|
1405
|
+
let prerollMs = 0;
|
|
1406
|
+
try {
|
|
1407
|
+
prerollMs = prerollOf?.(candidate, nodes) ?? 0;
|
|
1408
|
+
}
|
|
1409
|
+
catch (error) {
|
|
1410
|
+
console.error('[viewer] reveal preroll failed', error);
|
|
1411
|
+
}
|
|
1412
|
+
return prerollMs > 0 ? { ...candidate, prerollMs } : candidate;
|
|
1413
|
+
});
|
|
1414
|
+
return planLazyRetract(asked, at, lazyRetractTimingAt(speed), lazyTimingAt(speed));
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* A commit deleted nodes: each one that stages a reveal and is on screen leaves a ghost that plays the
|
|
1418
|
+
* reveal backwards, staggered like a build in reverse. Nothing plays for an undo (it plays its own
|
|
1419
|
+
* reverse), a load, the animation off, or a level the author hid.
|
|
1420
|
+
*/
|
|
1421
|
+
function playDeletions(commit) {
|
|
1422
|
+
if (commit.origin !== 'local' || isRestoringSceneHistory() || showsAtOnce())
|
|
1423
|
+
return;
|
|
1424
|
+
if (useViewer.getState().renderPaused)
|
|
1425
|
+
return;
|
|
1426
|
+
const staged = commitDeleteCandidates(commit, revealLevel()).filter((candidate) => !(retracts.has(candidate.id) || held.has(candidate.id)) &&
|
|
1427
|
+
!levelHiddenByAuthor(candidate.levelId) &&
|
|
1428
|
+
sceneRegistry.nodes.get(candidate.id)?.parent);
|
|
1429
|
+
if (staged.length === 0)
|
|
1430
|
+
return;
|
|
1431
|
+
const plan = planBack(staged, clockMs, currentSpeed(), commit.before.nodes);
|
|
1432
|
+
const groupsByKey = new Map();
|
|
1433
|
+
for (const entry of plan) {
|
|
1434
|
+
const original = sceneRegistry.nodes.get(entry.id);
|
|
1435
|
+
const own = original?.parent;
|
|
1436
|
+
if (!(original && own))
|
|
1437
|
+
continue;
|
|
1438
|
+
const root = original.clone(true);
|
|
1439
|
+
drawnInScenePass(root);
|
|
1440
|
+
dressDeletionGhost(original, root);
|
|
1441
|
+
const parent = ghostStand(entry.id, commit, own);
|
|
1442
|
+
own.add(root);
|
|
1443
|
+
// A deleted floor's pieces would go with their floor's object: they stand in the building.
|
|
1444
|
+
if (parent !== own) {
|
|
1445
|
+
own.updateWorldMatrix(true, false);
|
|
1446
|
+
parent.attach(root);
|
|
1447
|
+
}
|
|
1448
|
+
const key = `${entry.phase}:${entry.levelId}`;
|
|
1449
|
+
let group = groupsByKey.get(key);
|
|
1450
|
+
if (!group) {
|
|
1451
|
+
group = { phase: entry.phase, levelId: entry.levelId, ids: [], remaining: 0, started: false };
|
|
1452
|
+
groupsByKey.set(key, group);
|
|
1453
|
+
}
|
|
1454
|
+
group.ids.push(entry.id);
|
|
1455
|
+
group.remaining += 1;
|
|
1456
|
+
const cover = entry.style === 'cover' ? planCover(root, coverRangeAt(currentSpeed(), true)) : undefined;
|
|
1457
|
+
ghosts.set(entry.id, {
|
|
1458
|
+
id: entry.id,
|
|
1459
|
+
levelId: entry.levelId,
|
|
1460
|
+
root,
|
|
1461
|
+
parent,
|
|
1462
|
+
host: ghostHost(entry.id, commit),
|
|
1463
|
+
style: entry.style,
|
|
1464
|
+
height: entry.height,
|
|
1465
|
+
startMs: entry.startMs,
|
|
1466
|
+
durationMs: cover?.durationMs ?? entry.durationMs,
|
|
1467
|
+
...(cover ? { cover } : {}),
|
|
1468
|
+
group,
|
|
1469
|
+
pivot: null,
|
|
1470
|
+
...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}),
|
|
1471
|
+
});
|
|
1472
|
+
}
|
|
1473
|
+
holdLevelsAbove(commit, new Set(plan.map((entry) => entry.id).filter((id) => ghosts.has(id))));
|
|
1474
|
+
}
|
|
1475
|
+
/** A ghost has gone (or was cut short): its picture is taken away, and its group ends with its last. */
|
|
1476
|
+
function ghostGone(ghost, atMs) {
|
|
1477
|
+
if (!ghosts.delete(ghost.id))
|
|
1478
|
+
return;
|
|
1479
|
+
clearRevealPose(ghost.root);
|
|
1480
|
+
clearGhost(ghost.root);
|
|
1481
|
+
ghost.parent.remove(ghost.root);
|
|
1482
|
+
const { group } = ghost;
|
|
1483
|
+
group.remaining -= 1;
|
|
1484
|
+
if (group.remaining > 0)
|
|
1485
|
+
return;
|
|
1486
|
+
if (!group.started) {
|
|
1487
|
+
group.started = true;
|
|
1488
|
+
emitRetract('retract-start', group, atMs);
|
|
1489
|
+
}
|
|
1490
|
+
emitRetract('retract-end', group, atMs);
|
|
1491
|
+
}
|
|
1492
|
+
function stepGhosts(now) {
|
|
1493
|
+
for (const ghost of [...ghosts.values()]) {
|
|
1494
|
+
if (now < ghost.startMs)
|
|
1495
|
+
continue;
|
|
1496
|
+
if (!ghost.pivot) {
|
|
1497
|
+
ghost.pivot = revealPivot(revealLocalBounds(ghost.root), ghost.style);
|
|
1498
|
+
if (!ghost.group.started) {
|
|
1499
|
+
ghost.group.started = true;
|
|
1500
|
+
emitRetract('retract-start', ghost.group, now);
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
const t = (now - ghost.startMs) / ghost.durationMs;
|
|
1504
|
+
const pose = retractPoseAt(ghost.style, t, ghost.height, retractSample);
|
|
1505
|
+
setRevealPose(ghost.root, ghost.pivot, pose.scale, pose.lift);
|
|
1506
|
+
if (ghost.cover)
|
|
1507
|
+
liftCover(ghost.root, ghost.cover, t);
|
|
1508
|
+
if (fadesAway(ghost.style))
|
|
1509
|
+
setGhost(ghost.root, pose.opacity);
|
|
1510
|
+
if (t >= 1)
|
|
1511
|
+
ghostGone(ghost, now);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
function finishGhosts() {
|
|
1515
|
+
for (const ghost of [...ghosts.values()])
|
|
1516
|
+
ghostGone(ghost, clockMs);
|
|
1517
|
+
}
|
|
1518
|
+
const levelHolds = new Map();
|
|
1519
|
+
const easeInOut = (t) => (t < 0.5 ? 4 * t * t * t : 1 - (-2 * t + 2) ** 3 / 2);
|
|
1520
|
+
/**
|
|
1521
|
+
* A commit deleted levels whose pictures now play: each level that stood above them, in the same
|
|
1522
|
+
* building, and whose height changes, keeps its place until they have gone. The stack's elevations
|
|
1523
|
+
* in the store are not written; only what is presented waits.
|
|
1524
|
+
*/
|
|
1525
|
+
function holdLevelsAbove(commit, ghostIds) {
|
|
1526
|
+
const before = commit.before.nodes;
|
|
1527
|
+
const current = commit.current.nodes;
|
|
1528
|
+
const stack = getLevelElevations(before);
|
|
1529
|
+
const going = [...stack].filter(([id]) => !current[id]);
|
|
1530
|
+
if (going.length === 0 || ghostIds.size === 0)
|
|
1531
|
+
return;
|
|
1532
|
+
const levelMode = useViewer.getState().levelMode;
|
|
1533
|
+
const dropMs = (lazyOn() ? lazyRetractTimingAt(currentSpeed()) : retractTimingAt(currentSpeed()))
|
|
1534
|
+
.durationMs.drop;
|
|
1535
|
+
for (const [id, above] of stack) {
|
|
1536
|
+
const node = current[id];
|
|
1537
|
+
if (node?.type !== 'level' || node.visible === false)
|
|
1538
|
+
continue;
|
|
1539
|
+
if (!going.some(([, gone]) => gone.buildingId === above.buildingId && gone.ordinal < above.ordinal))
|
|
1540
|
+
continue;
|
|
1541
|
+
const object = sceneRegistry.nodes.get(id);
|
|
1542
|
+
if (!object)
|
|
1543
|
+
continue;
|
|
1544
|
+
const standing = levelHolds.get(id);
|
|
1545
|
+
const heldY = standing?.heldY ?? object.position.y;
|
|
1546
|
+
if (Math.abs(heldY - getLevelPresentationY(id, current, levelMode)) < 0.01)
|
|
1547
|
+
continue;
|
|
1548
|
+
if (standing) {
|
|
1549
|
+
for (const ghostId of ghostIds)
|
|
1550
|
+
standing.ghostIds.add(ghostId);
|
|
1551
|
+
standing.lowering = null;
|
|
1552
|
+
continue;
|
|
1553
|
+
}
|
|
1554
|
+
levelHolds.set(id, { ghostIds: new Set(ghostIds), dropMs, heldY, lowering: null });
|
|
1555
|
+
setHeldLevelY(id, heldY);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/** Holds each level at its place until its floor has gone, then eases it down to its elevation. */
|
|
1559
|
+
function stepLevelHolds(now) {
|
|
1560
|
+
if (levelHolds.size === 0)
|
|
1561
|
+
return;
|
|
1562
|
+
const nodes = useScene.getState().nodes;
|
|
1563
|
+
const levelMode = useViewer.getState().levelMode;
|
|
1564
|
+
for (const [id, hold] of [...levelHolds]) {
|
|
1565
|
+
if (!nodes[id]) {
|
|
1566
|
+
levelHolds.delete(id);
|
|
1567
|
+
releaseLevel(id);
|
|
1568
|
+
continue;
|
|
1569
|
+
}
|
|
1570
|
+
if (!hold.lowering) {
|
|
1571
|
+
if ([...hold.ghostIds].some((ghostId) => ghosts.has(ghostId)))
|
|
1572
|
+
continue;
|
|
1573
|
+
hold.lowering = { startMs: now, fromY: hold.heldY };
|
|
1574
|
+
}
|
|
1575
|
+
const t = Math.min(1, (now - hold.lowering.startMs) / Math.max(1, hold.dropMs));
|
|
1576
|
+
if (t >= 1) {
|
|
1577
|
+
levelHolds.delete(id);
|
|
1578
|
+
releaseLevel(id);
|
|
1579
|
+
continue;
|
|
1580
|
+
}
|
|
1581
|
+
const to = getLevelPresentationY(id, nodes, levelMode);
|
|
1582
|
+
hold.heldY = hold.lowering.fromY + (to - hold.lowering.fromY) * easeInOut(t);
|
|
1583
|
+
setHeldLevelY(id, hold.heldY);
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
/** Every level goes to its own height now (a load, an undo, a capture, the reveal stopping). */
|
|
1587
|
+
function releaseLevelHolds() {
|
|
1588
|
+
for (const id of levelHolds.keys())
|
|
1589
|
+
releaseLevel(id);
|
|
1590
|
+
levelHolds.clear();
|
|
1591
|
+
}
|
|
1592
|
+
function stepRetracts(now) {
|
|
1593
|
+
const nodes = useScene.getState().nodes;
|
|
1594
|
+
for (const retract of [...retracts.values()]) {
|
|
1595
|
+
if (!nodes[retract.id]) {
|
|
1596
|
+
// Taken out of the scene under it: nothing left to show going.
|
|
1597
|
+
if (retract.root) {
|
|
1598
|
+
clearRevealPose(retract.root);
|
|
1599
|
+
clearGhost(retract.root);
|
|
1600
|
+
}
|
|
1601
|
+
retractedAway(retract, now);
|
|
1602
|
+
continue;
|
|
1603
|
+
}
|
|
1604
|
+
if (now < retract.startMs)
|
|
1605
|
+
continue;
|
|
1606
|
+
const root = sceneRegistry.nodes.get(retract.id);
|
|
1607
|
+
if (!root) {
|
|
1608
|
+
if (now - retract.startMs > MOUNT_TIMEOUT_MS)
|
|
1609
|
+
retractedAway(retract, now);
|
|
1610
|
+
continue;
|
|
1611
|
+
}
|
|
1612
|
+
if (retract.root !== root) {
|
|
1613
|
+
if (retract.root) {
|
|
1614
|
+
clearRevealPose(retract.root);
|
|
1615
|
+
clearGhost(retract.root);
|
|
1616
|
+
}
|
|
1617
|
+
retract.root = root;
|
|
1618
|
+
}
|
|
1619
|
+
if (!retract.pivot) {
|
|
1620
|
+
retract.pivot = revealPivot(revealLocalBounds(root), retract.style);
|
|
1621
|
+
if (!retract.group.started) {
|
|
1622
|
+
retract.group.started = true;
|
|
1623
|
+
emitRetract('retract-start', retract.group, now);
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
const t = (now - retract.startMs) / retract.durationMs;
|
|
1627
|
+
const pose = retractPoseAt(retract.style, t, retract.height, retractSample);
|
|
1628
|
+
setRevealPose(root, retract.pivot, pose.scale, retract.from.lift + pose.lift);
|
|
1629
|
+
if (retract.cover)
|
|
1630
|
+
liftCover(root, retract.cover, t);
|
|
1631
|
+
if (fadesAway(retract.style))
|
|
1632
|
+
setGhost(root, retract.from.opacity * pose.opacity);
|
|
1633
|
+
if (t >= 1) {
|
|
1634
|
+
holdGone(retract, root, now);
|
|
1635
|
+
retractedAway(retract, now);
|
|
1636
|
+
}
|
|
1637
|
+
}
|
|
1638
|
+
for (const [id, hold] of [...held]) {
|
|
1639
|
+
if (!nodes[id] || now >= hold.until) {
|
|
1640
|
+
releaseHold(id, hold);
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
const root = sceneRegistry.nodes.get(id);
|
|
1644
|
+
if (!root)
|
|
1645
|
+
continue;
|
|
1646
|
+
if (root === hold.root) {
|
|
1647
|
+
// Its renderer may have dressed it in its own materials again.
|
|
1648
|
+
if (hold.fades)
|
|
1649
|
+
setGhost(root, 0);
|
|
1650
|
+
continue;
|
|
1651
|
+
}
|
|
1652
|
+
if (hold.root) {
|
|
1653
|
+
clearRevealPose(hold.root);
|
|
1654
|
+
clearGhost(hold.root);
|
|
1655
|
+
}
|
|
1656
|
+
dressHeld(hold, root);
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
/**
|
|
1660
|
+
* Ends every reverse play at once: what is going goes (and is held gone when the host is about to take
|
|
1661
|
+
* it out, `keepHeld`), or comes back whole (a capture, a load), and what waits on it resolves.
|
|
1662
|
+
*/
|
|
1663
|
+
function finishRetracts(keepHeld) {
|
|
1664
|
+
for (const retract of [...retracts.values()]) {
|
|
1665
|
+
const root = retract.root ?? sceneRegistry.nodes.get(retract.id) ?? null;
|
|
1666
|
+
if (root && keepHeld)
|
|
1667
|
+
holdGone(retract, root, clockMs);
|
|
1668
|
+
else if (root) {
|
|
1669
|
+
clearRevealPose(root);
|
|
1670
|
+
clearGhost(root);
|
|
1671
|
+
}
|
|
1672
|
+
retractedAway(retract, clockMs);
|
|
1673
|
+
}
|
|
1674
|
+
if (keepHeld)
|
|
1675
|
+
return;
|
|
1676
|
+
for (const [id, hold] of [...held])
|
|
1677
|
+
releaseHold(id, hold);
|
|
1678
|
+
}
|
|
1679
|
+
/**
|
|
1680
|
+
* Takes `ids` back, and what stands under them, by playing their construction in reverse: the last
|
|
1681
|
+
* phase built goes first (the furniture lifts away, the roof, the openings close, the walls sink,
|
|
1682
|
+
* the slab), the last piece to arrive first within a phase, quicker than the build. It is what an
|
|
1683
|
+
* Undo of an agent's build shows before the host takes the nodes out of the scene: the promise
|
|
1684
|
+
* resolves when all have gone, the nodes stay hidden until they leave the scene (or a couple of seconds
|
|
1685
|
+
* pass, and they come back whole), and the host removes them then. `retract-start` and `retract-end`
|
|
1686
|
+
* events say each phase's turn, on the reveal's clock, for step cards, sounds and the camera.
|
|
1687
|
+
*
|
|
1688
|
+
* Nothing animates (so `instant` is true and nothing is retracted) when no reveal runs, the animation is
|
|
1689
|
+
* off, or motion is reduced: the host removes the nodes at once.
|
|
1690
|
+
*/
|
|
1691
|
+
export function retractNodes(ids) {
|
|
1692
|
+
const asked = [...new Set(ids)];
|
|
1693
|
+
if (currentDriver === null || showsAtOnce()) {
|
|
1694
|
+
return Promise.resolve({ retracted: [], skipped: asked, instant: true });
|
|
1695
|
+
}
|
|
1696
|
+
const nodes = useScene.getState().nodes;
|
|
1697
|
+
const staged = nodeRevealCandidates(asked, nodes, revealLevel()).filter((candidate) => sceneRegistry.nodes.has(candidate.id) &&
|
|
1698
|
+
!pending.has(candidate.id) &&
|
|
1699
|
+
!retracts.has(candidate.id) &&
|
|
1700
|
+
!held.has(candidate.id) &&
|
|
1701
|
+
!levelHiddenByAuthor(candidate.levelId));
|
|
1702
|
+
const taken = new Set(staged.map((candidate) => candidate.id));
|
|
1703
|
+
const skipped = asked.filter((id) => !taken.has(id));
|
|
1704
|
+
if (staged.length === 0)
|
|
1705
|
+
return Promise.resolve({ retracted: [], skipped, instant: false });
|
|
1706
|
+
// What is up for the furniture carries on up from where it is; a build still playing is shown first.
|
|
1707
|
+
const from = new Map();
|
|
1708
|
+
for (const { id } of staged) {
|
|
1709
|
+
const lifted = lifts.get(id);
|
|
1710
|
+
if (lifted) {
|
|
1711
|
+
const x = Math.max(0, lifted.state.x);
|
|
1712
|
+
from.set(id, { lift: x * lifted.height, opacity: 1 - (1 - LIFT_GHOST_MIN) * Math.min(1, x) });
|
|
1713
|
+
lifts.delete(id);
|
|
1714
|
+
}
|
|
1715
|
+
liftWanted.delete(id);
|
|
1716
|
+
finishNode(id);
|
|
1717
|
+
}
|
|
1718
|
+
if (lifts.size === 0 && liftWanted.size === 0)
|
|
1719
|
+
deferredFinale = null;
|
|
1720
|
+
// The furniture goes in the open, as it came: the roof over it lifts out of the way first.
|
|
1721
|
+
const lifting = liftWanted.size;
|
|
1722
|
+
for (const entry of new Map(staged.map((c) => [`${c.phase}:${c.levelId}`, c])).values()) {
|
|
1723
|
+
wantLifts(entry.phase, entry.levelId, taken);
|
|
1724
|
+
}
|
|
1725
|
+
// A roof that goes over the furniture that goes with it goes first: it is what lets the furniture be
|
|
1726
|
+
// seen leaving, as it lifted for it to be seen arriving.
|
|
1727
|
+
const stagedPhases = new Set(staged.map((candidate) => candidate.phase));
|
|
1728
|
+
const clearing = staged.filter((candidate) => {
|
|
1729
|
+
const node = nodes[candidate.id];
|
|
1730
|
+
const clears = node && nodeRegistry.get(node.type)?.capabilities?.reveal?.clears;
|
|
1731
|
+
return clears !== undefined && stagedPhases.has(clears.for);
|
|
1732
|
+
});
|
|
1733
|
+
const clearingIds = new Set(clearing.map((candidate) => candidate.id));
|
|
1734
|
+
const roofFirst = clearing.some((candidate) => !from.has(candidate.id));
|
|
1735
|
+
const lead = liftWanted.size > lifting || roofFirst ? RETRACT_LIFT_LEAD_MS : 0;
|
|
1736
|
+
const speed = currentSpeed();
|
|
1737
|
+
const plan = [
|
|
1738
|
+
...planBack(clearing, clockMs, speed),
|
|
1739
|
+
...planBack(staged.filter((candidate) => !clearingIds.has(candidate.id)), clockMs + lead, speed),
|
|
1740
|
+
].sort((left, right) => left.startMs - right.startMs);
|
|
1741
|
+
const end = Math.max(...plan.map((entry) => entry.startMs + entry.durationMs));
|
|
1742
|
+
// What was already up for a phase going has nothing left to make room for.
|
|
1743
|
+
const phases = new Set(plan.map((entry) => entry.phase));
|
|
1744
|
+
for (const lift of lifts.values()) {
|
|
1745
|
+
if (phases.has(lift.phase)) {
|
|
1746
|
+
lift.releaseAt = Math.min(lift.releaseAt ?? Number.POSITIVE_INFINITY, end + LIFT_BEAT_MS);
|
|
1747
|
+
}
|
|
1748
|
+
}
|
|
1749
|
+
return new Promise((resolve) => {
|
|
1750
|
+
const run = {
|
|
1751
|
+
retracted: staged.map((candidate) => candidate.id),
|
|
1752
|
+
skipped,
|
|
1753
|
+
remaining: plan.length,
|
|
1754
|
+
resolve,
|
|
1755
|
+
};
|
|
1756
|
+
const groups = new Map();
|
|
1757
|
+
for (const entry of plan) {
|
|
1758
|
+
let group = groups.get(entry.group);
|
|
1759
|
+
if (!group) {
|
|
1760
|
+
group = {
|
|
1761
|
+
phase: entry.phase,
|
|
1762
|
+
levelId: entry.levelId,
|
|
1763
|
+
ids: [],
|
|
1764
|
+
remaining: 0,
|
|
1765
|
+
started: false,
|
|
1766
|
+
};
|
|
1767
|
+
groups.set(entry.group, group);
|
|
1768
|
+
}
|
|
1769
|
+
group.ids.push(entry.id);
|
|
1770
|
+
group.remaining += 1;
|
|
1771
|
+
// A covering lifts off in a time that follows its size: known now, with the roof still drawn.
|
|
1772
|
+
const mounted = entry.style === 'cover' ? sceneRegistry.nodes.get(entry.id) : undefined;
|
|
1773
|
+
const cover = mounted ? planCover(mounted, coverRangeAt(speed, true)) : undefined;
|
|
1774
|
+
retracts.set(entry.id, {
|
|
1775
|
+
id: entry.id,
|
|
1776
|
+
style: entry.style,
|
|
1777
|
+
height: entry.height,
|
|
1778
|
+
startMs: entry.startMs,
|
|
1779
|
+
durationMs: cover?.durationMs ?? entry.durationMs,
|
|
1780
|
+
...(cover ? { cover } : {}),
|
|
1781
|
+
from: from.get(entry.id) ?? { lift: 0, opacity: 1 },
|
|
1782
|
+
group,
|
|
1783
|
+
run,
|
|
1784
|
+
root: null,
|
|
1785
|
+
pivot: null,
|
|
1786
|
+
...(entry.prerollMs ? { prerollMs: entry.prerollMs } : {}),
|
|
1787
|
+
});
|
|
1788
|
+
// A plan entry that draws it hears that it is going.
|
|
1789
|
+
notifyPending(entry.id);
|
|
1790
|
+
}
|
|
1791
|
+
publishLevelsAtWork();
|
|
1792
|
+
// With a roof to lift first, the first group is said to start now: the camera and the card move
|
|
1793
|
+
// with the roof, not a beat after it, and its pieces follow once the way is clear.
|
|
1794
|
+
const first = groups.get(plan[0].group);
|
|
1795
|
+
if (lead > 0 && first && !first.started) {
|
|
1796
|
+
first.started = true;
|
|
1797
|
+
emitRetract('retract-start', first, clockMs);
|
|
1798
|
+
}
|
|
1799
|
+
});
|
|
1800
|
+
}
|
|
1801
|
+
function sayFinale(reveal, atMs) {
|
|
1802
|
+
reveal.finaleSaid = true;
|
|
1803
|
+
// What is lifted seats last: that is the finish, not this piece's landing.
|
|
1804
|
+
if (lifts.size > 0 || liftWanted.size > 0) {
|
|
1805
|
+
deferredFinale = {
|
|
1806
|
+
type: 'finale',
|
|
1807
|
+
phase: reveal.candidate.phase,
|
|
1808
|
+
levelId: reveal.levelId,
|
|
1809
|
+
source: reveal.candidate.source ?? null,
|
|
1810
|
+
};
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
emit({
|
|
1814
|
+
type: 'finale',
|
|
1815
|
+
id: reveal.id,
|
|
1816
|
+
phase: reveal.candidate.phase,
|
|
1817
|
+
levelId: reveal.levelId,
|
|
1818
|
+
source: reveal.candidate.source ?? null,
|
|
1819
|
+
atMs,
|
|
1820
|
+
});
|
|
1821
|
+
}
|
|
1822
|
+
/** Gives a warming node's meshes their culling back, and its place among the nodes that wait. */
|
|
1823
|
+
function restoreWarm(id) {
|
|
1824
|
+
const warm = warming.get(id);
|
|
1825
|
+
if (!warm)
|
|
1826
|
+
return false;
|
|
1827
|
+
for (const [object, culled] of warm.culled)
|
|
1828
|
+
object.frustumCulled = culled;
|
|
1829
|
+
warming.delete(id);
|
|
1830
|
+
return true;
|
|
1831
|
+
}
|
|
1832
|
+
function hasWaitingAncestor(id, nodes) {
|
|
1833
|
+
let parentId = nodes[id]?.parentId;
|
|
1834
|
+
for (let guard = 0; parentId && guard < 32; guard += 1) {
|
|
1835
|
+
if (pending.has(parentId) && !warming.has(parentId))
|
|
1836
|
+
return true;
|
|
1837
|
+
parentId = nodes[parentId]?.parentId;
|
|
1838
|
+
}
|
|
1839
|
+
return false;
|
|
1840
|
+
}
|
|
1841
|
+
/**
|
|
1842
|
+
* A commit arrived: the first node of each kind not yet on screen mounts now, hidden, so its first draw
|
|
1843
|
+
* (and with it a stall of a hundred milliseconds or more) comes while nothing moves. An opening is left
|
|
1844
|
+
* for its turn, since mounting it cuts its wall; a part of an assembled node mounts with its host.
|
|
1845
|
+
* The roofs that will lift for the furniture of this commit draw once as the ghost they will become.
|
|
1846
|
+
*/
|
|
1847
|
+
function chooseWarm(now) {
|
|
1848
|
+
// Nothing draws when only the 2D plan shows: there are no shaders to build, and no frames to wait for.
|
|
1849
|
+
if (revealLevel() === 'off' || useViewer.getState().renderPaused)
|
|
1850
|
+
return;
|
|
1851
|
+
const nodes = useScene.getState().nodes;
|
|
1852
|
+
// What a node's first draw costs follows its shaders: its kind, and for a catalog item its model,
|
|
1853
|
+
// whose materials are its own.
|
|
1854
|
+
const shadersOf = (node) => `${node.type}:${node.asset?.id ?? ''}`;
|
|
1855
|
+
const onScreen = new Set();
|
|
1856
|
+
const kinds = new Set();
|
|
1857
|
+
const phases = new Set();
|
|
1858
|
+
let started = false;
|
|
1859
|
+
let added = 0;
|
|
1860
|
+
for (const candidate of pending.values()) {
|
|
1861
|
+
phases.add(candidate.phase);
|
|
1862
|
+
const candidateNode = nodes[candidate.id];
|
|
1863
|
+
if (!candidateNode || warming.has(candidate.id) || candidate.style === 'cut')
|
|
1864
|
+
continue;
|
|
1865
|
+
const kind = shadersOf(candidateNode);
|
|
1866
|
+
if (kinds.has(kind))
|
|
1867
|
+
continue;
|
|
1868
|
+
if (!onScreen.has(candidateNode.type)) {
|
|
1869
|
+
onScreen.add(candidateNode.type);
|
|
1870
|
+
for (const id of sceneRegistry.byType[candidateNode.type] ?? []) {
|
|
1871
|
+
const mounted = nodes[id];
|
|
1872
|
+
if (mounted && !pending.has(id))
|
|
1873
|
+
kinds.add(shadersOf(mounted));
|
|
1874
|
+
}
|
|
1875
|
+
if (kinds.has(kind))
|
|
1876
|
+
continue;
|
|
1877
|
+
}
|
|
1878
|
+
if (levelHiddenByAuthor(candidate.levelId) || hasWaitingAncestor(candidate.id, nodes))
|
|
1879
|
+
continue;
|
|
1880
|
+
kinds.add(kind);
|
|
1881
|
+
warming.set(candidate.id, { culled: new Map(), builtTicks: 0 });
|
|
1882
|
+
notifyPending(candidate.id);
|
|
1883
|
+
started = true;
|
|
1884
|
+
added += 1;
|
|
1885
|
+
}
|
|
1886
|
+
if (liftsPlay()) {
|
|
1887
|
+
for (const phase of phases) {
|
|
1888
|
+
for (const id in nodes) {
|
|
1889
|
+
const node = nodes[id];
|
|
1890
|
+
const clears = node && nodeRegistry.get(node.type)?.capabilities?.reveal?.clears;
|
|
1891
|
+
const root = sceneRegistry.nodes.get(id);
|
|
1892
|
+
if (!(clears && clears.for === phase && root) || isNodeRevealing(id) || lifts.has(id))
|
|
1893
|
+
continue;
|
|
1894
|
+
if (ghostWarming.has(id))
|
|
1895
|
+
continue;
|
|
1896
|
+
ghostWarming.set(id, 0);
|
|
1897
|
+
started = true;
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
if (dust && !dustWarmed && added > 0) {
|
|
1902
|
+
dustWarmed = true;
|
|
1903
|
+
dust.requestWarm();
|
|
1904
|
+
started = true;
|
|
1905
|
+
}
|
|
1906
|
+
if (started) {
|
|
1907
|
+
warmWaiting = true;
|
|
1908
|
+
warmStartedMs = now;
|
|
1909
|
+
warmQuiet = 0;
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Keeps what mounted up front hidden, drawable (never culled), and counts the frames since it was
|
|
1914
|
+
* built; says when the plan may begin: everything has drawn and the frames are steady again.
|
|
1915
|
+
*/
|
|
1916
|
+
function stepWarm(now) {
|
|
1917
|
+
if (!warmWaiting && warming.size === 0 && ghostWarming.size === 0)
|
|
1918
|
+
return;
|
|
1919
|
+
warmQuiet = frameQuiet ? warmQuiet + 1 : 0;
|
|
1920
|
+
const { nodes, dirtyNodes } = useScene.getState();
|
|
1921
|
+
let ready = true;
|
|
1922
|
+
for (const [id, warm] of [...warming]) {
|
|
1923
|
+
if (!nodes[id]) {
|
|
1924
|
+
restoreWarm(id);
|
|
1925
|
+
continue;
|
|
1926
|
+
}
|
|
1927
|
+
const root = sceneRegistry.nodes.get(id);
|
|
1928
|
+
if (!root) {
|
|
1929
|
+
if (now - warmStartedMs < WARM_MOUNT_MS)
|
|
1930
|
+
ready = false;
|
|
1931
|
+
continue;
|
|
1932
|
+
}
|
|
1933
|
+
setRevealPose(root, ORIGIN, HIDDEN);
|
|
1934
|
+
root.traverse((object) => {
|
|
1935
|
+
if (warm.culled.has(object))
|
|
1936
|
+
return;
|
|
1937
|
+
warm.culled.set(object, object.frustumCulled);
|
|
1938
|
+
object.frustumCulled = false;
|
|
1939
|
+
});
|
|
1940
|
+
if (dirtyNodes.has(id)) {
|
|
1941
|
+
ready = false;
|
|
1942
|
+
continue;
|
|
1943
|
+
}
|
|
1944
|
+
warm.builtTicks += 1;
|
|
1945
|
+
if (warm.builtTicks < WARM_MIN_TICKS)
|
|
1946
|
+
ready = false;
|
|
1947
|
+
}
|
|
1948
|
+
for (const [id, ticks] of [...ghostWarming]) {
|
|
1949
|
+
const root = sceneRegistry.nodes.get(id);
|
|
1950
|
+
if (!(nodes[id] && root)) {
|
|
1951
|
+
ghostWarming.delete(id);
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
setGhost(root, LIFT_WARM_OPACITY);
|
|
1955
|
+
ghostWarming.set(id, ticks + 1);
|
|
1956
|
+
if (ticks + 1 < WARM_MIN_TICKS)
|
|
1957
|
+
ready = false;
|
|
1958
|
+
}
|
|
1959
|
+
if (warmWaiting &&
|
|
1960
|
+
((ready && warmQuiet >= WARM_QUIET_FRAMES) || now - warmStartedMs >= WARM_MAX_MS))
|
|
1961
|
+
warmWaiting = false;
|
|
1962
|
+
}
|
|
1963
|
+
/**
|
|
1964
|
+
* The plan for what waits, from `now`. On the lazy schedule, a node whose preroll has begun keeps its
|
|
1965
|
+
* turn (its plugin is playing it), and the rest are planned around it with their prerolls.
|
|
1966
|
+
*/
|
|
1967
|
+
function planPending(now) {
|
|
1968
|
+
// The person's speed is read here, when the plan is made: what is already moving keeps its own
|
|
1969
|
+
// durations, and what is planned now keeps this speed until it starts.
|
|
1970
|
+
planSpeed = currentSpeed();
|
|
1971
|
+
if (!lazyOn()) {
|
|
1972
|
+
const timing = revealTimingAt(planSpeed);
|
|
1973
|
+
return planReveal([...pending.values()], Math.max(now, lastStartMs + timing.nodeGapMs), timing);
|
|
1974
|
+
}
|
|
1975
|
+
const kept = [];
|
|
1976
|
+
for (const entry of queue) {
|
|
1977
|
+
if (pending.has(entry.id) && entry.prerollMs && entry.startMs - entry.prerollMs <= now)
|
|
1978
|
+
kept.push(entry);
|
|
1979
|
+
}
|
|
1980
|
+
const keptIds = new Set(kept.map((entry) => entry.id));
|
|
1981
|
+
const waiting = [];
|
|
1982
|
+
for (const candidate of pending.values()) {
|
|
1983
|
+
if (keptIds.has(candidate.id))
|
|
1984
|
+
continue;
|
|
1985
|
+
let prerollMs = 0;
|
|
1986
|
+
try {
|
|
1987
|
+
prerollMs = prerollOf?.(candidate) ?? 0;
|
|
1988
|
+
}
|
|
1989
|
+
catch (error) {
|
|
1990
|
+
console.error('[viewer] reveal preroll failed', error);
|
|
1991
|
+
}
|
|
1992
|
+
waiting.push(prerollMs > 0 ? { ...candidate, prerollMs } : candidate);
|
|
1993
|
+
}
|
|
1994
|
+
// What comes next waits for the prerolls already playing: nothing of it can pull their node in early.
|
|
1995
|
+
const from = Math.max(now, ...kept.map((entry) => entry.startMs));
|
|
1996
|
+
return [...kept, ...planLazyReveal(waiting, from, lazyTimingAt(planSpeed))].sort((left, right) => left.startMs - right.startMs || left.seq - right.seq);
|
|
1997
|
+
}
|
|
1998
|
+
function tickReveals(now, frameMs) {
|
|
1999
|
+
clockMs = now;
|
|
2000
|
+
frameQuiet = frameMs === undefined || frameMs < WARM_QUIET_FRAME_MS;
|
|
2001
|
+
if (revealLevel() === 'off' && (pending.size > 0 || active.size > 0 || retracts.size > 0))
|
|
2002
|
+
finishReveals();
|
|
2003
|
+
if (unplanned) {
|
|
2004
|
+
unplanned = false;
|
|
2005
|
+
for (const candidate of [...pending.values()]) {
|
|
2006
|
+
if (levelHiddenByAuthor(candidate.levelId))
|
|
2007
|
+
startReveal(candidate, now, false);
|
|
2008
|
+
}
|
|
2009
|
+
chooseWarm(now);
|
|
2010
|
+
replanning = true;
|
|
2011
|
+
}
|
|
2012
|
+
stepWarm(now);
|
|
2013
|
+
if (replanning && !warmWaiting) {
|
|
2014
|
+
replanning = false;
|
|
2015
|
+
queue = planPending(now);
|
|
2016
|
+
planned.clear();
|
|
2017
|
+
for (const entry of queue)
|
|
2018
|
+
planned.set(entry.id, entry);
|
|
2019
|
+
}
|
|
2020
|
+
let due = 0;
|
|
2021
|
+
while (due < queue.length && (queue[due]?.startMs ?? Number.POSITIVE_INFINITY) <= now) {
|
|
2022
|
+
const entry = queue[due++];
|
|
2023
|
+
planned.delete(entry.id);
|
|
2024
|
+
if (pending.has(entry.id))
|
|
2025
|
+
startReveal(entry, now, !levelHiddenByAuthor(entry.levelId));
|
|
2026
|
+
}
|
|
2027
|
+
if (due > 0)
|
|
2028
|
+
queue = queue.slice(due);
|
|
2029
|
+
for (const reveal of active.values())
|
|
2030
|
+
stepReveal(reveal, now);
|
|
2031
|
+
stepLifts(now);
|
|
2032
|
+
stepRetracts(now);
|
|
2033
|
+
stepGhosts(now);
|
|
2034
|
+
stepLevelHolds(now);
|
|
2035
|
+
stepGlides(now);
|
|
2036
|
+
const building = pending.size > 0 || active.size > 0 || lifts.size > 0 || liftWanted.size > 0;
|
|
2037
|
+
if (!building) {
|
|
2038
|
+
announced.clear();
|
|
2039
|
+
// A roof ghosted ahead of furniture that never came goes back to its own materials.
|
|
2040
|
+
for (const id of ghostWarming.keys()) {
|
|
2041
|
+
const root = sceneRegistry.nodes.get(id);
|
|
2042
|
+
if (root && !lifts.has(id))
|
|
2043
|
+
clearGhost(root);
|
|
2044
|
+
}
|
|
2045
|
+
ghostWarming.clear();
|
|
2046
|
+
completeBuild(now, false);
|
|
2047
|
+
}
|
|
2048
|
+
// No group is open and nothing waits for its turn: what the agent moved can begin without
|
|
2049
|
+
// closing a build's group early.
|
|
2050
|
+
if (changeQueue.length > 0 && groups.size === 0 && pending.size === 0 && active.size === 0) {
|
|
2051
|
+
flushChanges(now);
|
|
2052
|
+
}
|
|
2053
|
+
const busy = building ||
|
|
2054
|
+
retracts.size > 0 ||
|
|
2055
|
+
held.size > 0 ||
|
|
2056
|
+
glides.size > 0 ||
|
|
2057
|
+
ghosts.size > 0 ||
|
|
2058
|
+
levelHolds.size > 0 ||
|
|
2059
|
+
changeQueue.length > 0;
|
|
2060
|
+
publishLevelsAtWork();
|
|
2061
|
+
if (busy || tickedBusy)
|
|
2062
|
+
notifyTicks();
|
|
2063
|
+
tickedBusy = busy;
|
|
2064
|
+
}
|
|
2065
|
+
/**
|
|
2066
|
+
* Ends every reveal at once: waiting nodes mount, moving ones snap to their final pose, dust clears.
|
|
2067
|
+
* A reverse play ends too; its nodes stay gone when the host is about to remove them (`keepHeld`).
|
|
2068
|
+
*/
|
|
2069
|
+
function finishReveals(keepHeld = false) {
|
|
2070
|
+
const waiting = [...pending.values()];
|
|
2071
|
+
const busy = waiting.length > 0 || active.size > 0 || retracts.size > 0 || ghosts.size > 0;
|
|
2072
|
+
pending.clear();
|
|
2073
|
+
queue = [];
|
|
2074
|
+
planned.clear();
|
|
2075
|
+
unplanned = false;
|
|
2076
|
+
replanning = false;
|
|
2077
|
+
warmWaiting = false;
|
|
2078
|
+
for (const id of [...warming.keys()]) {
|
|
2079
|
+
restoreWarm(id);
|
|
2080
|
+
const root = sceneRegistry.nodes.get(id);
|
|
2081
|
+
if (root)
|
|
2082
|
+
clearRevealPose(root);
|
|
2083
|
+
}
|
|
2084
|
+
for (const id of ghostWarming.keys()) {
|
|
2085
|
+
const root = sceneRegistry.nodes.get(id);
|
|
2086
|
+
if (root && !lifts.has(id))
|
|
2087
|
+
clearGhost(root);
|
|
2088
|
+
}
|
|
2089
|
+
ghostWarming.clear();
|
|
2090
|
+
for (const candidate of waiting) {
|
|
2091
|
+
released(candidate);
|
|
2092
|
+
settle(candidate.id);
|
|
2093
|
+
closeNode(candidate.id, clockMs, true);
|
|
2094
|
+
}
|
|
2095
|
+
for (const reveal of [...active.values()])
|
|
2096
|
+
endReveal(reveal, true);
|
|
2097
|
+
announced.clear();
|
|
2098
|
+
dust?.clear();
|
|
2099
|
+
seatAllLifts();
|
|
2100
|
+
finishRetracts(keepHeld);
|
|
2101
|
+
finishGhosts();
|
|
2102
|
+
releaseLevelHolds();
|
|
2103
|
+
finishChanges();
|
|
2104
|
+
completeBuild(clockMs, true);
|
|
2105
|
+
publishLevelsAtWork();
|
|
2106
|
+
if (busy)
|
|
2107
|
+
notifyTicks();
|
|
2108
|
+
}
|
|
2109
|
+
/** Ends one node's reveal, and its parts' if it is assembled from them. */
|
|
2110
|
+
function finishNode(id) {
|
|
2111
|
+
if (restoreWarm(id)) {
|
|
2112
|
+
const root = sceneRegistry.nodes.get(id);
|
|
2113
|
+
if (root)
|
|
2114
|
+
clearRevealPose(root);
|
|
2115
|
+
}
|
|
2116
|
+
planned.delete(id);
|
|
2117
|
+
const waiting = pending.get(id);
|
|
2118
|
+
if (waiting && pending.delete(id)) {
|
|
2119
|
+
released(waiting);
|
|
2120
|
+
settle(id);
|
|
2121
|
+
closeNode(id, clockMs, true);
|
|
2122
|
+
}
|
|
2123
|
+
const reveal = active.get(id);
|
|
2124
|
+
if (reveal)
|
|
2125
|
+
endReveal(reveal, true);
|
|
2126
|
+
const lifted = lifts.get(id);
|
|
2127
|
+
if (lifted)
|
|
2128
|
+
seatLift(lifted, clockMs, false);
|
|
2129
|
+
liftWanted.delete(id);
|
|
2130
|
+
finishGlide(id);
|
|
2131
|
+
const own = parts.get(id);
|
|
2132
|
+
if (own)
|
|
2133
|
+
for (const part of [...own])
|
|
2134
|
+
finishNode(part);
|
|
2135
|
+
}
|
|
2136
|
+
/** Ends the reveal of `ids` and of their hosts. */
|
|
2137
|
+
function finishNodes(ids) {
|
|
2138
|
+
const nodes = useScene.getState().nodes;
|
|
2139
|
+
for (const id of ids) {
|
|
2140
|
+
let node = nodes[id];
|
|
2141
|
+
for (let guard = 0; node && guard < 32; guard += 1) {
|
|
2142
|
+
finishNode(node.id);
|
|
2143
|
+
node = node.parentId ? nodes[node.parentId] : undefined;
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
/** Whether a store change hid or showed a level that a waiting node stands on. */
|
|
2148
|
+
function pendingLevelVisibilityChanged(nodes, previous) {
|
|
2149
|
+
const seen = new Set();
|
|
2150
|
+
for (const candidate of pending.values()) {
|
|
2151
|
+
const levelId = candidate.levelId;
|
|
2152
|
+
if (!levelId || seen.has(levelId))
|
|
2153
|
+
continue;
|
|
2154
|
+
seen.add(levelId);
|
|
2155
|
+
if (nodes[levelId]?.visible !== previous[levelId]?.visible)
|
|
2156
|
+
return true;
|
|
2157
|
+
}
|
|
2158
|
+
return false;
|
|
2159
|
+
}
|
|
2160
|
+
function mediaPrefersReducedMotion() {
|
|
2161
|
+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function')
|
|
2162
|
+
return () => false;
|
|
2163
|
+
const query = window.matchMedia('(prefers-reduced-motion: reduce)');
|
|
2164
|
+
return () => query.matches;
|
|
2165
|
+
}
|
|
2166
|
+
/** The reduced-motion reading of the driver in force; the media query while none runs. */
|
|
2167
|
+
let motionReduced = mediaPrefersReducedMotion();
|
|
2168
|
+
/**
|
|
2169
|
+
* Whether the person's animation setting asks for no motion: Off, or reduced motion. What eases
|
|
2170
|
+
* for the eye outside a build (the dive under the ground) is instant then, as the builds are.
|
|
2171
|
+
*/
|
|
2172
|
+
export function revealMotionOff() {
|
|
2173
|
+
return motionReduced() || revealLevel() === 'off';
|
|
2174
|
+
}
|
|
2175
|
+
let currentDriver = null;
|
|
2176
|
+
/**
|
|
2177
|
+
* The reveal without a canvas: `<ConstructionReveal>` calls `tick` every
|
|
2178
|
+
* frame. One driver runs at a time; a newer one takes over.
|
|
2179
|
+
*/
|
|
2180
|
+
export function startConstructionReveal(options) {
|
|
2181
|
+
finishReveals();
|
|
2182
|
+
const token = {};
|
|
2183
|
+
currentDriver = token;
|
|
2184
|
+
revealLevel = options.level ?? (() => 'full');
|
|
2185
|
+
revealSpeed = options.speed ?? (() => 1);
|
|
2186
|
+
planSpeed = 1;
|
|
2187
|
+
dust = options.dust ?? null;
|
|
2188
|
+
const reducedMotion = options.prefersReducedMotion ?? mediaPrefersReducedMotion();
|
|
2189
|
+
motionReduced = reducedMotion;
|
|
2190
|
+
const instant = () => {
|
|
2191
|
+
const viewer = useViewer.getState();
|
|
2192
|
+
return (reducedMotion() ||
|
|
2193
|
+
revealLevel() === 'off' ||
|
|
2194
|
+
viewer.renderContext === 'viewer' ||
|
|
2195
|
+
viewer.isExporting);
|
|
2196
|
+
};
|
|
2197
|
+
showsAtOnce = instant;
|
|
2198
|
+
const deletes = options.deletes ?? options.reveals;
|
|
2199
|
+
const stops = [
|
|
2200
|
+
subscribeSceneCommits((commit) => {
|
|
2201
|
+
if (currentDriver !== token)
|
|
2202
|
+
return;
|
|
2203
|
+
if (commit.origin === 'load')
|
|
2204
|
+
return finishReveals();
|
|
2205
|
+
if (deletes(commit))
|
|
2206
|
+
playDeletions(commit);
|
|
2207
|
+
if (!options.reveals(commit))
|
|
2208
|
+
return publishLevelsAtWork();
|
|
2209
|
+
const candidates = revealCandidates(commit, revealLevel());
|
|
2210
|
+
// Furniture the agent moved, that is shown and not still on its way in.
|
|
2211
|
+
const moved = commitChangeCandidates(commit, revealLevel());
|
|
2212
|
+
const movedIds = new Set(moved.map((change) => change.id));
|
|
2213
|
+
const changes = [
|
|
2214
|
+
...moved,
|
|
2215
|
+
...commitEditCandidates(commit, revealLevel()).filter((edit) => !movedIds.has(edit.id)),
|
|
2216
|
+
].filter((change) => !(pending.has(change.id) || active.has(change.id) || isNodeRevealing(change.id)) &&
|
|
2217
|
+
!levelHiddenByAuthor(change.levelId));
|
|
2218
|
+
if (changes.length > 0)
|
|
2219
|
+
queueChanges(changes);
|
|
2220
|
+
publishLevelsAtWork();
|
|
2221
|
+
if (candidates.length === 0)
|
|
2222
|
+
return;
|
|
2223
|
+
registerCandidates(candidates, clockMs);
|
|
2224
|
+
if (instant()) {
|
|
2225
|
+
for (const candidate of candidates)
|
|
2226
|
+
announce(candidate);
|
|
2227
|
+
for (const candidate of candidates)
|
|
2228
|
+
closeNode(candidate.id, clockMs, false);
|
|
2229
|
+
completeBuild(clockMs, false);
|
|
2230
|
+
return;
|
|
2231
|
+
}
|
|
2232
|
+
const assembling = new Set();
|
|
2233
|
+
for (const candidate of candidates) {
|
|
2234
|
+
pending.set(candidate.id, candidate);
|
|
2235
|
+
if (candidate.host !== undefined) {
|
|
2236
|
+
if (!isRevealAssembling(candidate.host))
|
|
2237
|
+
assembling.add(candidate.host);
|
|
2238
|
+
hostOf.set(candidate.id, candidate.host);
|
|
2239
|
+
const own = parts.get(candidate.host) ?? new Set();
|
|
2240
|
+
parts.set(candidate.host, own.add(candidate.id));
|
|
2241
|
+
}
|
|
2242
|
+
notifyPending(candidate.id);
|
|
2243
|
+
}
|
|
2244
|
+
for (const hostId of assembling)
|
|
2245
|
+
notifyAssembly(hostId, true);
|
|
2246
|
+
publishLevelsAtWork();
|
|
2247
|
+
unplanned = true;
|
|
2248
|
+
}),
|
|
2249
|
+
useScene.subscribe((state, previous) => {
|
|
2250
|
+
if (currentDriver !== token)
|
|
2251
|
+
return;
|
|
2252
|
+
if (isRestoringSceneHistory() || state.hydrationId !== previous.hydrationId) {
|
|
2253
|
+
// An undo is about to take what a reverse play was taking back out of the scene: it stays gone.
|
|
2254
|
+
return finishReveals(isRestoringSceneHistory());
|
|
2255
|
+
}
|
|
2256
|
+
// A waiting node on a level that just hid starts at once and holds no slot (failure mode 10).
|
|
2257
|
+
if (pending.size > 0 &&
|
|
2258
|
+
state.nodes !== previous.nodes &&
|
|
2259
|
+
pendingLevelVisibilityChanged(state.nodes, previous.nodes)) {
|
|
2260
|
+
unplanned = true;
|
|
2261
|
+
}
|
|
2262
|
+
}),
|
|
2263
|
+
useViewer.subscribe((state, previous) => {
|
|
2264
|
+
if (currentDriver !== token)
|
|
2265
|
+
return;
|
|
2266
|
+
if (state.isExporting && !previous.isExporting)
|
|
2267
|
+
return finishReveals();
|
|
2268
|
+
if (state.selection.selectedIds !== previous.selection.selectedIds ||
|
|
2269
|
+
state.externalSelectedIds !== previous.externalSelectedIds ||
|
|
2270
|
+
state.previewSelectedIds !== previous.previewSelectedIds) {
|
|
2271
|
+
finishNodes([
|
|
2272
|
+
...state.selection.selectedIds,
|
|
2273
|
+
...state.externalSelectedIds,
|
|
2274
|
+
...state.previewSelectedIds,
|
|
2275
|
+
]);
|
|
2276
|
+
}
|
|
2277
|
+
}),
|
|
2278
|
+
];
|
|
2279
|
+
const onCapture = () => {
|
|
2280
|
+
if (currentDriver === token)
|
|
2281
|
+
finishReveals();
|
|
2282
|
+
};
|
|
2283
|
+
emitter.on('thumbnail:before-capture', onCapture);
|
|
2284
|
+
return {
|
|
2285
|
+
tick(nowMs, frameMs) {
|
|
2286
|
+
if (currentDriver === token)
|
|
2287
|
+
tickReveals(nowMs, frameMs);
|
|
2288
|
+
},
|
|
2289
|
+
stop() {
|
|
2290
|
+
for (const stop of stops)
|
|
2291
|
+
stop();
|
|
2292
|
+
emitter.off('thumbnail:before-capture', onCapture);
|
|
2293
|
+
if (currentDriver !== token)
|
|
2294
|
+
return;
|
|
2295
|
+
currentDriver = null;
|
|
2296
|
+
finishReveals();
|
|
2297
|
+
showsAtOnce = () => true;
|
|
2298
|
+
dustWarmed = false;
|
|
2299
|
+
changeQueue = [];
|
|
2300
|
+
queuedChanges.clear();
|
|
2301
|
+
changeGroups.clear();
|
|
2302
|
+
changeGroupOf.clear();
|
|
2303
|
+
changeRun = null;
|
|
2304
|
+
changeLiftReleaseAt = null;
|
|
2305
|
+
glideClockMs = Number.NEGATIVE_INFINITY;
|
|
2306
|
+
frameQuiet = true;
|
|
2307
|
+
revealLevel = () => 'full';
|
|
2308
|
+
revealSpeed = () => 1;
|
|
2309
|
+
motionReduced = mediaPrefersReducedMotion();
|
|
2310
|
+
planSpeed = 1;
|
|
2311
|
+
dust = null;
|
|
2312
|
+
lastStartMs = Number.NEGATIVE_INFINITY;
|
|
2313
|
+
buildLandedMs = Number.NEGATIVE_INFINITY;
|
|
2314
|
+
liftClockMs = Number.NEGATIVE_INFINITY;
|
|
2315
|
+
tickedBusy = false;
|
|
2316
|
+
},
|
|
2317
|
+
};
|
|
2318
|
+
}
|
|
2319
|
+
/**
|
|
2320
|
+
* Mount inside `<Viewer>` to stage the commits `reveals` selects, at the
|
|
2321
|
+
* host's `level`. Hosts that never mount it (the read-only viewer, the bake)
|
|
2322
|
+
* show every write at once.
|
|
2323
|
+
*/
|
|
2324
|
+
export function ConstructionReveal({ reveals, deletes, level = 'full', speed = 1, }) {
|
|
2325
|
+
const revealsRef = useRef(reveals);
|
|
2326
|
+
const deletesRef = useRef(deletes);
|
|
2327
|
+
const levelRef = useRef(level);
|
|
2328
|
+
const speedRef = useRef(speed);
|
|
2329
|
+
const driverRef = useRef(null);
|
|
2330
|
+
const clockRef = useRef(0);
|
|
2331
|
+
const [pool] = useState(() => new DustPool());
|
|
2332
|
+
const renderPaused = useViewer((state) => state.renderPaused);
|
|
2333
|
+
useEffect(() => {
|
|
2334
|
+
revealsRef.current = reveals;
|
|
2335
|
+
}, [reveals]);
|
|
2336
|
+
useEffect(() => {
|
|
2337
|
+
deletesRef.current = deletes;
|
|
2338
|
+
}, [deletes]);
|
|
2339
|
+
useEffect(() => {
|
|
2340
|
+
levelRef.current = level;
|
|
2341
|
+
}, [level]);
|
|
2342
|
+
useEffect(() => {
|
|
2343
|
+
speedRef.current = speed;
|
|
2344
|
+
}, [speed]);
|
|
2345
|
+
useEffect(() => {
|
|
2346
|
+
const driver = startConstructionReveal({
|
|
2347
|
+
reveals: (commit) => revealsRef.current(commit),
|
|
2348
|
+
deletes: (commit) => (deletesRef.current ?? revealsRef.current)(commit),
|
|
2349
|
+
level: () => levelRef.current,
|
|
2350
|
+
speed: () => speedRef.current,
|
|
2351
|
+
dust: pool,
|
|
2352
|
+
});
|
|
2353
|
+
driverRef.current = driver;
|
|
2354
|
+
return () => {
|
|
2355
|
+
driver.stop();
|
|
2356
|
+
if (driverRef.current === driver)
|
|
2357
|
+
driverRef.current = null;
|
|
2358
|
+
};
|
|
2359
|
+
}, [pool]);
|
|
2360
|
+
// The canvas stops its frames when only the 2D plan shows; the plan still
|
|
2361
|
+
// plays the reveal, so the reveal keeps its own clock meanwhile.
|
|
2362
|
+
useEffect(() => {
|
|
2363
|
+
if (!renderPaused)
|
|
2364
|
+
return;
|
|
2365
|
+
let request = 0;
|
|
2366
|
+
let last = null;
|
|
2367
|
+
const step = (time) => {
|
|
2368
|
+
request = requestAnimationFrame(step);
|
|
2369
|
+
const frameMs = last === null ? undefined : Math.max(0, time - last);
|
|
2370
|
+
if (frameMs !== undefined)
|
|
2371
|
+
clockRef.current += Math.min(MAX_STEP_MS, frameMs);
|
|
2372
|
+
last = time;
|
|
2373
|
+
driverRef.current?.tick(clockRef.current, frameMs);
|
|
2374
|
+
};
|
|
2375
|
+
request = requestAnimationFrame(step);
|
|
2376
|
+
return () => cancelAnimationFrame(request);
|
|
2377
|
+
}, [renderPaused]);
|
|
2378
|
+
useFrame((_, delta) => {
|
|
2379
|
+
clockRef.current += Math.min(MAX_STEP_MS, delta * 1000);
|
|
2380
|
+
driverRef.current?.tick(clockRef.current, delta * 1000);
|
|
2381
|
+
});
|
|
2382
|
+
return _jsx(ConstructionDust, { clock: clockRef, pool: pool });
|
|
2383
|
+
}
|