🎮ArcadeLab

Medieval Battle

by FlashKoala39
778 lines18.9 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport"
      content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">

<title>Medieval Battle</title>

<style>
* {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
    touch-action: none;
}

html, body {
    width: 100%;
    height: 100%;
    overflow: hidden;
    background: #111;
    font-family: Arial, sans-serif;
}

#game {
    position: fixed;
    inset: 0;
    background: #4d8a3a;
}

canvas {
    display: block;
    width: 100%;
    height: 100%;
}

/* HUD */
#hud {
    position: fixed;
    top: 12px;
    left: 12px;
    right: 12px;
    display: flex;
    justify-content: space-between;
    align-items: flex-start;
    pointer-events: none;
    z-index: 10;
}

.panel {
    background: rgba(20,20,20,.78);
    color: white;
    padding: 9px 12px;
    border-radius: 12px;
    border: 1px solid rgba(255,255,255,.2);
    box-shadow: 0 3px 10px rgba(0,0,0,.3);
}

#hpBar {
    width: 150px;
    height: 15px;
    background: #401010;
    border-radius: 10px;
    overflow: hidden;
    margin-top: 5px;
}

#hpFill {
    height: 100%;
    width: 100%;
    background: linear-gradient(#55e75a,#159b28);
}

/* Controls */
#controls {
    position: fixed;
    inset: 0;
    pointer-events: none;
    z-index: 20;
}

#joystick {
    position: absolute;
    left: 25px;
    bottom: 25px;
    width: 125px;
    height: 125px;
    border-radius: 50%;
    background: rgba(0,0,0,.25);
    border: 2px solid rgba(255,255,255,.3);
    pointer-events: auto;
}

#stick {
    position: absolute;
    width: 55px;
    height: 55px;
    left: 33px;
    top: 33px;
    border-radius: 50%;
    background: rgba(255,255,255,.45);
    border: 2px solid white;
}

#attack {
    position: absolute;
    right: 25px;
    bottom: 35px;
    width: 95px;
    height: 95px;
    border-radius: 50%;
    border: 4px solid rgba(255,255,255,.6);
    background: radial-gradient(circle,#ff6363,#a90000);
    color: white;
    font-size: 34px;
    box-shadow: 0 5px 15px rgba(0,0,0,.4);
    pointer-events: auto;
}

#attack:active {
    transform: scale(.9);
}

/* Message */
#message {
    position: fixed;
    top: 50%;
    left: 50%;
    transform: translate(-50%,-50%);
    color: white;
    background: rgba(0,0,0,.85);
    padding: 25px 30px;
    border-radius: 18px;
    text-align: center;
    display: none;
    z-index: 50;
}

#message h1 {
    margin-bottom: 10px;
}

#restart {
    margin-top: 15px;
    padding: 12px 25px;
    border: 0;
    border-radius: 10px;
    background: #d89b32;
    color: white;
    font-weight: bold;
    font-size: 16px;
}
</style>
</head>

<body>

<div id="game">
    <canvas id="canvas"></canvas>
</div>

<div id="hud">
    <div class="panel">
        <b>⚔️ Knight</b>
        <div id="hpBar">
            <div id="hpFill"></div>
        </div>
    </div>
    <div class="panel">
        🛡️ Allies: <span id="allyCount">0</span> | ☠️ Enemies: <span id="enemyCount">0</span>
    </div>
</div>

<div id="controls">
    <div id="joystick">
        <div id="stick"></div>
    </div>
    <button id="attack">⚔</button>
</div>

<div id="message">
    <h1 id="messageTitle">Game Over</h1>
    <p id="messageText"></p>
    <button id="restart">Play Again</button>
</div>

<script>
// --- AUDIO SYSTEM (Web Audio API) ---
let audioCtx = null;

function initAudio() {
    if (!audioCtx) {
        audioCtx = new (window.AudioContext || window.webkitAudioContext)();
    }
    if (audioCtx.state === 'suspended') {
        audioCtx.resume();
    }
}

function playSoundAttack() {
    initAudio();
    if (!audioCtx) return;

    let osc = audioCtx.createOscillator();
    let gain = audioCtx.createGain();

    osc.type = 'sawtooth';
    osc.frequency.setValueAtTime(300, audioCtx.currentTime);
    osc.frequency.exponentialRampToValueAtTime(80, audioCtx.currentTime + 0.15);

    gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
    gain.gain.linearRampToValueAtTime(0.01, audioCtx.currentTime + 0.15);

    osc.connect(gain);
    gain.connect(audioCtx.destination);

    osc.start();
    osc.stop(audioCtx.currentTime + 0.15);
}

