Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 10 additions & 4 deletions MANIFEST.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -33,15 +33,15 @@ efd1af2bcdd0939bda2432e6823f09135f6d8345796e8a6f9690886b0b104b05 site/assets/wo
27eaa1da60c9ae26c54512c6b986e2d568f5ec9a968d6a46d3d785f9117e5e4e site/cinematic-world.css
6c2c82fb1de7d8a0051edaa1e5f5711fc8881865ad8638fa0c41a709dce54696 site/data/sovereign-genesis.json
0670d7be3b270c8e627c5f0089e25ba065c3326f6bcf0f06ce9ae5cf3c5cf64b site/icon.svg
a2c950a45727b3527c5541f0347c74fa422ed103e073a2dabef6df37dfc7b6c0 site/index.html
37edda29fd3d7dc28535024733ec4e770cf16b8e2e8dc1ceeda73d1c1bc154b2 site/index.html
a31f15ff86c87f9eafb1723fb3fc59186f27fb32a75b00d287fc7efbdeec5b1c site/medieval-art.js
49897b8d946b85c84a4e0f177a3c7773a0c6f9acfef0025d308ccec2ac3a7ef1 site/medieval-world.css
82197a81454575b5c941ec0c52924c0a53f1d92bf70371e9c16df0912a29dcf0 site/observer-connection.js
73d7b00031479ba5d4a2093567c4d9628b50cf3e9a8a6c4be646ad3d73497bd4 site/pixi-observer.js
f57bf13a527ada7c6c792760f6ab976412df64a4a125f305be7e86dda6a32cbf site/pixi-observer.js
5edab258856c44d89ea1df51472037697db95909c2d696607ef7c30a503a4838 site/preview.png
513b703d86b97ed70136c2c1a050f5810f89f14cfba8ec340fd055639413cbad site/sovereign-renderer.js
772ee873b012f6ddf0f36b79c8c95b737c1a45b4362f0b997afcb7a68b77b233 site/sovereign-renderer.js
59d83fee261298ef4c90a650ab3b350d4533ab61e7ad3f315e45cdd83443de74 site/sovereign-world.css
572984f1295862408b6e810619583bbb866ab3130daecbdd968c3865f863d9dd site/sovereign-world.js
d639481c2258cfabfaa97bead83e2009cffa3a5535fca4c8c14729dade54fce4 site/sovereign-world.js
aaa5a63cab5926e0355e8257bd562ec2db4c8890904ef1894767063925ceadf1 site/terrain-model.js
c98fca62534d141f285a6c92bfd6257c26d462a3daff72672a4d76f2399d1f21 site/transient-physics.js
d179b80fb34aae0db437dfa72c1a6e6704209a8119b8d991ad49321d8584da8f tests/browser-art.mjs
Expand Down Expand Up @@ -116,3 +116,9 @@ fba432ea0721d0c250d7b91aa9df1c914219740354dcd941f92ea65354d00a96 world/terrain.
e56357ae087309b86d670288998026568373b7268778157b38612fb38d81eba7 worker/src/persistence.js
279876578ed24eade882ad165e12f0952f1ac985d4b22065f24067dd720c886e tests/test_sovereign_persistence_budget.mjs
1219637898a43e4d623897ea15f1ff495554b4b6f2279e6e2466eba072e7b59e tests/test_sovereign_ledger_compaction.mjs
9942697150f9cc3be949b4869f67c678731db377e284602df4faef8bc6c4fb5e site/observer-concepts.js
ab0d9a1e3aa3efbc09f241543d9a0714a196d997849049e62fb0a22eae73b1c9 site/citizen-animation.js
dab555575cd5a79e1e1be63a155033788d40995f6b5c69b3b82fe87132233009 site/spine-citizen-adapter.js
4fd200df9a9fc14a16eb12445665f4dfd58777fdba8426da4f00ca99b21d8fbe docs/SPINE_OBSERVER_INTEGRATION.md
164c56738f05469e36d0cb3c913656154381bda6a177505d5d2f06d87d1665ad tests/test_sovereign_observer_readability_upgrade.mjs
98d88f126a1bca9443485b2cbc2f4614dbc975f9f96d0dfb72ed37465e2c3e56 tests/test_sovereign_citizen_animation_upgrade.mjs
42 changes: 42 additions & 0 deletions docs/SPINE_OBSERVER_INTEGRATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# Optional Spine Citizen renderer

CYMONIA's canonical world does not depend on animation middleware. Citizen animation is an Observer concern: canonical actions select a visual state, while the browser renderer may never create or modify world state.

