mirror of
https://github.com/samvallad33/vestige.git
synced 2026-04-25 00:36:22 +02:00
feat(v2.3-terrarium): Memory Birth Ritual + event pipeline fix
v2.3 "Terrarium" headline feature. When a MemoryCreated event arrives, a
glowing orb materialises in the cosmic center (camera-relative z=-40),
gestates for ~800ms growing from a tiny spark into a full orb, then arcs
along a dynamic quadratic Bezier curve to the live position of the real
node, and on arrival hands off to the existing RainbowBurst + Shockwave +
RippleWave cascade. The target position is re-resolved every frame so
the force simulation can move the destination during flight without the
orb losing its mark.
**New primitive — EffectManager.createBirthOrb()** (effects.ts):
Accepts a camera, a color, a live target-position getter, and an
arrival callback. Owns a sprite pair (outer halo + inner bright core),
both depthTest:false with renderOrder 999/1000 so the orb is always
visible through the starfield and the graph.
- Gestation phase: easeOutCubic growth + sinusoidal pulse, halo tints
from neutral to event color as the ritual charges.
- Flight phase: QuadraticBezierCurve3 with control point at midpoint
raised on Y by 30 + 15% of orb-to-target distance (shooting-star
arc). Sampled with easeInOutQuad. Orb shrinks ~35% approaching target.
- Arrival: fires onArrive callback once, then fades out over 8 frames
while expanding slightly (energy dispersal).
- Caller's onArrive triggers the burst cascade at arrivePos (NOT the
original spawnPos — the force sim may have moved the target during
the ritual, so we re-read nodeManager.positions on arrival).
- Dispose path integrated with existing EffectManager.dispose().
**Event pipeline fix — Graph3D.processEvents()**:
Previously tracked `processedEventCount` assuming APPEND order, but
websocket.ts PREPENDS new events (index 0) and caps the array at
MAX_EVENTS. Result: only the first MemoryCreated event after page
load fired correctly; subsequent ones reprocessed the oldest entry.
Fixed to walk from index 0 until hitting the last-processed event
by reference identity — correct regardless of array direction or
eviction pressure. Events are then processed oldest-first so causes
precede effects. Found while wiring the v2.3 demo button; would have
manifested as "first orb only" in production.
**Demo trigger** (Settings -> Birth Ritual Preview):
Button that calls websocket.injectEvent() with a synthetic
MemoryCreated event, cycling through node types (fact / concept /
pattern / decision / person / place) to showcase the type-color
mapping. Downstream consumers can't distinguish synthetic from real,
so this drives the full ritual end-to-end. Intended for demo clip
recording for the Wednesday launch.
**Test coverage:**
- events.test.ts now tests the v2.3 birth ritual path: spawns 2+
sprites in the scene immediately, and fires the full arrival
cascade after driving the effects.update() loop past the ritual
duration.
- three-mock.ts extended with Vector3.addVectors, Vector3.applyQuaternion,
Color.multiplyScalar, Quaternion, QuadraticBezierCurve3, Texture,
and Object3D.quaternion/renderOrder so production code runs unaltered
in tests.
Build + typecheck:
- npm run check: 0 errors, 0 warnings across 583 files
- npm test: 251/251 pass (net +0 from v2.2)
- npm run build: clean adapter-static output
The Sanhedrin Shatter (anti-birth ritual for hallucination veto) needs
server-side event plumbing and is deferred. Ship this as the Wednesday
visual mic-drop.
This commit is contained in:
parent
f01375b815
commit
f40aa2e086
8 changed files with 393 additions and 29 deletions
|
|
@ -59,8 +59,13 @@
|
|||
let nebulaMaterial: THREE.ShaderMaterial;
|
||||
let postStack: PostProcessingStack;
|
||||
|
||||
// Event tracking
|
||||
let processedEventCount = 0;
|
||||
// Event tracking — we track the last-processed event by reference identity
|
||||
// rather than by count, because the WebSocket store PREPENDS new events
|
||||
// at index 0 and CAPS the array at MAX_EVENTS, so a numeric high-water
|
||||
// mark would drift out of alignment (and did for ~3 versions — v2.3
|
||||
// demo uncovered this while trying to fire multiple MemoryCreated events
|
||||
// in sequence).
|
||||
let lastProcessedEvent: VestigeEvent | null = null;
|
||||
|
||||
// Internal tracking: initial nodes + live-added nodes
|
||||
let allNodes: GraphNode[] = [];
|
||||
|
|
@ -157,10 +162,19 @@
|
|||
}
|
||||
|
||||
function processEvents() {
|
||||
if (!events || events.length <= processedEventCount) return;
|
||||
if (!events || events.length === 0) return;
|
||||
|
||||
const newEvents = events.slice(processedEventCount);
|
||||
processedEventCount = events.length;
|
||||
// Walk the feed from newest (index 0) backward until we hit the last
|
||||
// event we already processed. Everything between is fresh. This is
|
||||
// robust against both (a) prepend ordering and (b) the MAX_EVENTS cap
|
||||
// dropping old entries off the tail.
|
||||
const fresh: VestigeEvent[] = [];
|
||||
for (const e of events) {
|
||||
if (e === lastProcessedEvent) break;
|
||||
fresh.push(e);
|
||||
}
|
||||
if (fresh.length === 0) return;
|
||||
lastProcessedEvent = events[0];
|
||||
|
||||
const mutationCtx: GraphMutationContext = {
|
||||
effects,
|
||||
|
|
@ -180,8 +194,11 @@
|
|||
},
|
||||
};
|
||||
|
||||
for (const event of newEvents) {
|
||||
mapEventToEffects(event, mutationCtx, allNodes);
|
||||
// Process oldest-first so cause precedes effect (e.g. MemoryCreated
|
||||
// fires before a ConnectionDiscovered that references the new node).
|
||||
// `fresh` is newest-first from the walk above, so iterate reversed.
|
||||
for (let i = fresh.length - 1; i >= 0; i--) {
|
||||
mapEventToEffects(fresh[i], mutationCtx, allNodes);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
expect(distToN1).toBeLessThan(20);
|
||||
});
|
||||
|
||||
it('triggers rainbow burst effect', () => {
|
||||
it('spawns a v2.3 birth orb in the scene', () => {
|
||||
const childrenBefore = scene.children.length;
|
||||
|
||||
mapEventToEffects(
|
||||
|
|
@ -168,16 +168,19 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
allNodes
|
||||
);
|
||||
|
||||
// Scene should have new particles (rainbow burst + shockwave + possibly more)
|
||||
expect(scene.children.length).toBeGreaterThan(childrenBefore);
|
||||
// Birth orb adds a halo sprite + bright core sprite to the scene
|
||||
// immediately. The arrival-cascade effects (rainbow burst, shockwaves,
|
||||
// ripple wave) are deferred to the orb's onArrive callback — covered
|
||||
// by the "fires arrival cascade after ritual" test below.
|
||||
expect(scene.children.length).toBeGreaterThanOrEqual(childrenBefore + 2);
|
||||
});
|
||||
|
||||
it('triggers double shockwave (second delayed)', () => {
|
||||
it('fires the arrival cascade after the birth ritual completes', () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
mapEventToEffects(
|
||||
makeEvent('MemoryCreated', {
|
||||
id: 'double-shock',
|
||||
id: 'cascade-check',
|
||||
content: 'test',
|
||||
node_type: 'fact',
|
||||
}),
|
||||
|
|
@ -185,13 +188,23 @@ describe('Event-to-Mutation Pipeline', () => {
|
|||
allNodes
|
||||
);
|
||||
|
||||
const initialChildren = scene.children.length;
|
||||
const afterSpawn = scene.children.length;
|
||||
|
||||
// Advance past the setTimeout
|
||||
vi.advanceTimersByTime(200);
|
||||
// Drive the effects update loop past the full ritual duration
|
||||
// (gestation 48 + flight 90 = 138 frames). Each tick is one frame;
|
||||
// we run 150 to give onArrive room to fire.
|
||||
for (let i = 0; i < 150; i++) {
|
||||
effects.update(nodeManager.meshMap, camera, nodeManager.positions);
|
||||
}
|
||||
|
||||
// Second shockwave should have been added
|
||||
expect(scene.children.length).toBeGreaterThan(initialChildren);
|
||||
// Advance the setTimeout that schedules the delayed second shockwave.
|
||||
vi.advanceTimersByTime(250);
|
||||
|
||||
// The arrival cascade should have added a rainbow burst, shockwave,
|
||||
// ripple wave, and delayed second shockwave to the scene. Even after
|
||||
// the orb fades out and is removed, the burst particles persist long
|
||||
// enough that children.length should exceed the post-spawn count.
|
||||
expect(scene.children.length).toBeGreaterThan(afterSpawn);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -93,6 +93,52 @@ export class Vector3 {
|
|||
this.z = s;
|
||||
return this;
|
||||
}
|
||||
|
||||
addVectors(a: Vector3, b: Vector3) {
|
||||
this.x = a.x + b.x;
|
||||
this.y = a.y + b.y;
|
||||
this.z = a.z + b.z;
|
||||
return this;
|
||||
}
|
||||
|
||||
applyQuaternion(_q: Quaternion) {
|
||||
// Mock: identity transform. Tests don't care about actual
|
||||
// camera-relative positioning; production uses real THREE math.
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class Quaternion {
|
||||
x = 0;
|
||||
y = 0;
|
||||
z = 0;
|
||||
w = 1;
|
||||
}
|
||||
|
||||
export class QuadraticBezierCurve3 {
|
||||
v0: Vector3;
|
||||
v1: Vector3;
|
||||
v2: Vector3;
|
||||
constructor(v0: Vector3, v1: Vector3, v2: Vector3) {
|
||||
this.v0 = v0;
|
||||
this.v1 = v1;
|
||||
this.v2 = v2;
|
||||
}
|
||||
getPoint(t: number): Vector3 {
|
||||
// Standard quadratic Bezier evaluation, faithful enough for tests
|
||||
// that only care that points land on the curve.
|
||||
const one = 1 - t;
|
||||
return new Vector3(
|
||||
one * one * this.v0.x + 2 * one * t * this.v1.x + t * t * this.v2.x,
|
||||
one * one * this.v0.y + 2 * one * t * this.v1.y + t * t * this.v2.y,
|
||||
one * one * this.v0.z + 2 * one * t * this.v1.z + t * t * this.v2.z
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Texture {
|
||||
needsUpdate = false;
|
||||
dispose() {}
|
||||
}
|
||||
|
||||
export class Vector2 {
|
||||
|
|
@ -157,6 +203,13 @@ export class Color {
|
|||
offsetHSL(_h: number, _s: number, _l: number) {
|
||||
return this;
|
||||
}
|
||||
|
||||
multiplyScalar(s: number) {
|
||||
this.r *= s;
|
||||
this.g *= s;
|
||||
this.b *= s;
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
export class BufferAttribute {
|
||||
|
|
@ -329,6 +382,8 @@ export class SpriteMaterial extends BaseMaterial {
|
|||
export class Object3D {
|
||||
position = new Vector3();
|
||||
scale = new Vector3(1, 1, 1);
|
||||
quaternion = new Quaternion();
|
||||
renderOrder = 0;
|
||||
userData: Record<string, unknown> = {};
|
||||
children: Object3D[] = [];
|
||||
parent: Object3D | null = null;
|
||||
|
|
@ -428,6 +483,9 @@ export function installThreeMock() {
|
|||
Vector3,
|
||||
Vector2,
|
||||
Color,
|
||||
Quaternion,
|
||||
QuadraticBezierCurve3,
|
||||
Texture,
|
||||
BufferAttribute,
|
||||
BufferGeometry,
|
||||
SphereGeometry,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import * as THREE from 'three';
|
||||
import { getGlowTexture } from './nodes';
|
||||
|
||||
export interface PulseEffect {
|
||||
nodeId: string;
|
||||
|
|
@ -49,6 +50,28 @@ interface ConnectionFlash {
|
|||
intensity: number;
|
||||
}
|
||||
|
||||
// v2.3 Memory Birth Ritual. The orb gestates at a camera-relative "cosmic
|
||||
// center" point for `gestationFrames`, then flies along a dynamic quadratic
|
||||
// Bezier curve to the live position of its target node for `flightFrames`,
|
||||
// then calls `onArrive` and disposes itself. The target position is
|
||||
// resolved via `getTargetPos` on every frame so the force simulation can
|
||||
// move the node during the flight and the orb stays glued to it.
|
||||
interface BirthOrb {
|
||||
sprite: THREE.Sprite;
|
||||
core: THREE.Sprite;
|
||||
startPos: THREE.Vector3;
|
||||
getTargetPos: () => THREE.Vector3 | undefined;
|
||||
color: THREE.Color;
|
||||
age: number;
|
||||
gestationFrames: number;
|
||||
flightFrames: number;
|
||||
arriveFired: boolean;
|
||||
onArrive: () => void;
|
||||
/** Last known target position. If the target disappears mid-flight we keep
|
||||
* using this snapshot so the orb still lands somewhere sensible. */
|
||||
lastTargetPos: THREE.Vector3;
|
||||
}
|
||||
|
||||
export class EffectManager {
|
||||
pulseEffects: PulseEffect[] = [];
|
||||
private spawnBursts: SpawnBurst[] = [];
|
||||
|
|
@ -57,6 +80,7 @@ export class EffectManager {
|
|||
private implosions: ImplosionEffect[] = [];
|
||||
private shockwaves: Shockwave[] = [];
|
||||
private connectionFlashes: ConnectionFlash[] = [];
|
||||
private birthOrbs: BirthOrb[] = [];
|
||||
private scene: THREE.Scene;
|
||||
|
||||
constructor(scene: THREE.Scene) {
|
||||
|
|
@ -231,6 +255,88 @@ export class EffectManager {
|
|||
this.connectionFlashes.push({ line, intensity: 1.0 });
|
||||
}
|
||||
|
||||
/**
|
||||
* v2.3 Memory Birth Ritual. Spawn a glowing orb at a point in front of the
|
||||
* camera ("cosmic center"), gestate for ~800ms, then arc along a quadratic
|
||||
* Bezier curve to the live position of the target node, which is resolved
|
||||
* on every frame via `getTargetPos`. On arrival, `onArrive` fires — caller
|
||||
* is responsible for adding the real node to the graph and triggering
|
||||
* arrival-time bursts.
|
||||
*
|
||||
* The target getter can return undefined if the node has been removed
|
||||
* mid-flight; the orb then flies to the last known target position so the
|
||||
* burst still fires somewhere coherent rather than snapping to origin.
|
||||
*/
|
||||
createBirthOrb(
|
||||
camera: THREE.Camera,
|
||||
color: THREE.Color,
|
||||
getTargetPos: () => THREE.Vector3 | undefined,
|
||||
onArrive: () => void,
|
||||
opts: { gestationFrames?: number; flightFrames?: number; distanceFromCamera?: number } = {}
|
||||
) {
|
||||
const gestationFrames = opts.gestationFrames ?? 48; // ~800ms
|
||||
const flightFrames = opts.flightFrames ?? 90; // ~1500ms
|
||||
const distanceFromCamera = opts.distanceFromCamera ?? 40;
|
||||
|
||||
// Place the orb slightly in front of the camera, in view-space,
|
||||
// projected back into world coordinates. This way the orb always
|
||||
// appears "right in front of the user's face" regardless of where
|
||||
// the camera has been orbited to.
|
||||
const startPos = new THREE.Vector3(0, 0, -distanceFromCamera)
|
||||
.applyQuaternion(camera.quaternion)
|
||||
.add(camera.position);
|
||||
|
||||
// Outer glow halo
|
||||
const haloMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: color.clone(),
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false, // always visible, even through other nodes
|
||||
});
|
||||
const sprite = new THREE.Sprite(haloMat);
|
||||
sprite.position.copy(startPos);
|
||||
sprite.scale.set(0.5, 0.5, 1);
|
||||
sprite.renderOrder = 999;
|
||||
|
||||
// Inner bright core — stays hot white during gestation, tints at launch
|
||||
const coreMat = new THREE.SpriteMaterial({
|
||||
map: getGlowTexture(),
|
||||
color: new THREE.Color(0xffffff),
|
||||
transparent: true,
|
||||
opacity: 0.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
depthTest: false,
|
||||
});
|
||||
const core = new THREE.Sprite(coreMat);
|
||||
core.position.copy(startPos);
|
||||
core.scale.set(0.2, 0.2, 1);
|
||||
core.renderOrder = 1000;
|
||||
|
||||
this.scene.add(sprite);
|
||||
this.scene.add(core);
|
||||
|
||||
// Snapshot the current target so we have a fallback.
|
||||
const initialTarget = getTargetPos()?.clone() ?? startPos.clone();
|
||||
|
||||
this.birthOrbs.push({
|
||||
sprite,
|
||||
core,
|
||||
startPos,
|
||||
getTargetPos,
|
||||
color: color.clone(),
|
||||
age: 0,
|
||||
gestationFrames,
|
||||
flightFrames,
|
||||
arriveFired: false,
|
||||
onArrive,
|
||||
lastTargetPos: initialTarget,
|
||||
});
|
||||
}
|
||||
|
||||
update(
|
||||
nodeMeshMap: Map<string, THREE.Mesh>,
|
||||
camera: THREE.Camera,
|
||||
|
|
@ -431,6 +537,97 @@ export class EffectManager {
|
|||
}
|
||||
(flash.line.material as THREE.LineBasicMaterial).opacity = flash.intensity;
|
||||
}
|
||||
|
||||
// v2.3 Birth orbs — gestate at cosmic center, then arc to live node
|
||||
// position along a quadratic Bezier curve. Target position is
|
||||
// re-resolved every frame so the force simulation can move the
|
||||
// destination during flight without the orb losing its mark.
|
||||
for (let i = this.birthOrbs.length - 1; i >= 0; i--) {
|
||||
const orb = this.birthOrbs[i];
|
||||
orb.age++;
|
||||
const totalFrames = orb.gestationFrames + orb.flightFrames;
|
||||
|
||||
// Refresh the live target snapshot — if the node still exists we
|
||||
// track it, otherwise we fall back to the last known position.
|
||||
const live = orb.getTargetPos();
|
||||
if (live) orb.lastTargetPos.copy(live);
|
||||
|
||||
const haloMat = orb.sprite.material as THREE.SpriteMaterial;
|
||||
const coreMat = orb.core.material as THREE.SpriteMaterial;
|
||||
|
||||
if (orb.age <= orb.gestationFrames) {
|
||||
// Gestation phase — pulse brighter + grow from a tiny spark
|
||||
// into a full orb. Sits still at the cosmic center.
|
||||
const t = orb.age / orb.gestationFrames;
|
||||
const ease = 1 - Math.pow(1 - t, 3); // easeOutCubic
|
||||
const pulse = 0.85 + Math.sin(orb.age * 0.35) * 0.15;
|
||||
const haloScale = 0.5 + ease * 4.5 * pulse;
|
||||
const coreScale = 0.2 + ease * 1.8 * pulse;
|
||||
orb.sprite.scale.set(haloScale, haloScale, 1);
|
||||
orb.core.scale.set(coreScale, coreScale, 1);
|
||||
haloMat.opacity = ease * 0.95;
|
||||
coreMat.opacity = ease;
|
||||
// Subtle warm-up — core white, halo tints toward the event
|
||||
// color as gestation completes.
|
||||
haloMat.color.copy(orb.color).multiplyScalar(0.7 + ease * 0.3);
|
||||
orb.sprite.position.copy(orb.startPos);
|
||||
orb.core.position.copy(orb.startPos);
|
||||
} else if (orb.age <= totalFrames) {
|
||||
// Flight phase — Bezier along a dynamic curve. Arc the
|
||||
// control point upward for a shooting-star trajectory.
|
||||
const t = (orb.age - orb.gestationFrames) / orb.flightFrames;
|
||||
const ease = t < 0.5
|
||||
? 2 * t * t
|
||||
: 1 - Math.pow(-2 * t + 2, 2) / 2; // easeInOutQuad
|
||||
|
||||
const target = orb.lastTargetPos;
|
||||
const control = new THREE.Vector3()
|
||||
.addVectors(orb.startPos, target)
|
||||
.multiplyScalar(0.5);
|
||||
control.y += 30 + target.distanceTo(orb.startPos) * 0.15;
|
||||
|
||||
const curve = new THREE.QuadraticBezierCurve3(orb.startPos, control, target);
|
||||
const p = curve.getPoint(ease);
|
||||
orb.sprite.position.copy(p);
|
||||
orb.core.position.copy(p);
|
||||
|
||||
// Trail effect — shrink + brighten as it approaches target
|
||||
const flightProgress = ease;
|
||||
const shrink = 1 - flightProgress * 0.35;
|
||||
orb.sprite.scale.setScalar(5 * shrink);
|
||||
orb.core.scale.setScalar(2 * shrink);
|
||||
haloMat.opacity = 0.95;
|
||||
coreMat.opacity = 1.0;
|
||||
// Shift halo fully to the event color during flight
|
||||
haloMat.color.copy(orb.color);
|
||||
} else if (!orb.arriveFired) {
|
||||
// Docking — fire the arrival callback once. Let the caller
|
||||
// trigger burst/ripple effects at the exact target point.
|
||||
orb.arriveFired = true;
|
||||
try {
|
||||
orb.onArrive();
|
||||
} catch (e) {
|
||||
// Effects must never take down the render loop.
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[birth-orb] onArrive threw', e);
|
||||
}
|
||||
// Fade the orb out over a few more frames instead of popping.
|
||||
} else {
|
||||
// Post-arrival fade (8 frames ≈ 130ms)
|
||||
const fadeAge = orb.age - totalFrames;
|
||||
const fade = Math.max(0, 1 - fadeAge / 8);
|
||||
haloMat.opacity = 0.95 * fade;
|
||||
coreMat.opacity = 1.0 * fade;
|
||||
orb.sprite.scale.setScalar(5 * (1 + (1 - fade) * 2));
|
||||
if (fade <= 0) {
|
||||
this.scene.remove(orb.sprite);
|
||||
this.scene.remove(orb.core);
|
||||
haloMat.dispose();
|
||||
coreMat.dispose();
|
||||
this.birthOrbs.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose() {
|
||||
|
|
@ -464,6 +661,12 @@ export class EffectManager {
|
|||
flash.line.geometry.dispose();
|
||||
(flash.line.material as THREE.Material).dispose();
|
||||
}
|
||||
for (const orb of this.birthOrbs) {
|
||||
this.scene.remove(orb.sprite);
|
||||
this.scene.remove(orb.core);
|
||||
(orb.sprite.material as THREE.Material).dispose();
|
||||
(orb.core.material as THREE.Material).dispose();
|
||||
}
|
||||
this.pulseEffects = [];
|
||||
this.spawnBursts = [];
|
||||
this.rainbowBursts = [];
|
||||
|
|
@ -471,5 +674,6 @@ export class EffectManager {
|
|||
this.implosions = [];
|
||||
this.shockwaves = [];
|
||||
this.connectionFlashes = [];
|
||||
this.birthOrbs = [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -125,7 +125,11 @@ export function mapEventToEffects(
|
|||
// Find spawn position near related nodes
|
||||
const spawnPos = findSpawnPosition(newNode, allNodes, nodePositions);
|
||||
|
||||
// Add to all managers
|
||||
// Add to all managers NOW so the force simulation can start
|
||||
// settling the target position during the birth orb's flight.
|
||||
// NodeManager materialises from scale 0 via easeOutElastic, so the
|
||||
// node grows in as the orb descends onto it — visually the orb
|
||||
// looks like it pulls the memory into existence on contact.
|
||||
const pos = nodeManager.addNode(newNode, spawnPos);
|
||||
forceSim.addNode(data.id, pos);
|
||||
|
||||
|
|
@ -133,17 +137,35 @@ export function mapEventToEffects(
|
|||
liveSpawnedNodes.push(data.id);
|
||||
evictOldestLiveNode(ctx, allNodes);
|
||||
|
||||
// Spectacular effects: rainbow burst + double shockwave + ripple wave
|
||||
// v2.3 Memory Birth Ritual — cosmic-center orb, Bezier flight,
|
||||
// arrival burst cascade. The old burst/ripple/shockwave cascade
|
||||
// now fires on arrival at the docking target, not at spawn, so
|
||||
// the camera's eye has time to track the orb in.
|
||||
const color = new THREE.Color(NODE_TYPE_COLORS[newNode.type] || '#00ffd1');
|
||||
effects.createRainbowBurst(spawnPos, color);
|
||||
effects.createShockwave(spawnPos, color, camera);
|
||||
// Second shockwave, hue-shifted, delayed via smaller initial scale
|
||||
const hueShifted = color.clone();
|
||||
hueShifted.offsetHSL(0.15, 0, 0);
|
||||
setTimeout(() => {
|
||||
effects.createShockwave(spawnPos, hueShifted, camera);
|
||||
}, 166); // ~10 frames at 60fps
|
||||
effects.createRippleWave(spawnPos);
|
||||
|
||||
effects.createBirthOrb(
|
||||
camera,
|
||||
color,
|
||||
// Re-resolve the live target position every frame — the node
|
||||
// is being pushed around by the force sim during flight.
|
||||
() => nodeManager.positions.get(newNode.id),
|
||||
() => {
|
||||
// Dock. Fire the arrival cascade at the node's current
|
||||
// position (not the original spawnPos — it has moved).
|
||||
const arrivePos = nodeManager.positions.get(newNode.id) ?? spawnPos;
|
||||
effects.createRainbowBurst(arrivePos, color);
|
||||
effects.createShockwave(arrivePos, color, camera);
|
||||
effects.createRippleWave(arrivePos);
|
||||
// Second shockwave, hue-shifted, ~166ms delay for a layered
|
||||
// crash feel rather than a single pop.
|
||||
setTimeout(() => {
|
||||
const delayedPos = nodeManager.positions.get(newNode.id) ?? arrivePos;
|
||||
effects.createShockwave(delayedPos, hueShifted, camera);
|
||||
}, 166);
|
||||
}
|
||||
);
|
||||
|
||||
onMutation({ type: 'nodeAdded', node: newNode });
|
||||
break;
|
||||
|
|
|
|||
|
|
@ -67,7 +67,7 @@ export function getNodeColor(node: GraphNode, mode: ColorMode): string {
|
|||
// hard-edged "glowing cubes" artefact reported in issue #31. Using a
|
||||
// soft radial gradient gives a real round halo and lets bloom do its job.
|
||||
let sharedGlowTexture: THREE.Texture | null = null;
|
||||
function getGlowTexture(): THREE.Texture {
|
||||
export function getGlowTexture(): THREE.Texture {
|
||||
if (sharedGlowTexture) return sharedGlowTexture;
|
||||
const size = 128;
|
||||
const canvas = document.createElement('canvas');
|
||||
|
|
|
|||
|
|
@ -81,11 +81,26 @@ function createWebSocketStore() {
|
|||
update(s => ({ ...s, events: [] }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject a synthetic event into the feed as if it had arrived over the
|
||||
* WebSocket. Used by the dev-mode "Preview Birth Ritual" button on the
|
||||
* Settings page to let Sam trigger a demo of the v2.3 Memory Birth
|
||||
* Ritual without ingesting a real memory. Downstream consumers —
|
||||
* InsightToast, Graph3D — cannot distinguish synthetic from real.
|
||||
*/
|
||||
function injectEvent(event: VestigeEvent) {
|
||||
update(s => {
|
||||
const events = [event, ...s.events].slice(0, MAX_EVENTS);
|
||||
return { ...s, events };
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
connect,
|
||||
disconnect,
|
||||
clearEvents
|
||||
clearEvents,
|
||||
injectEvent
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,30 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { api } from '$stores/api';
|
||||
import { isConnected, memoryCount, avgRetention } from '$stores/websocket';
|
||||
import { websocket, isConnected, memoryCount, avgRetention } from '$stores/websocket';
|
||||
import { fireDemoSequence } from '$stores/toast';
|
||||
|
||||
// v2.3 Birth Ritual demo — injects a synthetic MemoryCreated event so
|
||||
// Graph3D spawns a birth orb without needing a real ingest. Node types
|
||||
// cycle so back-to-back clicks show different colors. Pure dev/demo
|
||||
// affordance; production users see orbs fire on real ingests.
|
||||
const DEMO_NODE_TYPES = ['fact', 'concept', 'pattern', 'decision', 'person', 'place'];
|
||||
let birthCount = $state(0);
|
||||
function fireBirthRitualDemo() {
|
||||
const type = DEMO_NODE_TYPES[birthCount % DEMO_NODE_TYPES.length];
|
||||
birthCount++;
|
||||
websocket.injectEvent({
|
||||
type: 'MemoryCreated',
|
||||
data: {
|
||||
id: `demo-birth-${Date.now()}`,
|
||||
content: `Demo memory #${birthCount} — ${type}`,
|
||||
node_type: type,
|
||||
tags: ['demo', 'v2.3-birth-ritual'],
|
||||
retention: 0.9,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Operation states
|
||||
let consolidating = $state(false);
|
||||
let dreaming = $state(false);
|
||||
|
|
@ -108,6 +129,20 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<!-- v2.3 Terrarium — demo the Memory Birth Ritual on the Graph page -->
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-sm text-text font-medium">Birth Ritual Preview</div>
|
||||
<div class="text-xs text-dim">Inject a synthetic memory — switch to Graph to watch the orb fly in</div>
|
||||
</div>
|
||||
<button onclick={fireBirthRitualDemo}
|
||||
class="px-4 py-2 bg-dream/20 border border-dream/40 text-dream-glow text-sm rounded-xl hover:bg-dream/30 transition flex items-center gap-2">
|
||||
<span>✺</span> Trigger Birth
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Consolidation -->
|
||||
<div class="p-4 glass rounded-xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue