🎮ArcadeLab

Cowboy Reforged - Horse & Cactus Edition

by ElectricPirate55
705 lines44.7 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<title>Cowboy Reforged - Horse & Cactus Edition</title>
<link href="https://fonts.googleapis.com/css2?family=Rye&family=Inter:wght@600;800&display=swap" rel="stylesheet">
<style>
*{box-sizing:border-box;margin:0;padding:0}
html,body{width:100%;height:100%;overflow:hidden;background:#2b1a0e;font-family:Inter,sans-serif;touch-action:none;user-select:none}
#game-wrap{position:fixed;inset:0;background:#c19a6b;overflow:hidden}
#main-canvas{display:block;width:100%;height:100%;touch-action:none}
.vignette{position:fixed;inset:0;pointer-events:none;z-index:5;background:radial-gradient(ellipse at center, transparent 55%, rgba(60,20,5,0.25) 85%, rgba(30,10,0,0.6) 100%)}
.damage-flash{position:fixed;inset:0;pointer-events:none;z-index:6;background:radial-gradient(circle at center, rgba(239,68,68,0) 30%, rgba(239,68,68,0.35) 100%);opacity:0;transition:opacity 0.15s}
.hud{position:fixed;top:12px;left:12px;right:12px;display:flex;justify-content:space-between;z-index:20;pointer-events:none;gap:12px}
.panel{background:rgba(44,24,12,0.88);backdrop-filter:blur(12px);border:1.5px solid rgba(255,213,128,0.25);border-radius:14px;padding:10px 14px;color:#fde68a;box-shadow:0 8px 32px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,220,150,0.2);pointer-events:auto}
.player-panel{display:flex;align-items:center;gap:12px;min-width:230px}
.avatar{width:46px;height:46px;border-radius:10px;background:radial-gradient(circle at 30% 30%, #fbbf24, #92400e);display:flex;align-items:center;justify-content:center;font-size:24px;border:1.5px solid #fcd34d;box-shadow:0 3px 10px rgba(0,0,0,0.5)}
.player-name{font-family:Rye,serif;font-size:12px;letter-spacing:0.08em;color:#fde68a;line-height:1}
.hp-bar{width:138px;height:13px;background:#2a1a0e;border-radius:999px;overflow:hidden;border:1px solid rgba(255,255,255,0.15);margin-top:6px;position:relative}
.hp-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#ef4444 0%,#f87171 30%,#fbbf24 70%,#22c55e 100%);transition:width 0.18s ease-out;position:relative;box-shadow:inset 0 1px 0 rgba(255,255,255,0.4)}
.hp-shine{position:absolute;inset:0;background:linear-gradient(100deg,transparent 20%,rgba(255,255,255,0.6) 50%,transparent 80%);width:55%;animation:shine 2s infinite}
@keyframes shine{0%{transform:translateX(-120%) skewX(-12deg)}100%{transform:translateX(240%) skewX(-12deg)}}
.combo{font-size:10px;font-weight:800;color:#fde047;margin-top:4px;min-height:12px;text-shadow:0 1px 3px #000}
.stats-panel{min-width:160px;display:flex;flex-direction:column;gap:5px}
.stat-row{display:flex;justify-content:space-between;font-size:12px;gap:10px}
.stat-row span{opacity:0.85;font-weight:600}
.stat-row b{font-family:Rye,serif}
.minimap-wrap{position:fixed;top:88px;right:14px;width:122px;height:122px;border-radius:50%;overflow:hidden;border:2px solid rgba(251,191,36,0.5);box-shadow:0 8px 24px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.2);z-index:18;background:#a67c52}
.minimap-wrap canvas{width:100%;height:100%;display:block}
.controls{position:fixed;inset:0;pointer-events:none;z-index:25}
#joystick{position:absolute;left:22px;bottom:22px;width:138px;height:138px;border-radius:50%;background:radial-gradient(circle at 35% 35%, rgba(255,235,180,0.18), rgba(30,15,5,0.55));border:1.5px solid rgba(255,213,128,0.35);backdrop-filter:blur(8px);box-shadow:0 10px 30px rgba(0,0,0,0.5), inset 0 1px 0 rgba(255,255,255,0.2), 0 0 0 8px rgba(0,0,0,0.15);pointer-events:auto;touch-action:none}
#stick{position:absolute;width:58px;height:58px;left:40px;top:40px;border-radius:50%;background:radial-gradient(circle at 32% 28%, #fff, #fde68a 35%, #d97706 100%);border:1.5px solid #fff;box-shadow:0 4px 14px rgba(0,0,0,0.5);will-change:transform}
.right-stack{position:absolute;right:22px;bottom:22px;display:flex;flex-direction:column;align-items:center;gap:12px;pointer-events:auto}
.btn{border-radius:50%;border:2px solid rgba(255,255,255,0.8);color:white;box-shadow:0 10px 28px rgba(0,0,0,0.55), inset 0 1px 0 rgba(255,255,255,0.35);display:flex;align-items:center;justify-content:center;cursor:pointer;position:relative;touch-action:none;transition:transform 0.08s, opacity 0.2s}
.btn:active{transform:scale(0.92)}
#attackBtn{width:106px;height:106px;background:radial-gradient(circle at 35% 30%, #ef4444, #7f1d1d);font-size:36px}
#dashBtn{width:62px;height:62px;background:radial-gradient(circle at 30% 30%, #38bdf8, #0c4a6e);font-size:22px}
#mountBtn{width:78px;height:78px;background:radial-gradient(circle at 30% 30%, #a3e635, #3f6212);font-size:26px;border-color:#ecfccb;display:none}
#mountBtn.show{display:flex;animation:pop 0.3s cubic-bezier(.34,1.56,.64,1)}
@keyframes pop{0%{transform:scale(0.5)}100%{transform:scale(1)}}
.cooldown-ring{position:absolute;inset:-2px;border-radius:50%;pointer-events:none;mask:radial-gradient(circle, transparent 62%, black 63%);-webkit-mask:radial-gradient(circle, transparent 62%, black 63%)}
.hint{position:fixed;bottom:160px;left:50%;transform:translateX(-50%);background:rgba(0,0,0,0.75);color:#fde68a;padding:6px 12px;border-radius:999px;font-size:12px;font-weight:700;letter-spacing:0.05em;z-index:30;pointer-events:none;opacity:0;transition:opacity 0.2s;border:1px solid rgba(251,191,36,0.3)}
.hint.show{opacity:1}
.modal-backdrop{position:fixed;inset:0;background:rgba(20,10,5,0.78);backdrop-filter:blur(14px);z-index:60;display:flex;align-items:center;justify-content:center;padding:18px}
.message-modal{background:linear-gradient(180deg, rgba(60,30,15,0.96), rgba(25,12,5,0.98));border:1.5px solid rgba(251,191,36,0.4);box-shadow:0 20px 60px rgba(0,0,0,0.7), 0 0 40px rgba(251,191,36,0.15);border-radius:20px;padding:28px;text-align:center;min-width:300px;max-width:380px;width:100%;animation:modalPop 0.38s cubic-bezier(0.34,1.56,0.64,1)}
@keyframes modalPop{0%{transform:scale(0.88) translateY(12px);opacity:0}100%{transform:scale(1) translateY(0);opacity:1}}
.message-modal h1{font-family:Rye,serif;font-size:24px;color:#fde68a;margin-bottom:10px;text-shadow:0 2px 10px rgba(251,191,36,0.4)}
.message-modal p{color:#e7d5b8;font-size:13px;line-height:1.5;margin-bottom:18px}
#restart{padding:12px 26px;border:0;border-radius:10px;background:linear-gradient(180deg,#fbbf24,#d97706);color:#1f1300;font-weight:800;font-size:13px;letter-spacing:0.06em;cursor:pointer;box-shadow:0 4px 16px rgba(251,191,36,0.35)}
</style>
</head>
<body>
<div id="game-wrap">
<canvas id="main-canvas"></canvas>
<div class="vignette"></div>
<div class="damage-flash" id="dmgFlash"></div>
<div class="hud">
  <div class="panel player-panel">
    <div class="avatar" id="avatar">🤠</div>
    <div style="flex:1">
      <div class="player-name" id="pname">COWBOY</div>
      <div class="hp-bar"><div class="hp-fill" id="hpFill" style="width:100%"><div class="hp-shine"></div></div></div>
      <div class="combo" id="comboTxt"></div>
    </div>
  </div>
  <div class="panel stats-panel">
    <div class="stat-row"><span>WAVE</span><b id="waveTxt" style="color:#fde68a">1 / 5</b></div>
    <div class="stat-row"><span>🏜️ TOWN</span><b id="townHp" style="color:#fbbf24">100%</b></div>
    <div class="stat-row" style="font-size:11px;margin-top:2px"><span style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:6px">🐴 <b id="horseCount">0</b></span><span style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:6px">🌵 <b id="cactusTxt">0</b></span><span style="background:rgba(255,255,255,0.1);padding:2px 6px;border-radius:6px">☠️ <b id="enemyTxt">0</b> | ⚔️ <b id="killTxt">0</b></span></div>
  </div>
</div>
<div class="minimap-wrap"><canvas id="mini" width="122" height="122"></canvas></div>
<div class="hint" id="hint"></div>
<div class="controls">
  <div id="joystick"><div id="stick"></div></div>
  <div class="right-stack">
    <button class="btn" id="mountBtn" aria-label="Mount">🐴</button>
    <button class="btn" id="dashBtn" aria-label="Dash">💨</button>
    <button class="btn" id="attackBtn" aria-label="Attack"><span style="position:relative;z-index:1">🤠</span><div class="cooldown-ring" id="atkCdRing"></div></button>
  </div>
</div>
</div>

<script type="module">
const canvas = document.getElementById('main-canvas');
const ctx = canvas.getContext('2d', {alpha:false});
const mini = document.getElementById('mini');
const mctx = mini.getContext('2d');
const hpFill = document.getElementById('hpFill');
const comboTxt = document.getElementById('comboTxt');
const waveTxt = document.getElementById('waveTxt');
const townHpEl = document.getElementById('townHp');
const enemyTxt = document.getElementById('enemyTxt');
const killTxt = document.getElementById('killTxt');
const horseCountEl = document.getElementById('horseCount');
const cactusTxt = document.getElementById('cactusTxt');
const dmgFlash = document.getElementById('dmgFlash');
const hint = document.getElementById('hint');
const mountBtn = document.getElementById('mountBtn');
const avatar = document.getElementById('avatar');
const pname = document.getElementById('pname');

let W = innerWidth, H = innerHeight;
function resize(){
  W = innerWidth; H = innerHeight;
  const dpr = Math.max(1, devicePixelRatio||1);
  canvas.width = W*dpr; canvas.height = H*dpr;
  canvas.style.width = W+'px'; canvas.style.height = H+'px';
  ctx.setTransform(dpr,0,0,dpr,0,0);
}
resize(); addEventListener('resize', resize);

const WORLD = {w:5000, h:5000};
const TOWN = {x:2500, y:2500, w:620, h:520, hp:350, maxHp:350};

let audioCtx=null, gain=null;
function ensureAudio(){
  if(!audioCtx){ audioCtx = new (window.AudioContext||window.webkitAudioContext)(); gain = audioCtx.createGain(); gain.gain.value=0.22; const comp = audioCtx.createDynamicsCompressor(); comp.threshold.value=-24; comp.ratio.value=12; gain.connect(comp); comp.connect(audioCtx.destination); }
  if(audioCtx.state==='suspended') audioCtx.resume();
}
function sfx(type, variant=0){
  try{
    ensureAudio(); if(!audioCtx) return;
    const t = audioCtx.currentTime;
    const o = audioCtx.createOscillator(); const g = audioCtx.createGain(); const f = audioCtx.createBiquadFilter(); f.type='lowpass'; f.frequency.value=3200;
    o.connect(f); f.connect(g); g.connect(gain);
    if(type==='attack'){ o.type= variant===2?'triangle':'sawtooth'; o.frequency.setValueAtTime(380+variant*80,t); o.frequency.exponentialRampToValueAtTime(60,t+0.13); g.gain.setValueAtTime(0.9,t); g.gain.exponentialRampToValueAtTime(0.001,t+0.18); o.start(t); o.stop(t+0.19); }
    else if(type==='hit'){ o.type='square'; o.frequency.setValueAtTime(170,t); o.frequency.linearRampToValueAtTime(30,t+0.16); g.gain.setValueAtTime(0.7,t); g.gain.linearRampToValueAtTime(0.001,t+0.18); o.start(t); o.stop(t+0.2); }
    else if(type==='dash'){ o.type='sine'; o.frequency.setValueAtTime(220,t); o.frequency.linearRampToValueAtTime(650,t+0.12); g.gain.setValueAtTime(0.5,t); g.gain.linearRampToValueAtTime(0.001,t+0.14); o.start(t); o.stop(t+0.15); }
    else if(type==='horse'){ o.type='sine'; o.frequency.setValueAtTime(80,t); o.frequency.linearRampToValueAtTime(120,t+0.2); g.gain.setValueAtTime(0.6,t); g.gain.linearRampToValueAtTime(0.001,t+0.3); o.start(t); o.stop(t+0.32); const o2=audioCtx.createOscillator(); const g2=audioCtx.createGain(); o2.type='square'; o2.frequency.setValueAtTime(200,t+0.05); o2.frequency.linearRampToValueAtTime(100,t+0.25); g2.gain.setValueAtTime(0.3,t); g2.gain.linearRampToValueAtTime(0.001,t+0.3); o2.connect(g2); g2.connect(gain); o2.start(t); o2.stop(t+0.32); }
    else if(type==='cactus'){ o.type='triangle'; o.frequency.setValueAtTime(400,t); o.frequency.linearRampToValueAtTime(120,t+0.15); g.gain.setValueAtTime(0.4,t); g.gain.linearRampToValueAtTime(0.001,t+0.2); o.start(t); o.stop(t+0.22); }
  }catch{}
}

const rand = (a,b)=>Math.random()*(b-a)+a;
const clamp = (v,a,b)=>Math.max(a,Math.min(b,v));
const dist = (a,b,c,d)=>Math.hypot(a-c,b-d);

class Particle{
  constructor(x,y,vx,vy,color,size,life,grav=0.08){ this.x=x; this.y=y; this.vx=vx; this.vy=vy; this.color=color; this.size=size; this.life=life; this.max=life; this.grav=grav; }
  update(){ this.x+=this.vx; this.y+=this.vy; this.vy+=this.grav; this.life-=0.02; this.vx*=0.98; }
  get dead(){ return this.life<=0; }
}
class FloatText{
  constructor(x,y,text,color='#fff'){ this.x=x+rand(-10,10); this.y=y; this.vy=-2.2; this.life=1; this.text=text; this.color=color; }
  update(){ this.y+=this.vy; this.vy+=0.06; this.life-=0.018; }
}

let player = {x:2500, y:2900, radius:22, speed:3.8, baseSpeed:3.8, horseSpeed:6.2, hp:140, maxHp:140, dir:0, attackCd:0, attackMax:22, combo:0, comboTimer:0, dashCd:0, dashTime:0, isDashing:false, isMounted:false, mountedHorse:null, killCount:0, trail:0, lastCactusHit:0};

let enemies=[], allies=[], cacti=[], rocks=[], horses=[], particles=[], floatTexts=[], slashs=[];
let cam = {x:2500, y:2900};
let shake=0, wave=1, kills=0, gameOver=false, overData=null;
let input = {x:0,y:0,active:false};
let keys={};

function showHint(t, ms=1600){
  hint.textContent=t; hint.classList.add('show');
  clearTimeout(hint._t); hint._t=setTimeout(()=>hint.classList.remove('show'), ms);
}

function init(){
  enemies=[]; allies=[]; cacti=[]; rocks=[]; horses=[]; particles=[]; floatTexts=[]; slashs=[];
  player.x=2500; player.y=2900; player.hp=140; player.combo=0; player.comboTimer=0; player.attackCd=0; player.dashCd=0; player.dashTime=0; player.killCount=0; player.isMounted=false; player.mountedHorse=null; player.speed=player.baseSpeed;
  TOWN.hp=350; wave=1; kills=0; gameOver=false; overData=null; cam.x=player.x; cam.y=player.y; shake=0;

  // Cactus - pohon jadi kaktus berduri
  for(let i=0;i<260;i++){
    let x=rand(80,WORLD.w-80), y=rand(80,WORLD.h-80);
    if(dist(x,y,TOWN.x,TOWN.y)<550) continue;
    if(dist(x,y,player.x,player.y)<400) continue;
    cacti.push({x, y, type: Math.random()<0.65?'saguaro':'prickly', s:rand(0.8,1.45), h:rand(0,1000), dmg: Math.random()<0.5?12:18, hitCd:0});
  }
  // Rocks gurun
  for(let i=0;i<140;i++) rocks.push({x:rand(40,WORLD.w-40), y:rand(40,WORLD.h-40), s:rand(0.6,1.3), h:rand(0,1000)});

  // Kuda liar yang bisa ditunggangi
  for(let i=0;i<8;i++){
    let a=rand(0,Math.PI*2), r=rand(500,1700);
    horses.push({x:clamp(player.x+Math.cos(a)*r,100,WORLD.w-100), y:clamp(player.y+Math.sin(a)*r,100,WORLD.h-100), radius:30, dir:a, speed:1.0+Math.random()*0.5, state:'wander', wanderTimer:rand(0,120), color: ['#8B4513','#5C4033','#A0522D','#CD853F'][Math.floor(Math.random()*4)], mounted:false, rider:null});
  }

  for(let i=0;i<12;i++) spawnEnemy('outlaw');
  for(let i=0;i<2;i++) spawnEnemy('brute');
  for(let i=0;i<2;i++) spawnAlly();
}

function spawnEnemy(kind){
  if(enemies.length>=35) return;
  const ang=rand(0,Math.PI*2), r=rand(900,1900);
  const x=clamp(player.x+Math.cos(ang)*r,100,WORLD.w-100), y=clamp(player.y+Math.sin(ang)*r,100,WORLD.h-100);
  if(kind==='brute' || (!kind && Math.random()<0.22+wave*0.06)){
    enemies.push({x,y,radius:26,hp:125,maxHp:125,speed:1.05+rand(0,0.35),dir:ang,attackCd:rand(0,60),hitFlash:0,type:'brute'});
  }else{
    enemies.push({x,y,radius:20,hp:55,maxHp:55,speed:2.1+rand(0,0.6),dir:ang,attackCd:rand(0,40),hitFlash:0,type:'outlaw'});
  }
}
function spawnAlly(){
  if(allies.length>=10) return;
  allies.push({x:player.x+rand(-30,30), y:player.y+rand(-30,30), radius:19, hp:60, maxHp:60, speed:2.0, dir:0, attackCd:rand(0,30), hitFlash:0});
}

function tryMount(){
  if(player.isMounted){ // dismount
    const h = player.mountedHorse;
    if(h){ h.mounted=false; h.x=player.x+Math.cos(player.dir)*40; h.y=player.y+Math.sin(player.dir)*40; }
    player.isMounted=false; player.mountedHorse=null; player.speed=player.baseSpeed;
    avatar.textContent='🤠'; pname.textContent='COWBOY'; sfx('horse');
    showHint('DISMOUNTED - NORMAL SPEED');
    return;
  }
  let closest=null, best=1e9;
  for(let ho of horses){ if(ho.mounted) continue; const d=dist(player.x,player.y,ho.x,ho.y); if(d<best){ best=d; closest=ho; } }
  if(closest && best<75){
    player.isMounted=true; player.mountedHorse=closest; closest.mounted=true; player.speed=player.horseSpeed;
    avatar.textContent='🐎'; pname.textContent='MOUNTED COWBOY'; sfx('horse');
    showHint('MOUNTED! +70% SPEED - CACTUS STILL HURTS!');
    // efek debu
    for(let i=0;i<14;i++) particles.push(new Particle(player.x,player.y+10,rand(-3,3),rand(-3,0),'rgba(180,140,90,0.8)',rand(2,5),0.8,0.1));
  }else{
    showHint('GET CLOSER TO WILD HORSE! 🐴',1200);
  }
}

function dash(){
  if(player.dashCd>0 || player.isDashing || gameOver) return;
  let dx=input.x, dy=input.y;
  let len=Math.hypot(dx,dy);
  if(len<0.2){ dx=Math.cos(player.dir); dy=Math.sin(player.dir); } else { dx/=len; dy/=len; }
  player.isDashing=true; player.dashTime=14; player.dashCd= player.isMounted?55:88; player.trail=8;
  player._dx=dx*(player.isMounted?16:12); player._dy=dy*(player.isMounted?16:12);
  for(let i=0;i<12;i++) particles.push(new Particle(player.x,player.y+6,-dx*rand(1,4)+rand(-1,1),rand(-1,1),'rgba(200,220,255,0.7)',rand(2,4),0.7,0.02));
  sfx('dash');
}

function attack(){
  if(player.attackCd>0 || gameOver) return;
  ensureAudio();
  const dmgList=[28,28,46]; const rangeList=[112,118,142]; const arcList=[125,145,190];
  const dmg=dmgList[player.combo]||28; const range=rangeList[player.combo]||112; const arc=arcList[player.combo]||125;
  const finalRange = player.isMounted? range+38 : range;
  const finalDmg = player.isMounted? dmg+12 : dmg;
  player.attackCd= player.combo===2? (player.isMounted?22:28) : (player.isMounted?14:18);
  slashs.push({x:player.x, y:player.y, dir:player.dir, life:1, combo:player.combo, mounted:player.isMounted});
  sfx('attack', player.combo);
  let hitCount=0;
  for(let e of enemies){
    const dx=e.x-player.x, dy=e.y-player.y, d=Math.hypot(dx,dy);
    if(d>finalRange+e.radius) continue;
    let ang=Math.atan2(dy,dx), diff=Math.abs(ang-player.dir); diff=Math.atan2(Math.sin(diff),Math.cos(diff));
    if(Math.abs(diff)>arc*Math.PI/180/2) continue;
    e.hp-=finalDmg; e.hitFlash=9; hitCount++;
    const push=30+player.combo*12+(player.isMounted?18:0);
    e.x+=dx/(d||1)*push; e.y+=dy/(d||1)*push;
    for(let k=0;k<10+player.combo*3;k++) particles.push(new Particle(e.x,e.y-10,dx/(d||1)*rand(0.5,3)+rand(-1.5,1.5),dy/(d||1)*rand(0.5,2)+rand(-2,0),e.type==='brute'?'#7f1d1d':'#991b1b',rand(2,4.5),rand(0.5,1),0.18));
    floatTexts.push(new FloatText(e.x,e.y-28,`-${finalDmg}`, player.combo===2?'#fde047':'#fff'));
    if(e.hp<=0){ e.dead=true; kills++; player.killCount++; if(Math.random()<0.4) spawnAlly(); for(let k=0;k<14;k++) particles.push(new Particle(e.x,e.y,rand(-3,3),rand(-4,-0.5),'rgba(90,20,20,0.9)',rand(2,5),rand(0.6,1.1),0.15)); }
  }
  enemies=enemies.filter(e=>!e.dead);
  if(hitCount>0){ sfx('hit'); shake=Math.max(shake, 4+player.combo*1.6); }
  player.combo=(player.combo+1)%3; player.comboTimer=46; if(hitCount===0) player.comboTimer=Math.min(player.comboTimer,18);
}

// Controls
const joystick=document.getElementById('joystick'), stick=document.getElementById('stick');
function handleJoy(x,y){
  const r=joystick.getBoundingClientRect(), cx=r.left+r.width/2, cy=r.top+r.height/2;
  let dx=x-cx, dy=y-cy, max=52, len=Math.hypot(dx,dy);
  if(len>max){ dx=dx/len*max; dy=dy/len*max; }
  input.x=dx/max; input.y=dy/max; input.active=true;
  stick.style.transform=`translate(${dx}px, ${dy}px)`;
}
function resetJoy(){ input.x=0; input.y=0; input.active=false; stick.style.transform='translate(0px,0px)'; }
joystick.addEventListener('pointerdown',e=>{ e.target.setPointerCapture(e.pointerId); handleJoy(e.clientX,e.clientY); });
joystick.addEventListener('pointermove',e=>{ if(input.active) handleJoy(e.clientX,e.clientY); });
joystick.addEventListener('pointerup',resetJoy); joystick.addEventListener('pointercancel',resetJoy);
let lastTap=0;
joystick.addEventListener('pointerdown',()=>{ const now=performance.now(); if(now-lastTap<280) dash(); lastTap=now; });
document.getElementById('attackBtn').addEventListener('pointerdown',e=>{ e.preventDefault(); attack(); });
document.getElementById('dashBtn').addEventListener('pointerdown',e=>{ e.preventDefault(); dash(); });
mountBtn.addEventListener('pointerdown',e=>{ e.preventDefault(); tryMount(); });
addEventListener('keydown',e=>{ keys[e.code]=true; if(e.code==='Space'){ e.preventDefault(); attack(); } if(e.code==='ShiftLeft'||e.code==='ShiftRight'){ e.preventDefault(); dash(); } if(e.code==='KeyE'){ e.preventDefault(); tryMount(); } });
addEventListener('keyup',e=>{ keys[e.code]=false; });

function drawBackground(){
  // pasir gurun koboi
  ctx.fillStyle='#d8b48a';
  ctx.fillRect(0,0,W,H);
  // noise pasir
  const gx = cam.x - W/2 -200, gy = cam.y - H/2 -200, gx2 = cam.x + W/2 +200, gy2 = cam.y + H/2 +200;
  ctx.fillStyle='rgba(120,90,50,0.18)';
  const step=88;
  for(let x=Math.floor(gx/step)*step; x<gx2; x+=step){
    for(let y=Math.floor(gy/step)*step; y<gy2; y+=step){
      const n = Math.sin(x*0.0123 + y*0.078)*43758.5453; const f=n-Math.floor(n);
      if(f>0.72){
        const sx = x - cam.x + W/2 + (f*40-20);
        const sy = y - cam.y + H/2 + ((n*0.5)%1*40-20);
        ctx.beginPath(); ctx.arc(sx,sy,f*2.2,0,Math.PI*2); ctx.fill();
      }
    }
  }
  // bunga gurun kecil & tulang
  ctx.fillStyle='rgba(255,240,180,0.65)';
  for(let x=Math.floor(gx/140)*140; x<gx2; x+=140){
    for(let y=Math.floor(gy/140)*140; y<gy2; y+=140){
      const n = Math.sin(x*0.093+ y*0.113)*12345.67; const f=n-Math.floor(n);
      if(f>0.94){
        const sx=x - cam.x + W/2 +8, sy=y - cam.y + H/2 +8;
        ctx.beginPath(); ctx.arc(sx,sy,1.5,0,Math.PI*2); ctx.fill();
      }
    }
  }
  // grid tipis gurun
  ctx.strokeStyle='rgba(90,60,30,0.07)'; ctx.lineWidth=1;
  ctx.beginPath();
  const grid=180;
  const offX = -cam.x % grid, offY = -cam.y % grid;
  for(let x=offX; x<W; x+=grid){ ctx.moveTo(x,0); ctx.lineTo(x,H); }
  for(let y=offY; y<H; y+=grid){ ctx.moveTo(0,y); ctx.lineTo(W,y); }
  ctx.stroke();
}

function drawTown(){
  const sx = TOWN.x - cam.x + W/2, sy = TOWN.y - cam.y + H/2;
  if(sx<-600||sx>W+600||sy<-600||sy>H+600) return;
  ctx.save(); ctx.translate(sx,sy);
  // bayangan
  ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(0,TOWN.h/2+36,TOWN.w/2+32,30,0,0,Math.PI*2); ctx.fill();
  // pagar kayu
  ctx.strokeStyle='#5c3a21'; ctx.lineWidth=6; ctx.strokeRect(-TOWN.w/2-14,-TOWN.h/2-14,TOWN.w+28,TOWN.h+28);
  ctx.fillStyle='#8b5a2b'; ctx.fillRect(-TOWN.w/2-14,-TOWN.h/2-14,TOWN.w+28,TOWN.h+28);
  ctx.fillStyle='#6f4320';
  for(let i=-TOWN.w/2-14;i<TOWN.w/2+28;i+=28) ctx.fillRect(i,-TOWN.h/2-14,10,TOWN.h+28);
  // tanah dalam town
  ctx.fillStyle='#c9a87a'; ctx.fillRect(-TOWN.w/2,-TOWN.h/2,TOWN.w,TOWN.h);
  // bangunan saloon
  const bw=160, bh=140;
  [-TOWN.w/2+20, TOWN.w/2-bw-20].forEach((bx)=>{
    ctx.fillStyle='#4a2e18'; ctx.fillRect(bx,-TOWN.h/2+20,bw,bh);
    ctx.fillStyle='#7a4a2a'; ctx.fillRect(bx+6,-TOWN.h/2+26,bw-12,bh-18);
    ctx.fillStyle='#3a2010'; ctx.fillRect(bx+10,-TOWN.h/2+26,bw-20,14);
    ctx.fillStyle='#2b170c'; for(let y=0;y<6;y++) ctx.fillRect(bx+10,-TOWN.h/2+44+y*18,bw-20,8);
    // atap
    ctx.fillStyle='#2b1a0e'; ctx.beginPath(); ctx.moveTo(bx-8,-TOWN.h/2+20); ctx.lineTo(bx+bw/2,-TOWN.h/2-30); ctx.lineTo(bx+bw+8,-TOWN.h/2+20); ctx.closePath(); ctx.fill();
  });
  // sumur tengah
  ctx.fillStyle='#5a3a22'; ctx.beginPath(); ctx.arc(0,TOWN.h/2-40,38,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#2a1a0e'; ctx.beginPath(); ctx.arc(0,TOWN.h/2-40,22,0,Math.PI*2); ctx.fill();
  if(TOWN.hp<TOWN.maxHp){
    ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(-70,TOWN.h/2+20,140,9);
    ctx.fillStyle=TOWN.hp/TOWN.maxHp<0.3?'#ef4444':'#fbbf24'; ctx.fillRect(-70,TOWN.h/2+20,140*(TOWN.hp/TOWN.maxHp),9);
  }
  ctx.restore();
}

function drawCactusObj(x,y,o){
  const sx=x - cam.x + W/2, sy=y - cam.y + H/2;
  if(sx<-120||sx>W+120||sy<-120||sy>H+120) return;
  ctx.save(); ctx.translate(sx,sy); ctx.scale(o.s,o.s);
  ctx.fillStyle='rgba(0,0,0,0.22)'; ctx.beginPath(); ctx.ellipse(0,34,20,9,0,0,Math.PI*2); ctx.fill();
  const isHit = o.hitCd>0;
  // bayangan duri merah saat damage
  if(isHit){ ctx.shadowColor='#ef4444'; ctx.shadowBlur=14; }
  if(o.type==='saguaro'){
    ctx.fillStyle=isHit?'#5a8a4a':'#3a7d44';
    // batang utama
    ctx.beginPath(); ctx.roundRect?ctx.roundRect(-10,-30,20,64,8):ctx.fillRect(-10,-30,20,64); if(!ctx.roundRect){} else ctx.fill();
    if(!ctx.roundRect){ ctx.fillRect(-10,-30,20,64); }
    else { ctx.fill(); }
    ctx.fillStyle=isHit?'#6a9a5a':'#4a9a5a';
    ctx.fillRect(-8,-26,6,56);
    // lengan
    ctx.fillStyle=isHit?'#5a8a4a':'#3a7d44';
    ctx.beginPath(); ctx.moveTo(-10,0); ctx.quadraticCurveTo(-28,2,-26,18); ctx.lineTo(-18,20); ctx.quadraticCurveTo(-8,6,-2,0); ctx.fill();
    ctx.beginPath(); ctx.moveTo(10,-8); ctx.quadraticCurveTo(28,-6,26,12); ctx.lineTo(18,14); ctx.quadraticCurveTo(8,-2,2,-8); ctx.fill();
    // duri
    ctx.fillStyle='rgba(230,230,200,0.9)';
    for(let i=0;i<10;i++){ ctx.fillRect(-9+ (i%2?4:-4), -24+i*6, 5,1); ctx.fillRect(9- (i%2?4:-4), -20+i*6,5,1); }
  }else{
    // prickly pear
    ctx.fillStyle=isHit?'#6aaa4a':'#4a9a3a';
    for(let i=0;i<3;i++){ ctx.beginPath(); ctx.ellipse((i-1)*12, i*6-10, 14, 18, (i-1)*0.3,0,Math.PI*2); ctx.fill(); }
    ctx.fillStyle='rgba(255,255,200,0.9)';
    for(let i=0;i<8;i++){ ctx.beginPath(); ctx.arc(rand(-14,14), rand(-12,16), 1.2,0,Math.PI*2); ctx.fill(); }
  }
  if(o.type==='saguaro'){
    // indikator damage
    if(true){ ctx.fillStyle='rgba(239,68,68,0.0)'; }
  }
  ctx.restore();
  // aura bahaya
  if(dist(player.x,player.y,x,y)<80){
    ctx.save(); ctx.translate(sx,sy); ctx.strokeStyle='rgba(239,68,68,0.25)'; ctx.lineWidth=2; ctx.setLineDash([4,4]); ctx.beginPath(); ctx.arc(0,10,34*o.s,0,Math.PI*2); ctx.stroke(); ctx.restore();
  }
}

function drawRockObj(x,y,o){
  const sx=x - cam.x + W/2, sy=y - cam.y + H/2;
  if(sx<-120||sx>W+120||sy<-120||sy>H+120) return;
  ctx.save(); ctx.translate(sx,sy); ctx.scale(o.s,o.s);
  ctx.fillStyle='rgba(0,0,0,0.18)'; ctx.beginPath(); ctx.ellipse(2,10,18,6,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#8a7a6a'; ctx.beginPath(); ctx.ellipse(0,0,20,13,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#a09080'; ctx.beginPath(); ctx.ellipse(-4,-3,8,5,-0.3,0,Math.PI*2); ctx.fill();
  ctx.restore();
}

function drawHorseObj(x,y,h){
  const sx=x - cam.x + W/2, sy=y - cam.y + H/2;
  if(sx<-120||sx>W+120||sy<-120||sy>H+120) return;
  ctx.save(); ctx.translate(sx,sy); ctx.rotate(h.dir);
  if(h.mounted){ ctx.globalAlpha=0.2; }
  ctx.fillStyle='rgba(0,0,0,0.28)'; ctx.beginPath(); ctx.ellipse(0,14,26,9,0,0,Math.PI*2); ctx.fill();
  // body
  ctx.fillStyle=h.color;
  ctx.beginPath(); ctx.ellipse(0,0,28,14,0,0,Math.PI*2); ctx.fill();
  // head
  ctx.fillStyle=h.color; ctx.beginPath(); ctx.ellipse(28,-4,13,9,0.2,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#1a1a1a'; ctx.beginPath(); ctx.arc(36,-5,2,0,Math.PI*2); ctx.fill();
  // mane
  ctx.fillStyle='#2b1a0e'; ctx.beginPath(); ctx.ellipse(18,-10,14,5,-0.2,0,Math.PI*2); ctx.fill();
  // legs
  ctx.fillStyle='#3a2a1a'; ctx.fillRect(-18,6,5,12); ctx.fillRect(-6,6,5,12); ctx.fillRect(8,6,5,12); ctx.fillRect(18,6,5,12);
  // tail
  ctx.fillStyle='#2b1a0e'; ctx.beginPath(); ctx.ellipse(-26,0,8,6,0.5,0,Math.PI*2); ctx.fill();
  ctx.restore();
  if(!h.mounted && dist(player.x,player.y,x,y)<75){
    ctx.save(); ctx.translate(sx,sy-38); ctx.fillStyle='rgba(0,0,0,0.75)'; ctx.fillRect(-28,-10,56,18); ctx.fillStyle='#a3e635'; ctx.font='800 11px Inter'; ctx.textAlign='center'; ctx.fillText('🐴 [E] MOUNT',0,2); ctx.restore();
  }
}

function drawCowboy(x,y,dir,flash,weapon,attacking,isMounted){
  ctx.save(); ctx.translate(x,y); ctx.rotate(dir);
  const sc = isMounted?1.22:1;
  ctx.scale(sc,sc);
  ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(0,16,16,6.5,0,0,Math.PI*2); ctx.fill();
  // if mounted, horse already drawn below, draw legs tucked
  if(isMounted){
    ctx.fillStyle='#3a2a1a'; ctx.fillRect(-9,6,5,10); ctx.fillRect(4,6,5,10);
  }else{
    ctx.fillStyle='#1e1b18'; ctx.fillRect(-8,6,5,12); ctx.fillRect(3,6,5,12);
  }
  ctx.fillStyle=flash?'#fff':'#2a3a5a'; ctx.beginPath(); ctx.roundRect?ctx.roundRect(-11,-12,22,22,4):null; if(ctx.roundRect){ ctx.fill(); } else { ctx.fillRect(-11,-12,22,22); }
  ctx.fillStyle=flash?'#fff':'#4a6fa5'; ctx.fillRect(-5,-12,10,22);
  ctx.fillStyle='#d1a37a'; ctx.beginPath(); ctx.arc(0,-18,10,0,Math.PI*2); ctx.fill();
  // topi koboi
  ctx.fillStyle='#5c3a1a'; ctx.beginPath(); ctx.ellipse(0,-20,15,6,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#8b5a2b'; ctx.beginPath(); ctx.ellipse(0,-24,10,8,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#3a2410'; ctx.fillRect(-8,-26,16,3);
  // senjata
  const off = attacking?18:0;
  if(weapon==='revolver'){
    ctx.fillStyle='#9ca3af'; ctx.fillRect(10+off,-14,4,8); ctx.fillStyle='#4b2a0a'; ctx.fillRect(12+off,-12,2,14);
    ctx.fillStyle='#b45309'; ctx.beginPath(); ctx.arc(13+off,-5,4,0,Math.PI*2); ctx.fill();
    ctx.fillStyle='#e5e7eb'; ctx.beginPath(); ctx.moveTo(11+off,-16); ctx.lineTo(15+off,-16); ctx.lineTo(14+off,-38); ctx.lineTo(12+off,-38); ctx.closePath(); ctx.fill();
  }else if(weapon==='lasso'){
    ctx.strokeStyle='#d6b86a'; ctx.lineWidth=2; ctx.beginPath(); ctx.arc(18+off,-20,10, -0.5, 2.2); ctx.stroke();
  }
  ctx.restore();
}

function drawOutlaw(x,y,dir,flash,type,attacking){
  ctx.save(); ctx.translate(x,y); ctx.rotate(dir);
  const brute = type==='brute';
  ctx.scale(brute?1.18:1, brute?1.18:1);
  ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(0,16,16,6.5,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#1e1b18'; ctx.fillRect(-8,6,5,12); ctx.fillRect(3,6,5,12);
  ctx.fillStyle=flash?'#fff': brute?'#3f0a0a':'#450a0a'; ctx.fillRect(-11,-12,22,22);
  ctx.fillStyle=flash?'#fff': brute?'#7f1d1d':'#b91c1c'; ctx.fillRect(-5,-12,10,22);
  ctx.fillStyle='#9ca3af'; ctx.beginPath(); ctx.arc(-11,-8,5,0,Math.PI*2); ctx.arc(11,-8,5,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#d1a37a'; ctx.beginPath(); ctx.arc(0,-18,10,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#2a2a2a'; ctx.beginPath(); ctx.arc(0,-21,10,Math.PI,0); ctx.fill();
  ctx.fillStyle='#111'; ctx.fillRect(-5,-20,10,2);
  const off=attacking?18:0;
  if(brute){ ctx.fillStyle='#9ca3af'; ctx.fillRect(10+off,-10,4,8); ctx.fillStyle='#422006'; ctx.fillRect(12+off,-28,3,34); ctx.fillStyle='#9ca3af'; ctx.beginPath(); ctx.moveTo(12+off,-30); ctx.lineTo(12+off,-16); ctx.lineTo(26+off,-22); ctx.lineTo(24+off,-30); ctx.closePath(); ctx.fill(); }
  else{ ctx.fillStyle='#9ca3af'; ctx.fillRect(10+off,-10,3,6); ctx.fillStyle='#d1d5db'; ctx.beginPath(); ctx.moveTo(10+off,-12); ctx.lineTo(14+off,-12); ctx.lineTo(12+off,-24); ctx.closePath(); ctx.fill(); }
  ctx.restore();
}

function drawAlly(x,y,dir,attacking){
  ctx.save(); ctx.translate(x,y); ctx.rotate(dir);
  ctx.fillStyle='rgba(0,0,0,0.32)'; ctx.beginPath(); ctx.ellipse(0,16,14,6,0,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#1e3a2a'; ctx.fillRect(-7,6,4,10); ctx.fillRect(3,6,4,10);
  ctx.fillStyle='#14532d'; ctx.fillRect(-10,-12,20,20); ctx.fillStyle='#22c55e'; ctx.fillRect(-4,-12,8,20);
  ctx.fillStyle='#d1a37a'; ctx.beginPath(); ctx.arc(0,-18,9,0,Math.PI*2); ctx.fill();
  ctx.fillStyle='#14532d'; ctx.beginPath(); ctx.arc(0,-20,10,Math.PI,0); ctx.fill();
  const off=attacking?16:0;
  ctx.fillStyle='#9ca3af'; ctx.fillRect(9+off,-12,3,7); ctx.fillStyle='#78350f'; ctx.fillRect(10+off,-32,2,40); ctx.fillStyle='#cbd5e1'; ctx.beginPath(); ctx.moveTo(9+off,-32); ctx.lineTo(13+off,-32); ctx.lineTo(11+off,-46); ctx.closePath(); ctx.fill();
  ctx.restore();
}

let lastTime=performance.now(), acc=0;
function updateHUD(){
  const hpPct = clamp(player.hp/player.maxHp*100,0,100);
  hpFill.style.width=hpPct+'%';
  document.getElementById('atkCdRing').style.background=`conic-gradient(from -90deg, #fbbf24 ${player.attackCd/player.attackMax*360}deg, transparent ${player.attackCd/player.attackMax*360}deg)`;
  document.getElementById('atkCdRing').style.opacity= player.attackCd>0.5? '1':'0';
  document.getElementById('dashBtn').style.opacity= player.dashCd>2? '0.45':'1';
  comboTxt.textContent = player.comboTimer>0? `COMBO x${player.combo+1} ${player.isMounted?'🐎':''}` : (player.isMounted?'🐎 MOUNTED - CACTUS = DAMAGE!':'');
  waveTxt.textContent=`${wave} / 5`;
  townHpEl.textContent=Math.round(TOWN.hp/TOWN.maxHp*100)+'%';
  townHpEl.style.color= TOWN.hp/TOWN.maxHp<0.3?'#ef4444':'#fbbf24';
  enemyTxt.textContent=enemies.length;
  killTxt.textContent=kills;
  horseCountEl.textContent=horses.filter(h=>!h.mounted).length;
  cactusTxt.textContent=cacti.length;
  // mount button visibility
  let nearHorse = horses.find(h=>!h.mounted && dist(player.x,player.y,h.x,h.y)<78);
  if(nearHorse && !player.isMounted){ mountBtn.classList.add('show'); } else if(player.isMounted){ mountBtn.classList.add('show'); mountBtn.textContent='⬇️'; } else { mountBtn.classList.remove('show'); mountBtn.textContent='🐴'; }
  // minimap
  mctx.clearRect(0,0,122,122);
  mctx.fillStyle='#a67c52'; mctx.beginPath(); mctx.arc(61,61,60,0,Math.PI*2); mctx.fill();
  mctx.fillStyle='#7a4a2a'; mctx.fillRect(61-12,61-12,24,24);
  function mapDot(x,y,color,r=2.2){
    const mx = x/WORLD.w*100+11, my = y/WORLD.h*100+11;
    if(mx<4||mx>118||my<4||my>118) return;
    mctx.fillStyle=color; mctx.beginPath(); mctx.arc(mx,my,r,0,Math.PI*2); mctx.fill();
  }
  cacti.forEach(c=>mapDot(c.x,c.y,'#2d6a2d', c.type==='saguaro'?1.8:1.2));
  horses.forEach(h=>mapDot(h.x,h.y, h.mounted?'#fde68a':'#8B4513',3));
  enemies.forEach(e=>mapDot(e.x,e.y, e.type==='brute'?'#ef4444':'#f87171', e.type==='brute'?3:2));
  allies.forEach(a=>mapDot(a.x,a.y,'#60a5fa',2));
  mapDot(player.x,player.y,'#facc15',3.6);
  const px=player.x/WORLD.w*100+11, py=player.y/WORLD.h*100+11;
  mctx.strokeStyle='#facc15'; mctx.lineWidth=1; mctx.beginPath(); mctx.moveTo(px,py); mctx.lineTo(px+Math.cos(player.dir)*8, py+Math.sin(player.dir)*8); mctx.stroke();
}

let waveTimer=0;
function gameLoop(now){
  const dt = Math.min(33, now-lastTime)/16.666; lastTime=now;
  if(gameOver){ requestAnimationFrame(gameLoop); return; }

  let ix=input.x, iy=input.y;
  if(keys.KeyW||keys.ArrowUp) iy-=1;
  if(keys.KeyS||keys.ArrowDown) iy+=1;
  if(keys.KeyA||keys.ArrowLeft) ix-=1;
  if(keys.KeyD||keys.ArrowRight) ix+=1;
  let len=Math.hypot(ix,iy); if(len>1){ ix/=len; iy/=len; }

  if(player.isDashing){
    player.x+=player._dx*0.12*dt; player.y+=player._dy*0.12*dt;
    player.dashTime-=dt; player.trail-=dt;
    if(player.trail<=0){ particles.push(new Particle(player.x,player.y,rand(-0.5,0.5),rand(-0.5,0.5),'rgba(150,180,255,0.25)',14,0.45,0)); player.trail=2; }
    if(player.dashTime<=0){ player.isDashing=false; player._dx=0; player._dy=0; }
  }else{
    if(len>0.05){ player.x+=ix*player.speed*dt; player.y+=iy*player.speed*dt; player.dir=Math.atan2(iy,ix); if(Math.random()<0.28) particles.push(new Particle(player.x+rand(-4,4),player.y+10,rand(-0.4,0.4),rand(-0.2,0.6),'rgba(120,90,50,0.5)',rand(1.5,2.8),0.6,0.05)); }
  }
  player.x=clamp(player.x,40,WORLD.w-40); player.y=clamp(player.y,40,WORLD.h-40);
  if(player.attackCd>0) player.attackCd-=dt;
  if(player.dashCd>0) player.dashCd-=dt;
  if(player.comboTimer>0){ player.comboTimer-=dt; if(player.comboTimer<=0) player.combo=0; }

  // kuda liar wander
  for(let h of horses){
    if(h.mounted){ h.x=player.x; h.y=player.y; h.dir=player.dir; continue; }
    h.wanderTimer-=dt;
    if(h.wanderTimer<=0){ h.dir+=rand(-1,1); h.wanderTimer=rand(60,180); }
    h.x+=Math.cos(h.dir)*h.speed*0.6*dt; h.y+=Math.sin(h.dir)*h.speed*0.6*dt;
    h.x=clamp(h.x,60,WORLD.w-60); h.y=clamp(h.y,60,WORLD.h-60);
  }

  // kaktus damage - POHON JADI CACTUS YANG MEMBERI DAMAGE
  for(let c of cacti){
    if(c.hitCd>0) c.hitCd-=dt;
    const d = dist(player.x,player.y,c.x,c.y);
    const thresh = 30*c.s + player.radius -2;
    if(d < thresh){
      const ang=Math.atan2(player.y-c.y, player.x-c.x);
      const overlap=thresh-d;
      player.x+=Math.cos(ang)*overlap*0.7; player.y+=Math.sin(ang)*overlap*0.7;
      if(performance.now()-player.lastCactusHit>420){
        player.hp-=c.dmg; player.lastCactusHit=performance.now(); c.hitCd=14;
        shake=Math.max(shake,6); sfx('cactus'); floatTexts.push(new FloatText(player.x,player.y-30,`-${c.dmg} 🌵`,'#ef4444'));
        particles.push(new Particle(c.x,c.y-10,rand(-1.5,1.5),rand(-2,-0.5),'#3a7d44',rand(2,4),0.7,0.1));
        if(!player.isMounted) showHint(`CACTUS SPIKES! -${c.dmg} HP 🌵`,1000);
        else showHint(`CACTUS HURTS HORSE! -${c.dmg} HP 🌵`,1000);
      }
    }
    // musuh juga kena kaktus
    for(let e of enemies){
      const de=dist(e.x,e.y,c.x,c.y); const th=28*c.s + e.radius;
      if(de<th){
        const ang=Math.atan2(e.y-c.y, e.x-c.x); e.x+=Math.cos(ang)*(th-de)*0.8; e.y+=Math.sin(ang)*(th-de)*0.8;
        if(c.hitCd<=0 && Math.random()<0.02){ e.hp-=6; e.hitFlash=6; floatTexts.push(new FloatText(e.x,e.y-26,'-6 🌵','#f87171')); if(e.hp<=0) e.dead=true; }
      }
    }
  }

  // wave & spawn
  waveTimer+=dt;
  if(waveTimer>1000){ waveTimer=0; wave++; if(wave<=5){ spawnEnemy('brute'); for(let i=0;i<2+wave;i++) spawnEnemy('outlaw'); } else { for(let i=0;i<3;i++) spawnEnemy(); } }
  if(enemies.length<7 && Math.random()<0.02) spawnEnemy();

  // allies AI
  for(let a of allies){
    if(a.attackCd>0) a.attackCd-=dt;
    let target=null, best=1e9;
    for(let e of enemies){ const d=dist(a.x,a.y,e.x,e.y); if(d<best){ best=d; target=e; } }
    if(target){
      const dx=target.x-a.x, dy=target.y-a.y, d=Math.hypot(dx,dy)||1;
      a.dir=Math.atan2(dy,dx);
      if(d>52){ a.x+=dx/d*a.speed*dt; a.y+=dy/d*a.speed*dt; }
      else if(a.attackCd<=0){ target.hp-=16; target.hitFlash=6; a.attackCd=48; floatTexts.push(new FloatText(target.x,target.y-22,'-16','#60a5fa')); if(target.hp<=0){ target.dead=true; kills++; } }
    }else{
      const tx= TOWN.x - a.x, ty=TOWN.y-80 - a.y, td=Math.hypot(tx,ty);
      if(td>120){ a.dir=Math.atan2(ty,tx); a.x+=tx/td*a.speed*0.7*dt; a.y+=ty/td*a.speed*0.7*dt; }
    }
    a.x=clamp(a.x,30,WORLD.w-30); a.y=clamp(a.y,30,WORLD.h-30);
  }

  // enemies AI
  for(let e of enemies){
    if(e.attackCd>0) e.attackCd-=dt; if(e.hitFlash>0) e.hitFlash-=dt;
    let tx=player.x, ty=player.y, best=dist(player.x,player.y,e.x,e.y);
    for(let a of allies){ const d=dist(a.x,a.y,e.x,e.y); if(d<best-20){ best=d; tx=a.x; ty=a.y; } }
    if(dist(TOWN.x,TOWN.y,e.x,e.y)<380){ tx=TOWN.x; ty=TOWN.y; }
    const dx=tx-e.x, dy=ty-e.y, d=Math.hypot(dx,dy)||1;
    e.dir=Math.atan2(dy,dx);
    const need = e.type==='brute'?58:46;
    if(d>need){ e.x+=dx/d*e.speed*dt; e.y+=dy/d*e.speed*dt; }
    else if(e.attackCd<=0){
      if(tx===TOWN.x && ty===TOWN.y){ TOWN.hp-= e.type==='brute'?18:7; e.attackCd= e.type==='brute'?90:60; shake=Math.max(shake, e.type==='brute'?6:3); sfx('hit'); for(let i=0;i<8;i++) particles.push(new Particle(TOWN.x+rand(-40,40),TOWN.y+rand(-20,20),rand(-2,2),rand(-3,-0.5),'#6b7280',rand(2,4),0.8,0.12)); floatTexts.push(new FloatText(TOWN.x,TOWN.y-40,`-${e.type==='brute'?18:7}`,'#fbbf24')); }
      else if(dist(player.x,player.y,e.x,e.y)<60){ player.hp-= e.type==='brute'?18:8; e.attackCd=e.type==='brute'?85:52; shake=Math.max(shake, e.type==='brute'?8:4); sfx('hit'); for(let i=0;i<10;i++) particles.push(new Particle(player.x,player.y-10,rand(-2.5,2.5),rand(-2.5,1),'#ef4444',rand(2,4),0.7,0.12)); floatTexts.push(new FloatText(player.x,player.y-30,`-${e.type==='brute'?18:8}`,'#ef4444')); }
      else{ const al=allies.find(a=>dist(a.x,a.y,e.x,e.y)<55); if(al){ al.hp-= e.type==='brute'?22:12; if(al.hp<=0) allies=allies.filter(x=>x!==al); e.attackCd=e.type==='brute'?80:50; } }
    }
    e.x=clamp(e.x,20,WORLD.w-20); e.y=clamp(e.y,20,WORLD.h-20);
  }
  enemies=enemies.filter(e=>!e.dead);

  // particles
  particles.forEach(p=>p.update()); particles=particles.filter(p=>!p.dead);
  floatTexts.forEach(f=>f.update()); floatTexts=floatTexts.filter(f=>f.life>0);
  slashs.forEach(s=>s.life-=0.09); slashs=slashs.filter(s=>s.life>0);

  // cam
  cam.x += (player.x - cam.x)*0.12; cam.y += (player.y - cam.y)*0.12;
  if(shake>0) shake-=dt*0.45;

  // win/lose
  if(!gameOver){
    if(player.hp<=0){ gameOver=true; overData={title:'FALLEN FROM HORSE', text:`You survived ${wave} waves, killed ${kills} bandits. Too many cactus!`, win:false}; document.getElementById('game-wrap').insertAdjacentHTML('beforeend', `<div class="modal-backdrop"><div class="message-modal"><h1>${overData.title}</h1><p>${overData.text}</p><button id="restart">PLAY AGAIN</button></div></div>`); document.getElementById('restart').onclick=()=>{ document.querySelector('.modal-backdrop').remove(); init(); gameOver=false; requestAnimationFrame(gameLoop); }; }
    else if(TOWN.hp<=0){ gameOver=true; overData={title:'TOWN HAS FALLEN', text:`Cowboy town destroyed after ${kills} enemies. Protect the town with your horse!`, win:false}; document.getElementById('game-wrap').insertAdjacentHTML('beforeend', `<div class="modal-backdrop"><div class="message-modal"><h1>${overData.title}</h1><p>${overData.text}</p><button id="restart">TRY AGAIN</button></div></div>`); document.getElementById('restart').onclick=()=>{ document.querySelector('.modal-backdrop').remove(); init(); gameOver=false; requestAnimationFrame(gameLoop); }; }
    else if(kills>=60 || (wave>=5 && enemies.length===0 && kills>=35)){ gameOver=true; overData={title:'COWBOY LEGEND!', text:`You and ${allies.length} deputies plus ${horses.filter(h=>!h.mounted).length} wild horses made it! ${kills} bandits down in ${wave} waves.`, win:true}; document.getElementById('game-wrap').insertAdjacentHTML('beforeend', `<div class="modal-backdrop"><div class="message-modal win-true"><h1>${overData.title}</h1><p>${overData.text}</p><button id="restart">PLAY AGAIN</button></div></div>`); document.getElementById('restart').onclick=()=>{ document.querySelector('.modal-backdrop').remove(); init(); gameOver=false; requestAnimationFrame(gameLoop); }; }
  }

  // render
  const shx = shake>0? rand(-shake,shake):0, shy = shake>0? rand(-shake,shake):0;
  const prevCamX = cam.x, prevCamY = cam.y;
  cam.x+=shx; cam.y+=shy;
  drawBackground();
  drawTown();
  // sort by y for depth
  const drawables = [...cacti.map(c=>({y:c.y, fn:()=>drawCactusObj(c.x,c.y,c)})), ...rocks.map(r=>({y:r.y, fn:()=>drawRockObj(r.x,r.y,r)}))].sort((a,b)=>a.y-b.y);
  drawables.forEach(d=>d.fn());

  // particles small
  for(let p of particles){ if(p.size>8) continue; const sx=p.x - cam.x + W/2, sy=p.y - cam.y + H/2; if(sx<-20||sx>W+20||sy<-20||sy>H+20) continue; ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle=p.color; ctx.beginPath(); ctx.arc(sx,sy,p.size,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1; }

  for(let a of allies){ const sx=a.x - cam.x + W/2, sy=a.y - cam.y + H/2; drawAlly(sx,sy,a.dir,a.attackCd>30); if(a.hp<a.maxHp){ ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(sx-14,sy-34,28,4); ctx.fillStyle='#22c55e'; ctx.fillRect(sx-14,sy-34,28*(a.hp/a.maxHp),4); } }
  for(let h of horses){ drawHorseObj(h.x,h.y,h); }
  for(let e of enemies){ const sx=e.x - cam.x + W/2, sy=e.y - cam.y + H/2; drawOutlaw(sx,sy,e.dir,e.hitFlash>0,e.type,e.attackCd>35); ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillRect(sx-16,sy-38,32,4); ctx.fillStyle='#ef4444'; ctx.fillRect(sx-16,sy-38,32*(e.hp/e.maxHp),4); }

  // player + horse if mounted
  const psx = player.x - cam.x + W/2, psy = player.y - cam.y + H/2;
  if(player.isMounted){
    const ho = player.mountedHorse;
    if(ho){ drawHorseObj(player.x, player.y, { ...ho, dir:player.dir, color:ho.color, mounted:false }); }
    // debu kuda
    if(len>0.3 && Math.random()<0.4) particles.push(new Particle(player.x+rand(-8,8), player.y+12, rand(-1,1), rand(0,1), 'rgba(180,150,100,0.6)', rand(2,4), 0.6, 0.02));
  }
  if(player.isDashing){
    ctx.globalAlpha=0.28;
    for(let i=1;i<=3;i++){ const bx=psx-Math.cos(player.dir)*i*12, by=psy-Math.sin(player.dir)*i*12; drawCowboy(bx,by,player.dir,false,'revolver',false,player.isMounted); }
    ctx.globalAlpha=1;
  }
  drawCowboy(psx,psy,player.dir, player.hp<35, player.isMounted?'lasso':'revolver', player.attackCd>9, player.isMounted);

  for(let s of slashs){
    const sx=s.x - cam.x + W/2, sy=s.y - cam.y + H/2;
    ctx.save(); ctx.translate(sx,sy); ctx.rotate(s.dir); ctx.globalAlpha=s.life*0.85; ctx.strokeStyle=s.combo===2?'#fde047':'#fffbeb'; ctx.lineWidth=s.combo===2?5:3.2; ctx.lineCap='round';
    const arc=s.combo===0?1: s.combo===1?1.35:1.9, rad=s.combo===2? (s.mounted?78:62) : (s.mounted?64:48);
    ctx.beginPath(); ctx.arc(s.mounted?16:12,-4,rad,-arc/2,arc/2); ctx.stroke();
    ctx.globalAlpha=s.life*0.28; ctx.lineWidth=13; ctx.beginPath(); ctx.arc(s.mounted?16:12,-4,rad,-arc/2,arc/2); ctx.stroke(); ctx.restore();
  }
  for(let p of particles){ if(p.size<=8) continue; const sx=p.x - cam.x + W/2, sy=p.y - cam.y + H/2; ctx.globalAlpha=Math.max(0,p.life); ctx.fillStyle=p.color; ctx.beginPath(); ctx.arc(sx,sy,p.size,0,Math.PI*2); ctx.fill(); ctx.globalAlpha=1; }
  ctx.font='700 16px Inter, sans-serif'; ctx.textAlign='center';
  for(let f of floatTexts){ const sx=f.x - cam.x + W/2, sy=f.y - cam.y + H/2; ctx.globalAlpha=f.life; ctx.fillStyle='rgba(0,0,0,0.6)'; ctx.fillText(f.text,sx+1,sy+1); ctx.fillStyle=f.color; ctx.fillText(f.text,sx,sy); ctx.globalAlpha=1; }

  cam.x=prevCamX; cam.y=prevCamY;
  updateHUD();
  if(player.hp < 35){ dmgFlash.style.opacity = 0.35 + Math.sin(performance.now()/180)*0.12; } else if(shake>2){ dmgFlash.style.opacity = Math.min(0.45, shake*0.08); setTimeout(()=>dmgFlash.style.opacity=0,120); } else { dmgFlash.style.opacity=0; }

  requestAnimationFrame(gameLoop);
}

init();
requestAnimationFrame(gameLoop);

// tap to focus audio
addEventListener('pointerdown', ()=>ensureAudio(), {once:true});
</script>
</body>
</html>

Game Source: Cowboy Reforged - Horse & Cactus Edition

Creator: ElectricPirate55

Libraries: none

Complexity: complex (705 lines, 44.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: cowboy-reforged-horse-cactus-edition-electricpirate55" to link back to the original. Then publish at arcadelab.ai/publish.