function playSoundHit() {
    initAudio();
    if (!audioCtx) return;

    let osc = audioCtx.createOscillator();
    let gain = audioCtx.createGain();

    osc.type = 'square';
    osc.frequency.setValueAtTime(150, audioCtx.currentTime);
    osc.frequency.linearRampToValueAtTime(40, audioCtx.currentTime + 0.2);

    gain.gain.setValueAtTime(0.3, audioCtx.currentTime);
    gain.gain.linearRampToValueAtTime(0.01, audioCtx.currentTime + 0.2);

    osc.connect(gain);
    gain.connect(audioCtx.destination);

    osc.start();
    osc.stop(audioCtx.currentTime + 0.2);
}

function playSoundEnd(win) {
    initAudio();
    if (!audioCtx) return;

    let osc = audioCtx.createOscillator();
    let gain = audioCtx.createGain();

    osc.type = win ? 'sine' : 'sawtooth';
    if (win) {
        osc.frequency.setValueAtTime(261.63, audioCtx.currentTime);
        osc.frequency.setValueAtTime(329.63, audioCtx.currentTime + 0.15);
        osc.frequency.setValueAtTime(392.00, audioCtx.currentTime + 0.3);
    } else {
        osc.frequency.setValueAtTime(120, audioCtx.currentTime);
        osc.frequency.linearRampToValueAtTime(50, audioCtx.currentTime + 0.5);
    }

    gain.gain.setValueAtTime(0.4, audioCtx.currentTime);
    gain.gain.linearRampToValueAtTime(0.01, audioCtx.currentTime + (win ? 0.6 : 0.5));

    osc.connect(gain);
    gain.connect(audioCtx.destination);

    osc.start();
    osc.stop(audioCtx.currentTime + (win ? 0.6 : 0.5));
}

// --- GAME LOGIC ---
const canvas = document.getElementById("canvas");
const ctx = canvas.getContext("2d");

let W, H;

function resize() {
    W = canvas.width = window.innerWidth * devicePixelRatio;
    H = canvas.height = window.innerHeight * devicePixelRatio;

    canvas.style.width = window.innerWidth + "px";
    canvas.style.height = window.innerHeight + "px";

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

    W = window.innerWidth;
    H = window.innerHeight;
}

window.addEventListener("resize", resize);
resize();

const world = { width: 5000, height: 5000 };
let camera = { x: 0, y: 0 };

const player = {
    x: 2500,
    y: 2500,
    radius: 25,
    speed: 3.5,
    hp: 100,
    maxHp: 100,
    attackCooldown: 0,
    direction: 0
};

let enemies = [];
let allies = [];
let decorations = [];

let enemySpawnTimer = 0;
let enemyExtraAdded = 0;
const MAX_EXTRA_ENEMIES = 25;

function random(min,max) {
    return Math.random() * (max-min) + min;
}

function createWorld() {
    enemies = [];
    allies = [];
    decorations = [];
    enemySpawnTimer = 0;
    enemyExtraAdded = 0;

    // Trees
    for(let i=0; i<300; i++) {
        let x = random(100, world.width-100);
        let y = random(100, world.height-100);
        if(Math.hypot(x-player.x, y-player.y) < 400) continue;
        decorations.push({ type: "tree", x, y, size: random(.8, 1.4) });
    }

    // Rocks
    for(let i=0; i<150; i++) {
        decorations.push({
            type: "rock",
            x: random(50, world.width-50),
            y: random(50, world.height-50),
            size: random(.6, 1.3)
        });
    }

    // Initial Enemies
    for(let i=0; i<20; i++) {
        spawnEnemy();
    }
}

function spawnEnemy() {
    let angle = random(0, Math.PI*2);
    let distance = random(800, 1800);

    enemies.push({
        x: player.x + Math.cos(angle)*distance,
        y: player.y + Math.sin(angle)*distance,
        radius: 23,
        speed: random(1.1, 1.7),
        hp: 50,
        maxHp: 50,
        attackCooldown: random(0, 60),
        hitFlash: 0
    });
}

function spawnAlly(x, y) {
    allies.push({
        x: x,
        y: y,
        radius: 23,
        speed: 1.8,
        hp: 40,
        maxHp: 40,
        attackCooldown: random(0, 50)
    });
}

createWorld();

const castle = { x: 2500, y: 1200, width: 500, height: 400 };

