html-trap-2026 / src /engine.js
Vintendopower's picture
Add Night Trap browser application and Docker server
ea7fa1f verified
Raw History Blame Contribute Delete
14 kB
// Recovered main-game rules. Source references and limitations are in README.md.
export const COLORS=['Blue','Green','Orange','Purple','Red','Yellow'];
export const frameScale = value => Math.fround(Math.fround(Math.fround(Math.fround(value)*2)/Math.fround(23.976))*Math.fround(29.976));
export class NightTrapEngine {
constructor(data, seed=1987) {this.data=data;this.seed=seed;this.reset();}
reset() {
this.nodes=structuredClone(this.data.nodes);this.time=0;this.room='Hall 1';this.score=0;this.possible=0;
this.color=1;this.securityColor=1;this.flags={};this.log=[];this.events=[];this.cursor=0;
this.active=new Set();this.done=new Set();this.dead=new Set();this.videoStarts={};this.lastRoomFrames={};
this.randomState=this.seed;this.end=null;this.override=null;this.phase='game';this.forced=null;this.controlLocked=false;this.clockFrozenAt=null;this.checkpoint=null;this.lives=3;this.practice=false;this.inputs=[];
for(const r of this.data.rooms)this.activate(r.node,0);
this.processSchedule(0);this.update(0);
}
note(text,kind='event') {this.log.push({time:this.time,text,kind});if(this.log.length>300)this.log.shift();}
random(min,max) {this.randomState=(Math.imul(this.randomState,1664525)+1013904223)>>>0;return min+Math.floor(this.randomState/4294967296*(max-min));}
activate(id,at=this.time) {
const n=this.nodes[id];if(!n||this.active.has(n.id)||this.dead.has(n.id))return;
n.active=true;this.active.add(n.id);n.started=at;
if(n.video)this.videoStarts[n.id]=at;
for(const c of n.components.filter(c=>c.enabled)) {
if(c.type==='codeChange') {
let color=this.random(c.startOverride,7);if(color===this.securityColor)color=color%6+1;
c.chosen=color;n.video.clip=c[COLORS[color-1].toLowerCase()];
}
if(c.type==='setGameFlag')for(const k of Object.keys(c))if((k.endsWith('Captured')||k==='goofyBathroomSequence')&&k!=='cindyCaptured'&&c[k])this.flags[k]=true;
if(['videoSkip','defaultSimms'].includes(c.type)&&n.video)this.videoStarts[n.id]=at-c.startingFrame/this.clip(n).fps;
if(c.type==='destroyAlternatePath')this.deactivate(c.destroyThisVideo,c.destroyVideo);
if(c.type==='disableFix')this.deactivate(c.disableVideo);
if(c.type==='stopNextVideo'){this.deactivate(c.videoToStop);this.disableComponent(c.scriptToStop,'captureSequence');}
if(c.type==='stopJeff')this.disableComponent(c.sequenceContainer,'activateJeff');
if(c.type==='destroyAlternate'){
this.deactivate(c.alternateScene);
if(c.trapSequence)this.disableComponent(c.componentToDisable,'captureSequence');
}
if(c.type==='deactivateVideo')this.deactivate(c.videoToDeactivate);
if(c.type==='gameOverCheck')this.events.push({time:at+1,kind:'scoreCheck',component:c,ratio:this.possible?this.score/this.possible:0});
}
for(const child of n.children)if(this.nodes[child]?.active)this.activate(child,at);
}
forceSequence(component, original, failure) {
const room=component.roomName||original?.room||this.room;
this.forced={room,component:component.id,original:original?.id??null,failure};
this.controlLocked=true;this.clockFrozenAt=this.time;this.room=room;
if(failure){this.activate(failure);this.override=failure;}
this.note('SCAT override: camera control locked.','failure');
}
disableComponent(id,type) {const c=this.nodes[id]?.components.find(c=>c.type===type);if(c)c.enabled=false;}
deactivate(id,destroy=false) {
const n=this.nodes[id];if(!n)return;
for(const child of n.children)this.deactivate(child,destroy);
if(this.active.has(n.id))for(const c of n.components)if(c.enabled&&c.type==='codeChange'&&c.chosen)this.securityColor=c.chosen;
this.active.delete(n.id);n.active=false;if(destroy)this.dead.add(n.id);
}
clip(n) {return this.data.media[n?.video?.clip];}
elapsed(n) {return Math.max(0,this.time-(this.videoStarts[n?.id]??n?.started??this.time));}
frame(n) {const m=this.clip(n);return m?Math.min(m.frames-1,Math.floor(this.elapsed(n)*m.fps*(n.video.speed||1)+1e-6)):-1;}
processSchedule(to) {
while(this.cursor<this.data.schedule.length&&this.data.schedule[this.cursor].time<=to+1e-9) {
const e=this.data.schedule[this.cursor++];this.activate(e.node,e.time);
}
}
advance(seconds) {
if(this.end)return;
const target=this.time+seconds;
// Bound simulation steps so seeks/replays never skip a trap, score, or branch event.
while(this.time<target-1e-9&&!this.end) {
this.time=Math.min(target,this.time+1/60);this.processSchedule(this.time);this.update();
if(!this.practice&&!this.checkpoint&&this.time>=828)this.checkpoint={time:828,room:this.room,color:this.color,inputs:structuredClone(this.inputs)};
}
}
once(c,key,fn) {const id=c.id+':'+key;if(this.done.has(id))return;this.done.add(id);fn();}
update() {
for(const id of [...this.active]) {
const n=this.nodes[id];if(!this.active.has(id))continue;
const f=this.frame(n);const m=this.clip(n);
for(const c of n.components) {
if(!c.enabled)continue;
if(c.type==='possibleCaptured'&&f>=frameScale(c.whenPossible))this.once(c,'score',()=>{
if(c.totalToAddTo==='Captured')this.score+=c.total;
if(c.totalToAddTo==='Possible')this.possible+=c.total;
});
if(c.type==='setGameFlag'&&c.cindyCaptured&&f>=frameScale(c.cindyTime))this.flags.cindyCaptured=true;
if(c.type==='codeChange'&&(c.chosen===1||f>=frameScale(c.whenToChangeColor)))this.once(c,'color',()=>{this.securityColor=c.chosen;this.note('Security code changed. Listen to the conversation.','code');});
if(c.type==='nextVideo'&&m&&f>=(c.whenToDeactivateOverride?frameScale(c.whenToStart):m.frames-2))this.once(c,'next',()=>{this.activate(c.next);if(c.deactivateCurrentObject)this.deactivate(n.id);});
if(c.type==='alternateBathroom'&&!this.flags.goofyBathroomSequence&&f>=frameScale(c.whenToStart))this.once(c,'branch',()=>{this.activate(c.alternateVideo);this.deactivate(n.id);});
if(c.type==='alternateBranchCindy'&&this.flags.cindyCaptured&&this.frame(this.nodes[c.originalVideo])>=frameScale(c.whenToPlay))this.once(c,'branch',()=>{this.activate(c.nextVideo);this.deactivate(c.originalVideo);});
if(c.type==='updateStill')this.once(c,'still',()=>{if(this.nodes[c.stills])this.nodes[c.stills].sprite=c.newStill;});
if(c.type==='disableVideo'&&m){this.disableComponent(c.containerToDisable,'gameOverCheck');if(f>=m.frames-5)this.deactivate(c.containerToDisable);}
if(c.type==='gameEnding'&&f>=frameScale(c.whenToPlay))this.once(c,'ending',()=>{
const ending=!this.flags.sheilaCaptured?c.sheilaEnding:!this.flags.victorCaptured?c.victorEnding:this.score===this.possible?c.betterEnding:c.goodEnding;
this.activate(ending);this.deactivate(n.id);this.override=ending;this.note('Ending branch selected.','ending');
});
if(c.type==='startEnding'&&m&&f>=m.frames-2)this.once(c,'end',()=>{
this.phase='credits';this.override=c.next||277;this.activate(c.next||277);this.activate(200);this.activate(431);this.activate(544);this.deactivate(n.id);
this.note('Ending complete. Rolling credits started.','ending');
});
if(this.phase==='credits'&&n.id===544&&m&&f>=m.frames-2)this.once(c,'creditsEnd',()=>{this.phase='results';this.finish('Credits complete');});
if(c.type==='gameOverTrigger') {
const original=this.nodes[c.originalSequence];
if(this.active.has(original?.id)&&this.frame(original)>=frameScale(c.gameOverSequenceStart))this.once(c,'fail',()=>{
this.forceSequence(c,original,c.gameOver);
});
}
if(c.type==='autoCamera') {
const original=this.nodes[c.sequence];
if(this.active.has(original?.id)&&this.frame(original)>=frameScale(c.whenToSwitchCamera))this.once(c,'force',()=>this.forceSequence(c,original,c.scatOverrideVideo));
}
if(c.type==='scatOverride') {
const original=this.nodes[c.sequence];
if(this.active.has(original?.id)&&this.frame(original)>=frameScale(c.whenToActivateScatOverride))this.once(c,'force',()=>this.forceSequence(c,original,c.scatOverrideVideo));
}
if(c.type==='endingGameOver'&&f>=frameScale(c.gameOverStart))this.once(c,'over',()=>this.finish('Game over'));
// Some source thresholds exceed the last valid frame by a fraction (e.g. 500.1001 vs 500).
// Browser adaptation: a finished failure clip must still reach the game-over screen.
if(c.type==='gameOver'&&m&&(f>=frameScale(c.gameOverTrigger)||this.elapsed(n)>=m.duration))this.once(c,'over',()=>this.finish('Game over'));
if(c.type==='screenController'&&m&&!c.disableOverride&&!c.manualOverride&&f>=m.frames-1) {
this.lastRoomFrames[n.room]={clip:n.video.clip,time:Math.max(0,(m.frames-2)/m.fps)};
this.deactivate(c.destroyParent?n.parent:n.id,true);
}
}
}
// Components on the Sequences container use the global clock.
for(const c of this.nodes[731]?.components??[]) {
if(!c.enabled)continue;
if(c.type==='activateJeff'&&this.time>=c.whenToPlay&&!this.flags.troyCaptured)this.once(c,'jeff',()=>this.activate(c.videoSequence));
if(c.type==='tooManyAugs'&&this.time>=c.whenToStart)this.once(c,'checkpoint',()=>{
if(this.score/Math.max(1,this.possible)<.75&&!this.practice){this.activate(c.gameOverVideo);this.override=c.gameOverVideo;this.note('Capture ratio below 75%. SCAT override.','failure');}
});
}
for(const e of this.events.filter(e=>!e.done&&e.time<=this.time)) {
e.done=true;
if(e.kind==='scoreCheck') {const c=e.component;const pass=this.practice||e.ratio>=.75;this.activate(pass?c.continueGameVideo:c.gameOverVideo);if(!pass)this.override=c.gameOverVideo;}
}
}
windows(room=this.room) {
const result=[];
for(const id of this.active)for(const c of this.nodes[id].components) {
if(!c.enabled||!['captureSequence','captureMegan'].includes(c.type)||c.roomName!==room||this.done.has(c.id+':capture'))continue;
const original=this.nodes[c.originalSequence];if(!this.active.has(original?.id))continue;
const begin=frameScale(c.trapStart);const end=c.type==='captureMegan'?frameScale(c.last):Math.fround(begin+37.5);
result.push({component:c,node:id,original:original.id,frame:this.frame(original),begin,end,startTime:this.videoStarts[original.id]+Math.ceil(begin)/this.clip(original).fps});
}
return result;
}
trap() {
if(this.end)return {ok:false,reason:'ended'};
if(this.controlLocked){this.note('Trap controls disabled during SCAT override.','miss');return {ok:false,reason:'locked'};}
this.inputs.push({time:this.time,type:'trap',room:this.room,color:this.color});
const valid=this.windows().filter(w=>w.frame>=w.begin&&w.frame<=w.end);
if(!valid.length){this.note('Trap fired outside the capture window.','miss');return {ok:false,reason:'timing'};}
if(this.color!==this.securityColor){this.note('Trap rejected: security code mismatch.','miss');return {ok:false,reason:'color'};}
for(const w of valid) {
const c=w.component;this.done.add(c.id+':capture');this.activate(c.capturedSequence);
if(!c.dontDestroy)this.deactivate(c.originalSequence);
this.note('Capture confirmed in '+this.room+'.','capture');
}
this.update();return {ok:true,count:valid.length};
}
selectRoom(room) {if(!this.controlLocked&&this.data.rooms.some(r=>r.id===room))this.room=room;}
selectColor(color) {if(!this.controlLocked&&color>=1&&color<=6)this.color=color;}
view(room=this.room) {
if(this.override) {
const candidates=this.descendants(this.override).map(id=>this.nodes[id]).filter(n=>this.active.has(n.id)&&n.video);
if(candidates.length){const n=candidates[0];return {node:n.id,clip:n.video.clip,time:this.elapsed(n),loop:n.video.loop,override:true};}
}
const candidates=[...this.active].map(id=>this.nodes[id]).filter(n=>n.room===room&&n.video&&this.clip(n));
candidates.sort((a,b)=>(this.videoStarts[b.id]-this.videoStarts[a.id])||b.id-a.id);
const n=candidates[0];if(n)return {node:n.id,clip:n.video.clip,time:this.elapsed(n),loop:n.video.loop};
const stills=Object.values(this.nodes).filter(n=>n.room===room&&n.name==='stills');
const still=stills.find(n=>n.children?.length)||stills[0];
return {sprite:still?.sprite,still:true};
}
descendants(id) {const n=this.nodes[id];return n?[n.id,...n.children.flatMap(c=>this.descendants(c))]:[];}
finish(reason) {if(this.practice)return;this.end=reason;this.note(reason,'end');}
retry() {
if(!this.checkpoint||this.lives<=0)return false;
const cp=structuredClone(this.checkpoint);this.lives--;this.reset();this.practice=false;this.lives=Math.max(0,this.lives);
for(const input of cp.inputs??[]){this.advance(Math.max(0,input.time-this.time));this.selectRoom(input.room);this.selectColor(input.color);this.trap();}
this.advance(Math.max(0,cp.time-this.time));this.selectRoom(cp.room);this.selectColor(cp.color);
this.note(`Retry resumed at ${Math.floor(cp.time/60)}:${String(Math.floor(cp.time%60)).padStart(2,'0')} · ${this.lives} lives remaining.`,'retry');
return true;
}
snapshot() {return {version:2,seed:this.seed,time:this.time,room:this.room,color:this.color,practice:this.practice,inputs:this.inputs,lives:this.lives,checkpoint:this.checkpoint};}
restore(save) {
if(![1,2].includes(save.version)||!Number.isFinite(save.time)||save.time<0||save.time>1900)throw Error('Invalid save');
this.seed=save.seed;this.reset();this.practice=!!save.practice;this.lives=save.lives??3;this.checkpoint=save.checkpoint??null;
for(const input of save.inputs??[]){this.advance(Math.max(0,input.time-this.time));this.selectRoom(input.room);this.selectColor(input.color);this.trap();}
this.advance(Math.max(0,save.time-this.time));this.selectRoom(save.room);this.selectColor(save.color);
}
}