Back to Outbreak: Soycoon City
SOURCE / PINNED RELEASE

Made of little things.

Outbreak: Soycoon City

Release
db7750b1d009…
Author-recorded commit
3ee3f5fd27a6…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-b87c04571a9

Archive hash verified: dbebdc772b142a1c…. The source-to-build association is the author’s claim; it has not been independently rebuilt.

src/character-model.ts
import { AnimationClip, AnimationMixer, Bone, BufferAttribute, BufferGeometry, Color, Group, InterpolateDiscrete, LoopOnce, LoopRepeat, Matrix4, Mesh, MeshLambertMaterial, QuaternionKeyframeTrack, Skeleton, SkinnedMesh, VectorKeyframeTrack, type AnimationAction, type KeyframeTrack } from 'three';
import { clone } from 'three/addons/utils/SkeletonUtils.js';

// Offline glTF subset used by our checked-in Blender exports. No FileLoader,
// URLs, image requests or external buffers cross the napplet boundary.
type Accessor = { bufferView: number; byteOffset?: number; componentType: number; count: number; type: string; normalized?: boolean; sparse?: unknown };
type Primitive = { attributes: Record<string, number>; indices?: number; material?: number; mode?: number; extensions?: unknown; targets?: unknown };
type Document = {
  asset: {version: string}; extensionsRequired?: string[];
  buffers: {byteLength: number; uri?: string}[];
  bufferViews: {buffer: number; byteOffset?: number; byteLength: number; byteStride?: number}[];
  accessors: Accessor[];
  materials?: {pbrMetallicRoughness?: {baseColorFactor?: number[]; baseColorTexture?: unknown}; doubleSided?: boolean}[];
  meshes: {primitives: Primitive[]}[];
  nodes: {name?: string; children?: number[]; translation?: number[]; rotation?: number[]; scale?: number[]; matrix?: number[]; mesh?: number; skin?: number}[];
  skins: {joints: number[]; inverseBindMatrices: number}[];
  scenes: {nodes: number[]}[]; scene?: number;
  animations: {name: string; samplers: {input: number; output: number; interpolation?: string}[]; channels: {sampler: number; target: {node: number; path: string}}[]}[];
};
const components: Record<string, number> = {SCALAR:1,VEC2:2,VEC3:3,VEC4:4,MAT4:16};
export function decodeEmbeddedModel(uri: string): Uint8Array {
  if(!/^data:.*;base64,/.test(uri))throw new Error('Character models must be embedded GLB bytes.');
  const binary=atob(uri.slice(uri.indexOf(',')+1));return Uint8Array.from(binary,c=>c.charCodeAt(0));
}
export function readCharacter(bytes: Uint8Array): {root: Group; clips: AnimationClip[]} {
  const data=new DataView(bytes.buffer,bytes.byteOffset,bytes.byteLength);
  if(bytes.length<28||data.getUint32(0,true)!==0x46546c67||data.getUint32(4,true)!==2||data.getUint32(8,true)!==bytes.length)throw new Error('Invalid GLB container.');
  let doc: Document|undefined,bin:Uint8Array|undefined;
  for(let offset=12;offset+8<=bytes.length;){const size=data.getUint32(offset,true),kind=data.getUint32(offset+4,true);offset+=8;if(offset+size>bytes.length)throw new Error('Truncated GLB chunk.');const chunk=bytes.subarray(offset,offset+size);if(kind===0x4e4f534a)doc=JSON.parse(new TextDecoder().decode(chunk));else if(kind===0x004e4942)bin=chunk;offset+=size;}
  if(!doc||!bin||doc.asset.version!=='2.0'||doc.extensionsRequired?.length||doc.buffers.length!==1||doc.buffers[0].uri||doc.buffers[0].byteLength>bin.length)throw new Error('Use an uncompressed, self-contained glTF 2.0 character.');
  const gltf=doc,binary=bin;
  const attribute=(index:number):BufferAttribute=>{
    const a=gltf.accessors[index],v=gltf.bufferViews[a.bufferView],size=components[a.type];
    const types={5121:Uint8Array,5122:Int16Array,5123:Uint16Array,5125:Uint32Array,5126:Float32Array};
    const ArrayType=types[a.componentType as keyof typeof types];
    if(!v||v.buffer!==0||a.sparse||!ArrayType||!size)throw new Error('Unsupported GLB accessor.');
    const start=(v.byteOffset??0)+(a.byteOffset??0),element=ArrayType.BYTES_PER_ELEMENT,stride=v.byteStride??size*element;
    const end=start+(a.count-1)*stride+size*element;
    if(stride<size*element||start<0||end>binary.length||end>(v.byteOffset??0)+v.byteLength)throw new Error('GLB accessor exceeds its buffer.');
    const out=new ArrayType(a.count*size);const view=new DataView(binary.buffer,binary.byteOffset,binary.byteLength);
    for(let i=0;i<a.count;i++)for(let c=0;c<size;c++){const at=start+i*stride+c*element;out[i*size+c]=a.componentType===5126?view.getFloat32(at,true):a.componentType===5125?view.getUint32(at,true):a.componentType===5123?view.getUint16(at,true):a.componentType===5122?view.getInt16(at,true):view.getUint8(at);}
    return new BufferAttribute(out,size,a.normalized??false);
  };
  const materials=(gltf.materials??[]).map(m=>{if(m.pbrMetallicRoughness?.baseColorTexture)throw new Error('Export factor materials without image textures.');const factor=m.pbrMetallicRoughness?.baseColorFactor??[1,1,1,1];return new MeshLambertMaterial({color:new Color().setRGB(factor[0],factor[1],factor[2]),flatShading:true});});
  const jointIds=new Set(gltf.skins.flatMap(s=>s.joints));
  const names=new Set<string>();
  const nodes=gltf.nodes.map((n,i)=>{const o=jointIds.has(i)?new Bone():new Group();o.name=n.name??`Node_${i}`;if(names.has(o.name))throw new Error('Use unique names for character nodes.');names.add(o.name);if(n.matrix){new Matrix4().fromArray(n.matrix).decompose(o.position,o.quaternion,o.scale);}else{if(n.translation)o.position.fromArray(n.translation);if(n.rotation)o.quaternion.fromArray(n.rotation);if(n.scale)o.scale.fromArray(n.scale);}return o;});
  const skinned: {mesh:SkinnedMesh;skin:number}[]=[];
  const attrs:Record<string,string>={POSITION:'position',NORMAL:'normal',TEXCOORD_0:'uv',JOINTS_0:'skinIndex',WEIGHTS_0:'skinWeight'};
  gltf.nodes.forEach((n,i)=>{
    for(const child of n.children??[])nodes[i].add(nodes[child]);
    if(n.mesh===undefined)return;
    for(const p of gltf.meshes[n.mesh].primitives){
      if((p.mode!==undefined&&p.mode!==4)||p.extensions||p.targets)throw new Error('Export triangles without compression or shape keys.');
      const geometry=new BufferGeometry();for(const [key,index] of Object.entries(p.attributes))if(attrs[key])geometry.setAttribute(attrs[key],attribute(index));if(p.indices!==undefined)geometry.setIndex(attribute(p.indices));
      const material=materials[p.material??0]??new MeshLambertMaterial({flatShading:true});
      const mesh=n.skin===undefined?new Mesh(geometry,material):new SkinnedMesh(geometry,material);mesh.frustumCulled=false;nodes[i].add(mesh);
      if(mesh instanceof SkinnedMesh)skinned.push({mesh,skin:n.skin!});
    }
  });
  const root=new Group();for(const index of gltf.scenes[gltf.scene??0].nodes)root.add(nodes[index]);root.updateMatrixWorld(true);
  for(const {mesh,skin} of skinned){const s=gltf.skins[skin],a=attribute(s.inverseBindMatrices).array;const inverses=s.joints.map((_,i)=>new Matrix4().fromArray(a,i*16));mesh.bind(new Skeleton(s.joints.map(i=>nodes[i] as Bone),inverses),mesh.matrixWorld.clone());}
  const clips=gltf.animations.map(a=>{
    const tracks:KeyframeTrack[]=a.channels.map(c=>{const s=a.samplers[c.sampler];if(s.interpolation&& !['LINEAR','STEP'].includes(s.interpolation))throw new Error('Bake animation to linear keyframes.');const name=nodes[c.target.node].name,times=attribute(s.input).array,values=attribute(s.output).array;
      const track=c.target.path==='rotation'?new QuaternionKeyframeTrack(`${name}.quaternion`,times,values):new VectorKeyframeTrack(`${name}.${c.target.path==='translation'?'position':'scale'}`,times,values);if(s.interpolation==='STEP')track.setInterpolation(InterpolateDiscrete);return track;});
    return new AnimationClip(a.name,-1,tracks);
  });
  return {root,clips};
}
export class CharacterAsset {
  root: Group; clips:AnimationClip[];
  constructor(bytes:Uint8Array){const model=readCharacter(bytes);this.root=model.root;this.clips=model.clips;}
  instantiate():CharacterActor{return new CharacterActor(clone(this.root) as Group,this.clips);}
  dispose():void{const materials=new Set<MeshLambertMaterial>();this.root.traverse(o=>{if(o instanceof Mesh){o.geometry.dispose();for(const m of Array.isArray(o.material)?o.material:[o.material])materials.add(m as MeshLambertMaterial);}});for(const m of materials)m.dispose();}
}
export class CharacterActor {
  root:Group; mixer:AnimationMixer; actions=new Map<string,AnimationAction>(); current=''; token=-1;
  constructor(root:Group,clips:AnimationClip[]){this.root=root;this.mixer=new AnimationMixer(root);for(const c of clips)this.actions.set(c.name,this.mixer.clipAction(c));this.play('Idle');this.mixer.update(0);}
  play(name:string,once=false,token=-1,elapsed=0):void{
    if(this.current===name&&this.token===token)return;
    const action=this.actions.get(name);if(!action)throw new Error(`Missing character animation: ${name}`);
    const previous=this.actions.get(this.current);action.reset().setLoop(once?LoopOnce:LoopRepeat,once?1:Infinity);action.clampWhenFinished=once;action.time=Math.min(elapsed,action.getClip().duration);action.play();
    if(previous&&previous!==action){action.crossFadeFrom(previous,.08,false);}this.current=name;this.token=token;
  }
  update(dt:number):void{this.mixer.update(dt);}
  dispose():void{this.mixer.stopAllAction();this.mixer.uncacheRoot(this.root);const skeletons=new Set<Skeleton>();this.root.traverse(o=>{if(o instanceof SkinnedMesh)skeletons.add(o.skeleton);});for(const skeleton of skeletons)skeleton.dispose();this.root.removeFromParent();}
}