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.
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);});
}
