🎮ArcadeLab

Tidebreaker: Pixel Depths

by LaserBuilder68
1459 lines40.7 KB
▶ Play
<!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, viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<meta name="theme-color" content="#05080f">
<title>Tidebreaker: Pixel Depths</title>
<style>
  *{box-sizing:border-box;-webkit-tap-highlight-color:transparent;}
  html,body{
    margin:0;padding:0;height:100%;width:100%;
    background:#05080f;overflow:hidden;
    font-family:'Courier New',monospace;
    user-select:none;-webkit-user-select:none;
    touch-action:none;overscroll-behavior:none;
  }
  #game{position:fixed;inset:0;display:flex;flex-direction:column;}
  #stage{flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;min-height:0;}
  canvas{
    image-rendering:pixelated;
    image-rendering:-moz-crisp-edges;
    image-rendering:crisp-edges;
    display:block;
    touch-action:none;
  }
  #controls{
    display:none;
    flex:0 0 auto;
    height:min(150px,26vh);
    padding:8px 14px calc(12px + env(safe-area-inset-bottom));
    justify-content:space-between;align-items:flex-end;
    background:linear-gradient(to top,rgba(5,8,15,.9),rgba(5,8,15,0));
    pointer-events:none;
  }
  body.touch #controls{display:flex;}
  #controls .side{display:flex;gap:12px;align-items:flex-end;pointer-events:none;}
  #controls .btn{pointer-events:auto;}
  .side{display:flex;gap:12px;align-items:flex-end;}
  .btn{
    width:62px;height:62px;border-radius:50%;
    display:flex;align-items:center;justify-content:center;
    font-size:25px;line-height:1;
    color:#bfe9f5;
    background:rgba(30,70,100,.45);
    border:2px solid rgba(126,232,250,.35);
    box-shadow:0 0 14px rgba(0,0,0,.4) inset;
    transition:transform .05s,background .05s;
    touch-action:none;
  }
  .btn.on{background:rgba(126,232,250,.45);transform:scale(.92);color:#fff;}
  .btn.jmp{background:rgba(40,120,90,.45);border-color:rgba(120,255,200,.4);color:#c8ffe6;}
  .btn.jmp.on{background:rgba(120,255,200,.45);}
  .btn.atk{background:rgba(130,50,70,.45);border-color:rgba(255,140,170,.4);color:#ffd7e0;}
  .btn.atk.on{background:rgba(255,140,170,.5);}
  .side.right .btn:first-child{width:56px;height:56px;font-size:21px;}
</style>
</head>
<body>
<div id="game">
  <div id="stage">
    <canvas id="c" width="480" height="270"></canvas>
  </div>
  <div id="controls">
    <div class="side left">
      <div class="btn" id="bLeft">◀</div>
      <div class="btn" id="bRight">▶</div>
    </div>
    <div class="side right">
      <div class="btn atk" id="bAtk">⚔</div>
      <div class="btn jmp" id="bJump">▲</div>
    </div>
  </div>
</div>

<script>
(() => {
"use strict";

/* ============================================================
   CONSTANTS
   ============================================================ */
const VW = 480, VH = 270;
const GRAVITY      = 780;
const JUMP_VEL     = -350;
const MAX_FALL     = 340;
const RUN_SPEED    = 128;
const RUN_ACCEL    = 950;
const WATER_SPEED  = 74;
const WATER_ACCEL  = 430;
const WATER_GRAV   = 200;
const WATER_LIFT   = 520;
const WATER_JUMP   = -340;

/* ============================================================
   CANVAS / SCALING
   ============================================================ */
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
const stage = document.getElementById('stage');

function resize(){
  const r = stage.getBoundingClientRect();
  if(r.width < 4 || r.height < 4) return;
  const s = Math.min(r.width / VW, r.height / VH);
  canvas.style.width  = Math.max(1, Math.floor(VW * s)) + 'px';
  canvas.style.height = Math.max(1, Math.floor(VH * s)) + 'px';
}
window.addEventListener('resize', resize);
window.addEventListener('orientationchange', () => setTimeout(resize, 150));
window.addEventListener('gesturestart', e => e.preventDefault());

/* ============================================================
   INPUT
   ============================================================ */
const input = {
  left:0, right:0,
  jumpHeld:false, jumpBuf:0,
  attackHeld:false, attackBuf:0
};

function setAction(a, down){
  if(a === 'left')  input.left  = down ? 1 : 0;
  if(a === 'right') input.right = down ? 1 : 0;
  if(a === 'jump'){
    input.jumpHeld = down;
    if(down) input.jumpBuf = 0.15;
  }
  if(a === 'attack'){
    input.attackHeld = down;
    if(down) input.attackBuf = 0.15;
  }
}

function clearInput(){
  input.left = 0; input.right = 0;
  input.jumpHeld = false; input.jumpBuf = 0;
  input.attackHeld = false; input.attackBuf = 0;
}

const keyMap = {
  ArrowLeft:'left', KeyA:'left',
  ArrowRight:'right', KeyD:'right',
  Space:'jump', ArrowUp:'jump', KeyW:'jump', KeyZ:'jump',
  KeyJ:'attack', KeyX:'attack', KeyK:'attack'
};

window.addEventListener('keydown', e => {
  if(e.code === 'Enter' || e.code === 'Escape'){
    e.preventDefault();
    confirmPress();
    return;
  }
  const a = keyMap[e.code];
  if(a){ e.preventDefault(); setAction(a, true); }
}, {passive:false});

window.addEventListener('keyup', e => {
  const a = keyMap[e.code];
  if(a){ e.preventDefault(); setAction(a, false); }
}, {passive:false});

function bindButton(id, action){
  const el = document.getElementById(id);
  const down = e => { e.preventDefault(); el.classList.add('on'); setAction(action, true); };
  const up   = e => { e.preventDefault(); el.classList.remove('on'); setAction(action, false); };
  el.addEventListener('pointerdown', down);
  el.addEventListener('pointerup', up);
  el.addEventListener('pointercancel', up);
  el.addEventListener('pointerleave', up);
  el.addEventListener('contextmenu', e => e.preventDefault());
}
bindButton('bLeft','left');
bindButton('bRight','right');
bindButton('bJump','jump');
bindButton('bAtk','attack');

const isTouch = ('ontouchstart' in window) || (navigator.maxTouchPoints > 0) ||
                window.matchMedia('(pointer: coarse)').matches;
if(isTouch) document.body.classList.add('touch');
requestAnimationFrame(resize);
setTimeout(resize, 60);

canvas.addEventListener('pointerdown', e => {
  e.preventDefault();
  resumeAudio();
  if(state !== 'play') confirmPress();
});
window.addEventListener('pointerdown', resumeAudio, {passive:true});

/* ============================================================
   AUDIO
   ============================================================ */
let actx = null;
function resumeAudio(){
  try{
    if(!actx){
      const AC = window.AudioContext || window.webkitAudioContext;
      if(!AC) return;
      actx = new AC();
    }
    if(actx.state === 'suspended') actx.resume();
  }catch(e){ actx = null; }
}
function beep(freq, dur, type, vol){
  if(!actx) return;
  try{
    const t = actx.currentTime;
    const o = actx.createOscillator();
    const g = actx.createGain();
    o.type = type || 'square';
    o.frequency.setValueAtTime(freq, t);
    g.gain.setValueAtTime(vol || 0.05, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(actx.destination);
    o.start(t); o.stop(t + dur + 0.03);
  }catch(e){}
}
function slide(f1, f2, dur, type, vol){
  if(!actx) return;
  try{
    const t = actx.currentTime;
    const o = actx.createOscillator();
    const g = actx.createGain();
    o.type = type || 'square';
    o.frequency.setValueAtTime(f1, t);
    o.frequency.exponentialRampToValueAtTime(Math.max(30, f2), t + dur);
    g.gain.setValueAtTime(vol || 0.05, t);
    g.gain.exponentialRampToValueAtTime(0.0001, t + dur);
    o.connect(g); g.connect(actx.destination);
    o.start(t); o.stop(t + dur + 0.03);
  }catch(e){}
}

/* ============================================================
   HELPERS
   ============================================================ */
const clamp = (v,a,b) => v < a ? a : (v > b ? b : v);
const rectHit = (a,b) => a.x < b.x + b.w && a.x + a.w > b.x &&
                         a.y < b.y + b.h && a.y + a.h > b.y;

function coinRow(x, y, n, gap){
  const a = [];
  for(let i = 0; i < n; i++){
    a.push({x: x + i*gap, y: y, r: 5, taken:false, t: Math.random()*6});
  }
  return a;
}

/* ============================================================
   LEVELS  (platform heights tuned for a ~78px single jump
   and ~157px double jump from a ground top of y=230)
   ============================================================ */
const LEVELS = [

  /* ---------------- LEVEL 1 ---------------- */
  {
    name: 'Saltwind Shore',
    hint: 'Tap JUMP again in mid-air to double jump',
    w: 1700, h: 270,
    spawn: {x: 40, y: 190},
    waterLow: 400, waterHigh: 170,
    platforms: [
      {x:0,    y:230, w:400, h:40},
      {x:500,  y:230, w:380, h:40},
      {x:1000, y:230, w:700, h:40},
      {x:620,  y:170, w:120, h:14, oneway:true},
      {x:860,  y:140, w:100, h:14, oneway:true}
    ],
    crystals: [
      {x:330,  y:206, w:14, h:24, type:'raise'},
      {x:1300, y:206, w:14, h:24, type:'lower'}
    ],
    enemies: [
      {type:'crab', x:560,  y:200, w:16, h:16, minX:520,  maxX:850,  speed:36, dir: 1, hp:2},
      {type:'bat',  x:1050, y:150, w:16, h:12, minX:1010, maxX:1300, speed:46, dir: 1, baseY:150, hp:1},
      {type:'crab', x:1250, y:200, w:16, h:16, minX:1050, maxX:1420, speed:42, dir:-1, hp:2}
    ],
    coins: [
      ...coinRow(120, 200, 4, 22),
      ...coinRow(650, 148, 4, 22),
      ...coinRow(1090, 200, 5, 22),
      ...coinRow(1500, 200, 3, 22)
    ],
    goal: {x:1620, y:198, w:18, h:32}
  },

  /* ---------------- LEVEL 2 ---------------- */
  {
    name: 'Coral Ruins',
    hint: 'Raise the tide to swim up to high ledges',
    w: 2100, h: 270,
    spawn: {x: 40, y: 190},
    waterLow: 400, waterHigh: 140,
    platforms: [
      {x:0,    y:230, w:340, h:40},
      {x:510,  y:230, w:290, h:40},
      {x:970,  y:230, w:290, h:40},
      {x:1430, y:230, w:670, h:40},
      {x:400,  y:170, w:110, h:14, oneway:true},
      {x:700,  y:140, w:110, h:14, oneway:true},
      {x:1150, y:110, w:110, h:14, oneway:true},
      {x:1650, y:140, w:200, h:14, oneway:true}
    ],
    crystals: [
      {x:240,  y:206, w:14, h:24, type:'raise'},
      {x:1030, y:206, w:14, h:24, type:'raise'},
      {x:1550, y:206, w:14, h:24, type:'lower'}
    ],
    enemies: [
      {type:'crab', x:560,  y:200, w:16, h:16, minX:520,  maxX:780,  speed:40, dir: 1, hp:2},
      {type:'bat',  x:900,  y:170, w:16, h:12, minX:860,  maxX:1180, speed:52, dir: 1, baseY:170, hp:1},
      {type:'crab', x:1050, y:200, w:16, h:16, minX:990,  maxX:1240, speed:44, dir:-1, hp:2},
      {type:'bat',  x:1500, y:150, w:16, h:12, minX:1460, maxX:1900, speed:56, dir:-1, baseY:150, hp:1},
      {type:'crab', x:1750, y:200, w:16, h:16, minX:1480, maxX:2050, speed:48, dir: 1, hp:2}
    ],
    coins: [
      ...coinRow(80, 200, 5, 22),
      ...coinRow(420, 148, 4, 22),
      ...coinRow(720, 118, 4, 22),
      ...coinRow(1170, 88, 4, 22),
      ...coinRow(1700, 118, 5, 22)
    ],
    goal: {x:1780, y:108, w:18, h:32}
  },

  /* ---------------- LEVEL 3 ---------------- */
  {
    name: 'Drowned City',
    hint: 'Strike the Coral Golem, then reach the flag',
    w: 1900, h: 270,
    spawn: {x: 40, y: 190},
    waterLow: 400, waterHigh: 140,
    platforms: [
      {x:0,    y:230, w:1900, h:40},
      {x:300,  y:170, w:120, h:14, oneway:true},
      {x:600,  y:140, w:120, h:14, oneway:true},
      {x:900,  y:170, w:120, h:14, oneway:true},
      {x:1200, y:140, w:120, h:14, oneway:true},
      {x:1560, y:170, w:220, h:14, oneway:true}
    ],
    crystals: [
      {x:180,  y:206, w:14, h:24, type:'raise'},
      {x:1050, y:206, w:14, h:24, type:'raise'},
      {x:1400, y:206, w:14, h:24, type:'lower'}
    ],
    enemies: [
      {type:'crab', x:420,  y:200, w:16, h:16, minX:220,  maxX:700,  speed:52, dir: 1, hp:3},
      {type:'bat',  x:700,  y:150, w:16, h:12, minX:620,  maxX:980,  speed:60, dir: 1, baseY:150, hp:1},
      {type:'bat',  x:1080, y:100, w:16, h:12, minX:960,  maxX:1300, speed:60, dir:-1, baseY:100, hp:1},
      {type:'crab', x:1180, y:200, w:16, h:16, minX:960,  maxX:1380, speed:56, dir:-1, hp:3},
      {type:'golem',x:1450, y:170, w:30, h:36, hp:6, speed:52, dir:-1}
    ],
    coins: [
      ...coinRow(80, 200, 4, 22),
      ...coinRow(320, 148, 4, 22),
      ...coinRow(620, 118, 4, 22),
      ...coinRow(920, 148, 4, 22),
      ...coinRow(1220, 118, 4, 22),
      ...coinRow(1600, 148, 5, 22)
    ],
    goal: {x:1810, y:198, w:18, h:32}
  }
];

/* ============================================================
   GAME STATE
   ============================================================ */
let state = 'title';
let stateTimer = 0;
let levelIndex = 0;
let level = null;
let camX = 0;
let time = 0;
let shake = 0;
let hintTimer = 0;
let particles = [];
let totalCoins = 0;

const player = {
  x:0, y:0, py:0, w:10, h:14,
  vx:0, vy:0,
  onGround:false, coyote:0,
  airJumps:1,
  facing:1,
  hp:3, maxHp:3, invuln:0,
  attackTimer:0, attackCd:0, stun:0,
  coins:0, inWater:false, splashCd:0
};

/* ============================================================
   LEVEL LOAD
   ============================================================ */
function loadLevel(i){
  const L = LEVELS[i];

  level = {
    name: L.name,
    hint: L.hint,
    w: L.w, h: L.h,
    spawn: {x: L.spawn.x, y: L.spawn.y},
    waterLow: L.waterLow,
    waterHigh: L.waterHigh,
    waterY: L.waterLow,
    waterTarget: L.waterLow,
    platforms: L.platforms.map(p => Object.assign({}, p)),
    crystals: L.crystals.map(c => Object.assign({}, c, {flash:0})),
    enemies: L.enemies.map(e => Object.assign({}, e, {
      t: Math.random()*2,
      vy:0, py:0, onGround:false,
      hurt:0, dead:false
    })),
    coins: L.coins.map(c => Object.assign({}, c, {taken:false, t:Math.random()*6})),
    goal: Object.assign({}, L.goal)
  };

  player.x  = L.spawn.x;
  player.y  = L.spawn.y;
  player.py = player.y;
  player.vx = 0;
  player.vy = 0;
  player.hp = player.maxHp;
  player.invuln = 0;
  player.attackTimer = 0;
  player.attackCd = 0;
  player.stun = 0;
  player.facing = 1;
  player.inWater = false;
  player.splashCd = 0;
  player.onGround = false;
  player.coyote = 0;
  player.airJumps = 1;

  camX = 0;
  shake = 0;
  particles.length = 0;
  hintTimer = 4.5;
  clearInput();
}

function restartLevel(){
  const coins = player.coins;
  loadLevel(levelIndex);
  player.coins = coins;
  state = 'play';
  stateTimer = 0;
}

function startGame(){
  levelIndex = 0;
  player.coins = 0;
  totalCoins = 0;
  loadLevel(0);
  state = 'play';
  stateTimer = 0;
  resumeAudio();
}

function nextLevel(){
  levelIndex++;
  if(levelIndex >= LEVELS.length){
    state = 'win';
    stateTimer = 0;
    clearInput();
  } else {
    loadLevel(levelIndex);
    state = 'play';
    stateTimer = 0;
  }
}

function confirmPress(){
  if(state === 'title'){
    startGame();
  } else if(state === 'win'){
    state = 'title';
    stateTimer = 0;
    loadLevel(0);
  }
}

/* ============================================================
   PARTICLES
   ============================================================ */
function burst(x, y, color, count, speed){
  for(let i = 0; i < count; i++){
    if(particles.length > 220) return;
    const a = Math.random() * Math.PI * 2;
    const s = speed * (0.3 + Math.random() * 0.9);
    particles.push({
      x, y,
      vx: Math.cos(a) * s,
      vy: Math.sin(a) * s - 30,
      life: 0.5 + Math.random() * 0.5,
      max: 1,
      color,
      size: 1 + (Math.random() < 0.5 ? 0 : 1)
    });
  }
}

function updateParticles(dt){
  for(let i = particles.length - 1; i >= 0; i--){
    const p = particles[i];
    p.life -= dt;
    if(p.life <= 0){ particles.splice(i,1); continue; }
    p.vy += 260 * dt;
    p.vx *= 0.98;
    p.x += p.vx * dt;
    p.y += p.vy * dt;
  }
}

/* ============================================================
   PHYSICS
   ============================================================ */
function collideX(o, plats){
  for(let i = 0; i < plats.length; i++){
    const q = plats[i];
    if(q.oneway) continue;
    if(!rectHit(o,q)) continue;
    if(o.vx > 0)      o.x = q.x - o.w;
    else if(o.vx < 0) o.x = q.x + q.w;
    o.vx = 0;
  }
}

function collideY(o, plats){
  o.onGround = false;
  for(let i = 0; i < plats.length; i++){
    const q = plats[i];
    if(!rectHit(o,q)) continue;

    if(q.oneway){
      // only land when falling and previously above the surface
      if(o.vy >= 0 && o.py + o.h <= q.y + 5){
        o.y  = q.y - o.h;
        o.vy = 0;
        o.onGround = true;
      }
    } else {
      if(o.vy >= 0 && o.py + o.h <= q.y + 8){
        o.y  = q.y - o.h;
        o.vy = 0;
        o.onGround = true;
      } else if(o.vy < 0 && o.py >= q.y + q.h - 8){
        o.y  = q.y + q.h;
        o.vy = 0;
      }
    }
  }
}

/* ============================================================
   PLAYER
   ============================================================ */
function updatePlayer(dt){
  const p = player;
  const L = level;

  if(p.invuln > 0)      p.invuln -= dt;
  if(p.stun > 0)        p.stun -= dt;
  if(p.attackCd > 0)    p.attackCd -= dt;
  if(p.attackTimer > 0) p.attackTimer -= dt;
  if(p.splashCd > 0)    p.splashCd -= dt;

  /* ---- water detection ---- */
  const wasInWater = p.inWater;
  p.inWater = (L.waterY < L.h) && ((p.y + p.h * 0.7) > L.waterY);
  if(p.inWater && !wasInWater && p.splashCd <= 0){
    burst(p.x + p.w/2, L.waterY, '#9fe8ff', 10, 70);
    beep(700, 0.08, 'sine', 0.03);
    p.splashCd = 0.3;
  }

  /* ---- attack ---- */
  if((input.attackBuf > 0 || input.attackHeld) && p.attackCd <= 0 && p.stun <= 0){
    input.attackBuf = 0;
    p.attackCd    = 0.30;
    p.attackTimer = 0.18;
    doAttack();
  }

  /* ---- horizontal ---- */
  const dir = (input.right ? 1 : 0) - (input.left ? 1 : 0);
  const maxSpd = p.inWater ? WATER_SPEED : RUN_SPEED;
  const accel  = p.inWater ? WATER_ACCEL : RUN_ACCEL;

  if(p.stun <= 0){
    if(dir !== 0){
      p.vx += dir * accel * dt;
      if(Math.abs(p.vx) > maxSpd) p.vx = dir * maxSpd;
      p.facing = dir;
    } else {
      const fr = (p.onGround ? 1500 : 650) * dt;
      if(Math.abs(p.vx) <= fr) p.vx = 0;
      else p.vx -= Math.sign(p.vx) * fr;
    }
  } else {
    p.vx *= Math.max(0, 1 - 3 * dt);
  }

  /* ---- vertical ---- */
  if(p.inWater){
    p.vy += WATER_GRAV * dt;
    if(input.jumpHeld) p.vy -= WATER_LIFT * dt;
    p.vy = clamp(p.vy, -125, 75);
  } else {
    p.vy += GRAVITY * dt;
    if(p.vy > MAX_FALL) p.vy = MAX_FALL;
    // variable jump height
    if(!input.jumpHeld && p.vy < 0) p.vy += 1500 * dt;
  }

  /* ---- coyote time & air jumps ---- */
  if(p.onGround){
    p.coyote   = 0.10;
    p.airJumps = 1;
  } else if(p.coyote > 0){
    p.coyote -= dt;
  }

  /* ---- jump ---- */
  if(input.jumpBuf > 0){
    if(p.inWater){
      p.vy = WATER_JUMP;
      input.jumpBuf = 0;
      p.splashCd = 0;
      burst(p.x + p.w/2, p.y + p.h, '#bff2ff', 9, 95);
      beep(520, 0.10, 'square', 0.04);
    } else if(p.onGround || p.coyote > 0){
      p.vy = JUMP_VEL;
      p.onGround = false;
      p.coyote = 0;
      p.airJumps = 1;
      input.jumpBuf = 0;
      beep(430, 0.08, 'square', 0.04);
      burst(p.x + p.w/2, p.y + p.h, '#8fd0e0', 5, 55);
    } else if(p.airJumps > 0){
      p.airJumps--;
      p.vy = JUMP_VEL * 0.92;
      input.jumpBuf = 0;
      beep(620, 0.09, 'triangle', 0.04);
      burst(p.x + p.w/2, p.y + p.h, '#bff2ff', 10, 80);
    }
  }

  /* ---- integrate ---- */
  p.x += p.vx * dt;
  collideX(p, L.platforms);
  p.x = clamp(p.x, 0, L.w - p.w);

  p.py = p.y;
  p.y += p.vy * dt;
  collideY(p, L.platforms);

  /* ---- out of world ---- */
  if(p.y > L.h + 40){
    p.hp = 0;
    die();
  }
}

/* ---------- attack ---------- */
function doAttack(){
  const p = player;
  beep(660, 0.06, 'square', 0.035);
  slide(880, 320, 0.12, 'square', 0.03);

  const hb = {
    x: p.facing > 0 ? p.x + p.w - 2 : p.x - 16,
    y: p.y + 1,
    w: 18, h: 13
  };

  /* enemies */
  for(const e of level.enemies){
    if(e.dead || e.hurt > 0) continue;
    if(!rectHit(hb, e)) continue;

    e.hp--;
    e.hurt = 0.16;
    burst(e.x + e.w/2, e.y + e.h/2, '#ffd166', 9, 100);
    shake = Math.max(shake, 0.12);

    if(e.hp <= 0){
      e.dead = true;
      burst(e.x + e.w/2, e.y + e.h/2, '#ff8fa3', 18, 140);
      beep(200, 0.18, 'sawtooth', 0.05);
      shake = Math.max(shake, 0.28);
    } else {
      beep(300, 0.07, 'square', 0.04);
    }
  }

  /* tide crystals */
  for(const c of level.crystals){
    if(!rectHit(hb, c)) continue;
    c.flash = 0.35;
    const raising = c.type === 'raise';
    level.waterTarget = raising ? level.waterHigh : level.waterLow;
    burst(c.x + c.w/2, c.y + c.h/2,
          raising ? '#7ee8fa' : '#ffb37e', 14, 90);
    beep(raising ? 720 : 380, 0.22, 'sine', 0.05);
    slide(raising ? 400 : 700, raising ? 900 : 260, 0.4, 'sine', 0.04);
    shake = Math.max(shake, 0.2);
  }
}

/* ---------- damage / death ---------- */
function damagePlayer(n, fromX){
  const p = player;
  if(p.invuln > 0 || state !== 'play') return;

  p.hp -= n;
  p.invuln = 1.3;
  p.stun   = 0.22;
  p.vy     = -170;
  p.vx     = (p.x + p.w/2 < fromX ? -1 : 1) * 150;

  shake = Math.max(shake, 0.35);
  burst(p.x + p.w/2, p.y + p.h/2, '#ff5470', 12, 130);
  beep(150, 0.18, 'sawtooth', 0.06);

  if(p.hp <= 0) die();
}

function die(){
  if(state !== 'play') return;
  state = 'dead';
  stateTimer = 0;
  clearInput();
  burst(player.x + 5, player.y + 7, '#2ec4b6', 26, 160);
  burst(player.x + 5, player.y + 7, '#ffffff', 12, 110);
  slide(400, 80, 0.6, 'sawtooth', 0.06);
  shake = 0.5;
}

function clearLevel(){
  if(state !== 'play') return;
  state = 'clear';
  stateTimer = 0;
  clearInput();
  burst(level.goal.x + 9, level.goal.y + 10, '#ffe066', 26, 150);
  slide(400, 1000, 0.4, 'sine', 0.06);
}

/* ============================================================
   ENEMIES
   ============================================================ */
function updateEnemies(dt){
  const L = level;

  for(const e of L.enemies){
    if(e.dead) continue;
    if(e.hurt > 0) e.hurt -= dt;
    e.t += dt;

    if(e.type === 'crab'){
      e.x += e.dir * e.speed * dt;
      if(e.x < e.minX){ e.x = e.minX; e.dir = 1; }
      if(e.x + e.w > e.maxX){ e.x = e.maxX - e.w; e.dir = -1; }

      e.vy += GRAVITY * dt;
      if(e.vy > MAX_FALL) e.vy = MAX_FALL;
      e.py = e.y;
      e.y += e.vy * dt;
      collideY(e, L.platforms);

    } else if(e.type === 'bat'){
      e.x += e.dir * e.speed * dt;
      if(e.x < e.minX){ e.x = e.minX; e.dir = 1; }
      if(e.x + e.w > e.maxX){ e.x = e.maxX - e.w; e.dir = -1; }
      e.y = e.baseY + Math.sin(e.t * 2.4) * 22;

    } else if(e.type === 'golem'){
      const dx = (player.x + player.w/2) - (e.x + e.w/2);
      e.dir = dx >= 0 ? 1 : -1;

      e.vy += GRAVITY * dt;
      if(e.vy > MAX_FALL) e.vy = MAX_FALL;

      if(e.onGround && Math.abs(dx) > 34){
        e.x += e.dir * e.speed * dt;
      }
      if(e.onGround && e.t > 1.6){
        e.vy = -330;
        e.t  = 0;
        beep(110, 0.15, 'sawtooth', 0.05);
        burst(e.x + e.w/2, e.y + e.h, '#8899aa', 10, 90);
      }

      e.py = e.y;
      e.y += e.vy * dt;
      collideY(e, L.platforms);
      e.x = clamp(e.x, 0, L.w - e.w);
    }

    /* contact damage */
    if(state === 'play' && player.invuln <= 0 && rectHit(player, e)){
      damagePlayer(1, e.x + e.w/2);
    }
  }

  /* cleanup */
  for(let i = L.enemies.length - 1; i >= 0; i--){
    const e = L.enemies[i];
    if(e.dead || (e.type !== 'golem' && e.y > L.h + 80)){
      L.enemies.splice(i,1);
    }
  }
}

/* ============================================================
   WORLD
   ============================================================ */
function updateWorld(dt){
  const L = level;

  /* tide animation */
  if(L.waterY !== L.waterTarget){
    const diff = L.waterTarget - L.waterY;
    const step = 78 * dt;
    if(Math.abs(diff) <= step) L.waterY = L.waterTarget;
    else L.waterY += Math.sign(diff) * step;
  }

  for(const c of L.crystals) if(c.flash > 0) c.flash -= dt;
  if(hintTimer > 0) hintTimer -= dt;

  /* coins */
  for(const c of L.coins){
    if(c.taken) continue;
    c.t += dt;
    const box = {x:c.x - c.r, y:c.y - c.r, w:c.r*2, h:c.r*2};
    if(rectHit(player, box)){
      c.taken = true;
      player.coins++;
      totalCoins++;
      burst(c.x, c.y, '#ffe066', 8, 80);
      beep(900 + Math.random()*200, 0.08, 'square', 0.035);
    }
  }

  updatePlayer(dt);
  updateEnemies(dt);

  /* goal — locked until the golem is gone */
  const bossAlive = L.enemies.some(e => e.type === 'golem');
  if(!bossAlive && rectHit(player, L.goal)) clearLevel();
}

/* ============================================================
   RENDER
   ============================================================ */
const skyGrad = (() => {
  const g = ctx.createLinearGradient(0, 0, 0, VH);
  g.addColorStop(0,    '#061024');
  g.addColorStop(0.55, '#0e3350');
  g.addColorStop(1,    '#1b5f7d');
  return g;
})();

function drawBackground(){
  ctx.fillStyle = skyGrad;
  ctx.fillRect(0, 0, VW, VH);

  /* moon */
  ctx.fillStyle = 'rgba(230,245,255,0.85)';
  ctx.fillRect(392, 30, 16, 16);
  ctx.fillStyle = '#0e3350';
  ctx.fillRect(396, 26, 14, 14);

  /* far islands */
  ctx.fillStyle = '#0a2337';
  const p1 = -camX * 0.15;
  const span1 = VW + 480;
  for(let i = 0; i < 10; i++){
    let bx = (i * 240 + p1) % span1;
    if(bx < 0) bx += span1;
    bx -= 240;
    ctx.beginPath();
    ctx.moveTo(bx, 210);
    ctx.lineTo(bx + 70, 140);
    ctx.lineTo(bx + 140, 210);
    ctx.closePath();
    ctx.fill();
  }

  /* near islands */
  ctx.fillStyle = '#0d2c44';
  const p2 = -camX * 0.35;
  const span2 = VW + 640;
  for(let i = 0; i < 10; i++){
    let bx = (i * 320 + p2) % span2;
    if(bx < 0) bx += span2;
    bx -= 320;
    ctx.beginPath();
    ctx.moveTo(bx, 230);
    ctx.lineTo(bx + 95, 168);
    ctx.lineTo(bx + 190, 230);
    ctx.closePath();
    ctx.fill();
  }
}

function drawWater(){
  const L = level;
  if(L.waterY >= L.h) return;

  const wTop = L.waterY;

  ctx.fillStyle = 'rgba(38,132,196,0.42)';
  ctx.fillRect(0, wTop, L.w, L.h - wTop);

  ctx.fillStyle = 'rgba(12,60,110,0.35)';
  const deep = wTop + 60;
  if(deep < L.h) ctx.fillRect(0, deep, L.w, L.h - deep);

  /* surface line */
  ctx.fillStyle = 'rgba(150,240,255,0.75)';
  const startX = Math.floor(camX / 6) * 6;
  for(let x = startX; x < camX + VW + 6; x += 6){
    const yy = wTop + Math.sin(x * 0.06 + time * 3) * 1.6;
    ctx.fillRect(x, Math.round(yy), 6, 1);
  }

  /* shimmer */
  ctx.fillStyle = 'rgba(190,250,255,0.16)';
  for(let i = 0; i < 14; i++){
    const sx = ((i * 137 + time * 20) % (L.w + 200)) - 100;
    const sy = wTop + 12 + ((i * 53) % 80);
    if(sy < L.h) ctx.fillRect(sx, sy, 10, 1);
  }
}

function drawPlatforms(){
  const L = level;
  for(const p of L.platforms){
    if(p.x + p.w < camX - 20 || p.x > camX + VW + 20) continue;

    if(p.oneway){
      ctx.fillStyle = '#5b7a8c';
      ctx.fillRect(p.x, p.y, p.w, p.h);
      ctx.fillStyle = '#8fb4c4';
      ctx.fillRect(p.x, p.y, p.w, 3);
      ctx.fillStyle = 'rgba(0,0,0,0.25)';
      ctx.fillRect(p.x, p.y + p.h - 3, p.w, 3);
      ctx.fillStyle = '#e08a7a';
      for(let i = 6; i < p.w - 6; i += 26){
        ctx.fillRect(p.x + i, p.y + 5, 2, 2);
      }
    } else {
      ctx.fillStyle = '#3d5566';
      ctx.fillRect(p.x, p.y, p.w, p.h);
      ctx.fillStyle = '#7a9c5a';
      ctx.fillRect(p.x, p.y, p.w, 4);
      ctx.fillStyle = '#5d7d42';
      ctx.fillRect(p.x, p.y + 4, p.w, 2);
      ctx.fillStyle = 'rgba(0,0,0,0.22)';
      for(let i = 3; i < p.w - 8; i += 16){
        ctx.fillRect(p.x + i, p.y + 9, 5, 3);
      }
    }
  }
}

function drawCrystals(){
  for(const c of level.crystals){
    if(c.x + c.w < camX - 24 || c.x > camX + VW + 24) continue;

    const cx = c.x + c.w/2;
    const cy = c.y + c.h/2;
    const pulse = 0.6 + Math.sin(time * 4 + c.x) * 0.4;
    const raise = c.type === 'raise';
    const base   = raise ? '#2ea8d8' : '#e08a4a';
    const bright = raise ? '#9ff0ff' : '#ffd0a0';
    const flash  = c.flash > 0;

    ctx.fillStyle = raise
      ? 'rgba(126,232,250,' + (0.10 + pulse * 0.14) + ')'
      : 'rgba(255,180,120,' + (0.10 + pulse * 0.14) + ')';
    ctx.fillRect(cx - 16, cy - 18, 32, 36);

    ctx.fillStyle = '#2b3f52';
    ctx.fillRect(c.x - 2, c.y + c.h - 4, c.w + 4, 5);

    ctx.fillStyle = flash ? '#ffffff' : base;
    for(let i = 0; i < 7; i++){
      const ww = (i < 4) ? (i + 1) * 1.6 : (7 - i) * 1.6;
      ctx.fillRect(Math.round(cx - ww/2), Math.round(c.y + i * 3.2),
                   Math.round(ww), 4);
    }

    ctx.fillStyle = flash ? '#ffffff' : bright;
    ctx.fillRect(Math.round(cx - 1), Math.round(c.y + 5), 2, 6);

    if(raise){
      ctx.fillRect(cx - 1, c.y - 10, 2, 6);
      ctx.fillRect(cx - 3, c.y - 7, 6, 2);
    } else {
      ctx.fillRect(cx - 1, c.y - 4, 2, 6);
      ctx.fillRect(cx - 3, c.y - 4, 6, 2);
    }
  }
}

function drawCoins(){
  for(const c of level.coins){
    if(c.taken) continue;
    if(c.x < camX - 20 || c.x > camX + VW + 20) continue;

    const bob = Math.sin(c.t * 3) * 2;
    const y = c.y + bob;
    const w = Math.abs(Math.cos(c.t * 2.5)) * 4 + 1;

    ctx.fillStyle = '#e8b23a';
    ctx.fillRect(Math.round(c.x - w), Math.round(y - 5), Math.round(w*2), 10);
    ctx.fillStyle = '#ffe98a';
    ctx.fillRect(Math.round(c.x - w), Math.round(y - 5), Math.round(w*2), 3);
    ctx.fillStyle = '#a9772a';
    ctx.fillRect(Math.round(c.x - 1), Math.round(y - 2), 2, 4);
  }
}

function drawEnemies(){
  for(const e of level.enemies){
    if(e.x + e.w < camX - 30 || e.x > camX + VW + 30) continue;

    const x = Math.round(e.x);
    const y = Math.round(e.y);
    if(e.hurt > 0) ctx.globalAlpha = 0.55;

    if(e.type === 'crab'){
      ctx.fillStyle = 'rgba(0,0,0,0.25)';
      ctx.fillRect(x + 1, y + e.h - 1, e.w - 2, 2);

      const lg = Math.sin(e.t * 14) * 1.5;
      ctx.fillStyle = '#9c3b22';
      ctx.fillRect(x + 1, y + 12 + lg, 3, 4);
      ctx.fillRect(x + 12, y + 12 - lg, 3, 4);

      ctx.fillStyle = '#d9532f';
      ctx.fillRect(x + 1, y + 6, 14, 8);
      ctx.fillStyle = '#ef7a4d';
      ctx.fillRect(x + 2, y + 5, 12, 4);

      ctx.fillStyle = '#b8431f';
      ctx.fillRect(x - 3, y + 7, 4, 5);
      ctx.fillRect(x + 15, y + 7, 4, 5);

      ctx.fillStyle = '#ffffff';
      ctx.fillRect(x + 4, y + 3, 3, 3);
      ctx.fillRect(x + 9, y + 3, 3, 3);
      ctx.fillStyle = '#1a1a1a';
      ctx.fillRect(x + 5, y + 4, 1, 2);
      ctx.fillRect(x + 10, y + 4, 1, 2);

    } else if(e.type === 'bat'){
      const wing = Math.sin(e.t * 16) * 4;
      ctx.fillStyle = '#6c4bd6';
      ctx.fillRect(x - 3, y + 2 + wing, 5, 3);
      ctx.fillRect(x + 14, y + 2 - wing, 5, 3);
      ctx.fillStyle = '#8a63ff';
      ctx.fillRect(x + 3, y + 2, 10, 8);
      ctx.fillStyle = '#5a3cb0';
      ctx.fillRect(x + 3, y + 8, 10, 2);
      ctx.fillRect(x + 3, y, 2, 3);
      ctx.fillRect(x + 11, y, 2, 3);
      ctx.fillStyle = '#ff4d6d';
      ctx.fillRect(x + 5, y + 4, 2, 2);
      ctx.fillRect(x + 9, y + 4, 2, 2);

    } else if(e.type === 'golem'){
      ctx.fillStyle = 'rgba(0,0,0,0.3)';
      ctx.fillRect(x + 2, y + e.h - 2, e.w - 4, 3);

      ctx.fillStyle = '#4c5f70';
      ctx.fillRect(x + 4,  y + 24, 8, 12);
      ctx.fillRect(x + 18, y + 24, 8, 12);

      ctx.fillStyle = '#63798c';
      ctx.fillRect(x + 2, y + 8, e.w - 4, 20);
      ctx.fillStyle = '#7d94a8';
      ctx.fillRect(x + 4, y + 10, e.w - 8, 6);

      ctx.fillStyle = '#526678';
      ctx.fillRect(x - 4, y + 12, 6, 14);
      ctx.fillRect(x + e.w - 2, y + 12, 6, 14);

      ctx.fillStyle = '#6e8598';
      ctx.fillRect(x + 6, y, e.w - 12, 9);

      const coreGlow = 0.6 + Math.sin(time * 6) * 0.4;
      ctx.fillStyle = 'rgba(255,90,120,' + coreGlow + ')';
      ctx.fillRect(x + 10, y + 15, 10, 8);
      ctx.fillStyle = '#ffe0e6';
      ctx.fillRect(x + 13, y + 17, 4, 4);

      ctx.fillStyle = '#ff6b8a';
      ctx.fillRect(x + 8,  y + 3, 4, 3);
      ctx.fillRect(x + 18, y + 3, 4, 3);
    }

    ctx.globalAlpha = 1;
  }
}

function drawPlayer(){
  const p = player;
  if(p.invuln > 0 && Math.floor(time * 22) % 2 === 0) return;

  const x = Math.round(p.x);
  const y = Math.round(p.y);
  const f = p.facing;

  ctx.fillStyle = 'rgba(0,0,0,0.25)';
  ctx.fillRect(x + 1, y + p.h - 1, p.w - 2, 2);

  const walking = p.onGround && Math.abs(p.vx) > 12;
  const phase = walking ? Math.floor(time * 12) % 4 : 0;
  const legOff = [0, 1, 0, -1][phase];

  ctx.fillStyle = '#24384f';
  ctx.fillRect(x + 1, y + 10, 3, 4 + legOff);
  ctx.fillRect(x + 6, y + 10, 3, 4 - legOff);

  ctx.fillStyle = '#16222f';
  ctx.fillRect(x + 1, y + 13 + legOff, 3, 1);
  ctx.fillRect(x + 6, y + 13 - legOff, 3, 1);

  ctx.fillStyle = '#2ec4b6';
  ctx.fillRect(x, y + 5, 10, 6);
  ctx.fillStyle = '#1d8f86';
  ctx.fillRect(x, y + 9, 10, 2);

  ctx.fillStyle = '#d9a441';
  ctx.fillRect(x, y + 9, 10, 1);

  ctx.fillStyle = '#ffd9a0';
  ctx.fillRect(x + 1, y, 8, 6);

  ctx.fillStyle = '#1b4965';
  ctx.fillRect(x, y - 2, 10, 3);
  ctx.fillRect(x - 1, y + 1, 12, 1);

  ctx.fillStyle = '#12263a';
  ctx.fillRect(x + (f > 0 ? 6 : 2), y + 2, 2, 2);

  if(p.attackTimer > 0){
    const t  = 1 - (p.attackTimer / 0.18);
    const sw = 17;
    const sx = f > 0 ? x + 7 : x - sw + 3;
    const swing = Math.sin(t * Math.PI) * 8;
    const sy = y + 5 - swing;

    ctx.fillStyle = 'rgba(200,250,255,0.35)';
    ctx.fillRect(sx, sy, sw, 6);
    ctx.fillStyle = '#eaf7fa';
    ctx.fillRect(sx, sy + 2, sw, 3);
    ctx.fillStyle = '#8fd4e0';
    ctx.fillRect(sx, sy + 5, sw, 1);
  }
}

function drawGoal(){
  const g = level.goal;
  const x = Math.round(g.x);
  const y = Math.round(g.y);
  const bossAlive = level.enemies.some(e => e.type === 'golem');

  ctx.fillStyle = '#8b6b4a';
  ctx.fillRect(x + 8, y, 3, g.h);
  ctx.fillStyle = '#5d7d42';
  ctx.fillRect(x + 2, y + g.h - 4, 15, 4);

  const wave = Math.sin(time * 5) * 3;
  ctx.fillStyle = bossAlive ? '#7a3b4a' : '#ffd166';
  ctx.beginPath();
  ctx.moveTo(x + 11, y + 2);
  ctx.lineTo(x + 33, y + 7 + wave);
  ctx.lineTo(x + 33, y + 15 - wave);
  ctx.lineTo(x + 11, y + 18);
  ctx.closePath();
  ctx.fill();

  if(!bossAlive){
    ctx.fillStyle = 'rgba(255,230,120,' + (0.10 + Math.sin(time*4)*0.07) + ')';
    ctx.fillRect(x - 8, y - 8, 46, g.h + 16);
  }
}

function drawParticles(){
  for(const p of particles){
    ctx.globalAlpha = clamp(p.life / p.max, 0, 1);
    ctx.fillStyle = p.color;
    ctx.fillRect(Math.round(p.x), Math.round(p.y), p.size, p.size);
  }
  ctx.globalAlpha = 1;
}

/* ---------- HUD ---------- */
function drawHeart(cx, cy, filled){
  ctx.fillStyle = filled ? '#ff5470' : 'rgba(255,255,255,0.14)';
  ctx.fillRect(cx + 1, cy,     2, 1);
  ctx.fillRect(cx + 4, cy,     2, 1);
  ctx.fillRect(cx,     cy + 1, 7, 3);
  ctx.fillRect(cx + 1, cy + 4, 5, 1);
  ctx.fillRect(cx + 2, cy + 5, 3, 1);
  ctx.fillRect(cx + 3, cy + 6, 1, 1);
}

function drawHUD(){
  ctx.textBaseline = 'top';

  for(let i = 0; i < player.maxHp; i++){
    drawHeart(8 + i * 11, 8, i < player.hp);
  }

  ctx.fillStyle = '#e8b23a';
  ctx.fillRect(8, 22, 6, 6);
  ctx.fillStyle = '#ffe98a';
  ctx.fillRect(8, 22, 6, 2);

  ctx.fillStyle = '#ffffff';
  ctx.font = 'bold 9px "Courier New", monospace';
  ctx.textAlign = 'left';
  ctx.fillText('x' + player.coins, 18, 22);

  ctx.textAlign = 'center';
  ctx.fillStyle = 'rgba(255,255,255,0.85)';
  ctx.fillText('LV ' + (levelIndex + 1) + '  ' + level.name, VW/2, 8);

  /* tide meter */
  const mx = VW - 60, my = 8;
  const range = level.waterLow - level.waterHigh;
  const t = range > 0
    ? clamp((level.waterLow - level.waterY) / range, 0, 1)
    : 0;

  ctx.fillStyle = 'rgba(0,0,0,0.35)';
  ctx.fillRect(mx, my, 52, 8);
  ctx.fillStyle = 'rgba(126,232,250,0.8)';
  ctx.fillRect(mx + 1, my + 1, Math.round(50 * t), 6);
  ctx.strokeStyle = 'rgba(255,255,255,0.35)';
  ctx.lineWidth = 1;
  ctx.strokeRect(mx + 0.5, my + 0.5, 51, 7);

  ctx.textAlign = 'right';
  ctx.fillStyle = 'rgba(190,235,250,0.75)';
  ctx.font = 'bold 7px "Courier New", monospace';
  ctx.fillText('TIDE', mx - 4, my + 1);

  /* boss bar */
  const boss = level.enemies.find(e => e.type === 'golem');
  if(boss){
    const bw = 140;
    const bx = (VW - bw) / 2;
    const by = 24;
    ctx.fillStyle = 'rgba(0,0,0,0.45)';
    ctx.fillRect(bx - 1, by - 1, bw + 2, 9);
    ctx.fillStyle = '#ff5470';
    ctx.fillRect(bx, by, Math.round(bw * (boss.hp / 6)), 7);
    ctx.fillStyle = 'rgba(255,255,255,0.85)';
    ctx.font = 'bold 8px "Courier New", monospace';
    ctx.textAlign = 'center';
    ctx.fillText('CORAL GOLEM', VW/2, by + 12);
  }

  /* level hint */
  if(hintTimer > 0 && state === 'play'){
    const a = clamp(hintTimer, 0, 1);
    ctx.globalAlpha = a;
    ctx.textAlign = 'center';
    ctx.font = 'bold 8px "Courier New", monospace';
    ctx.fillStyle = 'rgba(0,0,0,0.5)';
    ctx.fillRect(VW/2 - 118, VH - 30, 236, 15);
    ctx.fillStyle = '#bff2ff';
    ctx.fillText(level.hint, VW/2, VH - 26);
    ctx.globalAlpha = 1;
  }
}

/* ---------- Overlays ---------- */
function drawOverlay(){
  if(state === 'play') return;

  ctx.fillStyle = 'rgba(4,10,20,0.72)';
  ctx.fillRect(0, 0, VW, VH);

  ctx.textAlign = 'center';
  ctx.textBaseline = 'middle';

  if(state === 'title'){
    const bob = Math.sin(time * 2) * 3;

    ctx.fillStyle = '#7ee8fa';
    ctx.font = 'bold 26px "Courier New", monospace';
    ctx.fillText('TIDEBREAKER', VW/2, 74 + bob);

    ctx.fillStyle = '#ffe066';
    ctx.font = 'bold 12px "Courier New", monospace';
    ctx.fillText('PIXEL DEPTHS', VW/2, 96 + bob);

    ctx.fillStyle = 'rgba(200,235,255,0.85)';
    ctx.font = 'bold 9px "Courier New", monospace';
    ctx.fillText('◀ ▶ move    ▲ jump (x2)    ⚔ attack', VW/2, 142);
    ctx.fillText('Smash tide crystals to raise & lower the sea', VW/2, 158);
    ctx.fillText('Survive three shores and free the ocean', VW/2, 174);

    if(Math.sin(time * 4) > -0.2){
      ctx.fillStyle = '#ffffff';
      ctx.font = 'bold 11px "Courier New", monospace';
      ctx.fillText('TAP  or  PRESS ENTER', VW/2, 214);
    }
  }

  if(state === 'dead'){
    ctx.fillStyle = '#ff5470';
    ctx.font = 'bold 22px "Courier New", monospace';
    ctx.fillText('SWEPT AWAY', VW/2, VH/2 - 12);
    ctx.fillStyle = 'rgba(220,240,255,0.8)';
    ctx.font = 'bold 10px "Courier New", monospace';
    ctx.fillText('the tide reclaims you...', VW/2, VH/2 + 14);
  }

  if(state === 'clear'){
    ctx.fillStyle = '#ffe066';
    ctx.font = 'bold 20px "Courier New", monospace';
    ctx.fillText('TIDE TURNED!', VW/2, VH/2 - 20);
    ctx.fillStyle = 'rgba(220,240,255,0.9)';
    ctx.font = 'bold 10px "Courier New", monospace';
    ctx.fillText(level.name + ' restored', VW/2, VH/2 + 2);
    ctx.fillText('coins: ' + player.coins, VW/2, VH/2 + 18);
  }

  if(state === 'win'){
    ctx.fillStyle = '#7ee8fa';
    ctx.font = 'bold 22px "Courier New", monospace';
    ctx.fillText('THE SEA IS FREE', VW/2, 86);

    ctx.fillStyle = '#ffe066';
    ctx.font = 'bold 11px "Courier New", monospace';
    ctx.fillText('You broke the Quiet.', VW/2, 116);

    ctx.fillStyle = 'rgba(220,240,255,0.9)';
    ctx.font = 'bold 10px "Courier New", monospace';
    ctx.fillText('Total coins: ' + totalCoins, VW/2, 146);

    if(Math.sin(time * 4) > -0.2){
      ctx.fillStyle = '#ffffff';
      ctx.font = 'bold 10px "Courier New", monospace';
      ctx.fillText('tap to return to title', VW/2, 200);
    }
  }
}

/* ============================================================
   RENDER
   ============================================================ */
function render(){
  let sx = 0, sy = 0;
  if(shake > 0){
    sx = (Math.random() - 0.5) * shake * 8;
    sy = (Math.random() - 0.5) * shake * 8;
  }

  ctx.setTransform(1, 0, 0, 1, 0, 0);
  ctx.clearRect(0, 0, VW, VH);

  if(!level){
    drawBackground();
    drawOverlay();
    return;
  }

  drawBackground();

  ctx.save();
  ctx.translate(Math.round(-camX + sx), Math.round(sy));

  drawWater();
  drawPlatforms();
  drawCoins();
  drawCrystals();
  drawGoal();
  drawEnemies();
  drawPlayer();
  drawParticles();

  ctx.restore();

  ctx.setTransform(1, 0, 0, 1, 0, 0);

  if(state === 'play' || state === 'dead' || state === 'clear'){
    drawHUD();
  }
  drawOverlay();
}

/* ============================================================
   MAIN LOOP
   ============================================================ */
let last = performance.now();

function frame(now){
  let dt = (now - last) / 1000;
  last = now;

  if(!isFinite(dt) || dt < 0) dt = 0;
  if(dt > 0.033) dt = 0.033;      // clamp for stable physics

  time += dt;
  if(shake > 0) shake = Math.max(0, shake - dt * 2.4);

  input.jumpBuf   = Math.max(0, input.jumpBuf   - dt);
  input.attackBuf = Math.max(0, input.attackBuf - dt);

  if(state === 'play'){
    updateWorld(dt);
    updateParticles(dt);

    const target = clamp(
      player.x + player.w/2 - VW/2,
      0,
      Math.max(0, level.w - VW)
    );
    camX += (target - camX) * Math.min(1, dt * 9);
    if(Math.abs(target - camX) < 0.4) camX = target;

  } else if(state === 'dead'){
    stateTimer += dt;
    updateParticles(dt);
    if(stateTimer > 1.4) restartLevel();

  } else if(state === 'clear'){
    stateTimer += dt;
    updateParticles(dt);
    if(stateTimer > 1.8) nextLevel();
  }

  render();
  requestAnimationFrame(frame);
}

/* ============================================================
   BOOT
   ============================================================ */
loadLevel(0);
state = 'title';
resize();
requestAnimationFrame(frame);

})();
</script>
</body>
</html>

Game Source: Tidebreaker: Pixel Depths

Creator: LaserBuilder68

Libraries: none

Complexity: complex (1459 lines, 40.7 KB)

The full source code is displayed above on this page.

Remix Instructions

To remix this game, copy the source code above and modify it. Add a ARCADELAB header at the top with "remix_of: tidebreaker-pixel-depths-laserbuilder68" to link back to the original. Then publish at arcadelab.ai/publish.