function drawWorld() {
    ctx.fillStyle = "#5c963e";
    ctx.fillRect(0, 0, W, H);

    ctx.strokeStyle = "rgba(30,70,20,.12)";
    ctx.lineWidth = 1;

    let grid = 100;
    let startX = -camera.x % grid;
    let startY = -camera.y % grid;

    for(let x=startX; x<W; x+=grid) {
        ctx.beginPath(); ctx.moveTo(x,0); ctx.lineTo(x,H); ctx.stroke();
    }
    for(let y=startY; y<H; y+=grid) {
        ctx.beginPath(); ctx.moveTo(0,y); ctx.lineTo(W,y); ctx.stroke();
    }

    drawCastle();

    for(const d of decorations) {
        const sx = d.x - camera.x;
        const sy = d.y - camera.y;
        if(sx < -100 || sx > W+100 || sy < -100 || sy > H+100) continue;
        if(d.type === "tree") drawTree(sx, sy, d.size);
        else drawRock(sx, sy, d.size);
    }
}

function drawTree(x,y,s) {
    ctx.fillStyle = "#654321";
    ctx.fillRect(x-8*s, y+5*s, 16*s, 35*s);
    ctx.fillStyle = "#164f20";
    ctx.beginPath(); ctx.arc(x, y-5*s, 35*s, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = "#287a2d";
    ctx.beginPath();
    ctx.arc(x-20*s, y+2*s, 25*s, 0, Math.PI*2);
    ctx.arc(x+20*s, y+2*s, 25*s, 0, Math.PI*2);
    ctx.fill();
}

function drawRock(x,y,s) {
    ctx.fillStyle = "#666";
    ctx.beginPath(); ctx.ellipse(x, y, 25*s, 16*s, 0, 0, Math.PI*2); ctx.fill();
    ctx.fillStyle = "#888";
    ctx.beginPath(); ctx.ellipse(x-7*s, y-5*s, 10*s, 6*s, 0, 0, Math.PI*2); ctx.fill();
}

function drawCastle() {
    const x = castle.x - camera.x;
    const y = castle.y - camera.y;

    ctx.fillStyle = "#777";
    ctx.fillRect(x - castle.width/2, y - castle.height/2, castle.width, castle.height);

    const tw = 90, th = 150;
    for(const tx of [x - castle.width/2, x + castle.width/2 - tw]) {
        ctx.fillStyle = "#666";
        ctx.fillRect(tx, y - castle.height/2 - th/2, tw, th);
        ctx.fillStyle = "#333";
        ctx.beginPath();
        ctx.moveTo(tx-10, y - castle.height/2 - th/2);
        ctx.lineTo(tx+tw/2, y - castle.height/2 - th/2 - 45);
        ctx.lineTo(tx+tw+10, y - castle.height/2 - th/2);
        ctx.closePath();
        ctx.fill();
    }

    ctx.fillStyle = "#2b1a10";
    ctx.beginPath(); ctx.arc(x, y + castle.height/2, 55, Math.PI, 0); ctx.fill();
    ctx.fillRect(x-55, y + castle.height/2, 110, 70);

    ctx.strokeStyle = "#222"; ctx.lineWidth = 5;
    ctx.beginPath(); ctx.moveTo(x, y - castle.height/2 - 50); ctx.lineTo(x, y - castle.height/2 - 120); ctx.stroke();
    ctx.fillStyle = "#b21f1f";
    ctx.beginPath();
    ctx.moveTo(x, y - castle.height/2 - 120);
    ctx.lineTo(x+70, y - castle.height/2 - 100);
    ctx.lineTo(x, y - castle.height/2 - 80);
    ctx.closePath(); ctx.fill();
}

function drawPlayer() {
    const x = player.x - camera.x;
    const y = player.y - camera.y;

    ctx.fillStyle = "rgba(0,0,0,.25)";
    ctx.beginPath(); ctx.ellipse(x, y+25, 25, 10, 0, 0, Math.PI*2); ctx.fill();

    ctx.fillStyle = "#292929";
    ctx.fillRect(x-13, y+10, 10, 22);
    ctx.fillRect(x+3, y+10, 10, 22);

    ctx.fillStyle = "#3c5c8c";
    ctx.fillRect(x-18, y-12, 36, 30);

    ctx.fillStyle = "#d49b72";
    ctx.beginPath(); ctx.arc(x, y-30, 17, 0, Math.PI*2); ctx.fill();

    ctx.fillStyle = "#aaa";
    ctx.beginPath(); ctx.arc(x, y-34, 19, Math.PI, 0); ctx.fill();

    ctx.save();
    ctx.translate(x, y);
    ctx.rotate(player.direction);
    ctx.fillStyle = "#ddd"; ctx.fillRect(18, -5, 45, 7);
    ctx.fillStyle = "#75451f"; ctx.fillRect(10, -7, 12, 11);
    ctx.restore();
}

function drawAlly(a) {
    const x = a.x - camera.x;
    const y = a.y - camera.y;

    ctx.fillStyle = "rgba(0,0,0,.25)";
    ctx.beginPath(); ctx.ellipse(x, y+24, 25, 9, 0, 0, Math.PI*2); ctx.fill();

    ctx.fillStyle = "#255a8e";
    ctx.fillRect(x-18, y-10, 36, 32);

    ctx.fillStyle = "#c18a65";
    ctx.beginPath(); ctx.arc(x, y-28, 16, 0, Math.PI*2); ctx.fill();

    ctx.fillStyle = "#225";
    ctx.beginPath(); ctx.arc(x, y-32, 18, Math.PI, 0); ctx.fill();

    ctx.fillStyle = "#030"; ctx.fillRect(x-20, y-50, 40, 5);
    ctx.fillStyle = "#3e3"; ctx.fillRect(x-20, y-50, 40 * (a.hp/a.maxHp), 5);
}

function drawEnemy(e) {
    const x = e.x - camera.x;
    const y = e.y - camera.y;

    ctx.fillStyle = "rgba(0,0,0,.25)";
    ctx.beginPath(); ctx.ellipse(x, y+24, 25, 9, 0, 0, Math.PI*2); ctx.fill();

    ctx.fillStyle = e.hitFlash > 0 ? "#fff" : "#8e2525";
    ctx.fillRect(x-18, y-10, 36, 32);

    ctx.fillStyle = "#c18a65";
    ctx.beginPath(); ctx.arc(x, y-28, 16, 0, Math.PI*2); ctx.fill();

    ctx.fillStyle = "#444";
    ctx.beginPath(); ctx.arc(x, y-32, 18, Math.PI, 0); ctx.fill();

    ctx.fillStyle = "#300"; ctx.fillRect(x-25, y-55, 50, 6);
    ctx.fillStyle = "#e33"; ctx.fillRect(x-25, y-55, 50 * (e.hp/e.maxHp), 6);
}

function attack() {
    if(player.attackCooldown > 0) return;
    player.attackCooldown = 25;
    
    playSoundAttack();

    let closest = null;
    let distance = Infinity;

    for(const e of enemies) {
        const d = Math.hypot(e.x - player.x, e.y - player.y);
        if(d < 100 && d < distance) {
            distance = d;
            closest = e;
        }
    }

    if(closest) {
        closest.hp -= 25;
        closest.hitFlash = 8;
        playSoundHit();

        const dx = closest.x - player.x;
        const dy = closest.y - player.y;
        const len = Math.hypot(dx, dy) || 1;

        closest.x += dx/len * 35;
        closest.y += dy/len * 35;

        if(closest.hp <= 0) {
            spawnAlly(closest.x, closest.y);
            enemies = enemies.filter(e => e !== closest);
        }
    }
}

let joyActive = false;
let joyX = 0;
let joyY = 0;

const joystick = document.getElementById("joystick");
const stick = document.getElementById("stick");

function updateJoystick(clientX, clientY) {
    const rect = joystick.getBoundingClientRect();
    const cx = rect.left + rect.width/2;
    const cy = rect.top + rect.height/2;

    let dx = clientX - cx;
    let dy = clientY - cy;
    const max = 45;
    const len = Math.hypot(dx, dy);

    if(len > max) {
        dx = dx/len * max;
        dy = dy/len * max;
    }

    joyX = dx / max;
    joyY = dy / max;
    stick.style.transform = `translate(${dx}px,${dy}px)`;
}

joystick.addEventListener("pointerdown", e => {
    joyActive = true;
    joystick.setPointerCapture(e.pointerId);
    updateJoystick(e.clientX, e.clientY);
});

joystick.addEventListener("pointermove", e => {
    if(joyActive) updateJoystick(e.clientX, e.clientY);
});

joystick.addEventListener("pointerup", resetJoystick);
joystick.addEventListener("pointercancel", resetJoystick);

function resetJoystick() {
    joyActive = false;
    joyX = 0;
    joyY = 0;
    stick.style.transform = "translate(0,0)";
}

document.getElementById("attack").addEventListener("pointerdown", e => {
    e.preventDefault();
    attack();
});

function update() {
    player.x += joyX * player.speed;
    player.y += joyY * player.speed;

    if(Math.abs(joyX) > .1 || Math.abs(joyY) > .1) {
        player.direction = Math.atan2(joyY, joyX);
    }

    player.x = Math.max(40, Math.min(world.width-40, player.x));
    player.y = Math.max(40, Math.min(world.height-40, player.y));

    if(player.attackCooldown > 0) player.attackCooldown--;

    // Enemy Spawn Timer (Every 50 Seconds, Max +25)
    enemySpawnTimer++;
    if (enemySpawnTimer >= 50 * 60) { // 60 frames per second * 50 seconds
        enemySpawnTimer = 0;
        if (enemyExtraAdded < MAX_EXTRA_ENEMIES) {
            spawnEnemy();
            enemyExtraAdded++;
        }
    }

    // Update Allies
    for(const a of allies) {
        if(a.attackCooldown > 0) a.attackCooldown--;

        let closestEnemy = null;
        let minDist = Infinity;
        for(const e of enemies) {
            const dist = Math.hypot(e.x - a.x, e.y - a.y);
            if(dist < minDist) {
                minDist = dist;
                closestEnemy = e;
            }
        }

        if(closestEnemy) {
            const dx = closestEnemy.x - a.x;
            const dy = closestEnemy.y - a.y;
            const dist = Math.hypot(dx, dy);

            if(dist > 45) {
                a.x += dx/dist * a.speed;
                a.y += dy/dist * a.speed;
            } else if(a.attackCooldown <= 0) {
                closestEnemy.hp -= 15;
                closestEnemy.hitFlash = 5;
                a.attackCooldown = 45;

                if(closestEnemy.hp <= 0) {
                    spawnAlly(closestEnemy.x, closestEnemy.y);
                    enemies = enemies.filter(item => item !== closestEnemy);
                }
            }
        } else {
            // Follow the player if no enemies are around
            const dx = player.x - a.x;
            const dy = player.y - a.y;
            const dist = Math.hypot(dx, dy);
            if(dist > 120) {
                a.x += dx/dist * a.speed;
                a.y += dy/dist * a.speed;
            }
        }
    }

    // Update Enemies
    for(const e of enemies) {
        if(e.attackCooldown > 0) e.attackCooldown--;
        if(e.hitFlash > 0) e.hitFlash--;

        // Find the closest target between Player or Allies
        let target = player;
        let minDist = Math.hypot(player.x - e.x, player.y - e.y);

        for(const a of allies) {
            const dist = Math.hypot(a.x - e.x, a.y - e.y);
            if(dist < minDist) {
                minDist = dist;
                target = a;
            }
        }

        const dx = target.x - e.x;
        const dy = target.y - e.y;
        const dist = Math.hypot(dx, dy);

        if(dist < 800 && dist > 50) {
            e.x += dx/dist * e.speed;
            e.y += dy/dist * e.speed;
        }

        if(dist < 60 && e.attackCooldown <= 0) {
            target.hp -= 8;
            e.attackCooldown = 60;
            playSoundHit();

            if(target === player && player.hp <= 0) {
                player.hp = 0;
                gameOver(false);
            }
        }
    }

    // Remove defeated allies
    allies = allies.filter(a => a.hp > 0);

    camera.x = player.x - W/2;
    camera.y = player.y - H/2;
    camera.x = Math.max(0, Math.min(world.width - W, camera.x));
    camera.y = Math.max(0, Math.min(world.height - H, camera.y));

    updateHUD();
}

function updateHUD() {
    document.getElementById("hpFill").style.width = player.hp + "%";
    document.getElementById("enemyCount").textContent = enemies.length;
    document.getElementById("allyCount").textContent = allies.length;
}

let ended = false;

function gameOver(win) {
    if(ended) return;
    ended = true;

    playSoundEnd(win);

    document.getElementById("message").style.display = "block";
    document.getElementById("messageTitle").textContent = win ? "🏆 Victory!" : "💀 Defeat!";
    document.getElementById("messageText").textContent = win ? "All enemies have been defeated." : "Your knight has fallen on the battlefield.";
}

document.getElementById("restart").addEventListener("click", () => {
    player.x = 2500;
    player.y = 2500;
    player.hp = 100;
    player.attackCooldown = 0;
    ended = false;

    document.getElementById("message").style.display = "none";
    createWorld();
});

function loop() {
    if(!ended) update();
    drawWorld();
    for(const a of allies) drawAlly(a);
    for(const e of enemies) drawEnemy(e);
    drawPlayer();
    requestAnimationFrame(loop);
}

loop();
</script>

</body>
</html>

Game Source: Medieval Battle

Creator: FlashKoala39

Libraries: none

Complexity: complex (778 lines, 18.9 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: medieval-battle-flashkoala39" to link back to the original. Then publish at arcadelab.ai/publish.