import { SIZE, createGame, tick, turn, interval } from './engine.js';
const $=id=>document.getElementById(id), canvas=$('board'), ctx=canvas.getContext('2d');
const BOARD_SIZE=660, cell=BOARD_SIZE/SIZE;
let pixelRatio=window.devicePixelRatio||1;
const audio=$('soundtrack'); audio.volume=.35;
let state, activeDifficulty='haunted', activeTypes=[], last=0, accumulator=0, musicWanted=false;
const read=(key,fallback)=>{try{return localStorage.getItem(key)||fallback}catch{return fallback}};
const save=(key,value)=>{try{localStorage.setItem(key,value)}catch{}};
$('difficulty').value=['cozy','haunted','cursed'].includes(read('hollow-difficulty',''))?read('hollow-difficulty',''):'haunted';
const savedColor=read('hollow-color','#b6ff54'); if(/^#[0-9a-f]{6}$/i.test(savedColor)) $('snake-color').value=savedColor;
$('atmosphere').checked=read('hollow-mist','on')==='on';
document.body.classList.toggle('no-mist',!$('atmosphere').checked);
const bestKey=()=>`hollow-best:${activeDifficulty}:${activeTypes.join(',')}`;
const best=()=>Math.max(0,Number(read(bestKey(),'0'))||0);
const announce=text=>{$('announcement').textContent=text};
function updateScore(){ $('score').textContent=String(state.score/10); $('best').textContent=String(best()/10); }
function setup(play=false){
activeDifficulty=$('difficulty').value;
activeTypes=[...document.querySelectorAll('.obstacle-options input:checked')].map(el=>el.value).sort();
state=createGame(activeTypes); accumulator=0;
$('difficulty-label').textContent=activeDifficulty[0].toUpperCase()+activeDifficulty.slice(1);
$('settings-note').textContent='Difficulty and obstacles apply to your next game.';
updateScore(); if(play) state.status='playing'; sync(); draw();
}
function sync(){
const playing=state.status==='playing'; $('overlay').hidden=playing;
$('pause').disabled=!['playing','paused'].includes(state.status);
$('pause').textContent=state.status==='paused'?'Resume':'Pause'; $('pause').setAttribute('aria-label',state.status==='paused'?'Resume game':'Pause game');
const labels={ready:'Ready',playing:'Playing',paused:'Paused',over:'Game over',won:'Completed'};
$('state-label').textContent=labels[state.status];
if(!playing){
const content={
ready:['Into the hollow','Collect potions. Avoid the ghosts.','Play'],
paused:['Paused','Pick up where you left off.','Resume'],
over:['Game over',`${state.score/10} potions collected.`,'Play again'],
won:['You cleared the hollow',`${state.score/10} potions collected.`,'Play again']
}[state.status];
$('overlay-title').textContent=content[0]; $('overlay-copy').textContent=content[1]; $('start').textContent=content[2];
}
}
function play(){ if(state.status==='paused'||state.status==='ready'){state.status='playing';accumulator=0;sync()}else setup(true); canvas.focus({preventScroll:true}); }
function pause(){ if(state.status==='playing'){state.status='paused';sync();announce('Game paused')}else if(state.status==='paused')play(); }
$('start').onclick=play; $('pause').onclick=pause;
$('restart').onclick=()=>{ $('settings-dialog').close(); setup(true); canvas.focus({preventScroll:true}); announce('A new game has begun') };
function openMenu(id){ if(state.status==='playing'){state.status='paused';sync()} $(id).showModal() }
$('help').onclick=()=>openMenu('help-dialog');
$('settings').onclick=()=>openMenu('settings-dialog');
$('close-help').onclick=$('help-done').onclick=()=>$('help-dialog').close();
$('close-settings').onclick=$('settings-done').onclick=()=>$('settings-dialog').close();
for(const el of [$('difficulty'),...document.querySelectorAll('.obstacle-options input')]) el.addEventListener('change',()=>{save('hollow-difficulty',$('difficulty').value);if(state.status==='ready')setup();else $('settings-note').textContent='Start a new game to apply these changes.'});
$('snake-color').oninput=()=>{save('hollow-color',$('snake-color').value);draw()};
$('atmosphere').onchange=()=>{document.body.classList.toggle('no-mist',!$('atmosphere').checked);save('hollow-mist',$('atmosphere').checked?'on':'off')};
function musicUI(on){$('music').setAttribute('aria-pressed',String(on));$('music').title=on?'Turn music off':'Turn music on';$('audio-status').textContent=on?'Music is playing.':'Music is off.'}
$('music').onclick=async()=>{
musicWanted=!musicWanted;
if(!musicWanted){audio.pause();musicUI(false);return}
try{await audio.play();if(!musicWanted){audio.pause();return}musicUI(true)}catch{musicWanted=false;musicUI(false);$('audio-status').textContent='Music could not play. Tap Music to retry.';announce('Music could not play. Tap Music to retry.')}
};
$('volume').oninput=()=>{audio.volume=Number($('volume').value)/100};
audio.addEventListener('error',()=>{musicWanted=false;musicUI(false);$('audio-status').textContent='Music is unavailable. Check the bundled audio file.';announce('Music is unavailable.')});
document.addEventListener('visibilitychange',()=>{if(document.hidden){if(state.status==='playing'){state.status='paused';sync()}audio.pause()}else if(musicWanted)audio.play().catch(()=>{musicWanted=false;musicUI(false)})});
window.addEventListener('blur',()=>{if(state.status==='playing'){state.status='paused';sync()}});
const keyDirections={ArrowUp:'up',ArrowDown:'down',ArrowLeft:'left',ArrowRight:'right',w:'up',s:'down',a:'left',d:'right'};
document.addEventListener('keydown',e=>{
if($('help-dialog').open||$('settings-dialog').open||e.ctrlKey||e.metaKey||e.altKey||['INPUT','SELECT','TEXTAREA','BUTTON','A'].includes(e.target.tagName))return;
const key=e.key.length===1?e.key.toLowerCase():e.key;
if(keyDirections[key]){e.preventDefault();turn(state,keyDirections[key]);}
else if(key===' '||key==='p'){e.preventDefault();if(state.status==='ready')play();else pause()}
});
for(const button of document.querySelectorAll('[data-direction]')) button.addEventListener('click',()=>{turn(state,button.dataset.direction)});
let touch=null;
canvas.addEventListener('pointerdown',e=>{touch={x:e.clientX,y:e.clientY};canvas.setPointerCapture(e.pointerId)});
canvas.addEventListener('pointerup',e=>{if(!touch)return;const dx=e.clientX-touch.x,dy=e.clientY-touch.y;touch=null;if(Math.max(Math.abs(dx),Math.abs(dy))<15)return;turn(state,Math.abs(dx)>Math.abs(dy)?dx>0?'right':'left':dy>0?'down':'up')});
canvas.addEventListener('pointercancel',()=>{touch=null});
function round(x,y,w,h,r,color){ctx.fillStyle=color;ctx.beginPath();ctx.roundRect(x,y,w,h,r);ctx.fill()}
function ellipse(x,y,rx,ry,color){ctx.fillStyle=color;ctx.beginPath();ctx.ellipse(x,y,rx,ry,0,0,Math.PI*2);ctx.fill()}
// All sprites use the same 64-unit drawing space and light from the upper left.
// Paths are cached so the game loop only paints the vector geometry.
const spritePaths=new Map();
function shape(data, fill, stroke, width=1.6){
let path=spritePaths.get(data);
if(!path){path=new Path2D(data);spritePaths.set(data,path)}
if(fill){ctx.fillStyle=fill;ctx.fill(path)}
if(stroke){ctx.strokeStyle=stroke;ctx.lineWidth=width;ctx.stroke(path)}
}
function gradient(x1,y1,x2,y2,stops){
const g=ctx.createLinearGradient(x1,y1,x2,y2);
for(const [offset,color] of stops)g.addColorStop(offset,color);
return g;
}
function icon(type,x,y){
ctx.save();ctx.translate((x+.5)*cell,(y+.5)*cell);ctx.scale(cell/64,cell/64);
ctx.lineJoin='round';ctx.lineCap='round';
ellipse(0,25,23,4,'#05091170');
if(type==='ghost'){
const body='M-23 21C-18 12-22 3-18-10C-15-24-4-29 7-25C19-22 21-12 20-3C20 6 25 10 28 11C24 16 19 16 15 13C15 20 20 24 23 25C16 29 9 22 5 21C0 28-7 28-12 21C-16 26-21 26-25 24Z';
shape(body,gradient(-20,-24,20,27,[[0,'#ffffff'],[.42,'#eaeefb'],[1,'#9b9dcc']]),'#858bad',1.5);
shape('M-15-5C-16-17-5-24 4-21',null,'#ffffff',2.2);
shape('M15-9C17 4 12 12 15 19C6 13 8 7 7 2C2 11 2 18-3 22C-7 18-6 10-8 7',null,'#777daa50',2.5);
ellipse(-8,-6,4.3,6.3,'#202c45');ellipse(8,-5,4,6,'#202c45');
ellipse(-9,-8,1.3,1.7,'#c6f2ff');ellipse(7,-7,1.2,1.6,'#c6f2ff');
shape('M-3 6Q0 3 3 6Q5 12 0 14Q-5 12-3 6Z','#495373');
}else if(type==='pumpkin'){
shape('M-5-17L-3-28Q1-31 5-27L2-18Z',gradient(-4,-29,5,-17,[[0,'#a2ae68'],[1,'#435939']]),'#344232');
shape('M3-22Q12-30 20-23Q13-17 3-22Z','#75864c','#4c6038',1.3);
shape('M-1-17C-13-25-27-15-28-1C-30 17-17 25-6 24Q0 27 6 24C20 25 30 13 28-2C26-18 14-24 1-17Z',gradient(-18,-20,20,24,[[0,'#ffb258'],[.35,'#f28630'],[1,'#a94225']]),'#71331f',1.7);
shape('M-7-17C-20-6-17 16-9 22M7-17C19-6 16 17 9 22',null,'#b74b255c',2.8);
shape('M-2-15C-8-5-8 15-3 22',null,'#ffd07a60',2.1);
shape('M-23-4C-23-12-17-16-12-16',null,'#ffd39a',1.6);
shape('M-18-4L-8-8L-7 2Z M7 2L8-8L18-4Z','#482622','#ffae4c',1);
shape('M-14 8L-6 11L-4 8L0 12L5 9L7 11L16 6Q12 20 1 19Q-10 19-14 8Z','#542622','#ffb04e',1);
shape('M-15-3L-9-5L-9-1Z M9-1L9-5L15-3Z','#ffd27b');
}else if(type==='candle'){
ellipse(0,23,24,5,gradient(-23,20,22,27,[[0,'#b89a63'],[.45,'#e4c48a'],[1,'#6f5638']]));
shape('M-22 21Q0 31 22 21L19 25Q0 33-19 25Z','#8d6b42','#b9965a',1);
shape('M-11-3L-12 21Q0 27 12 21L11-3Z',gradient(-12,0,12,0,[[0,'#ae946c'],[.3,'#fff0c7'],[.7,'#e4d2a5'],[1,'#ab9068']]),'#8b785c',1.3);
ellipse(0,-3,11,4,'#faebc7');
shape('M-11-3Q-7 0-5-2L-5 7Q-4 11-1 8L-1 0Q2-3 4 0L4 11Q7 16 9 11L9-2', '#f9e7bb','#e2cba0',1);
shape('M-7 13L-7 21',null,'#fff6d9',1.4);
shape('M0-3Q-2-8 1-10',null,'#604537',2);
// A pointed flame silhouette, with a small glow behind its crisp edge.
ctx.shadowColor='#ffb14a';ctx.shadowBlur=5;
shape('M1-30C3-22 13-18 8-10C5-4-3-5-6-10C-10-18-2-21 1-30Z',gradient(0,-30,0,-6,[[0,'#ffe6a2'],[.5,'#ffc35d'],[1,'#ed7f36']]));
ctx.shadowBlur=0;
shape('M1-20C0-15 6-13 3-9C-1-5-5-10-2-14Z','#fff8d9');
}else{
const bottle='M-7-18L7-18L7-6L19 4Q21 6 21 10L21 24Q21 28 17 28L-17 28Q-21 28-21 24L-21 10Q-21 6-19 4L-7-6Z';
ctx.shadowColor='#ff4bc8';ctx.shadowBlur=8;
shape(bottle,gradient(-21,-18,21,28,[[0,'#fff0fc'],[.22,'#e9a4da'],[.55,'#9b387f'],[1,'#461c51']]),'#ffe3f6',1.8);
ctx.shadowBlur=0;
shape('M-17 7Q-8 3 0 7Q8 11 17 7L17 23Q17 24 15 24L-15 24Q-17 24-17 23Z',gradient(0,5,0,26,[[0,'#ffa9ed'],[.35,'#ff55d3'],[1,'#e824ad']]),'#ffb4ee',1);
shape('M-17 7Q-8 3 0 7Q8 11 17 7',null,'#ffe9fb',1.5);
shape('M-11-3L-16 3M-17 12L-17 20M-3-14L-3-6',null,'#ffffffb8',2.1);
shape('M-9-27L9-27L7-18L-7-18Z',gradient(-9,-27,9,-18,[[0,'#ffdaa3'],[1,'#b98250']]),'#67462e',1.1);
round(-10,-19,20,4,1.8,'#f7d4ee');
shape('M-4-25L-4-22M3-24L3-21',null,'#865c3680',1);
// A single glint and bubbles distinguish the glass from the glowing liquid.
shape('M5 10L7 14L11 16L7 18L5 22L3 18L-1 16L3 14Z','#fff8fe');
ellipse(-8,17,2,2,'#ffd3f4');ellipse(11,21,1.2,1.2,'#ffe3f8');
}
ctx.restore();
}
function resizeCanvas(){
pixelRatio=window.devicePixelRatio||1;
const pixels=Math.max(1,Math.round(canvas.getBoundingClientRect().width*pixelRatio));
if(canvas.width!==pixels||canvas.height!==pixels){canvas.width=pixels;canvas.height=pixels}
if(state)draw();
}
function draw(){
ctx.setTransform(canvas.width/BOARD_SIZE,0,0,canvas.height/BOARD_SIZE,0,0);
ctx.clearRect(0,0,BOARD_SIZE,BOARD_SIZE);
ctx.fillStyle='#11131c';ctx.fillRect(0,0,BOARD_SIZE,BOARD_SIZE);
for(let y=0;y<SIZE;y++)for(let x=0;x<SIZE;x++){ctx.fillStyle=(x+y)%2?'#191a242f':'#23203030';ctx.fillRect(x*cell+1,y*cell+1,cell-2,cell-2)}
ctx.strokeStyle='#79728812';ctx.lineWidth=1;for(let i=0;i<=SIZE;i++){ctx.beginPath();ctx.moveTo(i*cell,0);ctx.lineTo(i*cell,BOARD_SIZE);ctx.stroke();ctx.beginPath();ctx.moveTo(0,i*cell);ctx.lineTo(BOARD_SIZE,i*cell);ctx.stroke()}
// Chipped stone corners frame the dungeon floor.
ctx.strokeStyle='#6a597746';ctx.lineWidth=3;for(const [x,y,sx,sy]of [[8,8,1,1],[652,8,-1,1],[8,652,1,-1],[652,652,-1,-1]]){ctx.beginPath();ctx.moveTo(x,y+sy*20);ctx.lineTo(x,y);ctx.lineTo(x+sx*20,y);ctx.stroke()}
for(const o of state.obstacles)icon(o.type,o.x,o.y);
if(state.food)icon('potion',state.food.x,state.food.y);
const color=$('snake-color').value;ctx.shadowBlur=5;ctx.shadowColor=color;
for(let i=state.snake.length-1;i>=0;i--){const p=state.snake[i];ctx.globalAlpha=i===0?1:.65+.3*(1-i/state.snake.length);round(p.x*cell+2,p.y*cell+2,cell-4,cell-4,i===0?8:6,color);if(i>0){const q=state.snake[i-1];if(q.x===p.x)round(p.x*cell+5,Math.min(q.y,p.y)*cell+cell/2,cell-10,cell,3,color);else round(Math.min(q.x,p.x)*cell+cell/2,p.y*cell+5,cell,cell-10,3,color)}}
ctx.globalAlpha=1;ctx.shadowBlur=0;
const h=state.snake[0], x=(h.x+.5)*cell,y=(h.y+.5)*cell,d=state.direction;
const eyes=d==='right'?[[5,-5],[5,5]]:d==='left'?[[-5,-5],[-5,5]]:d==='up'?[[-5,-5],[5,-5]]:[[-5,5],[5,5]];
for(const [dx,dy]of eyes){ellipse(x+dx,y+dy,3,3,'#13230d');ellipse(x+dx+.5,y+dy-.7,.8,.8,'#f0ffce')}
}
function frame(now){
if(pixelRatio!==(window.devicePixelRatio||1))resizeCanvas();
const elapsed=Math.min(now-last,250);last=now;
if(state.status==='playing'){
accumulator+=elapsed;
const delay=interval(activeDifficulty,state.score);
if(accumulator>=delay){accumulator-=delay;const score=state.score;tick(state);if(state.score!==score){if(state.score>best())save(bestKey(),String(state.score));updateScore();announce(`${state.score/10} potions collected`)}if(state.status!=='playing'){sync();announce(state.status==='won'?'You conquered the hollow':`Game over. ${state.score} points`)}draw()}
}else accumulator=0;
requestAnimationFrame(frame);
}
setup();
resizeCanvas();
new ResizeObserver(resizeCanvas).observe(canvas);
requestAnimationFrame(frame);