🎮ArcadeLab

Train Horde Survival Engine

by AstroGalaxy64
556 lines16.7 KB
▶ Play
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Train Horde Survival Engine</title>
    <style>
        * {
            box-sizing: border-box;
            margin: 0;
            padding: 0;
            user-select: none;
        }
        body {
            background-color: #111;
            color: #fff;
            font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
            overflow: hidden;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
        }
        #game-container {
            position: relative;
            width: 1000px;
            height: 600px;
            box-shadow: 0 0 20px rgba(0, 0, 0, 0.8);
            border: 2px solid #333;
        }
        canvas {
            background-color: #1a1a1a;
            display: block;
        }
        #ui-overlay {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            pointer-events: none;
            display: flex;
            flex-direction: column;
            justify-content: space-between;
            padding: 20px;
        }
        .hud-panel {
            background: rgba(0, 0, 0, 0.65);
            padding: 15px;
            border-radius: 8px;
            border: 1px solid #444;
            backdrop-filter: blur(4px);
            display: inline-block;
        }
        .hud-title {
            font-size: 14px;
            text-transform: uppercase;
            color: #888;
            letter-spacing: 1px;
            margin-bottom: 5px;
        }
        .hud-value {
            font-size: 20px;
            font-weight: bold;
            color: #00ffcc;
        }
        .stat-bar {
            width: 200px;
            height: 12px;
            background: #333;
            border-radius: 6px;
            overflow: hidden;
            margin-top: 5px;
            border: 1px solid #555;
        }
        .stat-fill {
            height: 100%;
            width: 100%;
            transition: width 0.1s linear;
        }
        #fuel-fill { background: #ffaa00; }
        #wall-fill { background: #00ccff; }
        #state-display {
            position: absolute;
            top: 20px;
            left: 50%;
            transform: translateX(-50%);
            text-align: center;
        }
        #state-name {
            font-size: 28px;
            font-weight: 800;
            letter-spacing: 2px;
            color: #fff;
            text-shadow: 0 0 10px rgba(255, 255, 255, 0.5);
        }
        #state-timer {
            font-size: 18px;
            color: #ffcc00;
            margin-top: 4px;
        }
        #prompt-ui {
            position: absolute;
            bottom: 80px;
            left: 50%;
            transform: translateX(-50%);
            background: rgba(255, 200, 0, 0.9);
            color: #000;
            padding: 8px 16px;
            border-radius: 20px;
            font-weight: bold;
            display: none;
            box-shadow: 0 0 10px rgba(255, 200, 0, 0.5);
        }
        #controls-hint {
            position: absolute;
            bottom: 20px;
            left: 20px;
            font-size: 12px;
            color: #aaa;
            background: rgba(0,0,0,0.5);
            padding: 8px;
            border-radius: 4px;
        }
    </style>
</head>
<body>

<div id="game-container">
    <canvas id="gameCanvas" width="1000" height="600"></canvas>
    
    <div id="ui-overlay">
        <div style="display: flex; justify-content: space-between;">
            <div class="hud-panel">
                <div class="hud-title">Train Status</div>
                <div style="font-size: 13px; margin-top: 3px;">Fuel:</div>
                <div class="stat-bar"><div id="fuel-fill" class="stat-fill"></div></div>
                <div style="font-size: 13px; margin-top: 6px;">Barricade HP:</div>
                <div class="stat-bar"><div id="wall-fill" class="stat-fill"></div></div>
            </div>

            <div id="state-display" class="hud-panel">
                <div id="state-name">LOBBY</div>
                <div id="state-timer">Starting in 15s</div>
            </div>

            <div class="hud-panel" style="text-align: right;">
                <div class="hud-title">Inventory</div>
                <div>Coal: <span id="coal-count" class="hud-value">3</span></div>
                <div>Scrap Metal: <span id="scrap-count" class="hud-value">3</span></div>
            </div>
        </div>

        <div id="prompt-ui">Press [E] to Interact</div>

        <div id="controls-hint">
            <b>Controls:</b> W/A/S/D - Move | Mouse - Aim & Shoot | E - Interact (Furnace/Wall)
        </div>
    </div>
</div>

<script>
// --- CORE ENGINE CONFIGURATION ---
const CANVAS_WIDTH = 1000;
const CANVAS_HEIGHT = 600;
const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

// --- GAME STATE MANAGER ---
const States = {
    LOBBY: "LOBBY",
    TRACK_RUN: "TRACK RUN",
    STATION_REST: "STATION REST",
    BOSS_HAZARD: "BOSS HAZARD",
    GAME_OVER: "GAME OVER"
};

