Cowboy Reforged - Horse & Cactus Edition
by ElectricPirate55705 lines44.7 KB
<!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.