## Current integration

The public MIT repository contains an action-to-animation contract, improved native PixiJS procedural Citizen motion, and `site/spine-citizen-adapter.js`, an optional adapter for a licensed Spine PixiJS v8 runtime and exported Citizen skeleton assets. The native renderer remains the automatic fallback.

No Spine Runtime source, package, CDN script, skeleton export, atlas, or proprietary asset is included or enabled by default.

## Licensing boundary

Spine Runtimes are governed by the Spine Runtimes License, not CYMONIA's MIT license. A valid Spine license is required when integrating or distributing the runtime under Esoteric Software's published terms. Keep the optional adapter disabled unless the project owner has satisfied those terms.

## Enabling a licensed rig

Configure the Observer before `sovereign-world.js` starts:

```js
window.CYMONIA_SPINE = {
Spine: LicensedSpineConstructor,
skeletonAlias: 'citizen-skeleton',
atlasAlias: 'citizen-atlas',
skeletonUrl: '/assets/licensed/citizen.skel',
atlasUrl: '/assets/licensed/citizen.atlas',
scale: 0.22,
animations: {
idle: 'idle',
walk: 'walk',
gather: 'gather',
build: 'build',
sleep: 'sleep',
communicate: 'communicate',
attack: 'attack',
defend: 'defend'
}
};
```

The adapter preloads the aliases through `PIXI.Assets`, creates one Spine container per living Citizen, and maps only canonical `currentAction.type` values to animation tracks. Missing assets or animations fall back safely.

A useful first rig should export at least `idle`, `walk`, `gather`, `build`, `sleep`, and `communicate`. Skins may represent Observer-side interpretations of canonical clothing, equipment and injuries, but they must never write back into the world.
38 changes: 38 additions & 0 deletions site/citizen-animation.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
const ACTION_ANIMATION={
MOVE:'walk',OBSERVE:'observe',REST:'idle',SLEEP:'sleep',EAT:'eat',DRINK:'drink',
GATHER:'gather',CARRY:'carry',CUT:'cut',DIG:'dig',HEAT:'work',COOL:'work',MIX:'work',
ASSEMBLE:'build',BUILD:'build',CARE:'care',TEACH:'communicate',COMMUNICATE:'communicate',
EXPERIMENT:'experiment',ATTACK:'attack',DEFEND:'defend',TRANSFER:'carry',
PROMISE:'communicate',CLAIM:'communicate',REPRODUCE:'idle',
};
function hashUnit(value){
let h=2166136261>>>0;
for(const ch of String(value||'')){h^=ch.charCodeAt(0);h=Math.imul(h,16777619)>>>0;}
return(h>>>0)/4294967295;
}
export function animationForCitizen(citizen){
return ACTION_ANIMATION[citizen?.currentAction?.type]||'idle';
}
export function citizenVisualPose(citizen,nowMs=Date.now()){
const action=animationForCitizen(citizen);
const phase=Number(nowMs)/180+hashUnit(citizen?.id)*Math.PI*2;
const wave=Math.sin(phase),pulse=Math.sin(phase*.5);
let y=0,rotation=0,scaleX=1,scaleY=1,shadowScale=1;
if(action==='walk'){
y=-Math.abs(wave)*1.6;rotation=wave*.035;scaleX=1+Math.abs(wave)*.025;
scaleY=1-Math.abs(wave)*.03;shadowScale=1-Math.abs(wave)*.08;
}else if(['gather','cut','dig','build','work'].includes(action)){
y=Math.abs(wave)*.55;rotation=wave*.065;scaleY=1-Math.abs(wave)*.025;shadowScale=1.03;
}else if(action==='carry'){
y=-Math.abs(wave)*.7;rotation=wave*.018;scaleY=.98;
}else if(action==='sleep'){
y=2.2;rotation=-.11;scaleX=1.05;scaleY=.86;shadowScale=1.18;
}else if(['communicate','care','observe','experiment'].includes(action)){
y=pulse*.25;rotation=wave*.025;scaleY=1+pulse*.015;
}else if(['attack','defend'].includes(action)){
y=-Math.abs(wave)*.7;rotation=wave*.09;scaleX=1+Math.abs(wave)*.04;scaleY=.97;
}else{
y=pulse*.18;scaleX=1-pulse*.006;scaleY=1+pulse*.012;
}
return {animation:action,y,rotation,scaleX,scaleY,shadowScale};
}
5 changes: 3 additions & 2 deletions site/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
<div class="brand"><b>CYMONIA</b><span>SOVEREIGN WORLD</span></div>
<div class="world-clock"><i id="liveDot"></i><strong id="worldDate">CONNECTING</strong><small id="worldStatus">CONNECTING</small></div>
<div class="top-metrics">
<span><b id="populationValue">—</b><small>PEOPLE</small></span>
<span><b id="populationValue">—</b><small id="populationLabel">PEOPLE</small></span>
<span><b id="structureValue">—</b><small>STRUCTURES</small></span>
<span><b id="organizationValue">—</b><small>GROUPS</small></span>
<span><b id="environmentValue">—</b><small>CLIMATE</small></span>
Expand All @@ -46,6 +46,7 @@
<button id="historyOpen"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M5 3h14v17H5zM8 7h8M8 11h8M8 15h5"/></svg></span><b>Chronicle</b></button>
<button id="followSelected"><span><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="8"/><circle cx="12" cy="12" r="3"/><path d="M12 1v3M12 20v3M1 12h3M20 12h3"/></svg></span><b>Follow</b></button>
<button id="centerWorld"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="m12 2 10 10-10 10L2 12zM12 7v10M7 12h10"/></svg></span><b>Center</b></button>
<button id="fitPopulation"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M4 9V4h5M15 4h5v5M20 15v5h-5M9 20H4v-5"/><circle cx="12" cy="12" r="3"/></svg></span><b>Fit</b></button>
<button id="overlayKnowledge"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3 5q5-3 9 0 4-3 9 0v15q-5-3-9 0-4-3-9 0zM12 5v15"/></svg></span><b>Knowledge</b></button>
<button id="overlayRelations"><span><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="5" cy="12" r="3"/><circle cx="19" cy="5" r="3"/><circle cx="19" cy="19" r="3"/><path d="m8 11 8-5M8 13l8 5"/></svg></span><b>Relations</b></button>
<button id="fullscreenWorld"><span><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M8 3H3v5M16 3h5v5M3 16v5h5M21 16v5h-5"/></svg></span><b>Fullscreen</b></button>
Expand All @@ -64,7 +65,7 @@
</section>