let currentState = States.LOBBY;
let stateTimer = 15;
let currentRunCount = 0;

// --- TRAIN & TRACK PHYSICS SYSTEM ---
const train = {
    x: CANVAS_WIDTH / 2 - 200,
    y: CANVAS_HEIGHT / 2 - 30,
    width: 400,
    height: 60,
    speed: 0,
    targetSpeed: 0,
    maxSpeed: 8,
    fuel: 100,
    maxFuel: 100,
    wallHealth: 100,
    maxWallHealth: 100,
    isAnchored: true
};

// Track generation variables
let trackOffset = 0;

// --- PLAYER SYSTEM ---
const player = {
    x: train.x + 50,
    y: train.y + 20,
    radius: 12,
    speed: 4,
    vx: 0,
    vy: 0,
    coal: 3,
    scrap: 3,
    isOnTrain: true
};

const keys = {};
const mouse = { x: 0, y: 0, isDown: false };

// --- INTERACTION OBJECTS ---
const furnace = { x: train.x + 20, y: train.y + 10, width: 40, height: 40 };
const barricade = { x: train.x + train.width - 20, y: train.y, width: 15, height: train.height };

// --- HORDE AI SYSTEM ---
let zombies = [];
const BATCH_SIZE = 10;
let currentBatchIndex = 0;

class Zombie {
    constructor(x, y) {
        this.x = x;
        this.y = y;
        this.radius = 10;
        this.speed = 2.5 + Math.random() * 1;
        this.hp = 50;
        this.isBoarded = false;
        this.relativeX = 0;
        this.relativeY = 0;
    }

    update(train) {
        if (this.hp <= 0) return;

        if (!this.isBoarded) {
            // Predict Lead Target Position (Train Intercept)
            let targetX = train.x + train.width / 2;
            let targetY = train.y + train.height / 2;
            
            let dx = targetX - this.x;
            let dy = targetY - this.y;
            let dist = Math.hypot(dx, dy);

            if (dist < 30 && train.wallHealth > 0) {
                // Damage Wall
                train.wallHealth = Math.max(0, train.wallHealth - 0.05);
            } else if (dist < 30 && train.wallHealth <= 0) {
                // Board Train
                this.isBoarded = true;
                this.relativeX = this.x - train.x;
                this.relativeY = this.y - train.y;
            } else {
                // Move Towards Train
                this.x += (dx / dist) * this.speed;
                this.y += (dy / dist) * this.speed;
            }
        } else {
            // Keep locked to relative local CFrame on train
            this.x = train.x + this.relativeX;
            this.y = train.y + this.relativeY;

            // Attack Player locally
            let dx = player.x - this.x;
            let dy = player.y - this.y;
            let dist = Math.hypot(dx, dy);
            if (dist > 5) {
                this.relativeX += (dx / dist) * 0.8;
                this.relativeY += (dy / dist) * 0.8;
            }
        }
    }
}

// --- COMBAT & RAYCAST SYSTEM ---
let bullets = [];

function fireWeapon(targetX, targetY) {
    const originX = player.x;
    const originY = player.y;
    
    const angle = Math.atan2(targetY - originY, targetX - originX);
    
    bullets.push({
        x: originX,
        y: originY,
        vx: Math.cos(angle) * 15,
        vy: Math.sin(angle) * 15,
        life: 40
    });

    // Server-Style Raycast Hit Verification against Zombies
    zombies.forEach(zombie => {
        let dx = zombie.x - originX;
        let dy = zombie.y - originY;
        let dist = Math.hypot(dx, dy);
        
        // Check line-of-sight proximity to beam trajectory
        let dot = (dx * Math.cos(angle) + dy * Math.sin(angle));
        if (dot > 0 && dot < 400) {
            let projX = originX + Math.cos(angle) * dot;
            let projY = originY + Math.sin(angle) * dot;
            let perpDist = Math.hypot(zombie.x - projX, zombie.y - projY);
            
            if (perpDist < zombie.radius + 5) {
                zombie.hp -= 25; // Hit registered
            }
        }
    });
}

// --- INPUT LISTENERS ---
window.addEventListener('keydown', e => keys[e.key.toLowerCase()] = true);
window.addEventListener('keyup', e => keys[e.key.toLowerCase()] = false);
canvas.addEventListener('mousemove', e => {
    const rect = canvas.getBoundingClientRect();
    mouse.x = e.clientX - rect.left;
    mouse.y = e.clientY - rect.top;
});
canvas.addEventListener('mousedown', e => {
    if (e.button === 0) fireWeapon(mouse.x, mouse.y);
});

