Back to Limb Race
SOURCE / PINNED RELEASE

Made of little things.

Limb Race

Release
6a8ae4fc626e…
Author-recorded commit
008c01ee6454…
License
LICENSE
Author’s source reference
nostr://npub182jczunncwe0jn6frpqwq3e0qjws7yqqnc3auccqv9nte2dnd63scjm4rf/wss%3A%2F%2Fgit.napplet.soy%2F/n-c2869f12ad8

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

src/level-editor.ts
import {fitCanvas} from './render.js';
import {levelTrack, PIECE_KINDS, PIECE_NAMES, validateLevel, type LevelSnapshot, type PieceKind} from './level-model.js';
import {createDraft, draftSnapshot, LevelPublisher, LevelStore, levelCollection, levelError, latestLevel, recordSnapshot, viewerKey, type LevelDraft, type LevelRecord} from './levels.js';

type Options={onPlay(snapshot:LevelSnapshot):void;onNotice(text:string):void};
const el=<T extends HTMLElement=HTMLElement>(id:string)=>document.getElementById(id) as T;
function action(label:string,run:()=>void):HTMLButtonElement{const b=document.createElement('button');b.textContent=label;b.addEventListener('click',run);return b;}
export function setupLevels(options:Options):void {
  const store=new LevelStore(),publisher=new LevelPublisher();
  const dialog=el<HTMLDialogElement>('level-editor'),picker=el<HTMLDialogElement>('tracks-dialog');
  let draft:LevelDraft|null=null,selected=0,tab='builtin',busy=false,loading=false,request=0;
  let records:LevelRecord[]=[],nextUntil:number|null=null,knownViewer='',status='';
  const ready=store.load().catch(error=>{status=levelError(error);});
  const message=(text:string)=>{el('level-status').textContent=text;};
  const libraryMessage=(text:string)=>{el('level-list-status').textContent=text;};
  function setBusy(value:boolean){busy=value;for(const button of dialog.querySelectorAll<HTMLButtonElement>('button,input,select'))button.disabled=value;}
  async function saveDraft(feedback=false):Promise<void>{
    if(!draft)return;
    try {const saved=await store.save(draft);if(feedback)message(saved?'Draft saved. Test it before publishing.':'Draft kept for this session. Persistent storage is unavailable.');}
    catch(error){message(`Draft kept for this session. ${levelError(error)}`);}
  }
  function changed(){if(!draft)return;draft.version++;publisher.clear();void saveDraft();draw();}
  function draw(){
    if(!draft)return;
    const canvas=el<HTMLCanvasElement>('level-canvas'),{ctx,w,h}=fitCanvas(canvas),track=levelTrack(draftSnapshot(draft));
    const sx=w/(track.length+100),sy=(h-36)/430,py=(y:number)=>16+(y-50)*sy;
    ctx.clearRect(0,0,w,h);ctx.fillStyle='#edf2e8';ctx.fillRect(0,0,w,h);
    const start=400+draft.data.pieces.slice(0,selected).reduce((n,p)=>n+p.width,0);
    ctx.fillStyle='#d3eb8899';ctx.fillRect(start*sx,0,draft.data.pieces[selected].width*sx,h);
    ctx.beginPath();ctx.moveTo(0,h);for(const p of track.ground)ctx.lineTo(p.x*sx,py(p.y));ctx.lineTo(w,h);ctx.closePath();ctx.fillStyle='#a6ced0';ctx.fill();
    ctx.beginPath();for(const [i,p]of track.ground.entries()){if(i===0)ctx.moveTo(p.x*sx,py(p.y));else ctx.lineTo(p.x*sx,py(p.y));}ctx.strokeStyle='#3e6852';ctx.lineWidth=2;ctx.stroke();
    ctx.fillStyle='#f35748';ctx.beginPath();ctx.arc(130*sx,py(300)-9,5,0,7);ctx.fill();ctx.fillStyle='#263c39';ctx.font='10px monospace';ctx.fillText('START',6,h-7);ctx.textAlign='right';ctx.fillText('FINISH',w-6,h-7);ctx.textAlign='left';
    el('level-length').textContent=`${draft.data.pieces.length} / 24 pieces · ${Math.round(track.length/10)} m`;
  }
  function controls(){
    if(!draft)return;
    const piece=draft.data.pieces[selected];
    el<HTMLSelectElement>('piece-kind').value=piece.kind;el<HTMLInputElement>('piece-width').value=String(piece.width);el<HTMLInputElement>('piece-height').value=String(piece.height);
    el<HTMLInputElement>('piece-height').disabled=piece.kind==='flat';
    el<HTMLButtonElement>('piece-left').disabled=selected===0;el<HTMLButtonElement>('piece-right').disabled=selected===draft.data.pieces.length-1;el<HTMLButtonElement>('piece-remove').disabled=draft.data.pieces.length===1;
    el<HTMLButtonElement>('unpublish-level').hidden=!draft.base||draft.base.deleted;el('publish-level').textContent=draft.base&&!draft.base.deleted?'Publish update':'Publish level';
    const list=el('piece-list');list.replaceChildren();
    draft.data.pieces.forEach((p,i)=>{const b=action(`${i+1} ${PIECE_NAMES[p.kind]}`,()=>{selected=i;controls();draw();});b.className='piece-chip';b.setAttribute('aria-pressed',String(i===selected));list.append(b);});
    draw();
  }
  async function openDraft(value:LevelDraft){
    await ready;
    if(!store.drafts.some(d=>d.id===value.id)&&store.drafts.length>=20){const text='You have 20 drafts. Remove one in Your levels before creating another.';if(dialog.open)message(text);else{await showTab('yours');libraryMessage(text);}return;}
    draft=structuredClone(value);draft.title=draft.title.slice(0,60);selected=0;publisher.clear();el<HTMLInputElement>('level-name').value=draft.title;picker.close();dialog.showModal();controls();message('Edit the terrain, test your track, then publish it to Community.');await saveDraft();
  }
  function applyPieces(pieces:LevelDraft['data']['pieces'],index=selected){
    if(!draft||busy)return;
    try {draft.data=validateLevel({physics:draft.data.physics,pieces});selected=Math.max(0,Math.min(index,pieces.length-1));changed();controls();message('Terrain updated. Test it to find out how it races.');}
    catch(error){message(levelError(error));}
  }
  for(const kind of PIECE_KINDS){
    el('piece-palette').append(action(`+ ${PIECE_NAMES[kind]}`,()=>{
      if(!draft||busy)return;
      const width=kind==='uphill'||kind==='downhill'?300:180,height=kind==='flat'?0:kind==='uphill'||kind==='downhill'?60:95;
      applyPieces([...draft.data.pieces,{kind,width,height}],draft.data.pieces.length);
    }));
  }
  el('piece-form').addEventListener('submit',event=>{
    event.preventDefault();if(!draft||busy)return;
    const pieces=structuredClone(draft.data.pieces),kind=el<HTMLSelectElement>('piece-kind').value as PieceKind;
    pieces[selected]={kind,width:Number(el<HTMLInputElement>('piece-width').value),height:kind==='flat'?0:Number(el<HTMLInputElement>('piece-height').value)};applyPieces(pieces);
  });
  el('piece-kind').addEventListener('change',()=>{el<HTMLInputElement>('piece-height').disabled=el<HTMLSelectElement>('piece-kind').value==='flat';});
  for(const [id,delta]of [['piece-left',-1],['piece-right',1]] as const)el(id).addEventListener('click',()=>{if(!draft||busy)return;const pieces=structuredClone(draft.data.pieces),target=selected+delta;if(target<0||target>=pieces.length)return;[pieces[selected],pieces[target]]=[pieces[target],pieces[selected]];applyPieces(pieces,target);});
  el('piece-remove').addEventListener('click',()=>{if(draft&&draft.data.pieces.length>1)applyPieces(draft.data.pieces.filter((_,i)=>i!==selected),selected-1);});
  el('level-name').addEventListener('input',()=>{if(draft){draft.title=el<HTMLInputElement>('level-name').value;changed();}});
  el('save-level').addEventListener('click',()=>void saveDraft(true));
  el('copy-level').addEventListener('click',()=>{if(draft&&!busy)void openDraft(createDraft(draft.data,`${draft.title.slice(0,50)} copy`));});
  el('test-level').addEventListener('click',async()=>{if(!draft||busy)return;await saveDraft();dialog.close();picker.close();options.onPlay(draftSnapshot(draft));});
  el('create-level').addEventListener('click',()=>void openDraft(createDraft()));
  el('level-canvas').addEventListener('pointerdown',event=>{
    if(!draft||busy)return;const bounds=el('level-canvas').getBoundingClientRect(),track=levelTrack(draftSnapshot(draft)),x=(event.clientX-bounds.left)/bounds.width*(track.length+100);let at=400;
    for(let i=0;i<draft.data.pieces.length;i++){at+=draft.data.pieces[i].width;if(x<at){selected=i;controls();return;}}
  });
  async function publish(deleted=false){
    if(!draft||busy)return;
    if(!draft.title.trim()){message('Name your level before publishing.');return;}
    setBusy(true);message(deleted?'Waiting for permission to unpublish…':'Saving draft, then asking the host to publish…');
    try {await saveDraft();const record=await publisher.publish(draft,deleted);draft.base=record;await saveDraft();message(deleted?'Unpublished from Community. Old public copies may still exist.':'Published to Community. Other players can now discover this level.');records=records.filter(r=>!(r.id===record.id&&r.author===record.author));if(!deleted)records.unshift(record);}
    catch(error){message(`${levelError(error)} Retry uses the same prepared change until you edit it.`);}
    finally {setBusy(false);controls();}
  }
  el('publish-level').addEventListener('click',()=>void publish());el('unpublish-level').addEventListener('click',()=>void publish(true));
  async function editRecord(record:LevelRecord){
    try {const current=await latestLevel(record);if(current.deleted)throw new Error('This level was unpublished.');await openDraft({id:current.id,title:current.title,version:Date.now(),data:validateLevel(current.data),base:current});}
    catch(error){libraryMessage(levelError(error));}
  }
  function renderList(){
    const list=el('level-list');list.replaceChildren();
    if(tab==='yours'){
      for(const value of store.drafts){const row=document.createElement('div');row.className='level-card';const title=document.createElement('strong');title.textContent=value.title||'Untitled track';const sub=document.createElement('small');sub.textContent=value.base&&!value.base.deleted?'Your draft · published version exists':'Your draft · only on this device';const actions=document.createElement('div');actions.className='level-card-actions';actions.append(action('Edit',()=>void openDraft(value)),action('Play',()=>{picker.close();options.onPlay(draftSnapshot(value));}),action('Remove draft',()=>{void store.remove(value.id).then(()=>renderList()).catch(error=>libraryMessage(levelError(error)));}));row.append(title,sub,actions);list.append(row);}
      libraryMessage(status||(!store.drafts.length?'Create a level to get started.':'Drafts stay on this device. Published levels also appear in Community.'));
    }else{
      for(const record of records){const row=document.createElement('div');row.className='level-card';const title=document.createElement('strong');title.textContent=record.title;const sub=document.createElement('small');sub.textContent=`${record.author.slice(0,8)}…${record.author.slice(-4)}${record.author===knownViewer?' · you':''} · ${Math.round(levelTrack(recordSnapshot(record)).length/10)} m`;const actions=document.createElement('div');actions.className='level-card-actions';actions.append(action('Play',()=>{picker.close();options.onPlay(recordSnapshot(record));}),action('Remix',()=>void openDraft(createDraft(validateLevel(record.data),`${record.title.slice(0,50)} remix`))));if(record.author===knownViewer)actions.append(action('Edit published',()=>void editRecord(record)));row.append(title,sub,actions);list.append(row);}
      libraryMessage(status||(!records.length?'No levels in this recent view yet. Publish the first.':'Recent public levels · player-made, with personal race times.'));
    }
    el<HTMLButtonElement>('more-levels').hidden=tab!=='community'||nextUntil===null;el<HTMLButtonElement>('refresh-levels').disabled=loading;
  }
  async function refresh(more=false){
    if(loading)return;
    const ticket=++request;loading=true;status='Loading community levels…';renderList();
    try{
      const library=await levelCollection();knownViewer=await viewerKey();const cursor=more?nextUntil:null;
      const page=await library.list({limit:40,...(cursor===null?{}:{until:cursor})});if(ticket!==request)return;
      const known=new Map((more?records:[]).map(r=>[`${r.author}:${r.id}`,r]));for(const record of page.records){const key=`${record.author}:${record.id}`,old=known.get(key);if(!old||record.createdAt>old.createdAt||(record.createdAt===old.createdAt&&record.revision<old.revision))known.set(key,record);}records=[...known.values()];
      const stalled=more&&page.nextUntil!==null&&cursor!==null&&page.nextUntil>=cursor;nextUntil=stalled?null:page.nextUntil;
      status=[page.incomplete?'Some relays did not respond. This list may be incomplete.':'',page.invalid?`${page.invalid} invalid or incompatible records skipped.`:'',stalled?'The time cursor stopped advancing. Refresh later; this is not an exhaustive list.':''].filter(Boolean).join(' ');
    }catch(error){status=levelError(error);}
    finally{if(ticket===request){loading=false;renderList();}}
  }
  async function showTab(value:string){
    tab=value;await ready;
    for(const name of ['builtin','yours','community'])el(`levels-${name}`).setAttribute('aria-pressed',String(tab===name));
    el('track-options').hidden=tab!=='builtin';el('level-library').hidden=tab==='builtin';el('track-picker-note').hidden=tab!=='builtin';
    if(tab==='community'){status='';renderList();void refresh();}else if(tab==='yours')renderList();
  }
  for(const name of ['builtin','yours','community'])el(`levels-${name}`).addEventListener('click',()=>void showTab(name));
  el('refresh-levels').addEventListener('click',()=>{if(tab==='community')void refresh();else void ready.then(renderList);});el('more-levels').addEventListener('click',()=>void refresh(true));
  el('track-picker').addEventListener('click',()=>{if(tab!=='builtin')void ready.then(renderList);});
  const observer=new ResizeObserver(()=>{if(dialog.open)draw();});observer.observe(el('level-canvas'));
  dialog.addEventListener('cancel',event=>{if(busy)event.preventDefault();});dialog.addEventListener('close',()=>void saveDraft());
  window.addEventListener('pagehide',()=>observer.disconnect(),{once:true});
  void ready.then(()=>{if(status)options.onNotice(status);});
}