<div id="toastLayer" class="toast-layer" aria-live="polite"></div>
<div class="control-hints" aria-hidden="true"><span>WASD</span> pan <i></i><span>WHEEL</span> zoom <i></i><span>DOUBLE CLICK</span> center <i></i><span>F</span> fullscreen</div>
<div class="control-hints" aria-hidden="true"><span>WASD</span> pan <i></i><span>WHEEL</span> zoom <i></i><span>DOUBLE CLICK</span> center <i></i><span>V</span> fit people <i></i><span>F</span> fullscreen</div>
<div class="truth-badge" data-mode="CONNECTING"><i></i><span id="truthText">CONNECTING TO CANONICAL WORLD</span></div>
</main>
<!-- GPU renderer and observer-only transient rigid-body effects. Neither library can mutate canonical world state. -->
Expand Down
93 changes: 93 additions & 0 deletions site/observer-concepts.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
const RESOURCE_LABELS={
water:'Water source',
food:'Edible resource',
timber:'Timber source',
stone:'Stone deposit',
clay:'Clay deposit',
ore:'Ore deposit',
};

const titleCase=value=>String(value||'').replace(/[_:-]+/g,' ').replace(/\b\w/g,m=>m.toUpperCase()).trim();
const clean=value=>String(value||'').replace(/\s+/g,' ').trim();