// --- GAME LOOP & STATE MACHINES ---
let lastTime = performance.now();

function updateGameState(dt) {
    stateTimer -= dt;

    if (stateTimer <= 0) {
        switch (currentState) {
            case States.LOBBY:
                currentState = States.TRACK_RUN;
                stateTimer = 60;
                currentRunCount++;
                train.isAnchored = false;
                break;
            case States.TRACK_RUN:
                if (currentRunCount % 3 === 0) {
                    currentState = States.BOSS_HAZARD;
                    stateTimer = 45;
                } else {
                    currentState = States.STATION_REST;
                    stateTimer = 20;
                    train.isAnchored = true;
                }
                break;
            case States.STATION_REST:
            case States.BOSS_HAZARD:
                currentState = States.TRACK_RUN;
                stateTimer = 60;
                currentRunCount++;
                train.isAnchored = false;
                break;
        }
    }

    // Spawn Horde in Track Run & Boss phases
    if ((currentState === States.TRACK_RUN || currentState === States.BOSS_HAZARD) && Math.random() < 0.05) {
        if (zombies.length < 50) {
            let spawnX = CANVAS_WIDTH + 20;
            let spawnY = Math.random() * CANVAS_HEIGHT;
            zombies.push(new Zombie(spawnX, spawnY));
        }
    }
}

function updatePhysics() {
    // 1. Train Acceleration & Fuel Dynamics
    if (!train.isAnchored && train.fuel > 0) {
        train.targetSpeed = train.maxSpeed;
        train.fuel = Math.max(0, train.fuel - 0.03);
    } else {
        train.targetSpeed = 0;
    }
    
    // Smooth Lerp Train Speed
    train.speed += (train.targetSpeed - train.speed) * 0.05;
    trackOffset = (trackOffset + train.speed) % 40;

    // 2. Player Relative Platform Grounding System
    player.vx = 0;
    player.vy = 0;
    if (keys['w']) player.vy -= player.speed;
    if (keys['s']) player.vy += player.speed;
    if (keys['a']) player.vx -= player.speed;
    if (keys['d']) player.vx += player.speed;

    player.x += player.vx;
    player.y += player.vy;

    // Check if player is on train bounding box
    if (player.x >= train.x && player.x <= train.x + train.width &&
        player.y >= train.y && player.y <= train.y + train.height) {
        player.isOnTrain = true;
        // Clamp inside train borders
        player.x = Math.max(train.x + player.radius, Math.min(train.x + train.width - player.radius, player.x));
        player.y = Math.max(train.y + player.radius, Math.min(train.y + train.height - player.radius, player.y));
    } else {
        player.isOnTrain = false;
    }

    // 3. Staggered Zombie Batch Updates (Performance Optimization)
    let totalZombies = zombies.length;
    if (totalZombies > 0) {
        let endIndex = Math.min(currentBatchIndex + BATCH_SIZE, totalZombies);
        for (let i = 0; i < totalZombies; i++) {
            zombies[i].update(train); // Active simulation
        }
        currentBatchIndex = (endIndex >= totalZombies) ? 0 : endIndex;
    }
    
    // Cleanup Dead Zombies
    zombies = zombies.filter(z => z.hp > 0);

    // 4. Update Bullets
    bullets.forEach(b => {
        b.x += b.vx;
        b.y += b.vy;
        b.life--;
    });
    bullets = bullets.filter(b => b.life > 0);

    // 5. Player Interaction System
    const nearFurnace = Math.hypot(player.x - (furnace.x + 20), player.y - (furnace.y + 20)) < 40;
    const nearWall = Math.hypot(player.x - barricade.x, player.y - (barricade.y + 30)) < 40;
    
    const promptUI = document.getElementById('prompt-ui');
    if (nearFurnace) {
        promptUI.style.display = 'block';
        promptUI.innerText = "Press [E] to Add Coal (+25 Fuel)";
        if (keys['e']) {
            if (player.coal > 0 && train.fuel < train.maxFuel) {
                player.coal--;
                train.fuel = Math.min(train.maxFuel, train.fuel + 25);
                keys['e'] = false;
            }
        }
    } else if (nearWall) {
        promptUI.style.display = 'block';
        promptUI.innerText = "Press [E] to Repair Barricade (+35 HP)";
        if (keys['e']) {
            if (player.scrap > 0 && train.wallHealth < train.maxWallHealth) {
                player.scrap--;
                train.wallHealth = Math.min(train.maxWallHealth, train.wallHealth + 35);
                keys['e'] = false;
            }
        }
    } else {
        promptUI.style.display = 'none';
    }
}

