Kiro Product
IDE
Feature Description
gap: 10px;
margin-top: 10px;
}
.upgrade-btn {
background: #2a2a2a;
border: 1px solid #444;
padding: 5px;
font-size: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
}
.upgrade-btn:hover { border-color: var(--color-gold); }
.upgrade-lvl { font-size: 10px; color: #888; margin-top: 2px; }
/* Floating text animation */
@keyframes floatUp {
0% { transform: translateY(0); opacity: 1; }
100% { transform: translateY(-30px); opacity: 0; }
}
.floater {
position: absolute;
font-weight: bold;
pointer-events: none;
animation: floatUp 1s ease-out forwards;
text-shadow: 1px 1px 0 #000;
}
</style>
<canvas id="gameCanvas"></canvas>
<div id="ui-layer">
<!-- HUD TOP -->
<div class="hud-top">
<div class="stat-box">
<span class="gold-icon">●</span> <span id="gold-display">0</span>
</div>
<div class="health-bars">
<div class="hp-bar-container">
<div id="hp-player" class="hp-bar-fill hp-player"></div>
</div>
<div class="timer" id="game-timer">00:00</div>
<div class="hp-bar-container">
<div id="hp-enemy" class="hp-bar-fill hp-enemy"></div>
</div>
</div>
<div class="stat-box">
<span class="pop-icon">♟</span> <span id="pop-display">0/20</span>
<button class="btn" style="margin-left: 10px; padding: 5px 10px;" onclick="game.togglePause()">II</button>
</div>
</div>
<!-- HUD BOTTOM -->
<div class="hud-bottom">
<!-- Stance Panel -->
<div class="panel">
<button class="btn" id="btn-defend" onclick="game.setStance('defend')">
Defend <span class="btn-key">Q</span>
</button>
<button class="btn" id="btn-attack" onclick="game.setStance('attack')">
Attack <span class="btn-key">E</span>
</button>
</div>
<!-- Spawn Panel -->
<div class="panel" id="spawn-panel">
<!-- Generated by JS -->
</div>
<!-- Upgrades -->
<div class="panel">
<button class="btn" onclick="game.toggleUpgradeMenu()">
Upgrades
</button>
</div>
</div>
</div>
<!-- Main Menu -->
<div id="menu-screen" class="modal">
<div class="modal-content">
<h1>Stick Battle: The Revolt</h1>
<p>Xây dựng đội quân, khai thác vàng, phá hủy tượng đài địch.</p>
<div class="setting-row">
<label>Độ khó:</label>
<select id="difficulty-select" style="padding: 5px;">
<option value="easy">Dễ</option>
<option value="normal" selected>Bình thường</option>
<option value="hard">Khó</option>
</select>
</div>
<div class="setting-row">
<label>Âm thanh:</label>
<input type="range" id="volume-slider" min="0" max="1" step="0.1" value="0.5">
</div>
<button class="big-btn" onclick="game.start()">VÀO TRẬN</button>
<div style="margin-top: 20px; font-size: 12px; color: #888; text-align: left;">
<p><b>Điều khiển:</b></p>
<p>A/D hoặc Mũi tên: Di chuyển Camera</p>
<p>1-5: Gọi quân | Q: Thủ | E: Công</p>
<p>Chuột trái: Chọn điểm tập trung (Rally)</p>
</div>
</div>
</div>
<!-- Upgrade Menu -->
<div id="upgrade-menu" class="modal hidden">
<div class="modal-content">
<h2>Nâng cấp</h2>
<div class="upgrade-grid" id="upgrade-list">
<!-- Generated JS -->
</div>
<button class="big-btn" style="margin-top: 15px; padding: 10px;" onclick="game.toggleUpgradeMenu()">Đóng</button>
</div>
</div>
<!-- End Screen -->
<div id="end-screen" class="modal hidden">
<div class="modal-content">
<h1 id="end-title">VICTORY</h1>
<div id="end-stats" style="text-align: left; margin: 20px 0;"></div>
<button class="big-btn" onclick="location.reload()">Chơi lại</button>
</div>
</div>
<script>
/**
* STICK BATTLE: THE REVOLT
* Single File Implementation
*/
// ==========================================
// 1. CONFIG & CONSTANTS
// ==========================================
const CONFIG = {
fps: 60,
gravity: 0.5,
groundY: 0, // Set dynamically
laneLength: 3000, // Width of the map
cameraSpeed: 15,
statueHP: 2000,
baseGold: 800, // Increased from 500 to 800
basePop: 20,
mineGoldAmount: 50, // Gold per trip
mineDuration: 120, // Frames to mine
};
const UNITS = {
miner: {
name: "Miner", key: '1', cost: 150, pop: 1,
hp: 100, dmg: 5, range: 30, speed: 2.5, cd: 60, type: 'miner'
},
sword: {
name: "Sword", key: '2', cost: 125, pop: 1,
hp: 180, dmg: 25, range: 40, speed: 3, cd: 45, type: 'melee'
},
spear: {
name: "Spear", key: '3', cost: 250, pop: 2,
hp: 250, dmg: 35, range: 90, speed: 2.5, cd: 55, type: 'melee'
},
archer: {
name: "Archer", key: '4', cost: 400, pop: 2,
hp: 120, dmg: 20, range: 450, speed: 3.5, cd: 80, type: 'ranged'
},
giant: {
name: "Giant", key: '5', cost: 1200, pop: 5,
hp: 1200, dmg: 100, range: 50, speed: 1.5, cd: 120, type: 'melee'
}
};
const UPGRADES = [
{ id: 'eco_speed', name: 'Fast Mining', desc: 'Đào nhanh hơn', cost: [300, 600, 1000], max: 3 },
{ id: 'eco_yield', name: 'Gold Yield', desc: '+Vàng/chuyến', cost: [400, 800, 1500], max: 3 },
{ id: 'eco_pop', name: 'Population', desc: '+5 Pop Cap', cost: [200, 400, 800, 1200], max: 4 },
{ id: 'mil_hp', name: 'Armor', desc: '+20% HP', cost: [300, 700, 1200], max: 3 },
{ id: 'mil_dmg', name: 'Weapons', desc: '+20% DMG', cost: [300, 700, 1200], max: 3 },
{ id: 'mil_cd', name: 'Haste', desc: '-10% CD', cost: [500, 1000, 2000], max: 3 },
];
// ==========================================
// 2. MATH & UTILS
// ==========================================
const Utils = {
clamp: (v, min, max) => Math.max(min, Math.min(v, max)),
rand: (min, max) => Math.random() * (max - min) + min,
dist: (e1, e2) => Math.abs(e1.x - e2.x),
rectIntersect: (r1, r2) => !(r2.left > r1.right || r2.right < r1.left || r2.top > r1.bottom || r2.bottom < r1.top),
// Parabolic projectile
getArcVelocity: (startX, startY, endX, endY, gravity, speed) => {
const dx = endX - startX;
const dy = endY - startY;
const time = Math.abs(dx) / speed;
const vy = (dy / time) - (0.5 * gravity * time);
return { vx: (dx / time), vy: vy };
}
};
// ==========================================
// 3. AUDIO SYSTEM (WebAudio)
// ==========================================
class AudioSys {
constructor() {
this.ctx = new (window.AudioContext || window.webkitAudioContext)();
this.masterGain = this.ctx.createGain();
this.masterGain.connect(this.ctx.destination);
this.volume = 0.5;
this.updateVolume(0.5);
}
updateVolume(val) {
this.volume = val;
this.masterGain.gain.value = this.volume;
}
playTone(freq, type, duration, vol = 1) {
if(this.ctx.state === 'suspended') this.ctx.resume();
const osc = this.ctx.createOscillator();
const gain = this.ctx.createGain();
osc.type = type;
osc.frequency.setValueAtTime(freq, this.ctx.currentTime);
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.01, this.ctx.currentTime + duration);
osc.connect(gain);
gain.connect(this.masterGain);
osc.start();
osc.stop(this.ctx.currentTime + duration);
}
playNoise(duration, vol = 1) {
if(this.ctx.state === 'suspended') this.ctx.resume();
const bufferSize = this.ctx.sampleRate * duration;
const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate);
const data = buffer.getChannelData(0);
for (let i = 0; i < bufferSize; i++) data[i] = Math.random() * 2 - 1;
const noise = this.ctx.createBufferSource();
noise.buffer = buffer;
const gain = this.ctx.createGain();
gain.gain.setValueAtTime(vol, this.ctx.currentTime);
gain.gain.linearRampToValueAtTime(0, this.ctx.currentTime + duration);
noise.connect(gain);
gain.connect(this.masterGain);
noise.start();
}
// SFX Presets
sfxUI() { this.playTone(800, 'sine', 0.1, 0.2); }
sfxSpawn() { this.playTone(400, 'triangle', 0.3, 0.3); }
sfxHit() { this.playNoise(0.1, 0.5); }
sfxShoot() { this.playTone(600, 'sawtooth', 0.1, 0.2); } // slide freq down ideally
sfxGiant() { this.playTone(100, 'square', 0.5, 0.6); this.playNoise(0.4, 0.5); }
sfxMine() { this.playTone(1200, 'sine', 0.1, 0.3); }
sfxWin() {
[440, 554, 659, 880].forEach((f, i) => setTimeout(() => this.playTone(f, 'square', 0.4, 0.4), i*200));
}
}
// ==========================================
// 4. RENDERING (Canvas 2D + Stickman Rig)
// ==========================================
class Renderer {
constructor(canvas) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d', { alpha: false });
this.width = canvas.width;
this.height = canvas.height;
this.dpr = window.devicePixelRatio || 1;
this.ctx.lineCap = 'round';
this.ctx.lineJoin = 'round';
// Colors
this.bgSky = this.ctx.createLinearGradient(0, 0, 0, this.height);
this.bgSky.addColorStop(0, '#2c3e50');
this.bgSky.addColorStop(1, '#4ca1af');
}
resize() {
this.width = window.innerWidth;
this.height = window.innerHeight;
this.canvas.width = this.width * this.dpr;
this.canvas.height = this.height * this.dpr;
this.ctx.scale(this.dpr, this.dpr);
CONFIG.groundY = this.height - 100;
// Recreate gradient
this.bgSky = this.ctx.createLinearGradient(0, 0, 0, this.height);
this.bgSky.addColorStop(0, '#203a43');
this.bgSky.addColorStop(1, '#2c5364');
}
drawEnvironment(cameraX) {
// Sky
this.ctx.fillStyle = this.bgSky;
this.ctx.fillRect(0, 0, this.width, this.height);
// Parallax Mountains
this.drawMountains(cameraX * 0.2, '#1a1a1a', 200);
this.drawMountains(cameraX * 0.5, '#2a2a2a', 100);
// Ground
this.ctx.fillStyle = '#1e272e';
this.ctx.fillRect(0, CONFIG.groundY, this.width, this.height - CONFIG.groundY);
// Grass line
this.ctx.strokeStyle = '#27ae60';
this.ctx.lineWidth = 5;
this.ctx.beginPath();
this.ctx.moveTo(0, CONFIG.groundY);
this.ctx.lineTo(this.width, CONFIG.groundY);
this.ctx.stroke();
// Rally Flag (if player)
if (game.player.rallyPoint) {
const rx = game.player.rallyPoint - cameraX;
if (rx > -50 && rx < this.width + 50) {
this.ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
this.ctx.beginPath();
this.ctx.moveTo(rx, CONFIG.groundY);
this.ctx.lineTo(rx, CONFIG.groundY - 100);
this.ctx.lineTo(rx + 40, CONFIG.groundY - 80);
this.ctx.lineTo(rx, CONFIG.groundY - 60);
this.ctx.fill();
}
}
}
drawMountains(offsetX, color, heightMod) {
this.ctx.fillStyle = color;
this.ctx.beginPath();
this.ctx.moveTo(0, this.height);
for(let x = 0; x <= this.width; x += 50) {
let noise = Math.sin((x + offsetX) * 0.01) * heightMod;
this.ctx.lineTo(x, CONFIG.groundY - noise - 50);
}
this.ctx.lineTo(this.width, this.height);
this.ctx.fill();
}
// THE STICKMAN RIG
drawUnit(unit, camX) {
const x = unit.x - camX;
const y = CONFIG.groundY - unit.yOffset; // yOffset allows flying or giants
// Culling
if (x < -100 || x > this.width + 100) return;
const ctx = this.ctx;
const time = unit.animTime * 0.2;
const dir = unit.isEnemy ? -1 : 1;
const scale = unit.scale;
ctx.save();
ctx.translate(x, y);
ctx.scale(dir * scale, scale); // Mirror for enemy
// Colors
const color = unit.isEnemy ? '#e74c3c' : '#4a90e2'; // Red vs Blue
const bodyColor = '
#111';
// Shadow
ctx.fillStyle = 'rgba(0,0,0,0.3)';
ctx.beginPath();
ctx.ellipse(0, 0, 15, 5, 0, 0, Math.PI * 2);
ctx.fill();
// Animation Vars
let legL = 0, legR = 0, armL = 0, armR = 0;
let torsoRot = 0;
if (unit.state === 'move') {
legL = Math.sin(time) * 0.8;
legR = Math.sin(time + Math.PI) * 0.8;
armL = Math.sin(time) * 0.5;
armR = Math.sin(time + Math.PI) * 0.5;
} else if (unit.state === 'mine') {
torsoRot = Math.sin(time * 2) * 0.2 + 0.2;
armR = -Math.PI / 2 + Math.sin(time * 2) * 1.5; // Mining motion
} else if (unit.state === 'attack') {
if (unit.type === 'ranged') {
armL = -0.5;
armR = -1.0; // Holding bow
} else {
armR = -Math.PI/2 + Math.sin(time * 3) * 1.5; // Swing
}
}
if (unit.isDead) {
ctx.globalAlpha = unit.fade;
torsoRot = 1.5; // Fallen
}
// --- DRAWING THE RIG ---
ctx.lineWidth = 4;
ctx.strokeStyle = bodyColor;
ctx.fillStyle = bodyColor;
// Legs
this.drawLimb(ctx, 0, -20, legL, 15, 15); // Back Leg
this.drawLimb(ctx, 0, -20, legR, 15, 15); // Front Leg
// Torso
ctx.save();
ctx.translate(0, -20);
ctx.rotate(torsoRot);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, -30);
ctx.stroke();
// Head
ctx.beginPath();
ctx.arc(0, -38, 8, 0, Math.PI * 2);
ctx.fill();
// Team Bandana/Detail
ctx.strokeStyle = color;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(-5, -42);
ctx.lineTo(5, -42);
ctx.stroke();
ctx.lineWidth = 4;
ctx.strokeStyle = bodyColor;
// Back Arm
this.drawLimb(ctx, 0, -25, armL, 12, 12);
// Weapon / Tool
this.drawWeapon(ctx, unit, armR);
// Front Arm
this.drawLimb(ctx, 0, -25, armR, 12, 12);
ctx.restore(); // Restore torso
ctx.restore(); // Restore global
}
drawLimb(ctx, ox, oy, angle, len1, len2) {
ctx.save();
ctx.translate(ox, oy);
ctx.rotate(angle);
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, len1); // Upper
ctx.stroke();
ctx.translate(0, len1);
ctx.rotate(angle * 0.5); // Simple IK-like bend
ctx.beginPath();
ctx.moveTo(0, 0);
ctx.lineTo(0, len2); // Lower
ctx.stroke();
ctx.restore();
}
drawWeapon(ctx, unit, armAngle) {
// Simple attachment based on arm position
// This is a simplified visual representation
ctx.save();
ctx.translate(0, -25);
ctx.rotate(armAngle);
ctx.translate(0, 20); // Hand pos
if (unit.typeStr === 'miner') {
// Pickaxe
ctx.fillStyle = '
#888';
ctx.fillRect(-2, 0, 4, 20); // Handle
ctx.fillStyle = '#aaa';
ctx.beginPath(); // Head
ctx.moveTo(0, 20);
ctx.lineTo(-10, 25);
ctx.lineTo(10, 25);
ctx.fill();
} else if (unit.typeStr === 'sword') {
// Sword
ctx.fillStyle = '#ccc';
ctx.fillRect(-2, 0, 4, 35);
ctx.fillStyle = '#e74c3c'; // Hilt
ctx.fillRect(-6, 5, 12, 2);
} else if (unit.typeStr === 'spear') {
ctx.fillStyle = '#8b4513';
ctx.fillRect(-2, -10, 4, 60);
ctx.fillStyle = '#ccc';
ctx.beginPath();
ctx.moveTo(0, 50);
ctx.lineTo(-3, 60);
ctx.lineTo(3, 60);
ctx.fill();
} else if (unit.typeStr === 'archer') {
// Bow
ctx.strokeStyle = '#8b4513';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(0, 10, 20, Math.PI, 0);
ctx.stroke();
ctx.lineWidth = 1;
ctx.strokeStyle = '#fff'; // String
ctx.beginPath();
ctx.moveTo(-20, 10);
ctx.lineTo(20, 10);
ctx.stroke();
} else if (unit.typeStr === 'giant') {
// Club
ctx.fillStyle = '#5d4037';
ctx.fillRect(-5, 0, 10, 50);
ctx.beginPath();
ctx.arc(0, 50, 15, 0, Math.PI*2);
ctx.fill();
}
ctx.restore();
}
drawStatue(statue, camX, isEnemy) {
const x = statue.x - camX;
if (x < -200 || x > this.width + 200) return;
const ctx = this.ctx;
const y = CONFIG.groundY;
ctx.fillStyle = isEnemy ? '
#333' : '
#333';
ctx.fillRect(x - 30, y - 200, 60, 200); // Base pillar
// Statue Head
ctx.fillStyle = isEnemy ? '#c0392b' : '#2980b9';
ctx.beginPath();
ctx.arc(x, y - 240, 40, 0, Math.PI * 2);
ctx.fill();
// Crown
ctx.fillStyle = '#f1c40f';
ctx.beginPath();
ctx.moveTo(x - 30, y - 260);
ctx.lineTo(x, y - 300);
ctx.lineTo(x + 30, y - 260);
ctx.fill();
// HP Bar
const hpPct = statue.hp / CONFIG.statueHP;
ctx.fillStyle = 'red';
ctx.fillRect(x - 40, y - 320, 80, 10);
ctx.fillStyle = '#0f0';
ctx.fillRect(x - 40, y - 320, 80 * hpPct, 10);
}
drawProjectile(proj, camX) {
const x = proj.x - camX;
const y = CONFIG.groundY - proj.y;
this.ctx.fillStyle = '#fff';
this.ctx.strokeStyle = '#000';
this.ctx.lineWidth = 1;
this.ctx.save();
this.ctx.translate(x, y);
this.ctx.rotate(Math.atan2(proj.vy, proj.vx));
// Arrow
this.ctx.beginPath();
this.ctx.moveTo(-10, 0);
this.ctx.lineTo(10, 0);
this.ctx.stroke();
this.ctx.beginPath(); // Tip
this.ctx.moveTo(5, -3);
this.ctx.lineTo(10, 0);
this.ctx.lineTo(5, 3);
this.ctx.fill();
this.ctx.restore();
}
drawParticles(particles, camX) {
particles.forEach(p => {
const x = p.x - camX;
const y = CONFIG.groundY - p.y;
this.ctx.fillStyle = p.color;
this.ctx.globalAlpha = p.life / p.maxLife;
this.ctx.beginPath();
this.ctx.arc(x, y, p.size, 0, Math.PI * 2);
this.ctx.fill();
});
this.ctx.globalAlpha = 1;
}
drawText(texts, camX) {
this.ctx.font = "bold 20px Arial";
this.ctx.textAlign = "center";
texts.forEach(t => {
const x = t.x - camX;
const y = CONFIG.groundY - t.y;
this.ctx.fillStyle = t.color;
this.ctx.strokeStyle = 'black';
this.ctx.lineWidth = 2;
this.ctx.strokeText(t.text, x, y);
this.ctx.fillText(t.text, x, y);
});
}
}
// ==========================================
// 5. ENTITIES
// ==========================================
class Entity {
constructor(x, typeDef, isEnemy) {
this.x = x;
this.yOffset = 0;
this.isEnemy = isEnemy;
this.typeStr = typeDef.name.toLowerCase();
// Stats
this.maxHp = typeDef.hp;
this.hp = this.maxHp;
this.dmg = typeDef.dmg;
this.range = typeDef.range;
this.speed = typeDef.speed;
this.maxCd = typeDef.cd;
this.cd = 0;
// Apply Upgrades
if (!isEnemy) {
this.maxHp *= (1 + (game.upgrades.mil_hp * 0.2));
this.hp = this.maxHp;
this.dmg *= (1 + (game.upgrades.mil_dmg * 0.2));
this.maxCd *= (1 - (game.upgrades.mil_cd * 0.1));
} else {
// Difficulty scaling
const diffMod = game.difficulty === 'hard' ? 1.2 : 1;
this.hp *= diffMod;
this.dmg *= diffMod;
}
// State
this.state = 'idle'; // idle, move, attack, mine
this.target = null;
this.animTime = Math.random() * 100;
this.isDead = false;
this.fade = 1;
this.scale = typeDef.name === 'Giant' ? 2 : 1;
// Miner specific
this.carryGold = 0;
}
update(dt) {
if (this.isDead) {
this.fade -= 0.02;
return;
}
if (this.cd > 0) this.cd--;
// Behavior
if (this.typeStr === 'miner') {
this.updateMiner();
} else {
this.updateFighter();
}
// Animation tick
if (this.state !== 'idle') this.animTime += this.speed * 0.5;
}
updateMiner() {
const basePos = this.isEnemy ? CONFIG.laneLength - 100 : 100;
// Simple logic: If full -> go base. If empty -> go mine
// Mine locations are fixed (e.g., center field or near base)
// Let's assume mines are scattered.
const minePos = this.isEnemy ? CONFIG.laneLength - 600 : 600;
if (this.carryGold > 0) {
// Return to base
const dist = Math.abs(this.x - basePos);
if (dist < 10) {
this.carryGold = 0;
game.addGold(this.isEnemy, CONFIG.mineGoldAmount + (this.isEnemy ? 0 : game.upgrades.eco_yield * 20));
audio.sfxUI(); // subtle coin sound
game.addText(this.x, 50, `+${CONFIG.mineGoldAmount}`, '#fdcb6e');
} else {
this.moveTowards(basePos);
}
} else {
// Go to mine
const dist = Math.abs(this.x - minePos);
if (dist < 10) {
this.state = 'mine';
// Mining time
if (this.cd <= 0) {
this.cd = CONFIG.mineDuration / (1 + (this.isEnemy?0:game.upgrades.eco_speed * 0.5));
this.state = 'idle'; // Reset anim
} else if (this.cd === 1) {
this.carryGold = 10;
audio.sfxMine();
}
} else {
this.moveTowards(minePos);
}
}
}
updateFighter() {
// Find Target
let targets = this.isEnemy ? game.player.units : game.enemy.units;
// Add Statue as target
const statueTarget = {
x: this.isEnemy ? 100 : CONFIG.laneLength - 100,
hp: this.isEnemy ? game.player.statueHP : game.enemy.statueHP,
rect: true // marker
};
let closest = null;
let minDist = 9999;
// Check units
targets.forEach(u => {
if (u.isDead) return;
const d = Math.abs(u.x - this.x);
if (d < minDist) { minDist = d; closest = u; }
});
// Check statue if no units close or no units at all
const distStatue = Math.abs(statueTarget.x - this.x);
if (!closest || distStatue < minDist) {
closest = statueTarget;
minDist = distStatue;
}
// Action
if (closest && minDist <= this.range) {
this.state = 'attack';
if (this.cd <= 0) {
this.attack(closest);
this.cd = this.maxCd;
}
} else {
// Movement Logic
// If defending and close to rally point, stop
// If player unit and stance is Defend
if (!this.isEnemy && game.player.stance === 'defend') {
const rally = game.player.rallyPoint;
if (Math.abs(this.x - rally) < 10) {
this.state = 'idle';
} else {
this.moveTowards(rally);
}
} else {
// Attack move
if (closest) {
this.moveTowards(closest.x);
} else {
this.state = 'idle';
}
}
}
}
moveTowards(targetX) {
this.state = 'move';
const dir = targetX > this.x ? 1 : -1;
this.x += dir * this.speed;
}
attack(target) {
this.animTime = 0; // Reset attack anim
if (this.typeStr === 'archer') {
audio.sfxShoot();
// Spawn Projectile
game.projectiles.push({
x: this.x,
y: 40,
target: target, // Homing target
vx: 0,
vy: 0,
speed: 15, // Homing speed
dmg: this.dmg,
isEnemy: this.isEnemy,
life: 200
});
} else {
// Melee Hit
setTimeout(() => {
if (this.isDead) return;
if(this.typeStr === 'giant') {
audio.sfxGiant();
game.shake = 10;
} else {
audio.sfxHit();
}
// Apply Damage
if (target.rect) {
// Statue
game.damageStatue(!this.isEnemy, this.dmg);
} else {
target.takeDamage(this.dmg);
}
}, 300); // Delay for animation sync
}
}
takeDamage(amount) {
this.hp -= amount;
game.addText(this.x, 80, `-${Math.floor(amount)}`, '#fff');
game.spawnParticles(this.x, 40, 3, '#f00'); // Blood
if (this.hp <= 0) {
this.isDead = true;
game.onUnitDeath(this);
}
}
}
// ==========================================
// 6. GAME CORE
// ==========================================
class Game {
constructor() {
this.canvas = document.getElementById('gameCanvas');
this.renderer = new Renderer(this.canvas);
// Game State
this.running = false;
this.paused = false;
this.frame = 0;
this.shake = 0;
this.difficulty = 'normal';
this.camera = { x: 0 };
this.keys = {};
this.mouse = { x: 0, y: 0, down: false };
this.reset();
this.setupInput();
// Loop - Fixed Timestep
let lastTime = 0;
const timeStep = 1000 / 60;
let accumulator = 0;
const loop = (timestamp) => {
if (!lastTime) lastTime = timestamp;
let deltaTime = timestamp - lastTime;
lastTime = timestamp;
// Cap dt to avoid spiral of death
if (deltaTime > 100) deltaTime = 100;
accumulator += deltaTime;
while (accumulator >= timeStep) {
if (this.running && !this.paused) {
this.update();
}
accumulator -= timeStep;
}
this.draw();
requestAnimationFrame(loop);
};
requestAnimationFrame(loop);
}
reset() {
this.player = {
gold: CONFIG.baseGold,
pop: 0,
popCap: CONFIG.basePop,
units: [],
statueHP: CONFIG.statueHP,
stance: 'attack', // attack, defend
rallyPoint: 300
};
this.enemy = {
gold: CONFIG.baseGold,
pop: 0,
popCap: CONFIG.basePop,
units: [],
statueHP: CONFIG.statueHP,
lastSpawn: 0,
targetUnit: null // AI Target saving
};
this.projectiles = [];
this.particles = [];
this.texts = [];
this.upgrades = {
eco_speed: 0, eco_yield: 0, eco_pop: 0,
mil_hp: 0, mil_dmg: 0, mil_cd: 0
};
this.timer = 0;
this.updateUI();
this.populateSpawnButtons();
}
start() {
const diffSelect = document.getElementById('difficulty-select');
this.difficulty = diffSelect.value;
const volSlider = document.getElementById('volume-slider');
audio.updateVolume(parseFloat(volSlider.value));
document.getElementById('menu-screen').classList.add('hidden');
document.getElementById('end-screen').classList.add('hidden');
this.reset();
this.running = true;
this.paused = false;
// Init free miners
this.spawnUnit('miner', false, true);
this.spawnUnit('miner', true, true);
}
// --- LOGIC ---
update() {
this.frame++;
this.timer++;
// Input Camera
if (this.keys['ArrowLeft'] || this.keys['a']) this.camera.x -= CONFIG.cameraSpeed;
if (this.keys['ArrowRight'] || this.keys['d']) this.camera.x += CONFIG.cameraSpeed;
this.camera.x = Utils.clamp(this.camera.x, 0, CONFIG.laneLength - this.renderer.width);
// Entities
[...this.player.units, ...this.enemy.units].forEach(u => u.update());
// Projectiles
for (let i = this.projectiles.length - 1; i >= 0; i--) {
let p = this.projectiles[i];
// Homing Logic for Arrows
if (p.target && (p.target.hp > 0 || p.target.rect)) {
let targetX = p.target.x;
let targetH = 30; // Aim at chest height
// If statue (has rect prop)
if (p.target.rect) {
targetX = p.isEnemy ? 100 : CONFIG.laneLength - 100;
targetH = 100;
}
const dx = targetX - p.x;
const dy = targetH - p.y;
const dist = Math.sqrt(dx * dx + dy * dy);
if (dist < p.speed) {
// Impact next frame
p.x = targetX;
p.y = targetH;
} else {
// Steer towards target
p.vx = (dx / dist) * p.speed;
p.vy = (dy / dist) * p.speed;
}
} else {
// Fallback to gravity if target dead
p.vy -= CONFIG.gravity;
}
p.x += p.vx;
p.y += p.vy;
// Ground hit check
if (p.y <= 0) {
this.projectiles.splice(i, 1);
continue;
}
// Unit Hit Check
let targets = p.isEnemy ? this.player.units : this.enemy.units;
// Check statue collision approx
let statueX = p.isEnemy ? 100 : CONFIG.laneLength - 100;
if (Math.abs(p.x - statueX) < 40 && p.y < 200) {
this.damageStatue(!p.isEnemy, p.dmg);
this.projectiles.splice(i, 1);
continue;
}
let hit = false;
for (let u of targets) {
if (!u.isDead && Math.abs(u.x - p.x) < 20 && Math.abs((CONFIG.groundY - u.yOffset) - (CONFIG.groundY - p.y)) < 60) {
u.takeDamage(p.dmg);
hit = true;
break;
}
}
if (hit) this.projectiles.splice(i, 1);
}
// Particles & Text
this.particles.forEach((p, i) => {
p.life--;
p.x += p.vx;
p.y += p.vy;
if (p.life <= 0) this.particles.splice(i, 1);
});
// Cleanup Dead
this.player.units = this.player.units.filter(u => u.fade > 0);
this.enemy.units = this.enemy.units.filter(u => u.fade > 0);
// Shake decay
if (this.shake > 0) this.shake *= 0.9;
if (this.shake < 0.5) this.shake = 0;
// AI Logic
if (this.frame % 60 === 0) this.updateAI();
// Update UI Text
if (this.frame % 10 === 0) {
this.updateUI();
// Timer
let sec = Math.floor(this.timer / 60);
let min = Math.floor(sec / 60);
sec = sec % 60;
document.getElementById('game-timer').innerText =
`${min < 10 ? '0'+min : min}:${sec < 10 ? '0'+sec : sec}`;
}
}
updateAI() {
// AI Logic based on difficulty
const ai = this.enemy;
const diff = this.difficulty;
// Income cheat for hard mode
if (diff === 'hard') ai.gold += 3; // Increased slightly for better challenge
if (diff === 'normal') ai.gold += 1;
// 1. Economy
const miners = ai.units.filter(u => u.typeStr === 'miner').length;
const desiredMiners = diff === 'easy' ? 3 : (diff === 'normal' ? 5 : 8); // Increased caps
// Always prioritize miners if below count and safe
if (miners < desiredMiners && ai.gold >= UNITS.miner.cost) {
// Don't spawn miner if base is under heavy attack (simple check)
const baseUnderAttack = this.player.units.some(u => u.x > CONFIG.laneLength - 600);
if (!baseUnderAttack || ai.gold > 500) {
this.spawnUnit('miner', true);
return;
}
}
// 2. Army Strategy
// Panic Check: Are enemies close?
const enemiesClose = this.player.units.some(u => u.x > CONFIG.laneLength - 500);
const criticalHP = ai.statueHP < CONFIG.statueHP * 0.4;
if (enemiesClose || criticalHP) {
// PANIC MODE: Buy whatever we can, prioritizing dps/tankiness if affordable, else cheap meatshields
const affordable = Object.values(UNITS).filter(u => u.key !== '1' && u.cost <= ai.gold && ai.pop + u.pop <= ai.popCap);
// Sort by cost descending to buy best unit possible, or random?
// Let's buy the most expensive affordable unit to get power out
if (affordable.length > 0) {
affordable.sort((a, b) => b.cost - a.cost);
this.spawnUnit(affordable[0].name.toLowerCase().split(' ')[0], true);
}
return;
}
// Normal Mode: Pick a target unit to save for if we don't have one
if (!ai.targetUnit) {
const roll = Math.random();
// Weights based on difficulty
// Easy: Mostly sword/archer
// Hard: More giant/spear/archer combos
if (diff === 'easy') {
if (roll < 0.5) ai.targetUnit = 'sword';
else if (roll < 0.9) ai.targetUnit = 'archer';
else ai.targetUnit = 'spear';
} else {
// Normal/Hard
if (roll < 0.3) ai.targetUnit = 'sword';
else if (roll < 0.55) ai.targetUnit = 'spear';
else if (roll < 0.85) ai.targetUnit = 'archer';
else ai.targetUnit = 'giant';
}
}
// Try to buy target
if (ai.targetUnit) {
const targetDef = Object.values(UNITS).find(u => u.name.toLowerCase().includes(ai.targetUnit));
if (targetDef) {
if (ai.gold >= targetDef.cost) {
if (ai.pop + targetDef.pop <= ai.popCap) {
this.spawnUnit(ai.targetUnit, true);
ai.targetUnit = null; // Reset target
}
}
// Else: Wait and save gold (do nothing)
} else {
ai.targetUnit = null; // Error fallback
}
}
}
spawnUnit(type, isEnemy, free = false) {
// Find key in UNITS
const uDef = Object.values(UNITS).find(u => u.name.toLowerCase().includes(type));
if (!uDef) return;
const faction = isEnemy ? this.enemy : this.player;
if (!free) {
if (faction.gold < uDef.cost) return; // Not enough gold
if (faction.pop + uDef.pop > faction.popCap) {
if(!isEnemy) this.addText(100 + this.camera.x, 200, "Max Pop!", "red");
return;
}
faction.gold -= uDef.cost;
}
faction.pop += uDef.pop;
const spawnX = isEnemy ? CONFIG.laneLength - 50 : 50;
const unit = new Entity(spawnX, uDef, isEnemy);
faction.units.push(unit);
if (!isEnemy) audio.sfxSpawn();
this.updateUI();
}
spawnParticles(x, y, count, color) {
for(let i=0; i el.remove(), 1000);
}
damageStatue(isEnemyStatue, amount) {
if (isEnemyStatue) {
this.enemy.statueHP -= amount;
if (this.enemy.statueHP <= 0) this.endGame(true);
} else {
this.player.statueHP -= amount;
if (this.player.statueHP <= 0) this.endGame(false);
}
this.updateUI();
}
onUnitDeath(unit) {
const faction = unit.isEnemy ? this.enemy : this.player;
const uDef = Object.values(UNITS).find(u => u.name.toLowerCase().includes(unit.typeStr));
if (uDef) faction.pop -= uDef.pop;
}
addGold(isEnemy, amount) {
if (isEnemy) this.enemy.gold += amount;
else this.player.gold += amount;
}
setStance(stance) {
this.player.stance = stance;
// Visual feedback
document.getElementById('btn-defend').classList.toggle('active-stance', stance === 'defend');
document.getElementById('btn-attack').classList.toggle('active-stance', stance === 'attack');
// Logic
if (stance === 'defend') {
// Rally point becomes focus.
// Units already check stance in update
}
}
buyUpgrade(id) {
const upg = UPGRADES.find(u => u.id === id);
const currentLvl = this.upgrades[id];
if (currentLvl >= upg.max) return;
const cost = upg.cost[currentLvl];
if (this.player.gold >= cost) {
this.player.gold -= cost;
this.upgrades[id]++;
// Apply Immediate Effects
if (id === 'eco_pop') this.player.popCap += 5;
audio.sfxUI();
this.populateUpgradeMenu(); // Refresh
}
}
// --- RENDER ---
draw() {
this.renderer.resize(); // Handle dynamic resize
const ctx = this.renderer.ctx;
const camX = this.camera.x;
// Shake
let shakeX = (Math.random() - 0.5) * this.shake;
let shakeY = (Math.random() - 0.5) * this.shake;
ctx.save();
ctx.translate(shakeX, shakeY);
this.renderer.drawEnvironment(camX);
// Draw Statues
this.renderer.drawStatue({ x: 50, hp: this.player.statueHP }, camX, false);
this.renderer.drawStatue({ x: CONFIG.laneLength - 50, hp: this.enemy.statueHP }, camX, true);
// Draw Units (Sorted by Y for slight depth, though mostly 1D)
const allUnits = [...this.player.units, ...this.enemy.units];
allUnits.forEach(u => this.renderer.drawUnit(u, camX));
// Projectiles & FX
this.projectiles.forEach(p => this.renderer.drawProjectile(p, camX));
this.renderer.drawParticles(this.particles, camX);
ctx.restore();
}
// --- UI HELPERS ---
updateUI() {
document.getElementById('gold-display').innerText = Math.floor(this.player.gold);
document.getElementById('pop-display').innerText = `${this.player.pop}/${this.player.popCap}`;
const hpPctP = Math.max(0, (this.player.statueHP / CONFIG.statueHP) * 100);
const hpPctE = Math.max(0, (this.enemy.statueHP / CONFIG.statueHP) * 100);
document.getElementById('hp-player').style.width = hpPctP + '%';
document.getElementById('hp-enemy').style.width = hpPctE + '%';
// Update Buttons state
Object.values(UNITS).forEach(u => {
const btn = document.getElementById(`btn-spawn-${u.key}`);
if (btn) btn.disabled = this.player.gold < u.cost || this.player.pop + u.pop > this.player.popCap;
});
}
populateSpawnButtons() {
const panel = document.getElementById('spawn-panel');
panel.innerHTML = '';
Object.values(UNITS).forEach(u => {
const btn = document.createElement('button');
btn.className = 'btn';
btn.id = `btn-spawn-${u.key}`;
btn.innerHTML = `
${u.name}
${u.cost} G
♟${u.pop}
${u.key}
`;
btn.onclick = () => this.spawnUnit(u.name.toLowerCase(), false);
panel.appendChild(btn);
});
this.setStance('attack'); // Default active visual
}
populateUpgradeMenu() {
const list = document.getElementById('upgrade-list');
list.innerHTML = '';
UPGRADES.forEach(u => {
const lvl = this.upgrades[u.id];
const isMax = lvl >= u.max;
const cost = isMax ? 'MAX' : u.cost[lvl];
const div = document.createElement('div');
div.className = 'upgrade-btn';
if (isMax) div.style.opacity = 0.5;
div.innerHTML = `
${u.name}
${u.desc}
${cost}
Lvl ${lvl}/${u.max}
`;
div.onclick = () => this.buyUpgrade(u.id);
list.appendChild(div);
});
}
toggleUpgradeMenu() {
const el = document.getElementById('upgrade-menu');
if (el.classList.contains('hidden')) {
this.populateUpgradeMenu();
el.classList.remove('hidden');
} else {
el.classList.add('hidden');
}
}
togglePause() {
this.paused = !this.paused;
}
setupInput() {
window.addEventListener('keydown', e => {
this.keys[e.key] = true;
if(UNITS[Object.keys(UNITS)[e.key - 1]]) { // 1-5
// Handled by UI but shortcut here
const uName = Object.values(UNITS).find(u => u.key === e.key).name.toLowerCase();
this.spawnUnit(uName, false);
}
if(e.key === 'q' || e.key === 'Q') this.setStance('defend');
if(e.key === 'e' || e.key === 'E') this.setStance('attack');
if(e.key === ' ') this.camera.x = 0; // Space home
if(e.key === 'p') this.togglePause();
});
window.addEventListener('keyup', e => this.keys[e.key] = false);
this.canvas.addEventListener('mousedown', e => {
// Rally point
const rect = this.canvas.getBoundingClientRect();
const x = (e.clientX - rect.left) + this.camera.x;
const y = e.clientY - rect.top;
if (y > CONFIG.groundY - 100) { // Click on ground
this.player.rallyPoint = x;
this.spawnParticles(x, CONFIG.groundY, 5, '#fff');
audio.sfxUI();
}
});
// Mouse pan at edges
window.addEventListener('mousemove', e => {
if (e.clientX < 50) this.keys['ArrowLeft'] = true;
else if (e.clientX > window.innerWidth - 50) this.keys['ArrowRight'] = true;
else {
delete this.keys['ArrowLeft'];
delete this.keys['ArrowRight'];
}
});
}
endGame(win) {
this.running = false;
audio.sfxWin();
const screen = document.getElementById('end-screen');
const title = document.getElementById('end-title');
const stats = document.getElementById('end-stats');
screen.classList.remove('hidden');
title.innerText = win ? "VICTORY!" : "DEFEAT";
title.style.color = win ? "#f1c40f" : "#e74c3c";
stats.innerHTML = `
Time: ${document.getElementById('game-timer').innerText}
Difficulty: ${this.difficulty.toUpperCase()}
Units Created: ${this.player.units.length}
`;
}
}
// Init
const audio = new AudioSys();
const game = new Game();
</script>
Use Case
ai hỏi
Additional Context
aa