function provenanceRows(entry){
return Array.isArray(entry?.provenance)?entry.provenance.filter(Boolean):[];
}
function provenanceEntityId(entry){
for(const p of provenanceRows(entry)){
const id=p?.entityId||p?.evidence?.entityId||p?.sourceEntityId;
if(id)return String(id);
}
return null;
}
function findEntity(world,id){
if(!world||!id)return null;
const resource=(world.resourceDeposits||[]).find(x=>x.id===id);
if(resource)return {kind:'resource',value:resource};
const building=(world.buildings||[]).find(x=>x.id===id);
if(building)return {kind:'building',value:building};
const project=(world.projects||[]).find(x=>x.id===id);
if(project)return {kind:'project',value:project};
const object=(world.objects||[]).find(x=>x.id===id);
if(object)return {kind:'object',value:object};
return null;
}
function entityLabel(entity){
if(!entity)return null;
const value=entity.value||{};
if(entity.kind==='resource')return RESOURCE_LABELS[value.type]||`${titleCase(value.type||'Natural')} resource`;
if(entity.kind==='building'){
const form=value.form||value.kind||value.type;
return form?`${titleCase(form)} structure`:'Constructed structure';
}
if(entity.kind==='project'){
const form=value.form||value.kind||value.type;
return form?`${titleCase(form)} construction project`:'Construction project';
}
if(entity.kind==='object'){
const material=titleCase(value.material);
const kind=titleCase(value.kind||value.type||'object');
return [material,kind].filter(Boolean).join(' ')||'Physical object';
}
return null;
}
function evidenceLabel(entry){
for(const p of provenanceRows(entry)){
const evidence=p?.evidence||{};
const appearance=clean(evidence.appearance);
if(appearance)return `Observed: ${appearance.slice(0,72)}`;
const touch=clean(evidence.touch);
if(touch)return `Observed material: ${touch.slice(0,64)}`;
if(p?.kind==='communication')return 'Shared concept';
if(p?.kind==='teaching')return 'Taught concept';
if(p?.kind==='experiment')return 'Experimental concept';
}
return null;
}

export function observerConceptView(world,citizen,entry={}){
const canonicalId=String(entry?.concept||'unknown');
const token=citizen?.language?.lexicon?.[canonicalId]||null;
const entityId=provenanceEntityId(entry);
const entity=findEntity(world,entityId);
let label=entityLabel(entity)||evidenceLabel(entry);
if(!label&&canonicalId.startsWith('kstruct:'))label='Observed structure';
if(!label&&token)label=`Shared concept “${token}”`;
if(!label)label='Unknown observed concept';

const confidence=Number.isFinite(Number(entry?.confidence))?Number(entry.confidence):null;
const kinds=[...new Set(provenanceRows(entry).map(p=>p?.kind).filter(Boolean))];
const tooltip=[
`Observer interpretation: ${label}`,
`Canonical concept: ${canonicalId}`,
confidence===null?null:`Confidence: ${Math.round(confidence*100)}%`,
kinds.length?`Learned by: ${kinds.join(', ')}`:null,
entityId?`Entity: ${entityId}`:null,
token?`Citizen word: ${token}`:null,
].filter(Boolean).join(' · ');

return {label,canonicalId,confidence,token,entityId,sourceKinds:kinds,tooltip};
}
export function observerConceptLabel(world,citizen,entry){
return observerConceptView(world,citizen,entry).label;
}
32 changes: 26 additions & 6 deletions site/pixi-observer.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import {terrainAtPublic,terrainDecoration,terrainPalette} from './terrain-model.js';
import {TransientMatterEffects} from './transient-physics.js';
import {isoPoint,sceneEntries,staticSceneKey,citizenFrame,artHash} from './medieval-art.js';
import {citizenVisualPose} from './citizen-animation.js';
import {SpineCitizenAdapter} from './spine-citizen-adapter.js';

