@oddessentials/agent-guild 1.30.1 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -434,6 +434,139 @@ export function clipFor(id, mode, n) {
434
434
  if (mode === 'work') return n % 3 === 2 && h % 2 ? set[1] : set[0];
435
435
  return n % 3 === 2 ? set[1 + (h % (set.length - 1))] : set[0];
436
436
  }
437
+ export const VIBE = {
438
+ // Must stay inside the ground every world keeps clear (Clearance.live_distance in agent-guild-builders).
439
+ roam: { radius: 13.2, half: 12.5, front: 30.5, split: 3, margin: .9, edge: 1.2, head: 2.4 },
440
+ flight: { height: [8.5, 12], lap: [26, 44], reach: [.45, 1], scale: 1.3, top: 2 },
441
+ };
442
+ export function vibeFrame(aspect, top = 0, bottom = 0) {
443
+ const { target, zoom } = overviewFor(aspect), height = CAMERA.height / zoom, width = height * aspect, { edge } = VIBE.roam;
444
+ return { target, right: [edge - width / 2, width / 2 - edge], up: [(bottom - .5) * height + edge, (.5 - top) * height - edge] };
445
+ }
446
+ export function inVibeFrame(frame, x, y, z) {
447
+ const { right, up } = viewBasis(), d = [x - frame.target[0], y - frame.target[1], z - frame.target[2]];
448
+ const r = dot(d, right), u = dot(d, up);
449
+ return r >= frame.right[0] && r <= frame.right[1] && u >= frame.up[0] && u <= frame.up[1];
450
+ }
451
+ function unit(value) {
452
+ let h = hash(value);
453
+ h = Math.imul(h ^ (h >>> 16), 0x45d9f3b); h = Math.imul(h ^ (h >>> 16), 0x45d9f3b);
454
+ return ((h ^ (h >>> 16)) >>> 0) / 4294967296;
455
+ }
456
+ // Clip the visible ground polygon to an axis-aligned interval. Destination
457
+ // geometry only: this neither builds nor checks a route.
458
+ function vibeClip(points, axis, low, high) {
459
+ for (const [edge, sign] of [[low, 1], [high, -1]]) {
460
+ const next = [];
461
+ for (let i = 0; i < points.length; i++) {
462
+ const a = points[i], b = points[(i + 1) % points.length], da = sign * (a[axis] - edge), db = sign * (b[axis] - edge);
463
+ if (da >= 0) next.push(a);
464
+ if ((da < 0) !== (db < 0)) { const t = da / (da - db); next.push({ x: a.x + t * (b.x - a.x), z: a.z + t * (b.z - a.z) }); }
465
+ }
466
+ points = next;
467
+ }
468
+ return points;
469
+ }
470
+ // A missed sample is not an empty view. Obstacle x edges divide the ground
471
+ // into at most 2B+1 bands, each with at most B+1 free z intervals. Check those
472
+ // finite regions, not a grid whose spacing could miss a narrow opening.
473
+ function vibeGap(polygon, boxes, near, far, seed) {
474
+ const { radius, half, front } = VIBE.roam;
475
+ polygon = vibeClip(vibeClip(polygon, 'x', -radius, radius), 'z', near, far);
476
+ const point = points => {
477
+ if (points.length < 3) return null;
478
+ const area = points.reduce((sum, a, i) => { const b = points[(i + 1) % points.length]; return sum + a.x * b.z - b.x * a.z; }, 0);
479
+ if (Math.abs(area) < 1e-10) return null;
480
+ const center = { x: 0, z: 0 };
481
+ for (const p of points) { center.x += p.x / points.length; center.z += p.z / points.length; }
482
+ const p = points[Math.floor(seed * points.length)], t = .25 + seed / 2;
483
+ return { x: center.x + (p.x - center.x) * t, z: center.z + (p.z - center.z) * t };
484
+ };
485
+ const ground = points => {
486
+ const p = point(points);
487
+ if (!p) return null;
488
+ if (Math.hypot(p.x, p.z) < radius || (Math.abs(p.x) <= half && p.z >= 0 && p.z <= front)) return p;
489
+ const apron = point(vibeClip(vibeClip(points, 'x', -half, half), 'z', 0, front));
490
+ if (apron) return apron;
491
+ // The plaza's obstacle excludes the circle's centre from every free
492
+ // region, so its nearest point is on an edge. Move from it into the
493
+ // polygon, staying inside the circle even for a small circular sliver.
494
+ let nearest = null, distance = Infinity;
495
+ for (let i = 0; i < points.length; i++) {
496
+ const a = points[i], b = points[(i + 1) % points.length], dx = b.x - a.x, dz = b.z - a.z;
497
+ const t = clamp(-(a.x * dx + a.z * dz) / (dx * dx + dz * dz || 1), 0, 1), q = { x: a.x + t * dx, z: a.z + t * dz }, d = Math.hypot(q.x, q.z);
498
+ if (d < distance) { nearest = q; distance = d; }
499
+ }
500
+ if (distance >= radius) return null;
501
+ const t = Math.min(.5, (radius - distance) / (2 * Math.hypot(p.x - nearest.x, p.z - nearest.z)));
502
+ return { x: nearest.x + t * (p.x - nearest.x), z: nearest.z + t * (p.z - nearest.z) };
503
+ };
504
+ if (!point(polygon)) return null;
505
+ const left = Math.min(...polygon.map(p => p.x)), right = Math.max(...polygon.map(p => p.x));
506
+ const edges = [...new Set([left, right, ...boxes.flatMap(b => [b.x - b.half, b.x + b.half]).filter(x => x > left && x < right)])].sort((a, b) => a - b);
507
+ const start = Math.floor(seed * (edges.length - 1));
508
+ for (let i = 0; i < edges.length - 1; i++) {
509
+ const k = (start + i) % (edges.length - 1), lo = edges[k], hi = edges[k + 1], x = (lo + hi) / 2;
510
+ const blocked = boxes.filter(b => Math.abs(x - b.x) < b.half).map(b => [b.z - b.half, b.z + b.half]).sort((a, b) => a[0] - b[0]);
511
+ let z = near;
512
+ for (const [low, high] of [...blocked, [far, far]]) {
513
+ if (low > z) {
514
+ const p = ground(vibeClip(vibeClip(polygon, 'x', lo, hi), 'z', z, Math.min(low, far)));
515
+ if (p) return p;
516
+ }
517
+ z = Math.max(z, high);
518
+ if (z >= far) break;
519
+ }
520
+ }
521
+ return null;
522
+ }
523
+ export function vibeStop(id, n, providers, props = [], frame = null) {
524
+ const { radius, half, front, split, margin, head } = VIBE.roam;
525
+ const [near, far] = (n + hash(id)) % 2 ? [split, front] : [-radius, split];
526
+ const boxes = [...obstacles(providers).map(box => ({ ...box, half: box.half + margin })),
527
+ ...props.map(p => ({ x: p.x, z: p.z, half: STAGING.station.half + margin }))];
528
+ const clear = (x, z) => (Math.hypot(x - STAGING.plaza.x, z - STAGING.plaza.z) <= radius || (Math.abs(x) <= half && z >= 0 && z <= front))
529
+ && !boxes.some(box => Math.abs(x - box.x) < box.half && Math.abs(z - box.z) < box.half);
530
+ if (frame) {
531
+ const { right, up, forward } = viewBasis(), [r0, r1] = frame.right, u0 = frame.up[0], u1 = frame.up[1] - head * up[1];
532
+ const at = (r, u) => {
533
+ const s = (-frame.target[1] - u * up[1]) / forward[1];
534
+ return { x: frame.target[0] + r * right[0] + u * up[0] + s * forward[0], z: frame.target[2] + r * right[2] + u * up[2] + s * forward[2] };
535
+ };
536
+ if (r1 > r0 && u1 > u0) {
537
+ const polygon = [at(r0, u0), at(r1, u0), at(r1, u1), at(r0, u1)];
538
+ for (const [low, high] of [[near, far], near === split ? [-radius, split] : [split, front]]) {
539
+ for (let k = 0; k < 48; k++) {
540
+ const p = at(r0 + unit(id + ':vibe:x:' + n + ':' + k) * (r1 - r0), u0 + unit(id + ':vibe:z:' + n + ':' + k) * (u1 - u0));
541
+ const x = round(p.x), z = round(p.z);
542
+ if (z >= low && z <= high && clear(x, z) && inVibeFrame(frame, x, 0, z) && inVibeFrame(frame, x, head, z)) return { x, z };
543
+ }
544
+ const p = vibeGap(polygon, boxes, low, high, unit(id + ':vibe:gap:' + n));
545
+ if (p) return p;
546
+ }
547
+ }
548
+ }
549
+ // No usable visible ground (or no frame): keep the original seeded walk.
550
+ // Never turn a crowded/covered view into a stationary, repeatedly retried unit.
551
+ for (let k = 0; k < 48; k++) {
552
+ const x = round((unit(id + ':vibe:x:' + n + ':' + k) * 2 - 1) * radius), z = round(near + unit(id + ':vibe:z:' + n + ':' + k) * (far - near));
553
+ if (clear(x, z)) return { x, z };
554
+ }
555
+ return { x: STAGING.plaza.x, z: round(STAGING.plaza.z + STAGING.plaza.half + margin + STAGING.station.half) };
556
+ }
557
+ export function vibeFlight(key, seconds, frame, companionHeight = 1) {
558
+ const { height, lap, reach, top, scale } = VIBE.flight, { right, up, forward } = viewBasis();
559
+ const pick = (name, [low, high]) => low + unit(key + ':vibe:' + name) * (high - low);
560
+ const out = pick('reach', reach), turn = unit(key + ':vibe:turn') < .5 ? 1 : -1, lobes = 2 + Math.floor(unit(key + ':vibe:lobes') * 3);
561
+ const angle = turn * 2 * Math.PI * (seconds / pick('lap', lap) + unit(key + ':vibe:start'));
562
+ const wide = out * (1 + .12 * Math.sin(lobes * angle + pick('phase', [0, 2 * Math.PI]))) / 1.12;
563
+ const y = (height[0] + height[1]) / 2 + (height[1] - height[0]) / 2 * Math.sin(2 * angle + pick('rise', [0, 2 * Math.PI]));
564
+ const [r0, r1] = frame.right, u0 = frame.up[0], u1 = frame.up[1] - Math.max(top, companionHeight * scale * up[1]);
565
+ const r = (r0 + r1) / 2 + Math.cos(angle) * Math.max(0, r1 - r0) / 2 * wide;
566
+ const u = (u0 + u1) / 2 + Math.sin(angle) * Math.max(0, u1 - u0) / 2 * wide;
567
+ const s = (y - frame.target[1] - u * up[1]) / forward[1];
568
+ return { x: frame.target[0] + r * right[0] + u * up[0] + s * forward[0], y, z: frame.target[2] + r * right[2] + u * up[2] + s * forward[2] };
569
+ }
437
570
  // Where a session's helpers stand, around it, as the renderer has always placed them.
