Kod · Taipei Dash

CC BY 4.0 · sila beri kredit @joe · v1

← Butiran permainan

Ambil, olah semula, ajar dengannya

Salin kod di bawah ke dalam ChatGPT, Claude, Gemini, Grok — mana-mana alat yang anda suka — ubah untuk kelas anda, kemudian terbitkan olahan semula anda di sini dengan kredit.

⬇ Muat turun HTML ⬇ Muat turun ZIP
Pemula gesaan suntingan AI
Sunting permainan bilik darjah ini untuk papan putih interaktif. Kekalkan sasaran sentuhan yang besar dan permainan seluruh kelas. Penjejakan, log masuk, pengumpulan data peribadi dan panggilan API jauh adalah dilarang. Kekalkan struktur HTML tunggalnya. Kekalkan matlamat pendidikan. Pencipta asal: @joe di 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>