const clamp=(v,a,b)=>Math.max(a,Math.min(b,v));
function renderWorldMinute(state,nowMs=Date.now()){const raw=state?.clock?.realEpochMs,epoch=Number(raw),canonical=Number(state?.clock?.worldMinute)||0;return raw!=null&&Number.isFinite(epoch)?Math.max(canonical,(nowMs-epoch)/1000):canonical;}
Expand All @@ -14,7 +16,7 @@ export class PixiObserverLayer{
constructor(canvasFallback,art){
this.canvasFallback=canvasFallback;this.art=art;this.app=null;this.ready=false;this.failed=false;this.state=null;this.staticKey='';this.hits=[];this.citizenSprites=new Map();this.frames=[];this.groundSource=null;this.staticEntries=[];
// Logical object layers share a depth-sorted container, so trees can occlude people correctly.
this.vegetationLayer=[];this.resourceLayer=[];this.structureLayer=[];this.citizenLayer=null;
this.vegetationLayer=[];this.resourceLayer=[];this.structureLayer=[];this.citizenLayer=null;this.spineAdapter=null;
this.init();
}
async init(){
Expand All @@ -32,6 +34,8 @@ export class PixiObserverLayer{
if(!this.art.atlas)throw new Error('Art atlas unavailable');
const source=PIXI.Texture.from(this.art.atlas).source;
this.frames=this.art.frames.map(f=>new PIXI.Texture({source,frame:new PIXI.Rectangle(f.x,f.y,f.w,f.h)}));
this.spineAdapter=new SpineCitizenAdapter(PIXI,globalThis.CYMONIA_SPINE);
await this.spineAdapter.preload();
this.ready=true;
}catch(error){console.warn('Pixi observer unavailable; Canvas fallback remains active.',error);this.app?.destroy(true,{children:true});this.app=null;this.failed=true;}
}
Expand Down Expand Up @@ -60,9 +64,17 @@ export class PixiObserverLayer{
}
ensureCitizens(){
const PIXI=globalThis.PIXI,alive=new Set();
for(const c of this.state.citizens||[]){if(!c.alive)continue;alive.add(c.id);if(this.citizenSprites.has(c.id))continue;
const container=new PIXI.Container(),body=this.sprite(citizenFrame(c),19),shadow=new PIXI.Graphics();shadow.ellipse(1,1,6,2.8).fill({color:0x1d281c,alpha:.22});
const task=new PIXI.Text({text:'',style:{fontFamily:'Georgia',fontSize:10,fill:0xf8e8b5,stroke:{color:0x283021,width:2}}});task.anchor.set(.5);task.position.set(0,-41);container.addChild(shadow,body,task);this.citizenLayer.addChild(container);this.citizenSprites.set(c.id,{container,body,task});
for(const c of this.state.citizens||[]){
if(!c.alive)continue;alive.add(c.id);if(this.citizenSprites.has(c.id))continue;
let entry=this.spineAdapter?.create(c)||null;
if(!entry){
const container=new PIXI.Container(),body=this.sprite(citizenFrame(c),19),shadow=new PIXI.Graphics();
shadow.ellipse(1,1,6,2.8).fill({color:0x1d281c,alpha:.22});
const task=new PIXI.Text({text:'',style:{fontFamily:'Georgia',fontSize:10,fill:0xf8e8b5,stroke:{color:0x283021,width:2}}});
task.anchor.set(.5);task.position.set(0,-41);container.addChild(shadow,body,task);
entry={container,body,task,shadow,baseScale:Math.abs(body.scale.x),spine:false};
}
this.citizenLayer.addChild(entry.container);this.citizenSprites.set(c.id,entry);
}
for(const [id,e] of this.citizenSprites)if(!alive.has(id)){e.container.destroy({children:true});this.citizenSprites.delete(id);}
}
Expand All @@ -75,8 +87,16 @@ export class PixiObserverLayer{
const hits=[];
for(const e of this.staticEntries){if(!e.id)continue;const t=terrainAtPublic(state,e.position.x,e.position.y),p=this.screenPoint(e.position.x,e.position.y,t.elevation,camera);hits.push({...this.art.hitRecord(e,p,camera.zoom),depth:point(e.position.x,e.position.y,t.elevation).y});}
for(const c of state.citizens||[]){if(!c.alive)continue;const e=this.citizenSprites.get(c.id),pos=citizenPosition(c,state),t=terrainAtPublic(state,pos.x,pos.y),p=point(pos.x,pos.y,t.elevation),a=c.currentAction,moving=a?.type==='MOVE'&&minute<Number(a.endsWorldMinute),active=c.id===selected||c.id===follow;
e.container.position.set(p.x,p.y);e.container.zIndex=p.y;e.body.y=moving?Math.sin(performance.now()/125+artHash(c.id)*Math.PI*2):0;
e.body.scale.x=Math.abs(e.body.scale.x)*(moving&&a.targetPosition.x-a.targetPosition.y<a.fromPosition.x-a.fromPosition.y?-1:1);
const flip=Boolean(moving&&a?.targetPosition&&a?.fromPosition&&a.targetPosition.x-a.targetPosition.y<a.fromPosition.x-a.fromPosition.y);
e.container.position.set(p.x,p.y);e.container.zIndex=p.y;
if(e.spine){
this.spineAdapter?.update(e,c,{flip});
}else{
const pose=citizenVisualPose(c,performance.now());
e.body.y=pose.y;e.body.rotation=pose.rotation;
e.body.scale.set((flip?-1:1)*e.baseScale*pose.scaleX,e.baseScale*pose.scaleY);
if(e.shadow)e.shadow.scale.x=pose.shadowScale;
}
e.task.text=a&&(active||camera.zoom>2)?ACTION_ICON[a.type]||'·':'';
const sp=this.screenPoint(pos.x,pos.y,t.elevation,camera);hits.push({...this.art.hitRecord({id:c.id,kind:'citizen',frame:citizenFrame(c),size:19},{x:sp.x,y:sp.y+e.body.y*camera.zoom},camera.zoom,e.body.scale.x<0),depth:p.y});
}
Expand Down
Loading
Loading