← back to Games Agentabrams
games/hopper/index.html
794 lines
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
<title>HOPPER</title>
<style>
html,body{margin:0;padding:0;height:100%;background:#0a0e14;overflow:hidden;}
body{
touch-action:none;
-webkit-user-select:none;user-select:none;-webkit-tap-highlight-color:transparent;
font-family:'Trebuchet MS','Segoe UI',system-ui,-apple-system,sans-serif;
display:flex;align-items:center;justify-content:center;
color:#cfe;
}
#wrap{position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center;}
canvas{display:block;background:#0a0e14;touch-action:none;image-rendering:optimizeQuality;}
</style>
</head>
<body>
<div id="wrap"><canvas id="c"></canvas></div>
<script>
(function(){
"use strict";
function boot(){
var canvas = document.getElementById('c');
if(!canvas){ return; }
var ctx = canvas.getContext('2d');
if(!ctx){ return; }
// ---------- Config ----------
var COLS = 13; // grid columns
var ROWS = 15; // grid rows (0 = home row, 14 = start row)
var BEST_KEY = "abrams-hopper-best";
var DPR = 1;
var cell = 40; // px per cell (computed on resize)
var W = COLS*cell, H = ROWS*cell;
var offX = 0, offY = 0; // canvas draw offsets (letterbox)
// ---------- Audio (lazy) ----------
var actx = null;
function ensureAudio(){
if(actx) return;
try{
var AC = window.AudioContext || window.webkitAudioContext;
if(AC){ actx = new AC(); }
}catch(e){ actx = null; }
}
function blip(freq, dur, type, vol){
if(!actx) return;
try{
if(actx.state === "suspended"){ actx.resume(); }
var o = actx.createOscillator();
var g = actx.createGain();
o.type = type || "square";
o.frequency.value = freq;
g.gain.value = 0.0001;
o.connect(g); g.connect(actx.destination);
var t = actx.currentTime;
var v = (vol==null?0.12:vol);
g.gain.setValueAtTime(0.0001, t);
g.gain.exponentialRampToValueAtTime(v, t+0.01);
g.gain.exponentialRampToValueAtTime(0.0001, t+dur);
o.start(t);
o.stop(t+dur+0.02);
}catch(e){}
}
function sHop(){ blip(660,0.08,"square",0.10); }
function sSplat(){ blip(120,0.28,"sawtooth",0.16); setTimeout(function(){blip(80,0.22,"sawtooth",0.14);},40); }
function sHome(){ blip(880,0.10,"triangle",0.14); setTimeout(function(){blip(1320,0.14,"triangle",0.12);},90); }
function sLevel(){ blip(523,0.10,"triangle",0.12); setTimeout(function(){blip(659,0.10,"triangle",0.12);},110); setTimeout(function(){blip(784,0.16,"triangle",0.12);},220); }
// ---------- Layout ----------
// Rows (top to bottom):
// 0 : HOME row (goal slots)
// 1..5 : RIVER lanes (logs/turtles)
// 6 : SAFE median
// 7..11 : ROAD lanes (cars)
// 12 : SAFE start-adjacent
// 13..14 : start area (grass)
var HOME_ROW = 0;
var RIVER_ROWS = [1,2,3,4,5];
var MEDIAN_ROW = 6;
var ROAD_ROWS = [7,8,9,10,11];
var SAFE_ROW = 12;
var START_ROW = 13;
// Home slots: positions (in columns) that must be filled
var HOME_SLOTS = []; // {col, filled}
function setupHomeSlots(){
HOME_SLOTS = [];
var cols = [1,4,6,8,11]; // 5 slots spread across
for(var i=0;i<cols.length;i++){
HOME_SLOTS.push({col:cols[i], filled:false});
}
}
// ---------- Entities ----------
var lanes = []; // active moving lanes: {row, type:'car'|'log', dir, speed, gap, len, color, items:[{x}]}
function buildLanes(level){
lanes = [];
var baseSpeed = 1.4 + (level-1)*0.28; // cells/sec baseline
// Road lanes (cars)
var carColors = ["#ff3b6b","#ffd23b","#3bff9d","#5b8cff","#ff8f3b"];
for(var r=0;r<ROAD_ROWS.length;r++){
var row = ROAD_ROWS[r];
var dir = (r%2===0)? 1 : -1;
var speed = baseSpeed*(0.75 + r*0.16) * (1 + (level-1)*0.05);
var len = 1 + (r%2); // 1 or 2 cells long
var gap = 3.2 + (r%3)*0.9;
lanes.push(makeLane(row,'car',dir,speed,gap,len,carColors[r%carColors.length]));
}
// River lanes (logs & turtles)
for(var rr=0;rr<RIVER_ROWS.length;rr++){
var rw = RIVER_ROWS[rr];
var d = (rr%2===0)? -1 : 1;
var sp = baseSpeed*(0.6 + rr*0.14) * (1 + (level-1)*0.05);
var isTurtle = (rr%2===1);
var llen = isTurtle ? (2 + (rr%2)) : (2 + (rr%3)); // 2..4
var lgap = 2.6 + (rr%2)*0.8;
var lane = makeLane(rw, 'log', d, sp, lgap, llen, isTurtle?"#2ee6c8":"#c8975a");
lane.turtle = isTurtle;
lanes.push(lane);
}
}
function makeLane(row,type,dir,speed,gap,len,color){
var items = [];
var span = len + gap;
// fill across a wider range so wrap looks continuous
var total = Math.ceil((COLS + span*2)/span);
var startX = -span;
for(var i=0;i<total;i++){
items.push({ x: startX + i*span + (Math.random()*0.4-0.2) });
}
return {row:row,type:type,dir:dir,speed:speed,gap:gap,len:len,color:color,items:items,span:span};
}
// ---------- Player ----------
var player = {col:6, row:START_ROW, px:6, py:START_ROW, riding:null, alive:true};
function resetPlayerPos(){
player.col = 6; player.row = START_ROW;
player.px = 6; player.py = START_ROW;
player.riding = null; player.alive = true;
}
// ---------- Game State ----------
var state = "title"; // title | play | dead | over | win
var level = 1;
var lives = 3;
var score = 0;
var best = 0;
try{ var b = localStorage.getItem(BEST_KEY); if(b){ best = parseInt(b,10)||0; } }catch(e){}
var timeMax = 30; // seconds per crossing
var timeLeft = timeMax;
var deadTimer = 0;
var farthestRow = START_ROW;
function startGame(){
level = 1; lives = 3; score = 0;
setupHomeSlots();
buildLanes(level);
resetPlayerPos();
timeMax = 30; timeLeft = timeMax;
farthestRow = START_ROW;
state = "play";
}
function nextLevel(){
level++;
setupHomeSlots();
buildLanes(level);
resetPlayerPos();
timeMax = Math.max(18, 30 - (level-1)*2);
timeLeft = timeMax;
farthestRow = START_ROW;
score += 500; // level bonus
state = "play";
sLevel();
}
function loseLife(){
lives--;
sSplat();
if(lives<=0){
commitBest();
state = "over";
}else{
state = "dead";
deadTimer = 0.9;
}
}
function commitBest(){
if(score>best){
best = score;
try{ localStorage.setItem(BEST_KEY, String(best)); }catch(e){}
}
}
// ---------- Movement ----------
function tryMove(dc,dr){
if(state!=="play" || !player.alive) return;
var nc = player.col + dc;
var nr = player.row + dr;
if(nc<0||nc>=COLS||nr<0||nr>ROWS-1) return;
// Landing on home row: must hit an empty slot
if(nr===HOME_ROW){
var slot = null;
for(var i=0;i<HOME_SLOTS.length;i++){
if(!HOME_SLOTS[i].filled && Math.abs(HOME_SLOTS[i].col - nc) <= 0){
slot = HOME_SLOTS[i]; break;
}
}
// allow snapping to nearest empty slot within 1 col
if(!slot){
for(var j=0;j<HOME_SLOTS.length;j++){
if(!HOME_SLOTS[j].filled && Math.abs(HOME_SLOTS[j].col - nc) === 1){
slot = HOME_SLOTS[j]; break;
}
}
}
if(slot){
slot.filled = true;
score += 100 + Math.round(timeLeft*10);
sHome();
// Check all filled
var allFilled = true;
for(var k=0;k<HOME_SLOTS.length;k++){ if(!HOME_SLOTS[k].filled){ allFilled=false; break; } }
if(allFilled){
nextLevel();
}else{
resetPlayerPos();
timeLeft = timeMax;
farthestRow = START_ROW;
}
return;
}else{
// hit the wall between slots => death
player.col = nc; player.row = nr;
player.px = nc; player.py = nr;
player.alive = false;
loseLife();
return;
}
}
player.col = nc; player.row = nr;
player.px = nc; player.py = nr;
player.riding = null;
// forward progress score
if(nr < farthestRow){
farthestRow = nr;
score += 10;
}
sHop();
}
// ---------- Update ----------
function update(dt){
if(state==="dead"){
deadTimer -= dt;
if(deadTimer<=0){
resetPlayerPos();
timeLeft = timeMax;
farthestRow = START_ROW;
state = "play";
}
return;
}
if(state!=="play") return;
// move lanes
for(var i=0;i<lanes.length;i++){
var ln = lanes[i];
var d = ln.dir * ln.speed * dt;
for(var m=0;m<ln.items.length;m++){
ln.items[m].x += d;
// wrap
if(ln.dir>0 && ln.items[m].x > COLS+1){
ln.items[m].x -= ln.span * ln.items.length;
}else if(ln.dir<0 && ln.items[m].x < -ln.len-1){
ln.items[m].x += ln.span * ln.items.length;
}
}
}
// timer
timeLeft -= dt;
if(timeLeft<=0){
timeLeft = 0;
player.alive = false;
loseLife();
return;
}
// River carry / drown logic
var row = player.row;
var inRiver = (RIVER_ROWS.indexOf(row) !== -1);
if(inRiver){
var onLog = null;
for(var L=0;L<lanes.length;L++){
if(lanes[L].row!==row) continue;
var lane = lanes[L];
for(var it=0; it<lane.items.length; it++){
var x0 = lane.items[it].x;
var x1 = x0 + lane.len;
// player center on this log?
if(player.px+0.5 >= x0-0.05 && player.px+0.5 <= x1+0.05){
onLog = {lane:lane, item:lane.items[it]};
break;
}
}
if(onLog) break;
}
if(onLog){
// carried by log
player.px += onLog.lane.dir * onLog.lane.speed * dt;
player.riding = onLog;
// carried off screen => drown
if(player.px < -0.6 || player.px > COLS-0.4){
player.alive = false;
loseLife();
return;
}
player.col = Math.round(player.px);
}else{
// in water, no log => drown
player.alive = false;
loseLife();
return;
}
}else{
player.riding = null;
player.px = player.col;
}
// Road collision
if(ROAD_ROWS.indexOf(row) !== -1){
for(var c=0;c<lanes.length;c++){
if(lanes[c].row!==row) continue;
var cl = lanes[c];
for(var ci=0; ci<cl.items.length; ci++){
var cx0 = cl.items[ci].x;
var cx1 = cx0 + cl.len;
var pcx = player.col + 0.5;
if(pcx > cx0 && pcx < cx1){
player.alive = false;
loseLife();
return;
}
}
}
}
}
// ---------- Render ----------
function drawRoundRect(x,y,w,h,r){
var rr = Math.min(r, w/2, h/2);
ctx.beginPath();
ctx.moveTo(x+rr,y);
ctx.arcTo(x+w,y,x+w,y+h,rr);
ctx.arcTo(x+w,y+h,x,y+h,rr);
ctx.arcTo(x,y+h,x,y,rr);
ctx.arcTo(x,y,x+w,y,rr);
ctx.closePath();
}
function render(){
ctx.save();
ctx.setTransform(DPR,0,0,DPR,0,0);
ctx.clearRect(0,0,canvas.width,canvas.height);
// full bg
ctx.fillStyle = "#0a0e14";
ctx.fillRect(0,0,canvas.width/DPR,canvas.height/DPR);
ctx.translate(offX, offY);
// clip all field content to the grid rect so lane items that spawn
// off-screen (x<0 or x>COLS) don't bleed into the letterbox area
ctx.beginPath(); ctx.rect(0, 0, W, H); ctx.clip();
// playfield bg
ctx.fillStyle = "#080b11";
ctx.fillRect(0,0,W,H);
// ----- row backgrounds -----
// home band
ctx.fillStyle = "#0d1a14";
ctx.fillRect(0, HOME_ROW*cell, W, cell);
// river band
ctx.fillStyle = "#071726";
ctx.fillRect(0, RIVER_ROWS[0]*cell, W, RIVER_ROWS.length*cell);
// subtle water lines
ctx.strokeStyle = "rgba(60,140,220,0.12)";
ctx.lineWidth = 1;
for(var rl=0; rl<RIVER_ROWS.length; rl++){
var ry = (RIVER_ROWS[rl])*cell + cell*0.5;
ctx.beginPath(); ctx.moveTo(0,ry); ctx.lineTo(W,ry); ctx.stroke();
}
// median
ctx.fillStyle = "#12241a";
ctx.fillRect(0, MEDIAN_ROW*cell, W, cell);
// road band
ctx.fillStyle = "#0c0f16";
ctx.fillRect(0, ROAD_ROWS[0]*cell, W, ROAD_ROWS.length*cell);
// lane dashes
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.setLineDash([cell*0.35, cell*0.3]);
ctx.lineWidth = 2;
for(var rd=0; rd<ROAD_ROWS.length; rd++){
var yy = (ROAD_ROWS[rd])*cell + cell;
if(rd<ROAD_ROWS.length){
ctx.beginPath(); ctx.moveTo(0,yy); ctx.lineTo(W,yy); ctx.stroke();
}
}
ctx.setLineDash([]);
// safe & start grass
ctx.fillStyle = "#0e1f16";
ctx.fillRect(0, SAFE_ROW*cell, W, (ROWS-SAFE_ROW)*cell);
// neon band edges
ctx.strokeStyle = "rgba(46,230,200,0.25)";
ctx.lineWidth = 2;
ctx.strokeRect(1,1,W-2,H-2);
// ----- home slots -----
for(var hs=0; hs<HOME_SLOTS.length; hs++){
var slot = HOME_SLOTS[hs];
var sx = slot.col*cell;
var sy = HOME_ROW*cell;
ctx.save();
if(slot.filled){
ctx.shadowColor = "#2ee6c8"; ctx.shadowBlur = 14;
ctx.fillStyle = "#2ee6c8";
drawRoundRect(sx+cell*0.16, sy+cell*0.16, cell*0.68, cell*0.68, 8);
ctx.fill();
ctx.shadowBlur = 0;
ctx.fillStyle = "#04120e";
ctx.fillRect(sx+cell*0.42, sy+cell*0.28, cell*0.16, cell*0.34);
}else{
ctx.strokeStyle = "rgba(120,255,220,0.55)";
ctx.lineWidth = 2;
drawRoundRect(sx+cell*0.14, sy+cell*0.14, cell*0.72, cell*0.72, 8);
ctx.stroke();
}
ctx.restore();
}
// ----- lanes -----
for(var i=0;i<lanes.length;i++){
var ln = lanes[i];
var y = ln.row*cell;
for(var m=0;m<ln.items.length;m++){
var ix = ln.items[m].x*cell;
if(ix > W+cell || ix < -ln.len*cell-cell) continue;
if(ln.type==='car'){
ctx.save();
ctx.shadowColor = ln.color; ctx.shadowBlur = 12;
ctx.fillStyle = ln.color;
drawRoundRect(ix+3, y+cell*0.18, ln.len*cell-6, cell*0.64, 7);
ctx.fill();
ctx.shadowBlur = 0;
// windows
ctx.fillStyle = "rgba(10,14,20,0.6)";
ctx.fillRect(ix+ (ln.dir>0? ln.len*cell-16 : 8), y+cell*0.28, 8, cell*0.44);
ctx.restore();
}else{
// log or turtle
ctx.save();
if(ln.turtle){
ctx.shadowColor = ln.color; ctx.shadowBlur = 10;
ctx.fillStyle = ln.color;
for(var t=0;t<ln.len;t++){
ctx.beginPath();
ctx.ellipse(ix + t*cell + cell*0.5, y+cell*0.5, cell*0.34, cell*0.30, 0, 0, Math.PI*2);
ctx.fill();
}
ctx.shadowBlur=0;
ctx.fillStyle="rgba(4,30,26,0.5)";
for(var t2=0;t2<ln.len;t2++){
ctx.beginPath();
ctx.ellipse(ix + t2*cell + cell*0.5, y+cell*0.5, cell*0.16, cell*0.14, 0, 0, Math.PI*2);
ctx.fill();
}
}else{
ctx.shadowColor = "rgba(200,150,90,0.5)"; ctx.shadowBlur = 8;
ctx.fillStyle = ln.color;
drawRoundRect(ix+2, y+cell*0.20, ln.len*cell-4, cell*0.60, 9);
ctx.fill();
ctx.shadowBlur=0;
ctx.strokeStyle="rgba(80,50,20,0.5)";
ctx.lineWidth=1;
for(var g=1;g<ln.len;g++){
ctx.beginPath();
ctx.moveTo(ix+g*cell, y+cell*0.22);
ctx.lineTo(ix+g*cell, y+cell*0.78);
ctx.stroke();
}
}
ctx.restore();
}
}
}
// ----- player -----
if(state!=="over" && state!=="title"){
var pxPix = player.px*cell;
var pyPix = player.row*cell;
ctx.save();
var glow = player.alive ? "#7dffb0" : "#ff5b6b";
ctx.shadowColor = glow; ctx.shadowBlur = 18;
ctx.fillStyle = glow;
// body
drawRoundRect(pxPix+cell*0.18, pyPix+cell*0.18, cell*0.64, cell*0.64, 10);
ctx.fill();
ctx.shadowBlur = 0;
// eyes
ctx.fillStyle = "#04120e";
ctx.beginPath();
ctx.arc(pxPix+cell*0.38, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
ctx.arc(pxPix+cell*0.62, pyPix+cell*0.40, cell*0.07, 0, Math.PI*2);
ctx.fill();
ctx.restore();
}
// ----- HUD -----
drawHUD();
// overlays
if(state==="title") drawTitle();
else if(state==="over") drawOver();
ctx.restore();
}
function drawHUD(){
var pad = 6;
ctx.save();
ctx.textBaseline = "top";
// score
var fs = Math.max(11, Math.round(cell*0.32));
ctx.font = "bold "+fs+"px 'Trebuchet MS',sans-serif";
ctx.fillStyle = "#8ff";
ctx.textAlign = "left";
ctx.fillText("SCORE "+score, pad+2, pad);
ctx.fillStyle = "#8fa";
ctx.textAlign = "center";
ctx.fillText("BEST "+best, W/2, pad);
// lives
ctx.textAlign = "right";
ctx.fillStyle = "#ff7d9c";
var lifeStr = "";
for(var i=0;i<lives;i++){ lifeStr += "♥"; }
ctx.fillText("LV"+level+" "+lifeStr, W-pad-2, pad);
// timer bar
if(state==="play" || state==="dead"){
var barY = HOME_ROW*cell + cell + 3;
var barH = 5;
var frac = Math.max(0, timeLeft/timeMax);
ctx.fillStyle = "rgba(255,255,255,0.08)";
ctx.fillRect(6, barY, W-12, barH);
var col = frac>0.5? "#2ee6c8" : (frac>0.25? "#ffd23b" : "#ff3b6b");
ctx.shadowColor = col; ctx.shadowBlur = 8;
ctx.fillStyle = col;
ctx.fillRect(6, barY, (W-12)*frac, barH);
ctx.shadowBlur = 0;
}
ctx.restore();
}
function neonTitleText(txt, cx, cy, size, color){
ctx.save();
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "bold "+size+"px 'Trebuchet MS',sans-serif";
// letter-spacing manual
var ls = size*0.16;
var chars = txt.split("");
var total = 0;
for(var i=0;i<chars.length;i++){ total += ctx.measureText(chars[i]).width + ls; }
total -= ls;
var x = cx - total/2;
ctx.shadowColor = color; ctx.shadowBlur = 22;
ctx.fillStyle = color;
for(var j=0;j<chars.length;j++){
var w = ctx.measureText(chars[j]).width;
ctx.fillText(chars[j], x + w/2, cy);
x += w + ls;
}
ctx.restore();
}
function drawTitle(){
ctx.save();
ctx.fillStyle = "rgba(4,7,12,0.82)";
ctx.fillRect(0,0,W,H);
neonTitleText("HOPPER", W/2, H*0.30, Math.round(cell*1.05), "#2ee6c8");
ctx.textAlign = "center";
ctx.fillStyle = "#bfe";
var s = Math.max(12, Math.round(cell*0.34));
ctx.font = s+"px 'Trebuchet MS',sans-serif";
ctx.fillText("Cross the road & river", W/2, H*0.44);
ctx.fillText("Fill all home slots", W/2, H*0.50);
ctx.fillStyle = "#8fa";
ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
ctx.fillText("ARROWS / WASD to hop", W/2, H*0.60);
// pulsing prompt
var a = 0.55 + 0.45*Math.sin(Date.now()/300);
ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
ctx.fillText("TAP / PRESS TO START", W/2, H*0.72);
ctx.restore();
}
function drawOver(){
ctx.save();
ctx.fillStyle = "rgba(12,4,8,0.85)";
ctx.fillRect(0,0,W,H);
neonTitleText("GAME OVER", W/2, H*0.34, Math.round(cell*0.82), "#ff3b6b");
ctx.textAlign="center";
var s = Math.max(12, Math.round(cell*0.4));
ctx.font = "bold "+s+"px 'Trebuchet MS',sans-serif";
ctx.fillStyle = "#8ff";
ctx.fillText("SCORE "+score, W/2, H*0.48);
ctx.fillStyle = "#8fa";
ctx.fillText("BEST "+best, W/2, H*0.55);
var a = 0.55 + 0.45*Math.sin(Date.now()/300);
ctx.fillStyle = "rgba(125,255,176,"+a.toFixed(3)+")";
ctx.font = "bold "+Math.round(cell*0.42)+"px 'Trebuchet MS',sans-serif";
ctx.fillText("TAP / PRESS TO RETRY", W/2, H*0.68);
ctx.restore();
}
// ---------- D-pad (touch) ----------
var dpad = null; // {cx,cy,r} in canvas coords
function computeDpad(){
// place bottom-left area over start grass
var r = cell*1.1;
dpad = { cx: r*1.15, cy: H - r*1.15, r: r };
}
function drawDpad(){
if(!dpad) return;
if(state!=="play" && state!=="dead") return;
ctx.save();
ctx.translate(offX, offY);
ctx.globalAlpha = 0.5;
var d = dpad;
var arm = d.r*0.62, w = d.r*0.42;
ctx.fillStyle = "rgba(46,230,200,0.14)";
ctx.strokeStyle = "rgba(46,230,200,0.55)";
ctx.lineWidth = 2;
function armRect(ang){
ctx.save();
ctx.translate(d.cx,d.cy);
ctx.rotate(ang);
drawRoundRect(w*0.2, -w/2, arm, w, 6);
ctx.fill(); ctx.stroke();
// arrow tip
ctx.fillStyle="rgba(180,255,235,0.8)";
ctx.beginPath();
ctx.moveTo(arm+w*0.2, 0);
ctx.lineTo(arm-w*0.05, -w*0.28);
ctx.lineTo(arm-w*0.05, w*0.28);
ctx.closePath();
ctx.fill();
ctx.fillStyle="rgba(46,230,200,0.14)";
ctx.restore();
}
armRect(-Math.PI/2); // up
armRect(Math.PI/2); // down
armRect(Math.PI); // left
armRect(0); // right
ctx.restore();
}
function dpadHit(x,y){
if(!dpad) return null;
var dx = x - dpad.cx, dy = y - dpad.cy;
var dist = Math.sqrt(dx*dx+dy*dy);
if(dist > dpad.r*1.35) return null;
if(dist < dpad.r*0.18) return null;
if(Math.abs(dx) > Math.abs(dy)){
return dx>0 ? "right" : "left";
}else{
return dy>0 ? "down" : "up";
}
}
// ---------- Input ----------
function handleDir(dir){
if(dir==="up") tryMove(0,-1);
else if(dir==="down") tryMove(0,1);
else if(dir==="left") tryMove(-1,0);
else if(dir==="right") tryMove(1,0);
}
function onKey(e){
var k = e.key;
var used = false;
if(k==="ArrowUp"||k==="w"||k==="W"){ used=true; act("up"); }
else if(k==="ArrowDown"||k==="s"||k==="S"){ used=true; act("down"); }
else if(k==="ArrowLeft"||k==="a"||k==="A"){ used=true; act("left"); }
else if(k==="ArrowRight"||k==="d"||k==="D"){ used=true; act("right"); }
else if(k===" "||k==="Enter"){ used=true; act("start"); }
if(used){ e.preventDefault(); }
}
function act(dir){
ensureAudio();
if(state==="title"){ startGame(); return; }
if(state==="over"){ startGame(); return; }
handleDir(dir);
}
window.addEventListener("keydown", onKey, {passive:false});
// touch / pointer
function canvasPoint(e){
var rect = canvas.getBoundingClientRect();
var cx = (e.clientX - rect.left) - offX;
var cy = (e.clientY - rect.top) - offY;
return {x:cx, y:cy};
}
function onPointerDown(e){
e.preventDefault();
ensureAudio();
if(state==="title"||state==="over"){ startGame(); return; }
var p = canvasPoint(e);
var hit = dpadHit(p.x, p.y);
if(hit){ handleDir(hit); return; }
// swipe-region fallback: tap relative to player => hop toward tap
var pcx = (player.px+0.5)*cell;
var pcy = (player.row+0.5)*cell;
var dx = p.x - pcx, dy = p.y - pcy;
if(Math.abs(dx)<cell*0.4 && Math.abs(dy)<cell*0.4){ tryMove(0,-1); return; }
if(Math.abs(dx) > Math.abs(dy)){ handleDir(dx>0?"right":"left"); }
else{ handleDir(dy>0?"down":"up"); }
}
canvas.addEventListener("pointerdown", onPointerDown, {passive:false});
canvas.addEventListener("touchstart", function(e){ e.preventDefault(); }, {passive:false});
// ---------- Resize ----------
function resize(){
var wrap = document.getElementById("wrap");
var availW = wrap.clientWidth || window.innerWidth;
var availH = wrap.clientHeight || window.innerHeight;
if(availW<=0) availW = window.innerWidth;
if(availH<=0) availH = window.innerHeight;
DPR = Math.min(window.devicePixelRatio||1, 2);
// choose cell to fit both dims
var cw = Math.floor(availW / COLS);
var ch = Math.floor(availH / ROWS);
cell = Math.max(12, Math.min(cw, ch));
W = COLS*cell; H = ROWS*cell;
canvas.width = Math.floor(availW*DPR);
canvas.height = Math.floor(availH*DPR);
canvas.style.width = availW+"px";
canvas.style.height = availH+"px";
offX = Math.floor((availW - W)/2);
offY = Math.floor((availH - H)/2);
computeDpad();
// rebuild lane item x extents not needed (grid-relative), but ensure lanes exist
if(lanes.length===0 && state!=="title"){ buildLanes(level); }
}
window.addEventListener("resize", resize);
// ---------- Loop ----------
var last = 0;
function frame(ts){
if(!last) last = ts;
var dt = (ts - last)/1000;
last = ts;
if(dt>0.1) dt = 0.1; // clamp
update(dt);
render();
drawDpad();
requestAnimationFrame(frame);
}
// ---------- Init ----------
setupHomeSlots();
resize();
requestAnimationFrame(frame);
}
if(document.readyState === "loading"){
document.addEventListener("DOMContentLoaded", boot);
}else{
boot();
}
})();
</script>
</body>
</html>