Skip to content

haha #6867

@diemdiemduyenduyen-web

Description

@diemdiemduyenduyen-web

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions