Kod · Taipei Dash
CC BY 4.0 · sila beri kredit @joe · v1
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.
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>