009
This commit is contained in:
parent
35f3810632
commit
089d28869b
13 changed files with 11741 additions and 27 deletions
|
|
@ -2,19 +2,27 @@
|
|||
"use strict";
|
||||
const $ = s => document.querySelector(s), $$ = s => [...document.querySelectorAll(s)];
|
||||
const canvas = $("#field"), ctx = canvas.getContext("2d");
|
||||
const ASCENT = window.CATALYST_MODE === "ascent";
|
||||
const CONFIG = ASCENT ? { experiment:"009_catalyst_ascent", revision:1, storage:"catalyst-ascent-last-jsonl", filename:"catalyst-ascent", choiceLimit:7 } : { experiment:"008_catalyst_ecology", revision:2, storage:"catalyst-ecology-last-jsonl", filename:"catalyst-ecology", choiceLimit:4 };
|
||||
const COLORS = { mint: "#68e0b5", cyan: "#66d8f2", violet: "#b28bf5", amber: "#efb55e", red: "#ee6f72", rose: "#f18cba" };
|
||||
const KEYS = { KeyW:[0,-1],ArrowUp:[0,-1],KeyS:[0,1],ArrowDown:[0,1],KeyA:[-1,0],ArrowLeft:[-1,0],KeyD:[1,0],ArrowRight:[1,0] };
|
||||
const ENEMIES = {
|
||||
mote:{name:"Motes",icon:"●",radius:.017,hp:2,speed:.068,color:COLORS.red},
|
||||
husk:{name:"Husks",icon:"⬢",radius:.026,hp:7,speed:.044,color:COLORS.amber},
|
||||
titan:{name:"Titans",icon:"◆",radius:.036,hp:18,speed:.029,color:COLORS.violet},
|
||||
brood:{name:"Broods",icon:"✹",radius:.032,hp:12,speed:.024,color:COLORS.rose}
|
||||
brood:{name:"Broods",icon:"✹",radius:.032,hp:12,speed:.024,color:COLORS.rose},
|
||||
ward:{name:"Wards",icon:"⬡",radius:.032,hp:14,speed:.034,color:"#75bff2"},
|
||||
renewal:{name:"Renewals",icon:"✚",radius:.038,hp:30,speed:.026,color:"#8fe28b"}
|
||||
};
|
||||
const EXPEDITIONS = {
|
||||
const ECOLOGY_EXPEDITIONS = {
|
||||
shoal:{name:"Shoal",copy:"Small bodies arrive in increasingly dense currents.",waves:[{mote:7},{mote:12},{mote:18,husk:1},{mote:24,husk:2},{mote:32,husk:3},{mote:36,husk:4},{mote:40,husk:5,titan:1},{mote:44,husk:6,titan:2}]},
|
||||
bastion:{name:"Bastion",copy:"Durable bodies concentrate health into fewer targets.",waves:[{husk:3},{husk:4},{titan:2,husk:2,mote:4},{titan:3,husk:3,mote:5},{titan:4,husk:4,mote:6},{titan:5,husk:5,mote:8},{titan:5,husk:7,mote:12},{titan:6,husk:8,mote:16}]},
|
||||
brood:{name:"Brood",copy:"Brood bodies release fresh motes while mixed bodies close in.",waves:[{mote:7},{brood:1,mote:6},{brood:2,mote:8},{brood:3,husk:2,mote:8},{brood:3,titan:2,mote:10},{brood:4,titan:2,husk:3,mote:10},{brood:4,titan:3,husk:4,mote:12},{brood:5,titan:3,husk:5,mote:14}]}
|
||||
};
|
||||
const ASCENT_EXPEDITIONS = {
|
||||
ascent:{name:"Ascent",copy:"The population acquires re-forming wards, regeneration, and spawning as the run climbs.",waves:[{mote:7},{mote:12,husk:1},{brood:1,mote:8},{ward:1,mote:10},{ward:2,husk:3,mote:10},{renewal:1,ward:2,mote:12},{renewal:2,brood:2,ward:2,mote:12},{renewal:2,ward:3,titan:2,mote:16},{renewal:3,brood:3,ward:3,titan:2,mote:18},{renewal:4,brood:4,ward:4,titan:3,mote:22}]}
|
||||
};
|
||||
const EXPEDITIONS = ASCENT ? ASCENT_EXPEDITIONS : ECOLOGY_EXPEDITIONS;
|
||||
const MODULES = {
|
||||
fork:{icon:"⋔",name:"Fork",short:"Fragments on primary hit",color:COLORS.cyan},
|
||||
bloom:{icon:"✦",name:"Bloom",short:"Seeking sparks on any kill",color:COLORS.mint},
|
||||
|
|
@ -26,16 +34,16 @@
|
|||
const clamp=(v,a,b)=>Math.max(a,Math.min(b,v)), round=v=>Math.round(v*1000)/1000;
|
||||
const norm=(x,y)=>{const d=Math.hypot(x,y)||1;return[x/d,y/d]}, dist=(a,b)=>Math.hypot(a.x-b.x,a.y-b.y);
|
||||
const session=crypto.randomUUID?crypto.randomUUID():`session-${Date.now()}`;
|
||||
const state={session,startedAt:Date.now(),logs:[],started:false,active:false,choosing:false,complete:false,expedition:"shoal",attempt:0,wave:0,waveAttempt:0,runTime:0,waveTime:0,choice:0,completed:new Set(),
|
||||
const state={session,startedAt:Date.now(),logs:[],started:false,active:false,choosing:false,complete:false,expedition:ASCENT?"ascent":"shoal",attempt:0,wave:0,waveAttempt:0,runTime:0,waveTime:0,choice:0,completed:new Set(),
|
||||
upgrades:{fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0},player:{x:.5,y:.5,vx:0,vy:0,health:6,inv:0,lastDamage:-99},keys:new Set(),pointer:{x:.75,y:.5,firing:false},fireCd:0,
|
||||
enemies:[],bullets:[],particles:[],effects:[],enemyId:0,bulletId:0,totalHitCounter:0,secondaryCounter:0,secondaryBudget:300,actions:null,lastFrame:performance.now(),snapshot:0,clearDelay:0,render:{size:1,ox:0,oy:0},bannerTimer:null,toastTimer:null};
|
||||
const freshActions=()=>({shots:0,primary_hits:0,fragment_hits:0,spark_hits:0,lance_hits:0,arc_triggers:0,focus_ruptures:0,conduit_lances:0,kills:0,spawned_motes:0,damage_taken:0}); state.actions=freshActions();
|
||||
function log(type,data={}){const e={schema:1,experiment:"008_catalyst_ecology",prototype_revision:2,session_id:state.session,elapsed_ms:Date.now()-state.startedAt,expedition:state.expedition,run_attempt:state.attempt,wave:state.wave+1,wave_attempt:state.waveAttempt,run_seconds:round(state.runTime),type,...data};state.logs.push(JSON.stringify(e));try{localStorage.setItem("catalyst-ecology-last-jsonl",state.logs.join("\n")+"\n")}catch(_){}}
|
||||
function log(type,data={}){const e={schema:1,experiment:CONFIG.experiment,prototype_revision:CONFIG.revision,session_id:state.session,elapsed_ms:Date.now()-state.startedAt,expedition:state.expedition,run_attempt:state.attempt,wave:state.wave+1,wave_attempt:state.waveAttempt,run_seconds:round(state.runTime),type,...data};state.logs.push(JSON.stringify(e));try{localStorage.setItem(CONFIG.storage,state.logs.join("\n")+"\n")}catch(_){}}
|
||||
const build=()=>Object.fromEntries(Object.entries(state.upgrades).filter(([,v])=>v));
|
||||
const secondaryScale=()=>1+state.upgrades.resonance*.55;
|
||||
function populationText(spec){return Object.entries(spec).map(([k,v])=>`${v} ${ENEMIES[k].name}`).join(" · ")}
|
||||
function layout(spec){const entries=[];for(const[k,count]of Object.entries(spec)){for(let i=0;i<count;i++)entries.push(k)}return entries.map((kind,i)=>{const n=entries.length,a=i/n*Math.PI*2+state.wave*.39,ring=.34+((i*5+state.wave)%4)*.035;return{kind,x:.5+Math.cos(a)*ring,y:.5+Math.sin(a)*ring}})}
|
||||
function spawnEnemy(kind,x,y,spawned=false){const d=ENEMIES[kind];state.enemies.push({id:++state.enemyId,kind,x,y,vx:0,vy:0,radius:d.radius,hp:d.hp,maxHp:d.hp,removed:false,flash:0,contact:0,focusHits:0,spawnCd:2+Math.random()*.7,spawnsLeft:kind==="brood"?2+Math.floor(state.wave/2):0});if(spawned)state.actions.spawned_motes++}
|
||||
function spawnEnemy(kind,x,y,spawned=false){const d=ENEMIES[kind];state.enemies.push({id:++state.enemyId,kind,x,y,vx:0,vy:0,radius:d.radius,hp:d.hp,maxHp:d.hp,removed:false,flash:0,contact:0,focusHits:0,spawnCd:2+Math.random()*.7,spawnsLeft:kind==="brood"?2+Math.floor(state.wave/2):0,ward:kind==="ward"?8:0,wardMax:kind==="ward"?8:0,wardWindow:0,wardCooldown:0,lastHit:-99,regenTotal:0});if(spawned)state.actions.spawned_motes++}
|
||||
function spawnWave(reason){state.waveAttempt++;state.waveTime=0;state.snapshot=0;state.clearDelay=0;state.totalHitCounter=0;state.secondaryCounter=0;state.secondaryBudget=300;state.enemies=[];state.bullets=[];state.particles=[];state.effects=[];state.player={x:.5,y:.5,vx:0,vy:0,health:6,inv:1,lastDamage:-99};state.fireCd=0;state.pointer.firing=false;layout(EXPEDITIONS[state.expedition].waves[state.wave]).forEach(e=>spawnEnemy(e.kind,e.x,e.y));state.active=state.started;state.choosing=false;state.complete=false;$("#choice-overlay").classList.add("hidden");$("#complete-overlay").classList.add("hidden");updateUI();log("wave_started",{reason,population:EXPEDITIONS[state.expedition].waves[state.wave],enemy_count:state.enemies.length,build:build()});showBanner(`Field ${state.wave+1}`);canvas.focus({preventScroll:true})}
|
||||
function startRun(id,reason){if(state.started&&(state.active||state.choosing)&&!state.complete)log("run_abandoned",{reason,remaining:state.enemies.length,build:build(),actions:state.actions});state.expedition=id;state.attempt++;state.wave=0;state.waveAttempt=0;state.runTime=0;state.choice=0;state.upgrades={fork:0,bloom:0,arc:0,focus:0,conduit:0,resonance:0};state.actions=freshActions();state.complete=false;state.choosing=false;$$('.expedition').forEach(b=>b.classList.toggle("selected",b.dataset.expedition===id));log("run_started",{reason,expedition:id});spawnWave("run_started")}
|
||||
function begin(){if(state.started)return;state.started=true;$("#start-overlay").classList.add("hidden");log("session_started",{viewport:[innerWidth,innerHeight]});startRun(state.expedition,"session_started")}
|
||||
|
|
@ -51,29 +59,29 @@
|
|||
function triggerArc(origin){const l=state.upgrades.arc;if(!l)return;const threshold=Math.max(3,7-l);if(state.totalHitCounter%threshold!==0)return;const targets=nearest(origin,1+l,.32,new Set([origin.id]));state.actions.arc_triggers++;for(const t of targets){if(!spendBudget())break;lineEffect("arc",origin,t);damageEnemy(t,.78*secondaryScale(),"arc");secondaryHit(t,"arc")}log("module_triggered",{module:"arc",source_id:origin.id,target_ids:targets.map(t=>t.id),threshold})}
|
||||
function triggerFocus(enemy){const l=state.upgrades.focus;if(!l)return;enemy.focusHits++;const threshold=Math.max(2,6-l);if(enemy.focusHits<threshold)return;enemy.focusHits=0;if(!spendBudget())return;state.actions.focus_ruptures++;effect("focus",enemy.x,enemy.y);damageEnemy(enemy,(3+l*2)*secondaryScale(),"focus");secondaryHit(enemy,"focus");log("module_triggered",{module:"focus",source_id:enemy.id,threshold})}
|
||||
function hit(enemy,bullet){if(enemy.removed||bullet.removed)return;bullet.removed=true;if(bullet.kind==="primary")state.actions.primary_hits++;else if(bullet.kind==="fragment")state.actions.fragment_hits++;else if(bullet.kind==="spark")state.actions.spark_hits++;else if(bullet.kind==="lance")state.actions.lance_hits++;damageEnemy(enemy,bullet.damage,bullet.kind);if(bullet.kind==="primary"){triggerFocus(enemy);triggerFork(enemy,bullet)}else secondaryHit(enemy,bullet.kind);state.totalHitCounter++;triggerArc(enemy)}
|
||||
function damageEnemy(enemy,amount,cause){if(enemy.removed)return;enemy.hp-=amount;enemy.flash=.1;burst(enemy.x,enemy.y,cause==="arc"?COLORS.violet:COLORS.mint,4);log("enemy_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,amount:round(amount),cause,hp_after:round(Math.max(0,enemy.hp))});if(enemy.hp>0)return;enemy.removed=true;state.actions.kills++;burst(enemy.x,enemy.y,ENEMIES[enemy.kind].color,12);log("enemy_killed",{enemy_id:enemy.id,enemy_kind:enemy.kind,cause,remaining_after:state.enemies.filter(e=>!e.removed).length});triggerBloom(enemy)}
|
||||
function damageEnemy(enemy,amount,cause){if(enemy.removed)return;enemy.lastHit=state.waveTime;if(enemy.kind==="ward"&&enemy.ward>0){if(cause==="focus"||cause==="lance"){enemy.ward=0;enemy.wardWindow=0;enemy.wardCooldown=2.8;log("ward_broken",{enemy_id:enemy.id,cause,bypass:true})}else{if(enemy.ward===enemy.wardMax)enemy.wardWindow=1.55;enemy.ward--;enemy.flash=.1;burst(enemy.x,enemy.y,"#75bff2",3);log("ward_hit",{enemy_id:enemy.id,cause,ward_after:enemy.ward,window_seconds:round(enemy.wardWindow)});if(enemy.ward>0)return;enemy.wardWindow=0;enemy.wardCooldown=2.8;log("ward_broken",{enemy_id:enemy.id,cause,bypass:false});return}}enemy.hp-=amount;enemy.flash=.1;burst(enemy.x,enemy.y,cause==="arc"?COLORS.violet:COLORS.mint,4);log("enemy_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,amount:round(amount),cause,hp_after:round(Math.max(0,enemy.hp))});if(enemy.hp>0)return;enemy.removed=true;state.actions.kills++;burst(enemy.x,enemy.y,ENEMIES[enemy.kind].color,12);log("enemy_killed",{enemy_id:enemy.id,enemy_kind:enemy.kind,cause,remaining_after:state.enemies.filter(e=>!e.removed).length});triggerBloom(enemy)}
|
||||
function damagePlayer(enemy){if(state.player.inv>0||!state.active)return;state.player.health--;state.player.inv=.85;state.player.lastDamage=state.runTime;state.actions.damage_taken++;const[dx,dy]=norm(state.player.x-enemy.x,state.player.y-enemy.y);state.player.vx+=dx*.22;state.player.vy+=dy*.22;burst(state.player.x,state.player.y,COLORS.red,10);log("player_damaged",{enemy_id:enemy.id,enemy_kind:enemy.kind,health_after:state.player.health});if(state.player.health<=0)defeat()}
|
||||
function defeat(){state.active=false;state.pointer.firing=false;state.keys.clear();log("player_defeated",{remaining:state.enemies.length,build:build(),actions:state.actions});showBanner("Field restoring");setTimeout(()=>{if(state.started&&!state.active&&!state.choosing&&!state.complete)spawnWave("defeat_restart")},800)}
|
||||
function clearWave(){if(!state.active)return;state.active=false;state.pointer.firing=false;log("wave_completed",{duration_seconds:round(state.waveTime),health:state.player.health,build:build(),actions:state.actions});const waves=EXPEDITIONS[state.expedition].waves;if(state.wave===waves.length-1){completeRun();return}if(state.choice<4){showChoices();return}state.wave++;state.waveAttempt=0;const expectedAttempt=state.attempt,expectedExpedition=state.expedition;log("post_build_field_advanced",{next_population:waves[state.wave],build:build()});updateUI();showBanner(`Build retained · Field ${state.wave+1}`);setTimeout(()=>{if(state.attempt===expectedAttempt&&state.expedition===expectedExpedition&&!state.active&&!state.choosing&&!state.complete)spawnWave("post_build_advance")},650)}
|
||||
function clearWave(){if(!state.active)return;state.active=false;state.pointer.firing=false;log("wave_completed",{duration_seconds:round(state.waveTime),health:state.player.health,build:build(),actions:state.actions});const waves=EXPEDITIONS[state.expedition].waves;if(state.wave===waves.length-1){completeRun();return}if(state.choice<CONFIG.choiceLimit){showChoices();return}state.wave++;state.waveAttempt=0;const expectedAttempt=state.attempt,expectedExpedition=state.expedition;log("post_build_field_advanced",{next_population:waves[state.wave],build:build()});updateUI();showBanner(`Build retained · Field ${state.wave+1}`);setTimeout(()=>{if(state.attempt===expectedAttempt&&state.expedition===expectedExpedition&&!state.active&&!state.choosing&&!state.complete)spawnWave("post_build_advance")},650)}
|
||||
function moduleDescription(key,next){if(key==="fork")return `${next*2} fragments leave every primary impact.`;if(key==="bloom")return `${2+next} seeking sparks leave every destroyed body.`;if(key==="arc")return `Every ${Math.max(3,7-next)} projectile hits chain into up to ${1+next} nearby bodies.`;if(key==="focus")return `${Math.max(2,6-next)} repeated primary hits rupture one body for ${3+next*2} damage.`;if(key==="conduit")return `Every ${Math.max(3,9-next*2)} secondary hits launch a ${Math.round((3+next*1.8)*10)/10}-damage lance at the healthiest body.`;return `All fragment, spark, Arc, Focus, and Conduit damage becomes ${(1+next*.55).toFixed(2)}×.`}
|
||||
function showChoices(){state.choosing=true;state.choice++;const nextSpec=EXPEDITIONS[state.expedition].waves[state.wave+1];$("#next-forecast").textContent=`Next field: ${populationText(nextSpec)}.`;const box=$("#choices");box.replaceChildren();for(const[key,d]of Object.entries(MODULES)){const next=state.upgrades[key]+1,b=document.createElement("button");b.className="choice";b.dataset.module=key;b.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${d.name}</b><small>${moduleDescription(key,next)}</small><em>${state.upgrades[key]?`Level ${state.upgrades[key]} → ${next}`:"Not selected"}</em>`;b.onclick=()=>choose(key);box.append(b)}$("#choice-overlay").classList.remove("hidden");log("choices_shown",{choice_number:state.choice,next_population:nextSpec,options:Object.keys(MODULES).map(k=>({key:k,current_level:state.upgrades[k]}))})}
|
||||
function choose(key){if(!state.choosing||!MODULES[key])return;state.upgrades[key]++;state.choosing=false;log("module_chosen",{choice_number:state.choice,module:key,new_level:state.upgrades[key],build:build()});state.wave++;state.waveAttempt=0;spawnWave("module_chosen")}
|
||||
function completeRun(){state.complete=true;state.active=false;state.completed.add(state.expedition);log("run_completed",{duration_seconds:round(state.runTime),build:build(),actions:state.actions});document.querySelector(`[data-expedition="${state.expedition}"]`).classList.add("complete");const ids=Object.keys(EXPEDITIONS),next=ids[(ids.indexOf(state.expedition)+1)%ids.length];$("#complete-title").textContent=`${EXPEDITIONS[state.expedition].name} stabilized.`;$("#next-expedition").textContent=`Start ${EXPEDITIONS[next].name}`;$("#next-expedition").dataset.expedition=next;$("#complete-overlay").classList.remove("hidden");updateUI()}
|
||||
function updatePlayer(dt){state.player.inv=Math.max(0,state.player.inv-dt);state.fireCd=Math.max(0,state.fireCd-dt);if(state.player.health<6&&state.runTime-state.player.lastDamage>3.5)state.player.health=Math.min(6,state.player.health+dt*.45);let x=0,y=0;for(const k of state.keys){const v=KEYS[k];if(v){x+=v[0];y+=v[1]}}if(x||y){[x,y]=norm(x,y);state.player.vx+=x*.92*dt;state.player.vy+=y*.92*dt}const drag=Math.pow(.025,dt);state.player.vx*=drag;state.player.vy*=drag;const s=Math.hypot(state.player.vx,state.player.vy);if(s>.29){state.player.vx*=.29/s;state.player.vy*=.29/s}state.player.x=clamp(state.player.x+state.player.vx*dt,.04,.96);state.player.y=clamp(state.player.y+state.player.vy*dt,.04,.96);if(state.pointer.firing)fire()}
|
||||
function updateEnemies(dt){for(const e of state.enemies){if(e.removed)continue;e.flash=Math.max(0,e.flash-dt);e.contact=Math.max(0,e.contact-dt);if(e.kind==="brood"&&e.spawnsLeft>0){e.spawnCd-=dt;if(e.spawnCd<=0){e.spawnCd=2.35;e.spawnsLeft--;const a=Math.random()*Math.PI*2;spawnEnemy("mote",clamp(e.x+Math.cos(a)*.055,.04,.96),clamp(e.y+Math.sin(a)*.055,.04,.96),true);log("brood_spawned",{brood_id:e.id,remaining_spawns:e.spawnsLeft})}}const[dx,dy]=norm(state.player.x-e.x,state.player.y-e.y),d=ENEMIES[e.kind];e.vx+=dx*d.speed*4.5*dt;e.vy+=dy*d.speed*4.5*dt;const drag=Math.pow(.12,dt);e.vx*=drag;e.vy*=drag;const s=Math.hypot(e.vx,e.vy);if(s>d.speed){e.vx*=d.speed/s;e.vy*=d.speed/s}e.x=clamp(e.x+e.vx*dt,e.radius,1-e.radius);e.y=clamp(e.y+e.vy*dt,e.radius,1-e.radius);if(dist(e,state.player)<e.radius+.022)damagePlayer(e)}for(let i=0;i<state.enemies.length;i++){const a=state.enemies[i];if(a.removed)continue;for(let j=i+1;j<state.enemies.length;j++){const b=state.enemies[j];if(b.removed)continue;const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||.001,min=a.radius+b.radius;if(d>=min)continue;const o=(min-d)/2,nx=dx/d,ny=dy/d;a.x-=nx*o;a.y-=ny*o;b.x+=nx*o;b.y+=ny*o}}state.enemies=state.enemies.filter(e=>!e.removed)}
|
||||
function updateEnemies(dt){for(const e of state.enemies){if(e.removed)continue;e.flash=Math.max(0,e.flash-dt);e.contact=Math.max(0,e.contact-dt);if(e.kind==="ward"){if(e.ward>0&&e.ward<e.wardMax){e.wardWindow-=dt;if(e.wardWindow<=0){const before=e.ward;e.ward=e.wardMax;log("ward_reset",{enemy_id:e.id,ward_before:before})}}else if(e.ward===0){e.wardCooldown-=dt;if(e.wardCooldown<=0){e.ward=e.wardMax;log("ward_reformed",{enemy_id:e.id,hp:round(e.hp)})}}}if(e.kind==="renewal"&&e.hp<e.maxHp){const before=e.hp;e.hp=Math.min(e.maxHp,e.hp+3.6*dt);e.regenTotal+=e.hp-before;if(e.regenTotal>=3){log("renewal_regenerated",{enemy_id:e.id,amount:round(e.regenTotal),hp:round(e.hp)});e.regenTotal=0}}if(e.kind==="brood"&&e.spawnsLeft>0){e.spawnCd-=dt;if(e.spawnCd<=0){e.spawnCd=2.35;e.spawnsLeft--;const a=Math.random()*Math.PI*2;spawnEnemy("mote",clamp(e.x+Math.cos(a)*.055,.04,.96),clamp(e.y+Math.sin(a)*.055,.04,.96),true);log("brood_spawned",{brood_id:e.id,remaining_spawns:e.spawnsLeft})}}const[dx,dy]=norm(state.player.x-e.x,state.player.y-e.y),d=ENEMIES[e.kind];e.vx+=dx*d.speed*4.5*dt;e.vy+=dy*d.speed*4.5*dt;const drag=Math.pow(.12,dt);e.vx*=drag;e.vy*=drag;const s=Math.hypot(e.vx,e.vy);if(s>d.speed){e.vx*=d.speed/s;e.vy*=d.speed/s}e.x=clamp(e.x+e.vx*dt,e.radius,1-e.radius);e.y=clamp(e.y+e.vy*dt,e.radius,1-e.radius);if(dist(e,state.player)<e.radius+.022)damagePlayer(e)}for(let i=0;i<state.enemies.length;i++){const a=state.enemies[i];if(a.removed)continue;for(let j=i+1;j<state.enemies.length;j++){const b=state.enemies[j];if(b.removed)continue;const dx=b.x-a.x,dy=b.y-a.y,d=Math.hypot(dx,dy)||.001,min=a.radius+b.radius;if(d>=min)continue;const o=(min-d)/2,nx=dx/d,ny=dy/d;a.x-=nx*o;a.y-=ny*o;b.x+=nx*o;b.y+=ny*o}}state.enemies=state.enemies.filter(e=>!e.removed)}
|
||||
function updateBullets(dt){for(const b of state.bullets){if(b.removed)continue;b.life-=dt;if(b.targetId&&b.kind!=="primary"){let t=state.enemies.find(e=>e.id===b.targetId&&!e.removed);if(!t){t=b.kind==="lance"?healthiest():nearest(b,1,.8)[0];b.targetId=t?.id||null}if(t){const[dx,dy]=norm(t.x-b.x,t.y-b.y);b.vx+=dx*1.3*dt;b.vy+=dy*1.3*dt;const targetSpeed=b.kind==="lance"?.65:.48,s=Math.hypot(b.vx,b.vy)||1;b.vx=b.vx/s*targetSpeed;b.vy=b.vy/s*targetSpeed}}b.x+=b.vx*dt;b.y+=b.vy*dt;if(b.life<=0||b.x<-.05||b.x>1.05||b.y<-.05||b.y>1.05){b.removed=true;continue}for(const e of state.enemies)if(!e.removed&&dist(b,e)<=b.radius+e.radius){hit(e,b);break}}state.bullets=state.bullets.filter(b=>!b.removed)}
|
||||
function updateEffects(dt){for(const p of state.particles){p.life-=dt;p.x+=p.vx*dt;p.y+=p.vy*dt;p.vx*=Math.pow(.12,dt);p.vy*=Math.pow(.12,dt)}for(const e of state.effects)e.life-=dt;state.particles=state.particles.filter(p=>p.life>0);state.effects=state.effects.filter(e=>e.life>0)}
|
||||
function update(dt){if(!state.active)return;state.runTime+=dt;state.waveTime+=dt;state.snapshot+=dt;updatePlayer(dt);updateEnemies(dt);updateBullets(dt);updateEffects(dt);if(!state.enemies.length){state.clearDelay+=dt;if(state.clearDelay>.6)clearWave()}if(state.snapshot>=5){state.snapshot=0;log("state_snapshot",{player:[round(state.player.x),round(state.player.y)],health:round(state.player.health),remaining:state.enemies.length,projectiles:state.bullets.length,secondary_budget:state.secondaryBudget,build:build(),actions:state.actions})}updateUI()}
|
||||
function update(dt){if(!state.active)return;state.runTime+=dt;state.waveTime+=dt;state.snapshot+=dt;updatePlayer(dt);updateEnemies(dt);updateBullets(dt);updateEffects(dt);if(!state.enemies.length){state.clearDelay+=dt;if(state.clearDelay>.6)clearWave()}if(state.snapshot>=5){state.snapshot=0;const capabilityState=state.enemies.filter(e=>e.kind==="ward"||e.kind==="renewal").map(e=>({id:e.id,kind:e.kind,hp:round(e.hp),ward:e.kind==="ward"?e.ward:undefined}));log("state_snapshot",{player:[round(state.player.x),round(state.player.y)],health:round(state.player.health),remaining:state.enemies.length,projectiles:state.bullets.length,secondary_budget:state.secondaryBudget,build:build(),capability_enemies:capabilityState,actions:state.actions})}updateUI()}
|
||||
function burst(x,y,color,count){for(let i=0;i<count;i++){const a=Math.random()*Math.PI*2,s=.03+Math.random()*.1;state.particles.push({x,y,vx:Math.cos(a)*s,vy:Math.sin(a)*s,color,life:.18+Math.random()*.3,maxLife:.48,radius:.002+Math.random()*.003})}}
|
||||
function effect(kind,x,y){state.effects.push({kind,x,y,life:.22,maxLife:.22})}function lineEffect(kind,a,b){state.effects.push({kind,x:a.x,y:a.y,tx:b.x,ty:b.y,life:.16,maxLife:.16})}
|
||||
const screen=(x,y)=>[state.render.ox+x*state.render.size,state.render.oy+y*state.render.size];
|
||||
function draw(){const dpr=devicePixelRatio||1,w=canvas.clientWidth,h=canvas.clientHeight;if(canvas.width!==Math.round(w*dpr)||canvas.height!==Math.round(h*dpr)){canvas.width=Math.round(w*dpr);canvas.height=Math.round(h*dpr)}ctx.setTransform(dpr,0,0,dpr,0,0);ctx.clearRect(0,0,w,h);const size=Math.max(100,Math.min(w-38,h-38));state.render={size,ox:(w-size)/2,oy:(h-size)/2};ctx.fillStyle="#081219";ctx.fillRect(state.render.ox,state.render.oy,size,size);ctx.strokeStyle="#182c34";ctx.lineWidth=1;for(let i=1;i<10;i++){const p=i/10*size;ctx.beginPath();ctx.moveTo(state.render.ox+p,state.render.oy);ctx.lineTo(state.render.ox+p,state.render.oy+size);ctx.stroke();ctx.beginPath();ctx.moveTo(state.render.ox,state.render.oy+p);ctx.lineTo(state.render.ox+size,state.render.oy+p);ctx.stroke()}ctx.strokeStyle="#36515b";ctx.lineWidth=2;ctx.strokeRect(state.render.ox,state.render.oy,size,size);for(const e of state.effects)drawEffect(e);for(const b of state.bullets){const[x,y]=screen(b.x,b.y);ctx.fillStyle=b.kind==="primary"?"#f5f8df":b.kind==="fragment"?COLORS.cyan:b.kind==="spark"?COLORS.mint:COLORS.rose;ctx.shadowColor=ctx.fillStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.arc(x,y,Math.max(2.5,b.radius*size),0,Math.PI*2);ctx.fill();ctx.shadowBlur=0}for(const e of state.enemies)drawEnemy(e);for(const p of state.particles){const[x,y]=screen(p.x,p.y);ctx.globalAlpha=clamp(p.life/p.maxLife,0,1);ctx.fillStyle=p.color;ctx.beginPath();ctx.arc(x,y,p.radius*size,0,Math.PI*2);ctx.fill();ctx.globalAlpha=1}drawPlayer()}
|
||||
function drawEnemy(e){const[x,y]=screen(e.x,e.y),r=e.radius*state.render.size;ctx.save();ctx.translate(x,y);ctx.fillStyle=e.flash?"#fff":ENEMIES[e.kind].color;ctx.shadowColor=ENEMIES[e.kind].color;ctx.shadowBlur=8;ctx.beginPath();const sides=e.kind==="mote"?0:e.kind==="husk"?6:e.kind==="titan"?4:8;if(!sides)ctx.arc(0,0,r,0,Math.PI*2);else for(let i=0;i<sides;i++){const a=i/sides*Math.PI*2,px=Math.cos(a)*r,py=Math.sin(a)*r;i?ctx.lineTo(px,py):ctx.moveTo(px,py)}ctx.closePath();ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#081015";ctx.lineWidth=2;ctx.stroke();if(e.hp<e.maxHp){ctx.fillStyle="#1b2b30";ctx.fillRect(-r,r+5,r*2,3);ctx.fillStyle=COLORS.mint;ctx.fillRect(-r,r+5,r*2*clamp(e.hp/e.maxHp,0,1),3)}if(e.focusHits){ctx.strokeStyle=COLORS.amber;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,r+4,-Math.PI/2,-Math.PI/2+Math.PI*2*e.focusHits/Math.max(2,6-state.upgrades.focus));ctx.stroke()}ctx.restore()}
|
||||
function drawEnemy(e){const[x,y]=screen(e.x,e.y),r=e.radius*state.render.size;ctx.save();ctx.translate(x,y);ctx.fillStyle=e.flash?"#fff":ENEMIES[e.kind].color;ctx.shadowColor=ENEMIES[e.kind].color;ctx.shadowBlur=8;ctx.beginPath();const sides=e.kind==="mote"?0:e.kind==="husk"?6:e.kind==="titan"?4:8;if(!sides)ctx.arc(0,0,r,0,Math.PI*2);else for(let i=0;i<sides;i++){const a=i/sides*Math.PI*2,px=Math.cos(a)*r,py=Math.sin(a)*r;i?ctx.lineTo(px,py):ctx.moveTo(px,py)}ctx.closePath();ctx.fill();ctx.shadowBlur=0;ctx.strokeStyle="#081015";ctx.lineWidth=2;ctx.stroke();if(e.kind==="ward"&&e.ward>0){ctx.strokeStyle="#b8e6ff";ctx.lineWidth=2.5;ctx.setLineDash([3,2]);ctx.beginPath();ctx.arc(0,0,r+6,0,Math.PI*2*e.ward/e.wardMax);ctx.stroke();ctx.setLineDash([])}if(e.kind==="renewal"&&e.hp<e.maxHp){ctx.strokeStyle="#c8ffae";ctx.lineWidth=2;ctx.beginPath();ctx.moveTo(-4,0);ctx.lineTo(4,0);ctx.moveTo(0,-4);ctx.lineTo(0,4);ctx.stroke()}if(e.hp<e.maxHp){ctx.fillStyle="#1b2b30";ctx.fillRect(-r,r+5,r*2,3);ctx.fillStyle=COLORS.mint;ctx.fillRect(-r,r+5,r*2*clamp(e.hp/e.maxHp,0,1),3)}if(e.focusHits){ctx.strokeStyle=COLORS.amber;ctx.lineWidth=2;ctx.beginPath();ctx.arc(0,0,r+4,-Math.PI/2,-Math.PI/2+Math.PI*2*e.focusHits/Math.max(2,6-state.upgrades.focus));ctx.stroke()}ctx.restore()}
|
||||
function drawPlayer(){const[x,y]=screen(state.player.x,state.player.y),r=.021*state.render.size,[ax,ay]=norm(state.pointer.x-state.player.x,state.pointer.y-state.player.y);ctx.save();ctx.translate(x,y);ctx.rotate(Math.atan2(ay,ax));ctx.globalAlpha=state.player.inv&&Math.floor(state.player.inv*16)%2?.35:1;ctx.fillStyle=COLORS.mint;ctx.shadowColor=COLORS.mint;ctx.shadowBlur=12;ctx.beginPath();ctx.moveTo(r*1.35,0);ctx.lineTo(-r*.8,r*.8);ctx.lineTo(-r*.52,0);ctx.lineTo(-r*.8,-r*.8);ctx.closePath();ctx.fill();ctx.restore()}
|
||||
function drawEffect(e){const a=clamp(e.life/e.maxLife,0,1);ctx.save();ctx.globalAlpha=a;if(e.tx!==undefined){const[x,y]=screen(e.x,e.y),[tx,ty]=screen(e.tx,e.ty);ctx.strokeStyle=e.kind==="arc"?COLORS.violet:COLORS.rose;ctx.lineWidth=3;ctx.shadowColor=ctx.strokeStyle;ctx.shadowBlur=9;ctx.beginPath();ctx.moveTo(x,y);ctx.lineTo((x+tx)/2+(Math.random()-.5)*10,(y+ty)/2+(Math.random()-.5)*10);ctx.lineTo(tx,ty);ctx.stroke()}else{const[x,y]=screen(e.x,e.y);ctx.strokeStyle=e.kind==="bloom"?COLORS.mint:e.kind==="fork"?COLORS.cyan:e.kind==="focus"?COLORS.amber:COLORS.rose;ctx.lineWidth=2;ctx.beginPath();ctx.arc(x,y,(1-a)*.05*state.render.size+4,0,Math.PI*2);ctx.stroke()}ctx.restore()}
|
||||
function updateUI(){const ids=Object.keys(EXPEDITIONS),idx=ids.indexOf(state.expedition),ex=EXPEDITIONS[state.expedition],total=ex.waves.length;$("#expedition-kicker").textContent=`EXPEDITION ${String(idx+1).padStart(2,"0")} · FIELD ${state.wave+1} OF ${total}`;$("#expedition-name").textContent=ex.name;$("#expedition-copy").textContent=ex.copy;$("#wave").textContent=`${state.wave+1} / ${total}`;$("#remaining").textContent=String(state.enemies.length);$("#health").textContent=Array.from({length:6},(_,i)=>i<Math.ceil(state.player.health)?"●":"○").join(" ");const s=Math.floor(state.runTime);$("#run-time").textContent=`${Math.floor(s/60)}:${String(s%60).padStart(2,"0")}`;const forecast=$("#forecast-list");forecast.replaceChildren();for(const[k,v]of Object.entries(ex.waves[state.wave])){const d=ENEMIES[k],el=document.createElement("div");el.className="population";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><b>${v}</b> ${d.name}`;forecast.append(el)}const box=$("#build-list");box.replaceChildren();const selected=Object.entries(state.upgrades).filter(([,v])=>v);if(!selected.length){const p=document.createElement("p");p.className="empty";p.textContent="No catalysts selected.";box.append(p)}else for(const[k,v]of selected){const d=MODULES[k],el=document.createElement("div");el.className="build-item";el.innerHTML=`<i style="color:${d.color}">${d.icon}</i><span><b>${d.name}</b><small>${d.short}</small></span><em>LV ${v}</em>`;box.append(el)}}
|
||||
function showBanner(text){const b=$("#banner");b.textContent=text;b.classList.add("show");clearTimeout(state.bannerTimer);state.bannerTimer=setTimeout(()=>b.classList.remove("show"),1000)}function toast(text){const t=$("#toast");t.textContent=text;t.classList.add("show");clearTimeout(state.toastTimer);state.toastTimer=setTimeout(()=>t.classList.remove("show"),3300)}
|
||||
async function save(){log("session_saved",{completed:[...state.completed],current_build:build(),actions:state.actions,event_count_before_save:state.logs.length});const body=state.logs.join("\n")+"\n",filename=`catalyst-ecology-${state.session}.jsonl`;try{const r=await fetch("/api/playtest-log",{method:"POST",headers:{"Content-Type":"application/x-ndjson","X-Playtest-Filename":filename},body});if(!r.ok)throw Error(r.status);const j=await r.json();toast(`Saved ${j.events} events to ${j.path}`)}catch(_){const blob=new Blob([body],{type:"application/x-ndjson"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();URL.revokeObjectURL(a.href);toast(`Server unavailable; downloaded ${filename}`)}}
|
||||
async function save(){log("session_saved",{completed:[...state.completed],current_build:build(),actions:state.actions,event_count_before_save:state.logs.length});const body=state.logs.join("\n")+"\n",filename=`${CONFIG.filename}-${state.session}.jsonl`;try{const r=await fetch("/api/playtest-log",{method:"POST",headers:{"Content-Type":"application/x-ndjson","X-Playtest-Filename":filename},body});if(!r.ok)throw Error(r.status);const j=await r.json();toast(`Saved ${j.events} events to ${j.path}`)}catch(_){const blob=new Blob([body],{type:"application/x-ndjson"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download=filename;a.click();URL.revokeObjectURL(a.href);toast(`Server unavailable; downloaded ${filename}`)}}
|
||||
function pointer(e){const r=canvas.getBoundingClientRect();state.pointer.x=clamp((e.clientX-r.left-state.render.ox)/state.render.size,0,1);state.pointer.y=clamp((e.clientY-r.top-state.render.oy)/state.render.size,0,1)}
|
||||
canvas.onpointermove=pointer;canvas.onpointerdown=e=>{pointer(e);if(e.button===0){state.pointer.firing=true;fire()}e.preventDefault();canvas.focus({preventScroll:true})};addEventListener("pointerup",e=>{if(e.button===0)state.pointer.firing=false});canvas.oncontextmenu=e=>e.preventDefault();addEventListener("keydown",e=>{if(KEYS[e.code]){state.keys.add(e.code);e.preventDefault()}});addEventListener("keyup",e=>state.keys.delete(e.code));addEventListener("blur",()=>{state.keys.clear();state.pointer.firing=false});document.addEventListener("visibilitychange",()=>{if(state.started)log("visibility_changed",{hidden:document.hidden})});
|
||||
$$('.expedition').forEach(b=>b.onclick=()=>selectExpedition(b.dataset.expedition));$("#begin").onclick=begin;$("#restart").onclick=()=>state.started?startRun(state.expedition,"restart_button"):begin();$("#save").onclick=save;$("#complete-save").onclick=save;$("#next-expedition").onclick=e=>startRun(e.currentTarget.dataset.expedition,"complete_next");$("#replay").onclick=()=>startRun(state.expedition,"complete_replay");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Experiment 008 Revision 2 Preliminary Analysis — Session 7b7c7a92
|
||||
|
||||
Status: telemetry and brief initial report inspected; targeted follow-up pending.
|
||||
Status: complete.
|
||||
|
||||
Source: `JSONL/catalyst-ecology-7b7c7a92-42cb-4ab2-8a81-d1316ea972c5.jsonl`
|
||||
|
||||
|
|
@ -77,3 +77,34 @@ No replay followed the clearer mature exposure. As always, this is ambiguous: th
|
|||
2. During fields five through eight, did any interaction become surprising, satisfying, or fun rather than merely easier to read? When, if at all, did the extra fields become repetition?
|
||||
3. Did the results suggest a specific alternative build worth replaying, and did the four-slot limit now feel productive or still premature/restrictive?
|
||||
|
||||
## Player Report
|
||||
|
||||
Build selection combined remembered knowledge from the first 008 run with some intuitive experimentation. Fork was chosen across all three expeditions because it seemed like a generally decent way to put more projectiles on screen. The player also observed what appeared to be double or triple hits on large bodies, reducing the number of primary shots needed to kill them.
|
||||
|
||||
The additional mature fields permitted a small amount of tactical refinement. The player developed a consistent movement strategy in each expedition. They did not report a large strategic change or a new build interaction that demanded another run.
|
||||
|
||||
Power-up selection felt more meaningful than in revision 1, but not significantly so. The player believed essentially any four-module combination—and possibly no modules—would still complete the encounters. Because every choice was simply beneficial under permissive combat, the four-choice cap did not feel like either a productive tradeoff or a frustrating restriction. It was strategically inert.
|
||||
|
||||
## Revised Interpretation
|
||||
|
||||
Revision 2 repaired observation time but exposed a second measurement problem: the problem space did not discriminate among compositions. A component budget matters only when inclusion changes credible capabilities and exclusion creates a relevant limitation. Here baseline output appeared sufficient, no run failed, damage was negligible, and later population growth never forced a build-specific response.
|
||||
|
||||
This does not mean higher difficulty is automatically the answer. Raising health/count until weak builds fail could create compulsory throughput and repeat Experiment 002's pressure-driven persistence. The more useful conclusion is:
|
||||
|
||||
> Compositional choices become meaningful when their causal differences change what the player can effectively do—not merely how many additional effects appear while every route already succeeds.
|
||||
|
||||
Fork may be a reusable abstraction rather than a whole answer. It increased the hit surface available to different consumers and appeared to overlap larger targets. But it may also simply be overtuned generic throughput. The player's causal interpretation of multi-hits is report evidence; telemetry confirms many fragment hits but does not resolve whether multiple fragments from one impact hit the same large target as perceived.
|
||||
|
||||
The consistent movement strategies show that longer exposure supported execution learning. They do not establish fun: players can optimize serviceable controls while completing an experiment. One final report distinction should establish whether movement/build refinement itself was enjoyable or merely the clearest available way to finish.
|
||||
|
||||
## Final Enjoyment Distinction
|
||||
|
||||
The player describes the building as **enjoyable but meaningless**. This is the clearest summary of 008 and resolves the remaining ambiguity.
|
||||
|
||||
The positive activity was real: selecting causal components, seeing more projectiles and trigger chains, and learning how a completed build behaved had some intrinsic enjoyment. The negative was not implementation friction or lack of legibility after revision 2. The environment placed so little demand on capability that the chosen architecture did not determine success, access, recovery, or a valued payoff. Refinement therefore lacked weight.
|
||||
|
||||
This is stronger and more specific than saying the game was easy. Difficulty matters only insofar as it makes different capabilities consequential. More enemy health alone could preserve the same universal throughput problem. The next experiment should use enemy capabilities that outgrow baseline fire through several possible causal routes, while exact qualitative choices continue over one escalating run.
|
||||
|
||||
Experiment 008 is closed. Do not add more fields or tune the same expeditions again. Its contribution is:
|
||||
|
||||
> Building can be enjoyable for this player, but it becomes meaningless when the problem is insensitive to what was built.
|
||||
|
|
|
|||
22
experiments/009_catalyst_ascent/README.md
Normal file
22
experiments/009_catalyst_ascent/README.md
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# Experiment 009 — Catalyst Ascent
|
||||
|
||||
Build one emitter through a ten-field ascent. The first seven fields each end with an exact catalyst choice; the complete build remains active for the last three. Later populations acquire capabilities that change what sustained fire alone can accomplish.
|
||||
|
||||
Run from the repository root:
|
||||
|
||||
```bash
|
||||
./experiments/009_catalyst_ascent/run.sh
|
||||
```
|
||||
|
||||
Then open <http://127.0.0.1:8000/experiments/009_catalyst_ascent/prototype/>.
|
||||
|
||||
Controls:
|
||||
|
||||
- Move with `WASD` or the arrow keys.
|
||||
- Aim with the mouse.
|
||||
- Hold the left mouse button to fire.
|
||||
- Choose one catalyst after each of the first seven fields.
|
||||
|
||||
A failed field restores with the current build. Play until you feel ready to stop, then click **Save JSONL**. Replaying with another build is optional.
|
||||
|
||||
Please play before reading [`hypothesis.md`](hypothesis.md).
|
||||
72
experiments/009_catalyst_ascent/hypothesis.md
Normal file
72
experiments/009_catalyst_ascent/hypothesis.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Experiment 009 Hypothesis — Private Until After Play
|
||||
|
||||
## Question
|
||||
|
||||
Does an enjoyable qualitative build become meaningful when later problems are sensitive to the capabilities it creates, or does added resistance merely turn a pleasant toy into compulsory throughput work?
|
||||
|
||||
## Why This Experiment
|
||||
|
||||
Experiment 008 separated two judgments that earlier tests often blurred: the player enjoyed building, but the build felt meaningless. Longer mature fields made the effects and modest movement refinements easier to see, yet the player believed nearly any selection—or no selection—could clear every population. The four-choice cap therefore excluded options on paper without producing a felt tradeoff.
|
||||
|
||||
The likely issue is not simply low difficulty. More enemy health could make upgrades mandatory while leaving every build interchangeable. Catalyst Ascent instead retains the exact six-component causal vocabulary and introduces qualitative enemy capabilities over one continuous run:
|
||||
|
||||
- **Wards** absorb eight hits inside a short opening window, reset partial progress, and later re-form. Focus ruptures and Conduit lances can breach them directly, while sufficiently dense products can strip them.
|
||||
- **Renewals** continuously regenerate, rewarding concentrated damage, chained effects, or amplified secondary output rather than intermittent baseline fire.
|
||||
- **Broods** keep creating small bodies, potentially feeding kill-triggered Bloom while increasing pressure if ignored.
|
||||
|
||||
The player chooses after fields one through seven, then carries the complete build through fields eight through ten. This preserves the positive authorship and visible cascade from 007/008 while allowing the environment to outgrow baseline capabilities. Failures restore the current field and build so execution mistakes do not erase the construction history.
|
||||
|
||||
## Competing Interpretations
|
||||
|
||||
1. Capability-sensitive enemies make construction both enjoyable and instrumentally meaningful; the player anticipates what the build can handle, notices a limitation, and adapts through one of several causal routes.
|
||||
2. Escalation compels continued play but creates no additional curiosity, repeating Experiment 002's distinction between persistence and reasoning.
|
||||
3. Ward, Renewal, and Brood labels prescribe obvious counters, reducing choices to loadout replacement as in Experiment 003.
|
||||
4. The exact qualitative effects and visible power trajectory sustain interest, but enemy capabilities add little; spectacle and composition remain sufficient without consequential exclusion.
|
||||
5. One universally dense effect network defeats every capability, so the experiment again measures throughput rather than meaningful construction.
|
||||
6. The environment finally makes a choice consequential, but a mistaken build creates a soft lock or feels punitive rather than recoverable.
|
||||
7. The player enjoys selecting and watching a build but still does not care whether it succeeds; capability relevance alone does not supply valued stakes.
|
||||
|
||||
## Evidence Priorities
|
||||
|
||||
Strong evidence:
|
||||
|
||||
- anticipating a later capability while choosing, then confirming or revising that prediction;
|
||||
- noticing that the current build changes which target, firing pattern, or movement strategy is viable;
|
||||
- identifying two causally different routes through the same enemy capability;
|
||||
- wanting to replay specifically to test a different route, rather than merely to finish or maximize a number;
|
||||
- a limitation generating a new build question without making the current run feel wasted;
|
||||
- the complete build continuing to reveal consequences in fields eight through ten.
|
||||
|
||||
Ambiguous evidence:
|
||||
|
||||
- completing all ten fields;
|
||||
- dying, clearing faster, or selecting seven upgrades;
|
||||
- using the textual Ward/Focus or Ward/Conduit relationship;
|
||||
- long play caused by durable enemies;
|
||||
- large effect counts or spectacular screen clearing without a reported or behavioral question.
|
||||
|
||||
Failure evidence:
|
||||
|
||||
- one obvious package answers every field;
|
||||
- enemies merely take longer to kill;
|
||||
- a capability is unreadable or is confused with ordinary health;
|
||||
- the player feels forced to take a named counter with no meaningful alternative;
|
||||
- a mistaken build makes progress impossible and recovery requires restarting the whole run;
|
||||
- building remains enjoyable but still feels unrelated to anything worth accomplishing.
|
||||
|
||||
## Analysis Guardrails
|
||||
|
||||
- Treat the player's “meaningless” report as evidence, not a final diagnosis. Test whether consequence sensitivity is the missing cause rather than assuming difficulty is the answer.
|
||||
- Do not infer meaning from necessity alone. A compulsory upgrade can be strategically empty.
|
||||
- Do not use clear rate, deaths, duration, or upgrade count as stand-alone proxies for fun.
|
||||
- Separate anticipation, observed causal attribution, revised understanding, and post-hoc explanation in both telemetry and questioning.
|
||||
- A selected counter is only meaningful evidence if the player perceived alternatives or learned why it worked.
|
||||
- Preserve the possibility that building is intrinsically enjoyable while the shooter objective remains emotionally irrelevant.
|
||||
|
||||
## Feedback Questions
|
||||
|
||||
Ask only after reviewing telemetry:
|
||||
|
||||
1. When, if ever, did the build start to feel like it enabled something baseline fire could not? What did they believe caused that difference?
|
||||
2. Did any enemy capability change a choice or tactic, and did it feel like a problem with multiple possible answers or a prescribed counter?
|
||||
3. Did a limitation create another build they wanted to try? At what point were they ready to stop?
|
||||
39
experiments/009_catalyst_ascent/prototype/index.html
Normal file
39
experiments/009_catalyst_ascent/prototype/index.html
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Catalyst Ascent — Experiment 009</title>
|
||||
<link rel="stylesheet" href="/experiments/008_catalyst_ecology/prototype/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div class="title"><span>EXPERIMENT 009</span><h1>Catalyst Ascent</h1></div>
|
||||
<nav aria-label="Run selection">
|
||||
<button class="expedition selected" data-expedition="ascent"><span>01</span> Ascent</button>
|
||||
</nav>
|
||||
<div class="top-actions"><button id="restart">Restart run</button><button id="save">Save JSONL</button></div>
|
||||
</header>
|
||||
<main>
|
||||
<aside>
|
||||
<section class="brief"><span id="expedition-kicker">EXPEDITION 01 · FIELD 1 OF 10</span><h2 id="expedition-name">Ascent</h2><p id="expedition-copy"></p></section>
|
||||
<section class="forecast"><h3>Current population</h3><div id="forecast-list"></div></section>
|
||||
<section class="controls"><h3>Controls</h3><p><kbd>WASD</kbd><span>Move.</span></p><p><kbd>HOLD LEFT</kbd><span>Fire toward cursor.</span></p></section>
|
||||
<section class="forecast"><h3>Developing capabilities</h3><div class="population"><i style="color:#75bff2">⬡</i><b>Ward</b> Strip its ring in one burst; rupture and lance breach it.</div><div class="population"><i style="color:#8fe28b">✚</i><b>Renewal</b> Continuously regenerates health.</div><div class="population"><i style="color:#f18cba">✹</i><b>Brood</b> Releases fresh motes while alive.</div></section>
|
||||
<section class="build"><h3>Seven-choice build</h3><div id="build-list"><p class="empty">No catalysts selected.</p></div></section>
|
||||
<section class="readouts">
|
||||
<div><span>INTEGRITY</span><b id="health">● ● ● ● ● ●</b></div><div><span>FIELD</span><b id="wave">1 / 10</b></div>
|
||||
<div><span>REMAINING</span><b id="remaining">0</b></div><div><span>RUN TIME</span><b id="run-time">0:00</b></div>
|
||||
</section>
|
||||
<section class="note">All six catalysts remain available at every choice. Repeats stack. A failed field restores locally with the same build.</section>
|
||||
</aside>
|
||||
<section class="playfield"><canvas id="field" tabindex="0"></canvas><div id="banner" class="banner"></div><div class="canvas-help">WASD move · aim with mouse · hold left to fire</div></section>
|
||||
</main>
|
||||
<div id="start-overlay" class="overlay"><div><span>ONE ESCALATING RUN</span><b>Build into changing resistance.</b><p>Ten fields. Choose after each of the first seven, then use the complete build through three more. Later populations acquire new capabilities. Stop whenever you want.</p><button id="begin">Begin Ascent</button></div></div>
|
||||
<div id="choice-overlay" class="overlay hidden"><div class="wide"><span>FIELD STABLE</span><b>Choose one catalyst</b><p id="next-forecast"></p><div id="choices"></div></div></div>
|
||||
<div id="complete-overlay" class="overlay hidden"><div><span>ASCENT COMPLETE</span><b id="complete-title"></b><p>Replay with another build, save, or stop.</p><div class="complete-actions"><button id="next-expedition"></button><button id="replay">Replay this ascent</button><button id="complete-save">Save JSONL</button></div></div></div>
|
||||
<div id="toast"></div>
|
||||
<script>window.CATALYST_MODE = "ascent";</script>
|
||||
<script src="/experiments/008_catalyst_ecology/prototype/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
3
experiments/009_catalyst_ascent/results/README.md
Normal file
3
experiments/009_catalyst_ascent/results/README.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Experiment 009 Results
|
||||
|
||||
No playtest has been analyzed yet.
|
||||
10
experiments/009_catalyst_ascent/run.sh
Executable file
10
experiments/009_catalyst_ascent/run.sh
Executable file
|
|
@ -0,0 +1,10 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
EXPERIMENT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
REPO_ROOT="$(cd "$EXPERIMENT_DIR/../.." && pwd)"
|
||||
|
||||
exec python3 "$REPO_ROOT/tools/playtest_server.py" \
|
||||
--directory "$REPO_ROOT" \
|
||||
--log-directory "$REPO_ROOT/JSONL" \
|
||||
--port 8000
|
||||
Loading…
Add table
Add a link
Reference in a new issue