Código · Taipei Dash

CC BY 4.0 · acredita a @joe · v1

← Detalles del juego

Tómalo, remezcla y enseña con él

Copia el código de abajo en ChatGPT, Claude, Gemini, Grok —la herramienta que quieras—, cámbialo para tu clase y publica tu remezcla aquí con crédito.

⬇ Descargar HTML ⬇ Descargar ZIP
Inicio de indicación de edición con IA
Edit this classroom game for interactive whiteboards. Keep large touch targets and whole-class play. Tracking, logins, personal-data collection, and remote API calls are prohibited. Preserve its single-HTML structure. Preserve the educational goal. Original creator: @joe on ClassBoard Games (CC BY 4.0).
taipei-dash.html · 29.5 KB
<!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>Stoplight: Taipei Dash</title>
    <link href="https://fonts.googleapis.com/css2?family=VT323&display=swap" rel="stylesheet">
    <style>
        body {
            margin: 0;
            padding: 0;
            background-color: #222;
            font-family: 'VT323', monospace;
            overflow: hidden;
            display: flex;
            justify-content: center;
            align-items: center;
            height: 100vh;
            color: white;
            touch-action: manipulation; /* Prevent double-tap zoom */
        }

        #game-container {
            position: relative;
            width: 100vw;
            height: 100vh;
            background: #1a1a1a;
            overflow: hidden;
            display: flex;
            justify-content: center;
            align-items: center;
        }

        canvas {
            display: block;
            /* Size is handled by JS to maintain aspect ratio */
            box-shadow: 0 0 20px rgba(0,0,0,0.5);
        }

        #ui-layer {
            position: absolute;
            top: 10px;
            right: 20px;
            text-align: right;
            pointer-events: none;
        }

        .score-box {
            font-size: 2rem;
            color: #fff;
            text-shadow: 2px 2px #000;
        }
        
        .penalty-text {
            color: #ff4444;
            animation: floatUp 1s ease-out forwards;
            position: absolute;
            font-size: 2rem;
            font-weight: bold;
            text-shadow: 2px 2px 0 #000;
            pointer-events: none;
        }
        
        @keyframes floatUp {
            0% { transform: translateY(0); opacity: 1; }
            100% { transform: translateY(-50px); opacity: 0; }
        }

        #start-screen, #game-over-screen {
            position: absolute;
            top: 0;
            left: 0;
            width: 100%;
            height: 100%;
            background: rgba(0, 0, 0, 0.85);
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            z-index: 10;
            text-align: center;
        }
        
        /* Big Score Display Styles */
        .final-score-container {
            font-size: 3rem;
            margin: 20px 0;
            color: #fff;
        }
        
        #final-score {
            font-size: 6rem;
            color: #FFD700;
            display: block;
            line-height: 1;
            text-shadow: 4px 4px 0 #000, 0 0 20px rgba(255, 215, 0, 0.5);
            margin: 10px 0;
            font-weight: bold;
        }

        h1 { font-size: 4rem; margin: 0; color: #FFD700; text-shadow: 4px 4px #FF0000; }
        p { font-size: 1.5rem; margin: 10px 0; color: #ddd; }
        .btn {
            background: #FF4444;
            color: white;
            border: 4px solid white;
            padding: 15px 30px;
            font-size: 2rem;
            font-family: 'VT323', monospace;
            cursor: pointer;
            margin-top: 20px;
            text-transform: uppercase;
            animation: pulse 1s infinite;
        }
        .btn:hover { background: #ff6666; }

        @keyframes pulse {
            0% { transform: scale(1); }
            50% { transform: scale(1.05); }
            100% { transform: scale(1); }
        }

        /* Mobile adjustments */
        @media (max-width: 600px) {
            h1 { font-size: 2.5rem; }
            .btn { font-size: 1.5rem; padding: 10px 20px; }
            /* Removed #game-container overrides as new default handles it */
        }
    </style>
</head>
<body>

<div id="game-container">
    <canvas id="gameCanvas"></canvas>
    
    <div id="ui-layer">
        <div class="score-box">LEVEL: <span id="level-display">1</span></div>
        <div class="score-box">SCORE: <span id="score-display">0</span></div>
    </div>

    <div id="start-screen">
        <h1>STOPLIGHT</h1>
        <p>Taipei Street Dash</p>
        <p style="color: #4ade80;">GREEN = SAFE... usually?</p>
        <p style="color: #ef4444;">RED = DANGER (Traffic!)</p>
        <p style="font-size: 1rem; color: #aaa;">Beware of Teacher Sandra on Green!</p>
        <button class="btn" onclick="startGame()">PLAY</button>
    </div>

    <div id="game-over-screen" style="display: none;">
        <h1 style="color: #ef4444;">GAME OVER</h1>
        <p id="death-msg">Watch out for traffic!</p>
        <div class="final-score-container">
            SCORE
            <span id="final-score">0</span>
        </div>
        <button class="btn" onclick="resetGame()">TRY AGAIN</button>
    </div>
</div>

<script>
/**
 * STOPLIGHT ARCADE
 * A single-file HTML5 Canvas game.
 */

const canvas = document.getElementById('gameCanvas');
const ctx = canvas.getContext('2d');

// Game State
let gameState = 'START'; // START, PLAYING, GAMEOVER, LEVEL_TRANSITION
let frameCount = 0;
let score = 0;
let level = 1;

// Scaling
let scale = 1;
const GAME_WIDTH = 800;
const GAME_HEIGHT = 450;

// Input Handling
let inputActive = false;

// Entities
let player = {
    lane: 0, // 0 = start, 1-5 = road, 6 = safe
    y: 0,
    width: 30,
    height: 30,
    color: '#00FF00', // This will be dynamically set
    isMoving: false,
    moveProgress: 0,
    // New: Character colors for randomization
    hairColor: '', 
    shirtColor: '',
    overallsColor: ''
};

let trafficLight = {
    state: 'RED', // RED, YELLOW, GREEN
    timer: 0,
    x: 100,
    y: 200,
    radius: 40
};

let hazards = []; // Array of taxis, trucks, sandras
let particles = []; // Effects

// Config
const LANES = 5;
const LANE_WIDTH = 80;
const ROAD_START_X = 300; // Where the road begins

// Utility to get random color
function getRandomColor() {
    const letters = '0123456789ABCDEF';
    let color = '#';
    for (let i = 0; i < 6; i++) {
        color += letters[Math.floor(Math.random() * 16)];
    }
    return color;
}

// Set initial random colors for the character
function setRandomCharacterColors() {
    player.hairColor = '#5d4037'; // Fixed brown hair, no hat
    player.shirtColor = getRandomColor();
    player.overallsColor = getRandomColor();
}
setRandomCharacterColors(); // Call once at start

// Resize Logic
function resize() {
    const aspect = GAME_WIDTH / GAME_HEIGHT;
    const windowWidth = window.innerWidth;
    const windowHeight = window.innerHeight;
    
    let w, h;
    
    if (windowWidth / windowHeight > aspect) {
        // Window is wider than game aspect -> fit height
        h = windowHeight;
        w = h * aspect;
    } else {
        // Window is taller than game aspect -> fit width
        w = windowWidth;
        h = w / aspect;
    }
    
    canvas.width = w;
    canvas.height = h;
    
    // Style width/height ensures it renders at the right size
    canvas.style.width = `${w}px`;
    canvas.style.height = `${h}px`;

    scale = w / GAME_WIDTH;
    
    ctx.imageSmoothingEnabled = false; // Keep pixel art sharp
}

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

// Input Listener
canvas.addEventListener('mousedown', handleInput);
canvas.addEventListener('touchstart', (e) => {
    e.preventDefault(); // Prevent scrolling
    handleInput();
}, { passive: false });

function handleInput() {
    if (gameState === 'PLAYING' && !player.isMoving) {
        movePlayer();
    }
}

function startGame() {
    document.getElementById('start-screen').style.display = 'none';
    document.getElementById('game-over-screen').style.display = 'none';
    score = 0;
    level = 1;
    setRandomCharacterColors(); // New colors for new game!
    updateUI();
    startLevel();
}

function resetGame() {
    startGame();
}

function startLevel() {
    gameState = 'PLAYING';
    player.lane = 0;
    player.isMoving = false;
    hazards = [];
    particles = [];
    
    // Reset Light Logic
    trafficLight.state = 'RED';
    trafficLight.timer = 120; // Start with red for a moment
}

function movePlayer() {
    if (player.lane < 6) {
        // RULE: Red Light Disqualification
        if (trafficLight.state === 'RED') {
            gameOver('jaywalking');
            return;
        }
        
        // RULE: Yellow Light Penalty
        if (trafficLight.state === 'YELLOW') {
            score -= 10;
            updateUI();
            showPenalty(player.x, player.y - 50);
        }

        player.isMoving = true;
        player.moveProgress = 0;
        // Target lane
        player.targetLane = player.lane + 1;
    }
}

function showPenalty(x, y) {
    // Map canvas coordinates to screen coordinates for HTML overlay
    particles.push({
        type: 'text',
        text: '-10',
        x: x,
        y: y,
        life: 60,
        color: '#ff0000'
    });
}

function updateUI() {
    document.getElementById('score-display').innerText = score;
    document.getElementById('level-display').innerText = level;
}

// --- GAME LOOP ---

function gameLoop() {
    update();
    draw();
    requestAnimationFrame(gameLoop);
}

function update() {
    if (gameState !== 'PLAYING') return;
    
    frameCount++;

    // 1. Traffic Light Logic
    updateTrafficLight();

    // 2. Hazard Spawning & Movement
    updateHazards();

    // 3. Player Movement
    updatePlayer();

    // 4. Collision Detection
    checkCollisions();
    
    // 5. Particles
    updateParticles();
}

function updateTrafficLight() {
    trafficLight.timer--;
    
    // Logic:
    // RED: Heavy Traffic (Danger)
    // GREEN: No/Low Traffic (Safe)
    // YELLOW: Getting dangerous
    
    if (trafficLight.timer <= 0) {
        switch (trafficLight.state) {
            case 'RED':
                trafficLight.state = 'GREEN';
                // UPDATE: Shorter and trickier green light
                const baseTime = Math.max(60, 180 - (level * 12));
                const variance = Math.random() * 60; // Add up to 1s of randomness
                trafficLight.timer = baseTime + variance; 
                break;
            case 'GREEN':
                trafficLight.state = 'YELLOW';
                trafficLight.timer = 100; // Shorter yellow warning too
                break;
            case 'YELLOW':
                trafficLight.state = 'RED';
                trafficLight.timer = 180; // 3 seconds red
                break;
        }
    }
}

function updateHazards() {
    // Spawn Rate depends on light state
    let spawnChance = 0;
    // DIFFICULTY UPDATE: Reduced speed scaling slightly so it remains playable
    let speedMultiplier = 1 + (level * 0.25); 

    // LOGIC: Normal cars on RED/YELLOW
    // Teacher Sandra (Scooter) ONLY on GREEN
    
    if (trafficLight.state === 'RED') {
        spawnChance = 0.08; 
    } else if (trafficLight.state === 'YELLOW') {
        spawnChance = 0.03; 
    } else if (trafficLight.state === 'GREEN') {
        // Sandra time!
        // REBALANCED: Reduced spawn rate significantly (was 0.04)
        spawnChance = 0.015 + (level * 0.003); 
    }

    // Spawn Logic
    if (Math.random() < spawnChance) {
        const lane = Math.floor(Math.random() * LANES) + 1;
        let type = 'taxi';
        let color = null;
        let spawnSpeed = (Math.random() * 3 + 4) * speedMultiplier;

        if (trafficLight.state === 'GREEN') {
            // GREEN LIGHT: Only Sandra spawns
            type = 'sandra_scooter';
            spawnSpeed = (Math.random() * 3 + 6) * speedMultiplier; // Slightly lowered base speed
        } else {
            // RED/YELLOW: Normal Traffic
            const typeRoll = Math.random();
            if (typeRoll < 0.35) {
                type = 'taxi';
            } else if (typeRoll < 0.7) {
                type = 'sedan'; 
                const carColors = ['#3498db', '#e74c3c', '#9b59b6', '#2ecc71', '#ecf0f1', '#7f8c8d', '#e67e22'];
                color = carColors[Math.floor(Math.random() * carColors.length)];
            } else if (typeRoll < 0.85) {
                type = 'truck'; 
            } else {
                type = '18wheeler';
            }
        }

        hazards.push({
            lane: lane,
            y: -250, // Spawn higher up
            type: type,
            color: color,
            speed: spawnSpeed,
            frame: 0
        });
    }

    // Move Hazards
    for (let i = hazards.length - 1; i >= 0; i--) {
        let h = hazards[i];
        h.y += h.speed;
        h.frame++;
        
        // Remove if off screen
        if (h.y > GAME_HEIGHT + 300) {
            hazards.splice(i, 1);
        }
    }
}

function updatePlayer() {
    const laneWidth = 80;
    const startX = ROAD_START_X - 60; // Lane 0 is sidewalk
    
    if (player.isMoving) {
        player.moveProgress += 0.15; // Animation speed
        if (player.moveProgress >= 1) {
            player.lane = player.targetLane;
            player.isMoving = false;
            player.moveProgress = 0;
            
            // Check Level Complete
            if (player.lane === 6) {
                levelComplete();
            }
        }
    }

    // Calculate visuals based on logical lane
    let currentLaneVisual = player.lane;
    if (player.isMoving) {
        currentLaneVisual = player.lane + (player.moveProgress); // Lerp
    }

    player.x = startX + (currentLaneVisual * laneWidth);
    player.y = GAME_HEIGHT / 2; // Center vertical for player
}

function checkCollisions() {
    // Player Hitbox
    const pRect = {
        x: player.x + 10,
        y: player.y + 10,
        w: 30,
        h: 30
    };

    for (let h of hazards) {
        const hX = (ROAD_START_X - 60) + (h.lane * 80) + 15; 
        
        let hW = 50;
        let hH = 80; // Standard car size
        
        if(h.type === 'truck') { hW = 54; hH = 100; }
        if(h.type === '18wheeler') { hW = 56; hH = 220; } 
        // REBALANCED: Reduced Sandra's collision width (was 60)
        if(h.type === 'sandra_scooter') { hW = 45; hH = 70; } 

        const hRect = {
            x: hX,
            y: h.y,
            w: hW,
            h: hH
        };

        if (
            pRect.x < hRect.x + hRect.w &&
            pRect.x + pRect.w > hRect.x &&
            pRect.y < hRect.y + hRect.h &&
            pRect.y + pRect.h > hRect.y
        ) {
            gameOver(h.type);
        }
    }
}

function levelComplete() {
    score += 20;
    level++;
    updateUI();
    createConfetti();
    
    // Short pause then reset position
    gameState = 'LEVEL_TRANSITION';
    setTimeout(() => {
        startLevel();
    }, 1000);
}

function gameOver(cause) {
    gameState = 'GAMEOVER';
    document.getElementById('final-score').innerText = score;
    
    let msg = "Hit by traffic!";
    let title = "CRASH!";
    
    if (cause === 'sandra_scooter') {
        title = "SQUASHED!";
        msg = "Teacher Sandra ran you over!";
    } else if (cause === 'jaywalking') {
        title = "DISQUALIFIED!";
        msg = "You crossed on a RED light!";
    } else if (cause === '18wheeler') {
        msg = "Flattened by an 18-Wheeler!";
    }
    
    document.querySelector('#game-over-screen h1').innerText = title;
    document.getElementById('death-msg').innerText = msg;
    
    document.getElementById('game-over-screen').style.display = 'flex';
}

function createConfetti() {
    for(let i=0; i<50; i++) {
        particles.push({
            x: player.x,
            y: player.y,
            vx: (Math.random() - 0.5) * 10,
            vy: (Math.random() - 0.5) * 10,
            life: 60,
            color: `hsl(${Math.random()*360}, 100%, 50%)`
        });
    }
}

function updateParticles() {
    for (let i = particles.length - 1; i >= 0; i--) {
        let p = particles[i];
        
        if (p.type === 'text') {
            p.y -= 1; // Float up
            p.life--;
            if(p.life <= 0) particles.splice(i, 1);
        } else {
            // Confetti
            p.x += p.vx;
            p.y += p.vy;
            p.life--;
            if(p.life <= 0) particles.splice(i, 1);
        }
    }
}

// --- DRAWING FUNCTIONS ---

function draw() {
    // Clear
    ctx.fillStyle = '#333';
    ctx.fillRect(0, 0, GAME_WIDTH, GAME_HEIGHT); // Bg
    
    ctx.save();
    ctx.scale(scale, scale);

    // 1. Draw Left Panel (Traffic Light)
    drawLeftPanel();

    // 2. Draw Road/Board
    drawBoard();

    // 3. Draw Hazards
    hazards.forEach(drawHazard);

    // 4. Draw Player
    drawPlayer();
    
    // 5. Particles
    particles.forEach(p => {
        if (p.type === 'text') {
            ctx.fillStyle = p.color;
            ctx.font = 'bold 30px VT323';
            ctx.fillText(p.text, p.x, p.y);
        } else {
            ctx.fillStyle = p.color;
            ctx.fillRect(p.x, p.y, 4, 4);
        }
    });

    ctx.restore();
}

function drawLeftPanel() {
    // Background area
    ctx.fillStyle = '#222';
    ctx.fillRect(0, 0, 250, GAME_HEIGHT);

    // Light Housing
    ctx.fillStyle = '#111';
    ctx.fillRect(70, 50, 110, 300);
    ctx.strokeStyle = '#555';
    ctx.lineWidth = 4;
    ctx.strokeRect(70, 50, 110, 300);

    // Lights
    const centerX = 125;
    
    // Red
    drawLightCircle(centerX, 100, 'RED', '#ff0000');
    // Yellow
    drawLightCircle(centerX, 200, 'YELLOW', '#ffcc00');
    // Green
    drawLightCircle(centerX, 300, 'GREEN', '#00ff00');

    // Text Label
    ctx.fillStyle = '#fff';
    ctx.font = '20px VT323';
    ctx.textAlign = 'center';
    ctx.fillText("TRAFFIC CTRL", centerX, 380);
}

function drawLightCircle(x, y, type, color) {
    ctx.beginPath();
    ctx.arc(x, y, 35, 0, Math.PI * 2);
    
    if (trafficLight.state === type) {
        ctx.fillStyle = color;
        ctx.shadowBlur = 20;
        ctx.shadowColor = color;
        ctx.fill();
        ctx.shadowBlur = 0; // Reset
        
        // Inner shine
        ctx.fillStyle = '#fff';
        ctx.beginPath();
        ctx.arc(x - 10, y - 10, 8, 0, Math.PI * 2);
        ctx.fill();
    } else {
        ctx.fillStyle = '#330000'; // Dim
        if(type === 'YELLOW') ctx.fillStyle = '#332200';
        if(type === 'GREEN') ctx.fillStyle = '#002200';
        ctx.fill();
    }
}

function drawBoard() {
    // Road Background
    ctx.fillStyle = '#555';
    ctx.fillRect(ROAD_START_X, 0, LANES * 80, GAME_HEIGHT);

    // Zebra Crossing
    const crosswalkY = (GAME_HEIGHT / 2) - 30; // Centered on player path
    const crosswalkHeight = 90; // Wider than player
    
    // Darker patch for crosswalk base
    ctx.fillStyle = '#4d4d4d';
    ctx.fillRect(ROAD_START_X, crosswalkY, LANES * 80, crosswalkHeight);

    // White Stripes
    ctx.fillStyle = '#e0e0e0';
    const stripeWidth = 20;
    const stripeGap = 20;
    const startStripeX = ROAD_START_X + 10;
    
    // Draw stripes across the entire road width
    for (let sx = startStripeX; sx < ROAD_START_X + (LANES * 80); sx += (stripeWidth + stripeGap)) {
        ctx.fillRect(sx, crosswalkY + 10, stripeWidth, crosswalkHeight - 20);
    }

    // Lane markers (Draw these lightly over the road but under cars)
    ctx.strokeStyle = 'rgba(255, 255, 255, 0.3)';
    ctx.lineWidth = 2;
    ctx.setLineDash([20, 20]);
    
    for (let i = 1; i < LANES; i++) {
        ctx.beginPath();
        ctx.moveTo(ROAD_START_X + (i * 80), 0);
        ctx.lineTo(ROAD_START_X + (i * 80), GAME_HEIGHT);
        ctx.stroke();
    }
    ctx.setLineDash([]);

    // Safe Zones
    ctx.fillStyle = '#444'; // Start Sidewalk
    ctx.fillRect(ROAD_START_X - 80, 0, 80, GAME_HEIGHT);
    
    ctx.fillStyle = '#4a4'; // End Grass
    ctx.fillRect(ROAD_START_X + (LANES * 80), 0, 80, GAME_HEIGHT);
    
    // Numbers
    ctx.fillStyle = 'rgba(255,255,255,0.2)';
    ctx.font = '40px VT323';
    for(let i=0; i<LANES; i++) {
        ctx.fillText(i+1, ROAD_START_X + (i * 80) + 40, GAME_HEIGHT - 20);
    }
    
    // City Background Vibes (Top skyline silhouette behind road)
    // Simple pixel art skyline
    ctx.fillStyle = '#1a1a2e';
    ctx.fillRect(ROAD_START_X, 0, 400, 100); // Night sky patch
}

function drawHazard(h) {
    const x = (ROAD_START_X - 60) + (h.lane * 80) + 15;
    const y = h.y;

    if (h.type === 'taxi') {
        // Taipei Taxi (Yellow)
        ctx.fillStyle = '#FCD116'; // Taxi Yellow
        ctx.fillRect(x, y, 50, 80);
        // Windows
        ctx.fillStyle = '#000';
        ctx.fillRect(x + 5, y + 15, 40, 15);
        ctx.fillRect(x + 5, y + 50, 40, 15);
        // Roof Light
        ctx.fillStyle = '#fff';
        ctx.fillRect(x + 20, y + 35, 10, 10);
        // Wheels
        ctx.fillStyle = '#111';
        ctx.fillRect(x-2, y+10, 4, 15);
        ctx.fillRect(x+48, y+10, 4, 15);
        ctx.fillRect(x-2, y+55, 4, 15);
        ctx.fillRect(x+48, y+55, 4, 15);

    } else if (h.type === 'sedan') {
        // Normal Sedan
        ctx.fillStyle = h.color; 
        ctx.fillRect(x, y, 50, 80);
        ctx.fillStyle = '#000';
        ctx.fillRect(x + 5, y + 15, 40, 15);
        ctx.fillRect(x + 5, y + 50, 40, 15);
        ctx.fillStyle = '#111';
        ctx.fillRect(x-2, y+10, 4, 15);
        ctx.fillRect(x+48, y+10, 4, 15);
        ctx.fillRect(x-2, y+55, 4, 15);
        ctx.fillRect(x+48, y+55, 4, 15);

    } else if (h.type === 'truck') {
        // Wellesley Van
        const busColor = '#FFB300';
        const busWidth = 54;
        const busHeight = 100;
        ctx.fillStyle = busColor;
        ctx.fillRect(x, y, busWidth, busHeight);
        ctx.fillStyle = '#111';
        ctx.fillRect(x + 2, y + 75, 50, 20); 
        ctx.fillRect(x + 5, y + 5, 44, 10);
        ctx.fillStyle = '#fff';
        ctx.fillRect(x + 2, y + 96, 10, 4);
        ctx.fillRect(x + 42, y + 96, 10, 4);
        ctx.fillStyle = '#fff';
        ctx.fillRect(x + 2, y + 30, 50, 25);
        ctx.fillStyle = '#000';
        ctx.font = '10px Arial'; 
        ctx.textAlign = 'center';
        ctx.fillText("Wellesley", x + 27, y + 45);
        ctx.fillStyle = '#cc0000';
        ctx.fillRect(x, y + 60, busWidth, 5);
        ctx.fillStyle = '#222';
        ctx.fillRect(x - 2, y + 15, 4, 18);
        ctx.fillRect(x + busWidth - 2, y + 15, 4, 18);
        ctx.fillRect(x - 2, y + 70, 4, 18);
        ctx.fillRect(x + busWidth - 2, y + 70, 4, 18);

    } else if (h.type === '18wheeler') {
        // 18 Wheeler
        const truckW = 56;
        // Cab
        ctx.fillStyle = '#c0392b';
        ctx.fillRect(x, y + 160, truckW, 60);
        ctx.fillStyle = '#333';
        ctx.fillRect(x + 4, y + 195, 48, 15);
        // Trailer
        ctx.fillStyle = '#bdc3c7';
        ctx.fillRect(x + 2, y, truckW - 4, 150);
        ctx.fillStyle = '#95a5a6';
        for(let i=0; i<140; i+=20) ctx.fillRect(x + 4, y + i, truckW - 8, 2);
        ctx.fillStyle = '#000';
        ctx.fillRect(x + 20, y + 150, 16, 10);
        // Wheels
        ctx.fillRect(x - 2, y + 10, 4, 15); ctx.fillRect(x + truckW - 2, y + 10, 4, 15);
        ctx.fillRect(x - 2, y + 30, 4, 15); ctx.fillRect(x + truckW - 2, y + 30, 4, 15);
        ctx.fillRect(x - 2, y + 170, 4, 15); ctx.fillRect(x + truckW - 2, y + 170, 4, 15);
        ctx.fillRect(x - 2, y + 200, 4, 15); ctx.fillRect(x + truckW - 2, y + 200, 4, 15);

    } else if (h.type === 'sandra_scooter') {
        // TEACHER SANDRA ON SCOOTER
        // She is "ample size" and "witchy"
        const wiggle = Math.sin(h.frame * 0.5) * 2;
        
        // Scooter (Silver/White Taiwanese style)
        ctx.fillStyle = '#bdc3c7';
        ctx.fillRect(x + 10, y + 20, 30, 60); // Chassis
        
        // Handlebars
        ctx.fillStyle = '#34495e';
        ctx.fillRect(x + 5, y + 70, 40, 5);
        
        // Wheels
        ctx.fillStyle = '#2c3e50';
        ctx.fillRect(x + 8, y + 10, 6, 15); // Back wheel
        ctx.fillRect(x + 36, y + 10, 6, 15);
        ctx.fillRect(x + 8, y + 75, 6, 15); // Front wheel
        ctx.fillRect(x + 36, y + 75, 6, 15);

        // SANDRA (Large/Ample)
        // Body - Spilling over the seat a bit
        ctx.fillStyle = '#8e44ad'; // Purple/Witchy dress color
        ctx.beginPath();
        ctx.ellipse(x + 25, y + 40, 24, 18, 0, 0, Math.PI * 2); // Wide body
        ctx.fill();
        
        // Shoulders
        ctx.fillRect(x + 5, y + 45, 40, 10);

        // Head
        ctx.fillStyle = '#f5cba7'; // Skin
        ctx.fillRect(x + 17, y + 50, 16, 16);
        
        // Hair - Wild/Witchy
        ctx.fillStyle = '#2c3e50';
        ctx.fillRect(x + 15, y + 50, 20, 6); // Top
        ctx.fillRect(x + 13, y + 50, 4, 18); // Side L
        ctx.fillRect(x + 33, y + 50, 4, 18); // Side R
        
        // Expression - Determined/Angry
        ctx.fillStyle = '#000';
        ctx.fillRect(x + 19, y + 56, 4, 2); // Eye
        ctx.fillRect(x + 27, y + 56, 4, 2); // Eye
        ctx.fillStyle = '#c0392b';
        ctx.fillRect(x + 22, y + 62, 6, 2); // Mouth
        
        // Arms reaching for handlebars
        ctx.strokeStyle = '#f5cba7';
        ctx.lineWidth = 5;
        ctx.beginPath();
        ctx.moveTo(x + 10, y + 50); // Shoulder L
        ctx.lineTo(x + 10, y + 70); // Handlebar L
        ctx.moveTo(x + 40, y + 50); // Shoulder R
        ctx.lineTo(x + 40, y + 70); // Handlebar R
        ctx.stroke();
        
        // Cape/Scarf blowing back
        ctx.fillStyle = '#9b59b6';
        ctx.beginPath();
        ctx.moveTo(x + 15, y + 45);
        ctx.lineTo(x + 5 + wiggle, y + 10);
        ctx.lineTo(x + 25, y + 45);
        ctx.fill();
        
        // Speech Bubble
        ctx.fillStyle = '#fff';
        ctx.beginPath();
        if (ctx.roundRect) ctx.roundRect(x + 45, y - 20, 80, 30, 5);
        else ctx.rect(x + 45, y - 20, 80, 30);
        ctx.fill();
        
        ctx.fillStyle = '#000';
        ctx.font = 'bold 12px Arial';
        ctx.textAlign = 'left';
        ctx.fillText("MOVE!!", x + 55, y);
    }
}

function drawPlayer() {
    const x = player.x;
    const y = player.y;
    
    // Animation Variables
    let bounce = 0;
    let leftLimbOffset = 0;
    let rightLimbOffset = 0;
    
    if(player.isMoving) {
        // Run Cycle: Faster frequency for running steps
        // Math.PI * 8 means 4 full steps during the transition
        const cycle = player.moveProgress * Math.PI * 8;
        
        // Bobbing: Absolute sine wave creates a "bouncing" run effect without flying
        bounce = Math.abs(Math.sin(cycle)) * 4; 
        
        // Limbs: Sine wave for swinging back and forth
        // Left and Right are opposite (Math.PI phase shift)
        leftLimbOffset = Math.sin(cycle) * 6;
        rightLimbOffset = Math.sin(cycle + Math.PI) * 6;
    }
    
    const py = y - bounce;
    
    // --- CHARACTER (NO MUSTACHE, POLISH EASTER EGG) ---
    ctx.fillStyle = '#000'; // Outline
    
    // Head Outline
    ctx.fillRect(x + 13, py - 12, 28, 30);
    // Torso/Arm Outline
    ctx.fillRect(x + 3, py + 18, 48, 34);
    // Leg Outline
    ctx.fillRect(x + 8, py + 48, 18, 18);
    ctx.fillRect(x + 28, py + 48, 18, 18);

    // -- COLORS --
    
    // Hair (Simple Brown Block)
    ctx.fillStyle = player.hairColor; 
    ctx.fillRect(x + 15, py - 10, 24, 10); // Hair Top
    ctx.fillRect(x + 13, py, 4, 10);       // Sideburn L
    ctx.fillRect(x + 37, py, 4, 10);       // Sideburn R

    // Face (Skin)
    ctx.fillStyle = '#ffccaa';
    ctx.fillRect(x + 17, py, 20, 14);
    
    // Eyes (Black dots)
    ctx.fillStyle = '#000';
    ctx.fillRect(x + 22, py + 4, 2, 4);
    ctx.fillRect(x + 30, py + 4, 2, 4);
    
    // Nose (Small, pinkish/skin) - No mustache
    ctx.fillStyle = '#eebb99';
    ctx.fillRect(x + 25, py + 9, 4, 3); 

    // Shirt
    ctx.fillStyle = player.shirtColor;
    ctx.fillRect(x + 15, py + 16, 24, 16); // Chest
    
    // Arms with alternating animation
    // Left Arm
    ctx.fillRect(x + 5, py + 20 + leftLimbOffset, 10, 20);  
    // Right Arm
    ctx.fillRect(x + 39, py + 20 + rightLimbOffset, 10, 20); 
    
    // Overalls
    ctx.fillStyle = player.overallsColor;
    ctx.fillRect(x + 17, py + 28, 20, 14); // Bib
    ctx.fillRect(x + 17, py + 38, 20, 4);  // Waist
    
    // POLISH EASTER EGG (White/Red squares on Bib)
    // White square
    ctx.fillStyle = '#ffffff';
    ctx.fillRect(x + 20, py + 30, 4, 4);
    // Red square
    ctx.fillStyle = '#dc143c';
    ctx.fillRect(x + 26, py + 30, 4, 4);
    
    // Gloves (White) - Follow arms
    ctx.fillStyle = '#fff';
    ctx.fillRect(x + 5, py + 36 + leftLimbOffset, 10, 10);
    ctx.fillRect(x + 39, py + 36 + rightLimbOffset, 10, 10);
    
    // Legs/Pants - Opposite to arms for running physics
    ctx.fillStyle = player.overallsColor;
    // Left Leg (Moves with Right Arm usually, so we use rightLimbOffset logic or just invert)
    ctx.fillRect(x + 10, py + 50 + rightLimbOffset, 14, 12); 
    // Right Leg
    ctx.fillRect(x + 30, py + 50 + leftLimbOffset, 14, 12); 
    
    // Boots (Brown) - Follow legs
    ctx.fillStyle = '#5d4037';
    ctx.fillRect(x + 8, py + 60 + rightLimbOffset, 18, 6);
    ctx.fillRect(x + 28, py + 60 + leftLimbOffset, 18, 6);
}

// Init Game Loop
requestAnimationFrame(gameLoop);

</script>
</body>
</html>