Código · Taipei Dash
CC BY 4.0 · acredita a @joe · v1
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.
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>