// --- RENDER ENGINE ---
function render() {
    ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);

    // 1. Draw Infinite Track Lines
    ctx.strokeStyle = "#444";
    ctx.lineWidth = 4;
    ctx.beginPath();
    ctx.moveTo(0, train.y - 10); ctx.lineTo(CANVAS_WIDTH, train.y - 10);
    ctx.moveTo(0, train.y + train.height + 10); ctx.lineTo(CANVAS_WIDTH, train.y + train.height + 10);
    ctx.stroke();

    // Procedural Ties
    ctx.strokeStyle = "#222";
    ctx.lineWidth = 6;
    for (let x = -trackOffset; x < CANVAS_WIDTH; x += 30) {
        ctx.beginPath();
        ctx.moveTo(x, train.y - 15);
        ctx.lineTo(x, train.y + train.height + 15);
        ctx.stroke();
    }

    // 2. Draw Train Carriages
    ctx.fillStyle = "#3a3d40";
    ctx.fillRect(train.x, train.y, train.width, train.height);
    ctx.strokeStyle = "#666";
    ctx.lineWidth = 3;
    ctx.strokeRect(train.x, train.y, train.width, train.height);

    // Furnace
    ctx.fillStyle = train.fuel > 0 ? "#ff5500" : "#222";
    ctx.fillRect(furnace.x, furnace.y, furnace.width, furnace.height);
    ctx.fillStyle = "#fff";
    ctx.font = "10px sans-serif";
    ctx.fillText("FURNACE", furnace.x + 0, furnace.y + 24);

    // Barricade / Wall
    let wallAlpha = train.wallHealth / train.maxWallHealth;
    ctx.fillStyle = `rgba(0, 180, 255, ${Math.max(0.2, wallAlpha)})`;
    ctx.fillRect(barricade.x, barricade.y, barricade.width, barricade.height);

    // 3. Draw Player
    ctx.fillStyle = "#00ffcc";
    ctx.beginPath();
    ctx.arc(player.x, player.y, player.radius, 0, Math.PI * 2);
    ctx.fill();

    // Player Sight/Aim Line
    ctx.strokeStyle = "rgba(0, 255, 204, 0.4)";
    ctx.beginPath();
    ctx.moveTo(player.x, player.y);
    ctx.lineTo(mouse.x, mouse.y);
    ctx.stroke();

    // 4. Draw Horde Zombies
    zombies.forEach(zombie => {
        ctx.fillStyle = zombie.isBoarded ? "#ff0055" : "#00ff55";
        ctx.beginPath();
        ctx.arc(zombie.x, zombie.y, zombie.radius, 0, Math.PI * 2);
        ctx.fill();
    });

    // 5. Draw Bullets / Raycasts
    ctx.fillStyle = "#ffff00";
    bullets.forEach(b => {
        ctx.beginPath();
        ctx.arc(b.x, b.y, 3, 0, Math.PI * 2);
        ctx.fill();
    });

    // 6. Update HUD UI Elements
    document.getElementById('state-name').innerText = currentState;
    document.getElementById('state-timer').innerText = `Time Remaining: ${Math.ceil(stateTimer)}s`;
    document.getElementById('fuel-fill').style.width = `${(train.fuel / train.maxFuel) * 100}%`;
    document.getElementById('wall-fill').style.width = `${(train.wallHealth / train.maxWallHealth) * 100}%`;
    document.getElementById('coal-count').innerText = player.coal;
    document.getElementById('scrap-count').innerText = player.scrap;
}

// --- MAIN ENGINE TICK ---
function engineLoop(timestamp) {
    let dt = (timestamp - lastTime) / 1000;
    lastTime = timestamp;

    if (!isNaN(dt)) {
        updateGameState(dt);
        updatePhysics();
        render();
    }

    requestAnimationFrame(engineLoop);
}

requestAnimationFrame(engineLoop);
</script>
</body>
</html>

Game Source: Train Horde Survival Engine

Creator: AstroGalaxy64

Libraries: none

Complexity: complex (556 lines, 16.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: train-horde-survival-engine-astrogalaxy64" to link back to the original. Then publish at arcadelab.ai/publish.