438
571
  export function helperOffset(i, count) {
439
572
  const angle = (i / Math.min(count, 6)) * Math.PI * 2;
@@ -1,6 +1,6 @@
1
1
  import * as T from './vendor/engine.js';
2
2
  import { WORLDS, PROVIDER_ORDER, characterIndex, GILDED, CAMERA, SUN, minZoom, overviewFor, clampPan, viewBasis, providerPositions, sessionPose, familiarPose, hash,
3
- stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS } from './model.mjs';
3
+ stage, nextStagingChange, doorFor, arrives, route, clipFor, helperOffset, stationProp, hallActivity, lookTarget, lookLimits, yardExtras, playableClip, STAGING, photoSize, marksFor, SHOWN_HELPERS, VIBE, vibeStop, vibeFlight, vibeFrame, inVibeFrame } from './model.mjs';
4
4
 
5
5
  const ASSETS = new URL('./assets/', import.meta.url);
6
6
  const vector = new T.Vector3();
@@ -16,7 +16,7 @@ const pieceFiles = (skin, slot, piece) => (slot === 'companion' ? [0, 1, 2, 3].m
16
16
  const vector2 = new T.Vector2();
17
17
  // How fast characters turn to a new heading, in radians a second, and how
18
18
  // long a clip change blends.
19
- const TURN = 7, FADE = .3;
19
+ const TURN = 7, FADE = .3, TAG = 44;
20
20
  // Models rigged before the staging contract name their clips differently.
21
21
  const LEGACY = { walk: 'working', idle_0: 'resting', idle_1: 'resting', idle_2: 'resting', pause: 'resting', work_0: 'working', work_1: 'working', idle: 'resting', move: 'working' };
22
22
  // How bright a hall's glow grows at low and high activity, over what the
@@ -139,15 +139,15 @@ function disposeWorld(root) {
139
139
  }
140
140
  export class YardRenderer {
141
141
  // clock gives the time staging is judged at; tests pass a fixed one.
142
- constructor(host,labels,{select,open,error=noop,piece=noop,clock=Date.now}) {
143
- this.host=host;this.labels=labels;this.onSelect=select;this.onOpen=open;this.onError=error;this.onPiece=piece;this.clock=clock;
142
+ constructor(host,labels,{select,open,error=noop,piece=noop,clock=Date.now,cover=[]}) {
143
+ this.host=host;this.labels=labels;this.cover=cover;this.onSelect=select;this.onOpen=open;this.onError=error;this.onPiece=piece;this.clock=clock;
144
144
  this.pieceSets={};this.shownPieces={skin:null,pieces:{}};this.pieceLoaders=new Map();this.preloads=new Map();
145
145
  this.scene=new T.Scene();this.units=new Map();this.halls=new Map();this.slots=new Map();this.cache=new Map();
146
146
  this.loadingManager=new T.LoadingManager();
147
147
  this.providers=[];this.sessions=[];this.selected=null;this.world=null;this.skin=null;this.request=0;this.worldRequest=0;this.pending=null;
148
148
  this.staged=null;this.seen=new Set();this.stageTimer=0;this.sparks=[];this.shows=[];this.marks=new T.Group();this.scene.add(this.marks);this.glyphs=null;this.markPlane=new T.PlaneGeometry(1,1);
149
149
  this.props=new Map();this.propSpots=[];this.station=null;this.effects=null;
150
- this.active=false;this.reduced=false;this.disposed=false;this.frame=0;this.lastTime=0;this.dirty=true;
150
+ this.active=false;this.reduced=false;this.vibe=false;this.vibeTime=0;this.vibeFrames={stop:null,flight:vibeFrame(1)};this.disposed=false;this.frame=0;this.lastTime=0;this.dirty=true;
151
151
  this.crowd=null;this.rows=new Map();
152
152
  this.list=Object.assign(document.createElement('div'),{className:'yard-crowd-list',id:'yard-crowd-list-'+(++crowdLists),hidden:true});
153
153
  this.list.setAttribute('role','group');
@@ -195,7 +195,7 @@ export class YardRenderer {
195
195
  this.canvas.addEventListener('pointerdown',this.onPointerDown);
196
196
  this.canvas.addEventListener('click',this.onClick);
197
197
  this.canvas.addEventListener('dblclick',this.onDouble);
198
- this.resizeObserver=new ResizeObserver(()=>this.resize());this.resizeObserver.observe(host);
198
+ this.resizeObserver=new ResizeObserver(()=>this.resize());for(const el of [host,...cover])this.resizeObserver.observe(el);
199
199
  this.resize();this.overview();
200
200
  }
201
201
  async asset(name,url) {
@@ -482,8 +482,22 @@ export class YardRenderer {
482
482
  this.camera.zoom=T.MathUtils.clamp(this.camera.zoom,this.controls.minZoom,this.controls.maxZoom);
483
483
  this.camera.updateProjectionMatrix();
484
484
  this.dirty=true;
485
+ this.frameVibe(w,h);
485
486
  if(this.atOverview)this.overview();else this.drawOnce();
486
487
  }
488
+ frameVibe(w,h) {
489
+ const box=this.host.getBoundingClientRect();let top=0,bottom=0;
490
+ for(const el of this.cover){
491
+ const r=el.getBoundingClientRect();
492
+ if(!r.width||!r.height)continue;
493
+ if(r.top+r.bottom<box.top+box.bottom)top=Math.max(top,r.bottom-box.top);else bottom=Math.max(bottom,box.bottom-r.top);
494
+ }
495
+ top=T.MathUtils.clamp(top,0,h);bottom=T.MathUtils.clamp(bottom,0,h);
496
+ this.vibeFrames={stop:vibeFrame(w/h,(top+TAG)/h,bottom/h),flight:vibeFrame(w/h,top/h,bottom/h)};
497
+ if(!this.vibing())return;
498
+ const {stop}=this.vibeFrames,{head}=VIBE.roam;
499
+ for(const unit of this.units.values())if(unit.moving&&unit.target&&!(inVibeFrame(stop,unit.target.x,0,unit.target.z)&&inVibeFrame(stop,unit.target.x,head,unit.target.z)))this.roam(unit);
500
+ }
487
501
  overview() {
488
502
  const {target,zoom}=overviewFor((this.camera.right-this.camera.left)/(this.camera.top-this.camera.bottom));
489
503
  this.atOverview=true;
@@ -922,6 +936,7 @@ export class YardRenderer {
922
936
  goTo(unit,place) {
923
937
  const target=unit.target;
924
938
  unit.mode=place.mode;
939
+ if(this.vibing()){if(!unit.moving)this.roam(unit);return;}
925
940
  if(target&&target.x===place.x&&target.z===place.z){unit.target=place;if(!unit.moving)this.play(unit);return;}
926
941
  unit.target=place;
927
942
  const at={x:unit.root.position.x,z:unit.root.position.z};
@@ -929,12 +944,33 @@ export class YardRenderer {
929
944
  else {unit.path=route(at,place,this.providers,this.propSpots);unit.moving=true;}
930
945
  this.play(unit);
931
946
  }
947
+ roam(unit) {
948
+ unit.roamed=(unit.roamed??-1)+1;
949
+ const at={x:unit.root.position.x,z:unit.root.position.z},stop=vibeStop(unit.session.id,unit.roamed,this.providers,this.propSpots,this.vibeFrames.stop);
950
+ unit.target=stop;unit.path=route(at,stop,this.providers,this.propSpots);unit.moving=true;
951
+ this.play(unit);
952
+ }
953
+ vibing() { return this.vibe&&!this.reduced; }
954
+ setVibe(value) {
955
+ if(this.vibe===Boolean(value))return;
956
+ const was=this.vibing();
957
+ this.vibe=Boolean(value);
958
+ if(was!==this.vibing())this.restage();
959
+ }
960
+ restage() {
961
+ if(!this.staged)return;
962
+ for(const [id,unit]of this.units){
963
+ const place=this.staged.places.get(id);
964
+ if(place){unit.target=null;this.goTo(unit,place);}
965
+ }
966
+ this.dirty=true;this.drawOnce();
967
+ }
932
968
  // Straight to its place, helpers with it.
933
969
  arrive(unit) {
934
970
  const place=unit.target;
935
971
  unit.root.position.set(place.x,.17,place.z);unit.path=[];unit.heading=place.face;unit.moving=false;
936
972
  if(unit.model)unit.model.rotation.y=unit.heading;
937
- for(const helper of unit.helpers)helper.model.position.set(place.x+helper.offset.x,.27,place.z+helper.offset.z);
973
+ for(const helper of unit.helpers){helper.model.position.set(place.x+helper.offset.x,.27,place.z+helper.offset.z);helper.model.scale.setScalar(.45);}
938
974
  }
939
975
  // The clip a character should be playing now: the walk while it moves,
940
976
  // otherwise its mode's seeded sequence (model.mjs clipFor).
@@ -966,7 +1002,7 @@ export class YardRenderer {
966
1002
  // Moves a walking character along its path at its stride's speed, turning
967
1003
  // smoothly, and turns a standing one to face its place.
968
1004
  advance(unit,dt) {
969
- const root=unit.root.position;
1005
+ const root=unit.root.position,vibing=this.vibing();
970
1006
  let want=unit.target?.face??unit.heading;
971
1007
  if(unit.moving&&unit.path.length) {
972
1008
  let step=unit.speed*dt;
@@ -976,7 +1012,7 @@ export class YardRenderer {
976
1012
  if(d<=step){root.x=next.x;root.z=next.z;unit.path.shift();step-=d;}
977
1013
  else {root.x+=dx/d*step;root.z+=dz/d*step;step=0;}
978
1014
  }
979
- if(!unit.path.length){unit.moving=false;this.play(unit);}
1015
+ if(!unit.path.length){if(vibing)this.roam(unit);else {unit.moving=false;this.play(unit);}}
980
1016
  this.dirty=true;
981
1017
  }
982
1018
  const turn=angleTo(unit.heading,want);
@@ -988,13 +1024,15 @@ export class YardRenderer {
988
1024
  // Helpers trail their character, closing most of the gap in a second.
989
1025
  const pull=1-Math.exp(-4*dt);
990
1026
  for(const helper of unit.helpers){
991
- const tx=root.x+helper.offset.x,tz=root.z+helper.offset.z,p=helper.model.position;
992
- const dx=tx-p.x,dz=tz-p.z,far=Math.hypot(dx,dz);
993
- p.x+=dx*pull;p.z+=dz*pull;
1027
+ const to=vibing?vibeFlight(unit.session.id+':'+helper.id,this.vibeTime,this.vibeFrames.flight,helper.height):{x:root.x+helper.offset.x,y:.27,z:root.z+helper.offset.z};
1028
+ const p=helper.model.position,dx=to.x-p.x,dy=to.y-p.y,dz=to.z-p.z,far=Math.hypot(dx,dz);
1029
+ const size=vibing?VIBE.flight.scale:.45,grow=size-helper.model.scale.x;
1030
+ p.x+=dx*pull;p.y+=dy*pull;p.z+=dz*pull;
1031
+ if(grow)helper.model.scale.setScalar(Math.abs(grow)<1e-3?size:helper.model.scale.x+grow*pull);
994
1032
  if(far>.05)helper.model.rotation.y=Math.atan2(dx,dz);
995
1033
  const moving=far>.08;
996
1034
  if(moving!==helper.moving){helper.moving=moving;this.helperClip(helper,moving?'move':helper.pose);}
997
- if(far>1e-3)this.dirty=true;
1035
+ if(far>1e-3||Math.abs(dy)>1e-3||grow)this.dirty=true;
998
1036
  }
999
1037
  this.fire(unit);
1000
1038
  }
@@ -1134,13 +1172,20 @@ export class YardRenderer {
1134
1172
  spark.points.geometry.attributes.position.needsUpdate=true;
1135
1173
  }
1136
1174
  }
1175
+ restHelper(unit,helper) {
1176
+ helper.model.position.set(unit.root.position.x+helper.offset.x,.27,unit.root.position.z+helper.offset.z);
1177
+ helper.model.scale.setScalar(.45);
1178
+ helper.moving=false;helper.mixer.stopAllAction();helper.action=null;
1179
+ this.helperClip(helper,helper.pose);
1180
+ helper.mixer.update(0);
1181
+ }
1137
1182
  helperClip(helper,name) {
1138
1183
  const clip=helper.clips.find(c=>c.name===name)||helper.clips.find(c=>c.name===LEGACY[name])||helper.clips[0];
1139
1184
  if(!clip)return;
1140
1185
  const action=helper.mixer.clipAction(clip);
1141
1186
  if(action===helper.action)return;
1142
1187
  action.reset().play();
1143
- if(helper.action){if(this.reduced)helper.action.stop();else helper.action.crossFadeTo(action,FADE,false);}
1188
+ if(helper.action)helper.action.crossFadeTo(action,FADE,false);
1144
1189
  helper.action=action;
1145
1190
  }
1146
1191
  clearHelpers(unit) {
@@ -1154,9 +1199,15 @@ export class YardRenderer {
1154
1199
  const kinds=WORLDS[this.skin].helpers,source=this.pieceSource('companion');
1155
1200
  const signature=JSON.stringify([source?.revision??null,agents]);
1156
1201
  if(unit.helperSignature===signature)return;
1157
- this.clearHelpers(unit);
1202
+ const shown=agents.slice(0,SHOWN_HELPERS);
1203
+ unit.helpers=unit.helpers.filter(helper=>{
1204
+ if(shown.some(agent=>agent.id===helper.id))return true;
1205
+ unit.crew.remove(helper.model);helper.mixer.stopAllAction();helper.mixer.uncacheRoot(helper.model);disposeSkeletons(helper.model);
1206
+ return false;
1207
+ });
1158
1208
  unit.helperSignature=signature;
1159
- const rev=unit.revision,generation=this.request;
1209
+ const rev=++unit.revision,generation=this.request;
1210
+ this.dirty=true;this.drawOnce();
1160
1211
  const stale=()=>this.disposed||generation!==this.request||rev!==unit.revision||!unit.root.parent;
1161
1212
  let pieces=null;
1162
1213
  if(source&&agents.length){
@@ -1171,26 +1222,31 @@ export class YardRenderer {
1171
1222
  }
1172
1223
  // The full helper count and all helper details remain in the canonical inspector.
1173
1224
  // Small squads preserve readable character silhouettes at the overview scale.
1174
- await Promise.all(agents.slice(0,SHOWN_HELPERS).map(async(agent,i)=>{
1225
+ await Promise.all(shown.map(async(agent,i)=>{
1226
+ const offset=helperOffset(i,agents.length),existing=unit.helpers.find(helper=>helper.id===agent.id);
1227
+ if(existing){
1228
+ existing.offset=offset;existing.pose=agent.pose;
1229
+ if(this.reduced)this.restHelper(unit,existing);
1230
+ else if(!existing.moving&&!this.vibing())this.helperClip(existing,agent.pose);
1231
+ this.dirty=true;this.drawOnce();
1232
+ return;
1233
+ }
1175
1234
  try {
1176
1235
  const n=hash(agent.name||agent.id)%4;
1177
1236
  const asset=pieces?pieces[n]:await this.asset((agent.shell?kinds.shell:kinds.agent)+n);
1178
1237
  if(stale())return;
1179
1238
  const model=T.cloneSkeleton(asset.scene);
1180
1239
  model.traverse(n=>{if(n.isSkinnedMesh)n.frustumCulled=false;});
1181
- const offset=helperOffset(i,agents.length);
1182
1240
  model.scale.setScalar(.45);model.position.set(unit.root.position.x+offset.x,.27,unit.root.position.z+offset.z);
1183
1241
  unit.crew.add(model);
1184
1242
  const mixer=new T.AnimationMixer(model);
1185
1243
  let yard=null;asset.scene.traverse(n=>{if(n.userData?.yard)yard=n.userData.yard;});
1186
- const helper={id:agent.id,top:yardExtras(yard,'helper').height*model.scale.y,model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null};
1187
- this.helperClip(helper,agent.pose);
1188
- mixer.update(.3+(hash(agent.id)%40)/10);
1244
+ const helper={id:agent.id,height:yardExtras(yard,'helper').height,get top(){return this.height*this.model.scale.y;},model,mixer,offset,pose:agent.pose,clips:asset.animations,moving:false,action:null};
1245
+ if(this.reduced)this.restHelper(unit,helper);
1246
+ else {this.helperClip(helper,agent.pose);mixer.update(.3+(hash(agent.id)%40)/10);}
1189
1247
  unit.helpers.push(helper);
1190
1248
  this.dirty=true;this.drawOnce();
1191
1249
  } catch {
1192
- // The next state transition can rebuild this incomplete squad. Routine
1193
- // unchanged updates do not call helpers(), so failure cannot loop.
1194
1250
  if(!stale())unit.helperSignature=null;
1195
1251
  }
1196
1252
  }));
@@ -1343,9 +1399,12 @@ export class YardRenderer {
1343
1399
  this.list.style.top=Math.max(6,ct+half+4+lh<=h-6?ct+half+4:ct-half-4-lh)+'px';
1344
1400
  }
1345
1401
  setReducedMotion(value) {
1402
+ const was=this.vibing();
1346
1403
  this.reduced=value;
1404
+ if(was!==this.vibing())this.restage();
1347
1405
  if(value)for(const unit of this.units.values()){this.unlook(unit);unit.lookYaw=0;unit.lookPitch=0;}
1348
1406
  if(value)for(const unit of this.units.values())if(unit.moving&&unit.target){this.arrive(unit);this.play(unit);}
1407
+ if(value)for(const unit of this.units.values())for(const helper of unit.helpers)this.restHelper(unit,helper);
1349
1408
  if(value){for(const item of this.halls.values())item.level=item.target;this.glow();this.stillEffects();this.clearFireworks();}
1350
1409
  this.dirty=true;this.drawOnce();
1351
1410
  }
@@ -1372,12 +1431,14 @@ export class YardRenderer {
1372
1431
  // Advances walks, turns, helpers, clips, glances, sparks and the halls'
1373
1432
  // glow and effects by dt seconds, without drawing.
1374
1433
  step(dt) {
1434
+ const vibing=this.vibing();
1435
+ if(vibing)this.vibeTime+=dt;
1375
1436
  for(const unit of this.units.values()) {
1376
1437
  this.advance(unit,dt);
1377
1438
  // Characters out of view animate five times a second, enough to stay
1378
1439
  // in step for when the camera comes back to them.
1379
1440
  let step=dt;
1380
- if(unit.offscreen){unit.lag=(unit.lag||0)+dt;if(unit.lag<.2)continue;step=unit.lag;}
1441
+ if(unit.offscreen&&!vibing){unit.lag=(unit.lag||0)+dt;if(unit.lag<.2)continue;step=unit.lag;}
1381
1442
  unit.lag=0;
1382
1443
  this.unlook(unit);
1383
1444
  for(const mixer of unit.mixers)mixer.update(step);
package/web/yard/view.js CHANGED
@@ -169,6 +169,20 @@ export function initYard(controller) {
169
169
  button.disabled = !renderer || disposed || motion.matches;
170
170
  button.title = motion.matches ? 'Fireworks off: reduced motion is enabled' : 'Fireworks in this Yard only';
171
171
  };
172
+ let vibe = false;
173
+ try { vibe = localStorage.getItem('agentGuild.vibe') === 'on'; } catch {}
174
+ const vibeButton = () => {
175
+ const button = $('yard-vibe');
176
+ button.setAttribute('aria-pressed', String(vibe));
177
+ button.disabled = motion.matches;
178
+ button.title = motion.matches ? 'Vibes Mode off: reduced motion is enabled' : vibe ? 'Vibes Mode is on in every Yard' : 'Vibes Mode: everyone roams, in every Yard';
179
+ };
180
+ const toggleVibe = () => {
181
+ vibe = !vibe;
182
+ try { localStorage.setItem('agentGuild.vibe', vibe ? 'on' : 'off'); } catch {}
183
+ renderer?.setVibe(vibe);
184
+ vibeButton();
185
+ };
172
186
  const sceneVisible = () => view === 'yard' && !$('app').hidden && document.visibilityState !== 'hidden' && $('terminal-panel').hidden;
173
187
  const skinEntry = (skin = root.dataset.skin) => (window.agentGuildSkins || []).find((item) => item.id === skin) || null;
174
188
  const yardOff = root.dataset.yard === 'none';
@@ -281,6 +295,7 @@ export function initYard(controller) {
281
295
  open: id => controller.openSession(id),
282
296
  error: () => { if (attempt === sceneAttempt) failScene(); },
283
297
  piece: pieceDrawn,
298
+ cover: [...document.querySelectorAll('.yard-heading > *, #yard-stage .yard-overlay')],
284
299
  });
285
300
  pendingRenderer = candidate;
286
301
  // Appearance can change while the first world is loading.
@@ -299,6 +314,7 @@ export function initYard(controller) {
299
314
  renderer = candidate;
300
315
  lastSkin = skin; lastTheme = theme; lastRevision = revision;
301
316
  renderer.setReducedMotion(motion.matches);
317
+ renderer.setVibe(vibe);
302
318
  renderer.update(snapshot.providers, snapshot.sessions);
303
319
  renderer.select(selected);
304
320
  renderer.setActive(sceneVisible());
@@ -465,6 +481,8 @@ export function initYard(controller) {
465
481
  $('yard-zoom-out').addEventListener('click',() => renderer?.zoom(1/1.2));
466
482
  $('yard-fireworks').addEventListener('click',celebrate);
467
483
  fireworksButton();
484
+ $('yard-vibe').addEventListener('click',toggleVibe);
485
+ vibeButton();
468
486
  $('yard-photo').hidden = !controller.photo;
469
487
  $('yard-photo').addEventListener('click',async () => {
470
488
  let YardPhoto;
@@ -490,7 +508,7 @@ export function initYard(controller) {
490
508
  setView(preferYard && skinAllowsYard() ? 'yard' : 'cards', { remember: false });
491
509
  renderer?.setActive(sceneVisible());
492
510
  };
493
- const motionChange = () => { renderer?.setReducedMotion(motion.matches); fireworksButton(); };
511
+ const motionChange = () => { renderer?.setReducedMotion(motion.matches); fireworksButton(); vibeButton(); };
494
512
  const pageHide = () => renderer?.setActive(false);
495
513
  const observer = new MutationObserver((records) => {
496
514
  if (records.some((record) => record.attributeName === 'data-skin' || record.attributeName === 'data-theme')) onAppearance();
@@ -507,6 +525,7 @@ export function initYard(controller) {
507
525
  return { update, select, setView, closePhoto, celebrate, dispose() {
508
526
  closePhoto();
509
527
  $('yard-fireworks').removeEventListener('click',celebrate);
528
+ $('yard-vibe').removeEventListener('click',toggleVibe);
510
529
  disposed=true; fireworksButton(); sceneAttempt++; worldAttempt++; clearTimeout(swapReveal); observer.disconnect(); pendingRenderer?.dispose(); renderer?.dispose();
511
530
  document.removeEventListener('visibilitychange',visibility);
512
531
  motion.removeEventListener('change',motionChange);
package/web/yard/yard.css CHANGED
@@ -265,3 +265,4 @@
265
265
  @container (max-width:440px) { .yard-play-text { display:none; } .yard-play-link { padding:0 7px; } }
266
266
  @container (max-width:380px) { .yard-play:not(.yard-play-solo) .yard-play-link { display:none; } .yard-play-chip { --play-more-w:44px; } .yard-play-more { padding:0; } .yard-play-more::before { display:none; } .yard-play-more-orb { display:block; grid-area:1 / 1; } .yard-play-caret { grid-area:1 / 1; place-self:end; width:9px; height:6px; margin:0 7px 6px 0; stroke-width:2.2; } }
267
267
  @container (max-width:340px) { :root:is([data-skin="orbital"],[data-skin="gnomeland"],[data-skin="goblinville"],[data-skin="grove"],[data-skin="guild"],[data-skin="professional"]) :is(.yard-eyebrow, #yard-subtitle) { display:none; } }
268
+ #yard-vibe[aria-pressed="true"] { border-color:var(--accent); color:var(--accent); }