From 4623c888b32d09c5a7b7259da419bced1360e9d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 14:05:25 +0000 Subject: [PATCH 1/2] Add PBS SMASH! - a Smash Bros style fighter with 2000s PBS Kids characters Two-player local platform fighter playable at /pbs-smash/: - 8 fighters with unique stats and specials: Arthur, Buster, D.W., Clifford, Curious George, WordGirl, Digit, and Caillou - 3 stages: Elwood City, Birdwell Island, and Cyberspace - Smash-style damage %, knockback scaling, stocks, blast zones, double jumps, projectiles, hitstop, and particle effects https://claude.ai/code/session_01XxvSNaJEdasGJTmW8VYZuv --- pbs-smash/game.js | 852 +++++++++++++++++++++++++++++++++++++++++++ pbs-smash/index.html | 104 ++++++ pbs-smash/style.css | 474 ++++++++++++++++++++++++ 3 files changed, 1430 insertions(+) create mode 100644 pbs-smash/game.js create mode 100644 pbs-smash/index.html create mode 100644 pbs-smash/style.css diff --git a/pbs-smash/game.js b/pbs-smash/game.js new file mode 100644 index 0000000..7f1d55e --- /dev/null +++ b/pbs-smash/game.js @@ -0,0 +1,852 @@ +// ============================================================ +// PBS SMASH! - A Super Smash Bros style platform fighter +// starring PBS Kids characters from the 2000s +// ============================================================ + +// ---------- CHARACTER ROSTER ---------- +const CHARACTERS = [ + { + id: 'arthur', name: 'ARTHUR', emoji: '🐵', show: 'Arthur', + color: '#ffca28', + speed: 5.2, jump: 14, weight: 1.0, power: 1.0, + special: 'book', specialName: 'Library Card Toss', + taunt: 'Having fun isn\'t hard, with a library card!' + }, + { + id: 'buster', name: 'BUSTER', emoji: '🐰', show: 'Arthur', + color: '#e0e0e0', + speed: 5.0, jump: 16.5, weight: 0.85, power: 0.9, + special: 'carrot', specialName: 'Carrot Lob', + taunt: 'Aliens are totally real.' + }, + { + id: 'dw', name: 'D.W.', emoji: '👧', show: 'Arthur', + color: '#f48fb1', + speed: 6.0, jump: 14.5, weight: 0.75, power: 0.85, + special: 'tantrum', specialName: 'Tantrum Spin', + taunt: 'I\'m telling MOM!' + }, + { + id: 'clifford', name: 'CLIFFORD', emoji: '🐶', show: 'Clifford', + color: '#e53935', + speed: 3.8, jump: 12, weight: 1.5, power: 1.45, + special: 'bark', specialName: 'BIG RED BARK', + taunt: 'Woof.' + }, + { + id: 'george', name: 'GEORGE', emoji: '🐒', show: 'Curious George', + color: '#8d6e63', + speed: 5.8, jump: 16, weight: 0.8, power: 0.9, + special: 'banana', specialName: 'Banana Bounce', + taunt: '*curious monkey noises*' + }, + { + id: 'wordgirl', name: 'WORDGIRL', emoji: '🦸', show: 'WordGirl', + color: '#d32f2f', + speed: 6.5, jump: 15, weight: 0.9, power: 1.05, + special: 'dash', specialName: 'Vocabulary Velocity', + taunt: 'WORD UP!' + }, + { + id: 'digit', name: 'DIGIT', emoji: '🐦', show: 'Cyberchase', + color: '#26c6da', + speed: 5.5, jump: 15.5, weight: 0.85, power: 0.95, + special: 'zap', specialName: 'Cyber Beak Bolt', + taunt: 'Yoiks!' + }, + { + id: 'caillou', name: 'CAILLOU', emoji: '👦', show: 'Caillou', + color: '#fff176', + speed: 4.8, jump: 13.5, weight: 0.7, power: 1.15, + special: 'scream', specialName: 'The Tantrum Heard Round The World', + taunt: '*whining intensifies*' + }, +]; + +// ---------- STAGES ---------- +const W = 1280, H = 720; +const STAGES = [ + { + id: 'elwood', name: 'Elwood City', + sky: ['#7ec8e3', '#cfeefb'], + accent: '#66bb6a', + platforms: [ + { x: 240, y: 560, w: 800, h: 30, main: true }, + { x: 380, y: 420, w: 180, h: 16 }, + { x: 720, y: 420, w: 180, h: 16 }, + { x: 550, y: 300, w: 180, h: 16 }, + ] + }, + { + id: 'birdwell', name: 'Birdwell Island', + sky: ['#ff8a65', '#ffe0b2'], + accent: '#e53935', + platforms: [ + { x: 180, y: 580, w: 420, h: 30, main: true }, + { x: 680, y: 580, w: 420, h: 30, main: true }, + { x: 480, y: 410, w: 320, h: 16 }, + ] + }, + { + id: 'cyberspace', name: 'Cyberspace', + sky: ['#0d0d2b', '#2a1a5e'], + accent: '#26c6da', + platforms: [ + { x: 290, y: 540, w: 700, h: 30, main: true }, + { x: 150, y: 400, w: 160, h: 16 }, + { x: 970, y: 400, w: 160, h: 16 }, + { x: 560, y: 330, w: 160, h: 16 }, + ] + }, +]; + +// ---------- GLOBAL STATE ---------- +const canvas = document.getElementById('gameCanvas'); +const ctx = canvas.getContext('2d'); +let scale = 1, offsetX = 0, offsetY = 0; + +let p1Char = null, p2Char = null, nextPick = 1; +let selectedStage = 0; +let game = null; + +const GRAVITY = 0.75; +const FRICTION = 0.82; +const AIR_FRICTION = 0.96; +const STOCKS = 3; +const MATCH_TIME = 99; + +// ---------- INPUT ---------- +const keys = {}; +window.addEventListener('keydown', e => { + keys[e.key.toLowerCase()] = true; + if (['arrowup', 'arrowdown', 'arrowleft', 'arrowright', ' '].includes(e.key.toLowerCase())) e.preventDefault(); +}); +window.addEventListener('keyup', e => { keys[e.key.toLowerCase()] = false; }); + +const P1_KEYS = { left: 'a', right: 'd', jump: 'w', attack: 'f', special: 'g' }; +const P2_KEYS = { left: 'arrowleft', right: 'arrowright', jump: 'arrowup', attack: 'k', special: 'l' }; + +// ---------- CANVAS SIZING ---------- +function resizeCanvas() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + scale = Math.min(canvas.width / W, canvas.height / H); + offsetX = (canvas.width - W * scale) / 2; + offsetY = (canvas.height - H * scale) / 2; +} +window.addEventListener('resize', resizeCanvas); +resizeCanvas(); + +// ---------- SCREEN MANAGEMENT ---------- +function showScreen(id) { + document.querySelectorAll('.screen').forEach(s => s.classList.add('hidden')); + document.getElementById(id).classList.remove('hidden'); + if (game && id !== 'gameScreen') { + game.running = false; + game = null; + } + if (id !== 'gameScreen') drawMenuBackground(); +} + +function drawMenuBackground() { + if (game) return; + ctx.fillStyle = '#1a0a2e'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + // floating stars + for (let i = 0; i < 60; i++) { + const x = (i * 137.5) % canvas.width; + const y = (i * 89.7 + Date.now() * 0.01 * ((i % 3) + 1)) % canvas.height; + ctx.fillStyle = `rgba(255,255,255,${0.1 + (i % 5) * 0.06})`; + ctx.beginPath(); + ctx.arc(x, y, 1 + (i % 3), 0, Math.PI * 2); + ctx.fill(); + } + if (!game) requestAnimationFrame(drawMenuBackground); +} + +// ---------- CHARACTER SELECT ---------- +function buildCharacterSelect() { + const grid = document.getElementById('charGrid'); + grid.innerHTML = ''; + CHARACTERS.forEach((c, i) => { + const cell = document.createElement('div'); + cell.className = 'char-cell'; + cell.id = 'cell-' + c.id; + cell.innerHTML = `${c.emoji}${c.name}`; + cell.onclick = () => pickCharacter(i); + grid.appendChild(cell); + }); + + const stages = document.getElementById('stageOptions'); + stages.innerHTML = ''; + STAGES.forEach((s, i) => { + const opt = document.createElement('div'); + opt.className = 'stage-option' + (i === selectedStage ? ' selected' : ''); + opt.textContent = s.name; + opt.onclick = () => { + selectedStage = i; + document.querySelectorAll('.stage-option').forEach((o, j) => + o.classList.toggle('selected', j === i)); + }; + stages.appendChild(opt); + }); +} + +function pickCharacter(i) { + const c = CHARACTERS[i]; + if (nextPick === 1) { + p1Char = i; + document.getElementById('p1Preview').textContent = c.emoji; + document.getElementById('p1Name').textContent = c.name; + nextPick = 2; + } else { + p2Char = i; + document.getElementById('p2Preview').textContent = c.emoji; + document.getElementById('p2Name').textContent = c.name; + nextPick = 1; + } + document.querySelectorAll('.char-cell').forEach(cell => { + cell.classList.remove('selected-p1', 'selected-p2'); + }); + if (p1Char !== null) document.getElementById('cell-' + CHARACTERS[p1Char].id).classList.add('selected-p1'); + if (p2Char !== null) document.getElementById('cell-' + CHARACTERS[p2Char].id).classList.add('selected-p2'); + document.getElementById('fightBtn').disabled = !(p1Char !== null && p2Char !== null); +} + +// ---------- FIGHTER ---------- +class Fighter { + constructor(charIndex, playerNum, x) { + this.char = CHARACTERS[charIndex]; + this.playerNum = playerNum; + this.keys = playerNum === 1 ? P1_KEYS : P2_KEYS; + this.w = 56; + this.h = 64; + this.spawnX = x; + this.respawn(true); + this.stocks = STOCKS; + this.damage = 0; + } + + respawn(initial = false) { + this.x = this.spawnX; + this.y = initial ? 300 : 100; + this.vx = 0; + this.vy = 0; + this.facing = this.playerNum === 1 ? 1 : -1; + this.onGround = false; + this.jumpsLeft = 2; + this.hitstun = 0; + this.attackTimer = 0; + this.attackCooldown = 0; + this.specialCooldown = 0; + this.invuln = initial ? 0 : 120; + this.damage = 0; + this.squash = 1; + this.spinTimer = 0; + this.dashTimer = 0; + this.jumpHeld = false; + this.dead = false; + this.deathTimer = 0; + } + + get hurtbox() { + return { x: this.x - this.w / 2, y: this.y - this.h, w: this.w, h: this.h }; + } + + update(g) { + if (this.dead) { + this.deathTimer--; + if (this.deathTimer <= 0 && this.stocks > 0) { + this.dead = false; + this.respawn(); + } + return; + } + + if (this.invuln > 0) this.invuln--; + if (this.attackCooldown > 0) this.attackCooldown--; + if (this.specialCooldown > 0) this.specialCooldown--; + if (this.attackTimer > 0) this.attackTimer--; + if (this.spinTimer > 0) this.spinTimer--; + if (this.dashTimer > 0) this.dashTimer--; + + const inHitstun = this.hitstun > 0; + if (inHitstun) this.hitstun--; + + // --- movement input --- + if (!inHitstun && this.dashTimer <= 0) { + const spd = this.char.speed; + if (keys[this.keys.left]) { + this.vx -= this.onGround ? spd * 0.3 : spd * 0.12; + this.facing = -1; + } + if (keys[this.keys.right]) { + this.vx += this.onGround ? spd * 0.3 : spd * 0.12; + this.facing = 1; + } + const maxSpd = this.spinTimer > 0 ? spd * 1.4 : spd; + this.vx = Math.max(-maxSpd, Math.min(maxSpd, this.vx)); + + // jump (with edge detection so holding doesn't multi-jump) + if (keys[this.keys.jump]) { + if (!this.jumpHeld && this.jumpsLeft > 0) { + this.vy = -this.char.jump * (this.jumpsLeft === 2 ? 1 : 0.88); + this.jumpsLeft--; + this.onGround = false; + this.squash = 1.25; + g.spawnParticles(this.x, this.y, 4, '#ffffff', 2); + } + this.jumpHeld = true; + } else { + this.jumpHeld = false; + } + + // attack + if (keys[this.keys.attack] && this.attackCooldown <= 0) { + this.attackTimer = 10; + this.attackCooldown = 24; + } + + // special + if (keys[this.keys.special] && this.specialCooldown <= 0) { + this.doSpecial(g); + } + } + + // --- physics --- + this.vy += GRAVITY * (this.vy > 0 ? 1.15 : 1); + this.vy = Math.min(this.vy, 22); + this.vx *= this.onGround ? FRICTION : AIR_FRICTION; + this.x += this.vx; + this.y += this.vy; + + // --- platform collision --- + this.onGround = false; + const stage = STAGES[selectedStage]; + for (const p of stage.platforms) { + const withinX = this.x > p.x - 10 && this.x < p.x + p.w + 10; + const falling = this.vy >= 0; + const feetPrev = this.y - this.vy; + if (withinX && falling && feetPrev <= p.y + 4 && this.y >= p.y && this.y <= p.y + p.h + this.vy + 4) { + // drop-through soft platforms by holding down... keep simple: main is solid, others pass holding nothing + this.y = p.y; + this.vy = 0; + if (!this.onGround) this.squash = Math.min(this.squash, 0.8); + this.onGround = true; + this.jumpsLeft = 2; + } + } + + // squash/stretch recovery + this.squash += (1 - this.squash) * 0.2; + + // --- melee hit detection --- + if (this.attackTimer > 4 && this.attackTimer <= 8) { + const reach = 48; + const hb = { + x: this.facing === 1 ? this.x : this.x - reach - this.w / 2, + y: this.y - this.h, + w: reach + this.w / 2, + h: this.h + }; + const foe = g.fighters.find(f => f !== this); + if (foe && !foe.dead && foe.invuln <= 0 && rectsOverlap(hb, foe.hurtbox)) { + g.hit(this, foe, 7 * this.char.power, 8, this.facing, -6); + this.attackTimer = 4; // single hit per swing + } + } + + // spinning tantrum multi-hit + if (this.spinTimer > 0 && this.spinTimer % 8 === 0) { + const foe = g.fighters.find(f => f !== this); + const hb = { x: this.x - 60, y: this.y - this.h - 10, w: 120, h: this.h + 20 }; + if (foe && !foe.dead && foe.invuln <= 0 && rectsOverlap(hb, foe.hurtbox)) { + g.hit(this, foe, 4, 6, Math.sign(foe.x - this.x) || this.facing, -5); + } + } + + // dash punch hit + if (this.dashTimer > 0) { + const foe = g.fighters.find(f => f !== this); + if (foe && !foe.dead && foe.invuln <= 0 && rectsOverlap(this.hurtbox, foe.hurtbox)) { + g.hit(this, foe, 12, 14, this.facing, -8); + this.dashTimer = 0; + } + } + + // --- blast zones --- + if (this.x < -150 || this.x > W + 150 || this.y > H + 180 || this.y < -500) { + g.koFighter(this); + } + } + + doSpecial(g) { + const c = this.char; + switch (c.special) { + case 'book': + g.projectiles.push(new Projectile(this, this.x + this.facing * 40, this.y - 40, + this.facing * 9, 0, '📚', 9, 9, 'straight')); + this.specialCooldown = 50; + break; + case 'carrot': + g.projectiles.push(new Projectile(this, this.x + this.facing * 30, this.y - 50, + this.facing * 7, -9, '🥕', 8, 8, 'arc')); + this.specialCooldown = 45; + break; + case 'tantrum': + this.spinTimer = 40; + this.specialCooldown = 90; + g.announce('TANTRUM!', 500); + break; + case 'bark': { + g.spawnShockwave(this.x + this.facing * 30, this.y - 30, this.facing); + const foe = g.fighters.find(f => f !== this); + if (foe && !foe.dead && foe.invuln <= 0) { + const dist = Math.abs(foe.x - this.x); + const inFront = Math.sign(foe.x - this.x) === this.facing || dist < 40; + if (dist < 200 && Math.abs(foe.y - this.y) < 120 && inFront) { + g.hit(this, foe, 14, 18, this.facing, -10); + } + } + this.specialCooldown = 100; + break; + } + case 'banana': + g.projectiles.push(new Projectile(this, this.x + this.facing * 30, this.y - 50, + this.facing * 6, -7, '🍌', 7, 7, 'bounce')); + this.specialCooldown = 40; + break; + case 'dash': + this.dashTimer = 14; + this.vx = this.facing * 16; + this.vy = -2; + this.specialCooldown = 80; + break; + case 'zap': + g.projectiles.push(new Projectile(this, this.x + this.facing * 40, this.y - 45, + this.facing * 13, 0, '⚡', 8, 7, 'straight')); + this.specialCooldown = 38; + break; + case 'scream': { + g.spawnScreamRing(this.x, this.y - 30); + const foe = g.fighters.find(f => f !== this); + if (foe && !foe.dead && foe.invuln <= 0) { + const dx = foe.x - this.x, dy = (foe.y - 30) - (this.y - 30); + if (Math.hypot(dx, dy) < 180) { + g.hit(this, foe, 11, 15, Math.sign(dx) || this.facing, -11); + } + } + this.specialCooldown = 110; + break; + } + } + } + + draw() { + if (this.dead) return; + if (this.invuln > 0 && Math.floor(this.invuln / 4) % 2 === 0) return; // flicker + + const px = offsetX + this.x * scale; + const py = offsetY + this.y * scale; + + ctx.save(); + ctx.translate(px, py); + + // shadow + ctx.fillStyle = 'rgba(0,0,0,0.25)'; + ctx.beginPath(); + ctx.ellipse(0, 2 * scale, 26 * scale * this.squash, 6 * scale, 0, 0, Math.PI * 2); + ctx.fill(); + + // spin rotation for tantrum + if (this.spinTimer > 0) { + ctx.rotate((this.spinTimer * 0.8) % (Math.PI * 2)); + } + + // dash trail + if (this.dashTimer > 0) { + ctx.shadowColor = this.char.color; + ctx.shadowBlur = 30 * scale; + } + + // body (emoji) + ctx.scale(this.facing * (1 / this.squash) * 0.9 + this.facing * 0.1, this.squash); + ctx.font = `${56 * scale}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'bottom'; + + // player ring indicator + ctx.restore(); + ctx.save(); + ctx.translate(px, py); + ctx.strokeStyle = this.playerNum === 1 ? '#4fc3f7' : '#ff6f00'; + ctx.lineWidth = 3 * scale; + ctx.beginPath(); + ctx.ellipse(0, 2 * scale, 30 * scale, 8 * scale, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + + ctx.save(); + ctx.translate(px, py - (this.h / 2) * scale * this.squash); + if (this.spinTimer > 0) ctx.rotate(this.spinTimer * 0.6); + ctx.scale(this.facing, 1); + ctx.font = `${58 * scale * (2 - this.squash)}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(this.char.emoji, 0, 0); + ctx.restore(); + + // attack swing visual + if (this.attackTimer > 4) { + ctx.save(); + ctx.translate(px + this.facing * 45 * scale, py - 35 * scale); + ctx.font = `${34 * scale}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.globalAlpha = 0.9; + ctx.fillText('💥', 0, 0); + ctx.restore(); + } + } +} + +// ---------- PROJECTILES ---------- +class Projectile { + constructor(owner, x, y, vx, vy, emoji, dmg, kb, kind) { + this.owner = owner; + this.x = x; this.y = y; + this.vx = vx; this.vy = vy; + this.emoji = emoji; + this.dmg = dmg; + this.kb = kb; + this.kind = kind; + this.life = 160; + this.bounces = 2; + this.rot = 0; + } + + update(g) { + this.life--; + this.rot += 0.2 * Math.sign(this.vx || 1); + if (this.kind === 'arc' || this.kind === 'bounce') this.vy += GRAVITY * 0.6; + this.x += this.vx; + this.y += this.vy; + + if (this.kind === 'bounce') { + const stage = STAGES[selectedStage]; + for (const p of stage.platforms) { + if (this.x > p.x && this.x < p.x + p.w && this.y > p.y && this.y < p.y + p.h + 12 && this.vy > 0) { + this.vy = -this.vy * 0.7; + this.y = p.y; + if (--this.bounces < 0) this.life = 0; + } + } + } + + const foe = g.fighters.find(f => f !== this.owner); + if (foe && !foe.dead && foe.invuln <= 0) { + const hb = foe.hurtbox; + if (this.x > hb.x - 12 && this.x < hb.x + hb.w + 12 && this.y > hb.y - 12 && this.y < hb.y + hb.h + 12) { + g.hit(this.owner, foe, this.dmg, this.kb, Math.sign(this.vx) || this.owner.facing, -6); + this.life = 0; + } + } + + if (this.x < -100 || this.x > W + 100 || this.y > H + 100) this.life = 0; + return this.life > 0; + } + + draw() { + ctx.save(); + ctx.translate(offsetX + this.x * scale, offsetY + this.y * scale); + ctx.rotate(this.rot); + ctx.font = `${30 * scale}px serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(this.emoji, 0, 0); + ctx.restore(); + } +} + +// ---------- GAME ---------- +function rectsOverlap(a, b) { + return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y; +} + +class Game { + constructor() { + this.fighters = [ + new Fighter(p1Char, 1, W * 0.32), + new Fighter(p2Char, 2, W * 0.68), + ]; + this.projectiles = []; + this.particles = []; + this.shockwaves = []; + this.running = true; + this.over = false; + this.freeze = 0; + this.timeLeft = MATCH_TIME * 60; + this.countdown = 180; // 3..2..1..GO + + document.getElementById('hudP1Name').textContent = this.fighters[0].char.name; + document.getElementById('hudP2Name').textContent = this.fighters[1].char.name; + this.updateHUD(); + } + + hit(attacker, victim, dmg, baseKb, dirX, dirY) { + victim.damage = Math.min(999, Math.round(victim.damage + dmg)); + const kbScale = 1 + victim.damage / 55; + const kb = baseKb * kbScale * attacker.char.power / victim.char.weight; + victim.vx = dirX * kb * 0.9; + victim.vy = dirY * (kb / 9) - 3; + victim.hitstun = Math.min(40, 8 + kb * 0.8); + victim.invuln = 8; + this.freeze = Math.min(8, 2 + dmg * 0.4); // hitstop for juice + this.spawnParticles(victim.x, victim.y - 40, 8, attacker.char.color, 5); + this.updateHUD(); + } + + koFighter(f) { + if (f.dead) return; + f.stocks--; + f.dead = true; + f.deathTimer = 90; + this.spawnParticles( + Math.max(30, Math.min(W - 30, f.x)), + Math.max(30, Math.min(H - 30, f.y)), + 20, '#ffffff', 9 + ); + this.announce(f.stocks > 0 ? 'KO!' : 'GAME!', 900); + this.updateHUD(); + if (f.stocks <= 0) this.endGame(); + } + + endGame(timeUp = false) { + if (this.over) return; + this.over = true; + let winner; + const [a, b] = this.fighters; + if (timeUp) { + if (a.stocks !== b.stocks) winner = a.stocks > b.stocks ? a : b; + else if (a.damage !== b.damage) winner = a.damage < b.damage ? a : b; + else winner = null; + } else { + winner = a.stocks > 0 ? a : b; + } + setTimeout(() => { + this.running = false; + const txt = document.getElementById('winnerText'); + const disp = document.getElementById('winnerDisplay'); + if (winner) { + txt.textContent = `${winner.char.name} WINS!`; + disp.textContent = winner.char.emoji; + disp.title = winner.char.taunt; + } else { + txt.textContent = 'SUDDEN TIE!'; + disp.textContent = '🤝'; + } + showScreen('resultsScreen'); + }, 1400); + } + + announce(text, ms) { + const el = document.createElement('div'); + el.className = 'announcer'; + el.textContent = text; + document.body.appendChild(el); + setTimeout(() => el.remove(), ms); + } + + spawnParticles(x, y, n, color, speed) { + for (let i = 0; i < n; i++) { + const a = Math.random() * Math.PI * 2; + const s = speed * (0.4 + Math.random()); + this.particles.push({ + x, y, vx: Math.cos(a) * s, vy: Math.sin(a) * s - 2, + life: 30 + Math.random() * 20, color, size: 3 + Math.random() * 4 + }); + } + } + + spawnShockwave(x, y, dir) { + this.shockwaves.push({ x, y, r: 10, maxR: 190, dir, life: 18 }); + } + + spawnScreamRing(x, y) { + this.shockwaves.push({ x, y, r: 10, maxR: 180, dir: 0, life: 22 }); + } + + updateHUD() { + const [a, b] = this.fighters; + const d1 = document.getElementById('hudP1Damage'); + const d2 = document.getElementById('hudP2Damage'); + d1.textContent = a.damage + '%'; + d2.textContent = b.damage + '%'; + for (const [el, f] of [[d1, a], [d2, b]]) { + el.classList.toggle('high', f.damage >= 80); + el.classList.toggle('medium', f.damage >= 40 && f.damage < 80); + } + document.getElementById('hudP1Stocks').textContent = '⭐'.repeat(Math.max(0, a.stocks)); + document.getElementById('hudP2Stocks').textContent = '⭐'.repeat(Math.max(0, b.stocks)); + } + + update() { + if (this.countdown > 0) { + const prev = Math.ceil(this.countdown / 60); + this.countdown--; + const now = Math.ceil(this.countdown / 60); + if (now !== prev || this.countdown === 179) { + if (this.countdown === 0) this.announce('GO!', 600); + else this.announce(String(now), 500); + } + return; + } + + if (this.freeze > 0) { this.freeze--; return; } + if (this.over) return; + + this.timeLeft--; + document.getElementById('hudTimer').textContent = Math.max(0, Math.ceil(this.timeLeft / 60)); + if (this.timeLeft <= 0) { this.endGame(true); return; } + + for (const f of this.fighters) f.update(this); + this.projectiles = this.projectiles.filter(p => p.update(this)); + + for (const p of this.particles) { + p.x += p.vx; p.y += p.vy; p.vy += 0.3; p.life--; + } + this.particles = this.particles.filter(p => p.life > 0); + + for (const s of this.shockwaves) { + s.r += (s.maxR - s.r) * 0.25; + s.life--; + } + this.shockwaves = this.shockwaves.filter(s => s.life > 0); + } + + draw() { + const stage = STAGES[selectedStage]; + + // sky gradient + const grad = ctx.createLinearGradient(0, 0, 0, canvas.height); + grad.addColorStop(0, stage.sky[0]); + grad.addColorStop(1, stage.sky[1]); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, canvas.width, canvas.height); + + // stage decoration + if (stage.id === 'cyberspace') { + ctx.strokeStyle = 'rgba(38,198,218,0.15)'; + ctx.lineWidth = 1; + const g = 60 * scale; + for (let x = offsetX % g; x < canvas.width; x += g) { + ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); + } + for (let y = offsetY % g; y < canvas.height; y += g) { + ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); + } + } else { + // clouds + ctx.fillStyle = 'rgba(255,255,255,0.5)'; + for (let i = 0; i < 5; i++) { + const cx = ((i * 280 + Date.now() * 0.012) % (canvas.width + 200)) - 100; + const cy = offsetY + (60 + i * 55) * scale; + ctx.beginPath(); + ctx.ellipse(cx, cy, 60 * scale, 20 * scale, 0, 0, Math.PI * 2); + ctx.ellipse(cx + 35 * scale, cy - 10 * scale, 40 * scale, 18 * scale, 0, 0, Math.PI * 2); + ctx.fill(); + } + } + + // platforms + for (const p of stage.platforms) { + const px = offsetX + p.x * scale; + const py = offsetY + p.y * scale; + const pw = p.w * scale, ph = p.h * scale; + ctx.fillStyle = p.main ? stage.accent : 'rgba(255,255,255,0.85)'; + roundRect(px, py, pw, ph, 8 * scale); + ctx.fill(); + if (p.main) { + ctx.fillStyle = 'rgba(0,0,0,0.15)'; + roundRect(px, py + ph * 0.55, pw, ph * 0.45, 6 * scale); + ctx.fill(); + // grass tufts / circuit dots + ctx.fillStyle = 'rgba(255,255,255,0.35)'; + for (let i = 0; i < p.w / 50; i++) { + ctx.fillRect(px + (i * 50 + 12) * scale, py + 3 * scale, 14 * scale, 3 * scale); + } + } + } + + // shockwaves + for (const s of this.shockwaves) { + ctx.save(); + ctx.strokeStyle = `rgba(255,255,255,${s.life / 22})`; + ctx.lineWidth = 5 * scale; + ctx.beginPath(); + if (s.dir === 0) { + ctx.arc(offsetX + s.x * scale, offsetY + s.y * scale, s.r * scale, 0, Math.PI * 2); + } else { + const start = s.dir === 1 ? -Math.PI / 2 : Math.PI / 2; + ctx.arc(offsetX + s.x * scale, offsetY + s.y * scale, s.r * scale, start, start + Math.PI * (s.dir === 1 ? 1 : -1) * -1, s.dir === 1); + } + ctx.stroke(); + ctx.restore(); + } + + // entities + for (const p of this.projectiles) p.draw(); + for (const f of this.fighters) f.draw(); + + // particles + for (const p of this.particles) { + ctx.fillStyle = p.color; + ctx.globalAlpha = Math.min(1, p.life / 20); + ctx.beginPath(); + ctx.arc(offsetX + p.x * scale, offsetY + p.y * scale, p.size * scale, 0, Math.PI * 2); + ctx.fill(); + ctx.globalAlpha = 1; + } + + // offscreen indicators + for (const f of this.fighters) { + if (f.dead) continue; + if (f.y < -10) { + const ix = offsetX + Math.max(30, Math.min(W - 30, f.x)) * scale; + ctx.font = `${30 * scale}px serif`; + ctx.textAlign = 'center'; + ctx.fillText('🔻', ix, offsetY + 40 * scale); + ctx.font = `${22 * scale}px serif`; + ctx.fillText(f.char.emoji, ix, offsetY + 70 * scale); + } + } + } +} + +function roundRect(x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); +} + +// ---------- MAIN LOOP ---------- +function startFight() { + showScreen('gameScreen'); + game = new Game(); + requestAnimationFrame(gameLoop); +} + +function gameLoop() { + if (!game || !game.running) return; + game.update(); + game.draw(); + requestAnimationFrame(gameLoop); +} + +// ---------- INIT ---------- +buildCharacterSelect(); +drawMenuBackground(); diff --git a/pbs-smash/index.html b/pbs-smash/index.html new file mode 100644 index 0000000..725f824 --- /dev/null +++ b/pbs-smash/index.html @@ -0,0 +1,104 @@ + + + + + + PBS SMASH! - PBS Kids Fighting Game + + + + + + +
+ + + +
+ + + + + + + + + + + + + + + + diff --git a/pbs-smash/style.css b/pbs-smash/style.css new file mode 100644 index 0000000..12508dc --- /dev/null +++ b/pbs-smash/style.css @@ -0,0 +1,474 @@ +@import url('https://fonts.googleapis.com/css2?family=Fredoka+One&family=Nunito:wght@700;900&display=swap'); + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + background: #1a0a2e; + overflow: hidden; + font-family: 'Nunito', sans-serif; + color: white; + width: 100vw; + height: 100vh; +} + +#gameCanvas { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 0; +} + +.screen { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + z-index: 10; + transition: opacity 0.3s; +} + +.screen.hidden { + display: none; +} + +/* Game screen must not block the canvas */ +#gameScreen { + pointer-events: none; +} + +/* TITLE SCREEN */ +.title-logo h1 { + font-family: 'Fredoka One', cursive; + font-size: 6rem; + text-align: center; + color: #4fc3f7; + text-shadow: + 4px 4px 0 #1565c0, + -2px -2px 0 #1565c0, + 2px -2px 0 #1565c0, + -2px 2px 0 #1565c0, + 0 6px 20px rgba(0,0,0,0.5); + letter-spacing: 4px; + animation: titleBounce 2s ease-in-out infinite; +} + +.smash-text { + color: #ff6f00; + font-size: 7rem; + text-shadow: + 4px 4px 0 #bf360c, + -2px -2px 0 #bf360c, + 2px -2px 0 #bf360c, + -2px 2px 0 #bf360c, + 0 0 40px rgba(255,111,0,0.6); +} + +@keyframes titleBounce { + 0%, 100% { transform: translateY(0); } + 50% { transform: translateY(-10px); } +} + +.subtitle { + font-size: 1.4rem; + color: #b39ddb; + text-align: center; + margin-top: 8px; + letter-spacing: 6px; + text-transform: uppercase; +} + +.menu-buttons { + margin-top: 40px; + display: flex; + flex-direction: column; + gap: 16px; +} + +.menu-btn { + font-family: 'Fredoka One', cursive; + font-size: 1.5rem; + padding: 14px 60px; + border: none; + border-radius: 50px; + background: linear-gradient(135deg, #ff6f00, #ff8f00); + color: white; + cursor: pointer; + text-shadow: 2px 2px 0 rgba(0,0,0,0.3); + box-shadow: 0 6px 0 #e65100, 0 8px 20px rgba(0,0,0,0.4); + transition: all 0.1s; + letter-spacing: 2px; +} + +.menu-btn:hover { + transform: translateY(-2px); + box-shadow: 0 8px 0 #e65100, 0 10px 24px rgba(0,0,0,0.4); +} + +.menu-btn:active { + transform: translateY(4px); + box-shadow: 0 2px 0 #e65100, 0 4px 10px rgba(0,0,0,0.4); +} + +.menu-btn:disabled { + background: #555; + box-shadow: 0 6px 0 #333; + cursor: not-allowed; +} + +.menu-btn.small { + font-size: 1rem; + padding: 10px 30px; + background: linear-gradient(135deg, #7c4dff, #651fff); + box-shadow: 0 6px 0 #4527a0, 0 8px 20px rgba(0,0,0,0.4); +} + +.menu-btn.small:hover { + box-shadow: 0 8px 0 #4527a0, 0 10px 24px rgba(0,0,0,0.4); +} + +.footer-text { + position: absolute; + bottom: 30px; + color: #7c4dff; + font-size: 0.9rem; + letter-spacing: 3px; + opacity: 0.7; +} + +/* HOW TO PLAY */ +#howToPlay h2 { + font-family: 'Fredoka One', cursive; + font-size: 2.5rem; + color: #4fc3f7; + margin-bottom: 30px; +} + +.controls-grid { + display: flex; + gap: 60px; + margin-bottom: 30px; +} + +.control-column { + background: rgba(255,255,255,0.08); + border-radius: 20px; + padding: 24px 32px; + border: 2px solid rgba(255,255,255,0.1); +} + +.control-column h3 { + font-family: 'Fredoka One', cursive; + color: #ff8f00; + margin-bottom: 12px; + font-size: 1.3rem; +} + +.control-column p { + margin: 8px 0; + font-size: 1.1rem; +} + +kbd { + background: rgba(255,255,255,0.15); + border: 1px solid rgba(255,255,255,0.3); + border-radius: 6px; + padding: 3px 10px; + font-family: 'Nunito', sans-serif; + font-weight: 900; + font-size: 1rem; +} + +.tip { + color: #b39ddb; + font-size: 1rem; + margin-bottom: 24px; + max-width: 500px; + text-align: center; +} + +/* CHARACTER SELECT */ +#characterSelect h2 { + font-family: 'Fredoka One', cursive; + font-size: 2.2rem; + color: #4fc3f7; + margin-bottom: 20px; +} + +.select-container { + display: flex; + align-items: flex-start; + gap: 30px; + margin-bottom: 20px; +} + +.player-select { + width: 160px; + text-align: center; +} + +.player-select h3 { + font-family: 'Fredoka One', cursive; + color: #ff8f00; + margin-bottom: 10px; +} + +.char-preview { + width: 140px; + height: 160px; + background: rgba(255,255,255,0.06); + border-radius: 16px; + border: 3px solid rgba(255,255,255,0.15); + display: flex; + align-items: center; + justify-content: center; + font-size: 5rem; + margin: 0 auto; +} + +#p1Select .char-preview { border-color: rgba(79,195,247,0.5); } +#p2Select .char-preview { border-color: rgba(255,111,0,0.5); } + +.char-name { + margin-top: 8px; + font-family: 'Fredoka One', cursive; + font-size: 1rem; + color: #b39ddb; +} + +.character-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + max-width: 420px; +} + +.char-cell { + width: 92px; + height: 92px; + background: rgba(255,255,255,0.06); + border-radius: 14px; + border: 3px solid rgba(255,255,255,0.12); + cursor: pointer; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + transition: all 0.15s; + font-size: 2.6rem; + position: relative; +} + +.char-cell:hover { + background: rgba(255,255,255,0.12); + transform: scale(1.08); + border-color: rgba(255,255,255,0.3); +} + +.char-cell.selected-p1 { + border-color: #4fc3f7; + background: rgba(79,195,247,0.15); + box-shadow: 0 0 20px rgba(79,195,247,0.3); +} + +.char-cell.selected-p2 { + border-color: #ff6f00; + background: rgba(255,111,0,0.15); + box-shadow: 0 0 20px rgba(255,111,0,0.3); +} + +.char-cell.selected-p1.selected-p2 { + border-color: #ab47bc; + box-shadow: 0 0 20px rgba(171,71,188,0.4); +} + +.char-cell-name { + font-size: 0.55rem; + font-weight: 900; + color: rgba(255,255,255,0.7); + margin-top: 2px; + text-align: center; + line-height: 1; +} + +/* STAGE SELECT */ +.stage-select { + margin-bottom: 16px; + text-align: center; +} + +.stage-select h3 { + font-family: 'Fredoka One', cursive; + color: #ff8f00; + margin-bottom: 10px; +} + +.stage-options { + display: flex; + gap: 12px; + justify-content: center; +} + +.stage-option { + padding: 10px 20px; + border-radius: 12px; + background: rgba(255,255,255,0.06); + border: 2px solid rgba(255,255,255,0.12); + cursor: pointer; + font-family: 'Fredoka One', cursive; + font-size: 0.85rem; + color: white; + transition: all 0.15s; +} + +.stage-option:hover { + background: rgba(255,255,255,0.12); +} + +.stage-option.selected { + border-color: #7c4dff; + background: rgba(124,77,255,0.2); + box-shadow: 0 0 16px rgba(124,77,255,0.3); +} + +#characterSelect .menu-btn { + margin-top: 6px; +} + +/* HUD */ +.hud { + position: fixed; + bottom: 0; + left: 0; + right: 0; + display: flex; + justify-content: space-between; + align-items: flex-end; + padding: 16px 40px; + z-index: 20; + pointer-events: none; +} + +.hud-player { + background: rgba(0,0,0,0.6); + backdrop-filter: blur(10px); + border-radius: 16px; + padding: 12px 24px; + min-width: 180px; + text-align: center; + border: 2px solid rgba(255,255,255,0.1); +} + +#hudP1 { border-color: rgba(79,195,247,0.4); } +#hudP2 { border-color: rgba(255,111,0,0.4); } + +.hud-name { + font-family: 'Fredoka One', cursive; + font-size: 1rem; + color: #b39ddb; + margin-bottom: 4px; +} + +.hud-damage { + font-family: 'Fredoka One', cursive; + font-size: 2.4rem; + color: white; + text-shadow: 0 0 20px rgba(255,255,255,0.3); + transition: color 0.2s; +} + +.hud-damage.high { + color: #ff5252; + text-shadow: 0 0 20px rgba(255,82,82,0.5); +} + +.hud-damage.medium { + color: #ffab40; + text-shadow: 0 0 20px rgba(255,171,64,0.4); +} + +.hud-stocks { + margin-top: 6px; + font-size: 1.2rem; + letter-spacing: 4px; + min-height: 1.5rem; +} + +.hud-center { + text-align: center; +} + +.hud-timer { + font-family: 'Fredoka One', cursive; + font-size: 2rem; + color: white; + background: rgba(0,0,0,0.5); + border-radius: 50%; + width: 64px; + height: 64px; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid rgba(255,255,255,0.15); +} + +/* RESULTS */ +#resultsScreen { + background: rgba(10,5,25,0.85); +} + +#resultsScreen h2 { + font-family: 'Fredoka One', cursive; + font-size: 3rem; + color: #ff8f00; + text-shadow: 3px 3px 0 rgba(0,0,0,0.4); + margin-bottom: 20px; +} + +.winner-display { + font-size: 6rem; + margin-bottom: 20px; + animation: winnerBounce 0.6s ease-in-out infinite alternate; +} + +@keyframes winnerBounce { + from { transform: scale(1) rotate(-3deg); } + to { transform: scale(1.1) rotate(3deg); } +} + +.result-buttons { + display: flex; + gap: 16px; + flex-wrap: wrap; + justify-content: center; +} + +/* ANNOUNCER TEXT */ +.announcer { + position: fixed; + top: 40%; + left: 50%; + transform: translate(-50%, -50%); + font-family: 'Fredoka One', cursive; + font-size: 5rem; + color: white; + text-shadow: 4px 4px 0 rgba(0,0,0,0.5), 0 0 40px rgba(255,111,0,0.5); + z-index: 100; + pointer-events: none; + animation: announceIn 0.3s ease-out; +} + +@keyframes announceIn { + from { transform: translate(-50%, -50%) scale(3); opacity: 0; } + to { transform: translate(-50%, -50%) scale(1); opacity: 1; } +} From 7efebc1b2b1fbe06f3f03321210a6565f2e51350 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 10 Jun 2026 14:24:01 +0000 Subject: [PATCH 2/2] Replace emoji with hand-drawn vector sprites; add 1-player CPU mode - 8 custom canvas-drawn fighters with legally distinct parody names: ARTY, BLASTER, D-DUB, BIG RED, JORGE, THESAURA, GIGABYTE, BALDWIN - Sprites render in-game with squash/stretch, and are pre-rendered to offscreen canvases for the character select, results, and indicators - New 1 PLAYER mode: CPU opponent with approach/attack/special logic, edge awareness, and off-stage recovery - Title screen now offers 1 PLAYER / 2 PLAYERS https://claude.ai/code/session_01XxvSNaJEdasGJTmW8VYZuv --- pbs-smash/game.js | 402 ++++++++++++++++++++++++++++++++++++------- pbs-smash/index.html | 9 +- pbs-smash/style.css | 21 ++- 3 files changed, 365 insertions(+), 67 deletions(-) diff --git a/pbs-smash/game.js b/pbs-smash/game.js index 7f1d55e..18067f2 100644 --- a/pbs-smash/game.js +++ b/pbs-smash/game.js @@ -4,65 +4,284 @@ // ============================================================ // ---------- CHARACTER ROSTER ---------- +// All fighters are 100% legally distinct original characters. Any +// resemblance to beloved public-television icons is purely educational. const CHARACTERS = [ { - id: 'arthur', name: 'ARTHUR', emoji: '🐵', show: 'Arthur', - color: '#ffca28', + id: 'arthur', name: 'ARTY', tagline: 'A bespectacled aardvark who loves the library', + color: '#ffca28', size: 1.0, speed: 5.2, jump: 14, weight: 1.0, power: 1.0, special: 'book', specialName: 'Library Card Toss', taunt: 'Having fun isn\'t hard, with a library card!' }, { - id: 'buster', name: 'BUSTER', emoji: '🐰', show: 'Arthur', - color: '#e0e0e0', + id: 'buster', name: 'BLASTER', tagline: 'A rabbit who believes in aliens', + color: '#e0e0e0', size: 1.0, speed: 5.0, jump: 16.5, weight: 0.85, power: 0.9, special: 'carrot', specialName: 'Carrot Lob', taunt: 'Aliens are totally real.' }, { - id: 'dw', name: 'D.W.', emoji: '👧', show: 'Arthur', - color: '#f48fb1', + id: 'dw', name: 'D-DUB', tagline: 'A little sister with a big attitude', + color: '#f48fb1', size: 0.92, speed: 6.0, jump: 14.5, weight: 0.75, power: 0.85, special: 'tantrum', specialName: 'Tantrum Spin', taunt: 'I\'m telling MOM!' }, { - id: 'clifford', name: 'CLIFFORD', emoji: '🐶', show: 'Clifford', - color: '#e53935', + id: 'clifford', name: 'BIG RED', tagline: 'An enormous crimson canine', + color: '#e53935', size: 1.18, speed: 3.8, jump: 12, weight: 1.5, power: 1.45, special: 'bark', specialName: 'BIG RED BARK', taunt: 'Woof.' }, { - id: 'george', name: 'GEORGE', emoji: '🐒', show: 'Curious George', - color: '#8d6e63', + id: 'george', name: 'JORGE', tagline: 'A monkey with too many questions', + color: '#8d6e63', size: 0.95, speed: 5.8, jump: 16, weight: 0.8, power: 0.9, special: 'banana', specialName: 'Banana Bounce', taunt: '*curious monkey noises*' }, { - id: 'wordgirl', name: 'WORDGIRL', emoji: '🦸', show: 'WordGirl', - color: '#d32f2f', + id: 'wordgirl', name: 'THESAURA', tagline: 'A superhero with a synonym for everything', + color: '#d32f2f', size: 1.0, speed: 6.5, jump: 15, weight: 0.9, power: 1.05, special: 'dash', specialName: 'Vocabulary Velocity', taunt: 'WORD UP!' }, { - id: 'digit', name: 'DIGIT', emoji: '🐦', show: 'Cyberchase', - color: '#26c6da', + id: 'digit', name: 'GIGABYTE', tagline: 'A cybernetic bird from inside the internet', + color: '#ab47bc', size: 1.0, speed: 5.5, jump: 15.5, weight: 0.85, power: 0.95, special: 'zap', specialName: 'Cyber Beak Bolt', taunt: 'Yoiks!' }, { - id: 'caillou', name: 'CAILLOU', emoji: '👦', show: 'Caillou', - color: '#fff176', + id: 'caillou', name: 'BALDWIN', tagline: 'A bald four-year-old with unresolved anger', + color: '#fdd835', size: 0.95, speed: 4.8, jump: 13.5, weight: 0.7, power: 1.15, special: 'scream', specialName: 'The Tantrum Heard Round The World', taunt: '*whining intensifies*' }, ]; +// ---------- CHARACTER SPRITES ---------- +// Hand-drawn vector fighters. Each draws facing RIGHT in a local space +// with feet at (0,0), head up around y=-160, width roughly ±45. +const SPRITES = (() => { + const TAU = Math.PI * 2; + function ell(c, x, y, rx, ry, fill) { + c.fillStyle = fill; + c.beginPath(); c.ellipse(x, y, rx, ry, 0, 0, TAU); c.fill(); + } + function rr(c, x, y, w, h, r, fill) { + c.fillStyle = fill; + c.beginPath(); + c.moveTo(x + r, y); + c.arcTo(x + w, y, x + w, y + h, r); + c.arcTo(x + w, y + h, x, y + h, r); + c.arcTo(x, y + h, x, y, r); + c.arcTo(x, y, x + w, y, r); + c.closePath(); c.fill(); + } + function eyes(c, x, y, gap, r = 4) { + ell(c, x - gap, y, r + 2, r + 3, '#fff'); + ell(c, x + gap, y, r + 2, r + 3, '#fff'); + ell(c, x - gap + 1.5, y + 0.5, r - 1, r, '#222'); + ell(c, x + gap + 1.5, y + 0.5, r - 1, r, '#222'); + } + function smile(c, x, y, r, color = '#5d4037') { + c.strokeStyle = color; c.lineWidth = 2.5; c.lineCap = 'round'; + c.beginPath(); c.arc(x, y, r, 0.15 * Math.PI, 0.85 * Math.PI); c.stroke(); + } + function star(c, x, y, r, fill) { + c.fillStyle = fill; + c.beginPath(); + for (let i = 0; i < 10; i++) { + const a = -Math.PI / 2 + i * Math.PI / 5; + const rad = i % 2 === 0 ? r : r * 0.45; + c[i ? 'lineTo' : 'moveTo'](x + Math.cos(a) * rad, y + Math.sin(a) * rad); + } + c.closePath(); c.fill(); + } + + return { + // ARTY — tan aardvark, round ears, yellow sweater, round glasses + arthur(c) { + const skin = '#eec9a2', sweater = '#fbc02d'; + ell(c, -11, -3, 10, 5, '#6d4c41'); ell(c, 13, -3, 10, 5, '#6d4c41'); + rr(c, -18, -30, 14, 28, 4, '#5c7fbe'); rr(c, 4, -30, 14, 28, 4, '#5c7fbe'); + rr(c, -32, -76, 10, 34, 5, sweater); rr(c, 22, -76, 10, 34, 5, sweater); + ell(c, -27, -41, 6, 6, skin); ell(c, 27, -41, 6, 6, skin); + rr(c, -23, -80, 46, 52, 12, sweater); + c.fillStyle = '#fff'; + c.beginPath(); c.moveTo(-10, -80); c.lineTo(0, -70); c.lineTo(10, -80); c.closePath(); c.fill(); + ell(c, -15, -120, 9, 10, skin); ell(c, 15, -120, 9, 10, skin); + ell(c, -15, -120, 4.5, 5.5, '#d9a87c'); ell(c, 15, -120, 4.5, 5.5, '#d9a87c'); + ell(c, 0, -97, 26, 24, skin); + c.strokeStyle = '#7a5230'; c.lineWidth = 3; + c.beginPath(); c.arc(-9, -99, 8, 0, TAU); c.stroke(); + c.beginPath(); c.arc(10, -99, 8, 0, TAU); c.stroke(); + c.beginPath(); c.moveTo(-1, -99); c.lineTo(2, -99); c.stroke(); + ell(c, -8, -99, 2.5, 3, '#222'); ell(c, 11, -99, 2.5, 3, '#222'); + ell(c, 1, -88, 4, 3, '#8d6e63'); + smile(c, 0, -86, 7); + }, + + // BLASTER — pale rabbit, long ears, orange shirt + buster(c) { + const fur = '#ececec', inner = '#f8bbd0', shirt = '#ef5350'; + ell(c, -11, -3, 10, 5, '#fafafa'); ell(c, 13, -3, 10, 5, '#fafafa'); + c.strokeStyle = '#bdbdbd'; c.lineWidth = 2; + c.beginPath(); c.moveTo(-19, -4); c.lineTo(-3, -4); c.moveTo(5, -4); c.lineTo(21, -4); c.stroke(); + rr(c, -18, -30, 14, 28, 4, '#5c7fbe'); rr(c, 4, -30, 14, 28, 4, '#5c7fbe'); + rr(c, -32, -76, 10, 34, 5, shirt); rr(c, 22, -76, 10, 34, 5, shirt); + ell(c, -27, -41, 6, 6, fur); ell(c, 27, -41, 6, 6, fur); + rr(c, -23, -80, 46, 52, 12, shirt); + rr(c, -16, -158, 13, 48, 7, fur); rr(c, 4, -160, 13, 48, 7, fur); + rr(c, -12.5, -152, 6, 34, 3, inner); rr(c, 7.5, -154, 6, 34, 3, inner); + ell(c, 0, -97, 26, 25, fur); + eyes(c, 1, -100, 9, 4); + ell(c, 1, -88, 3.5, 2.5, '#e57373'); + smile(c, 0, -86, 7, '#9e9e9e'); + c.strokeStyle = '#bdbdbd'; c.lineWidth = 1.5; + c.beginPath(); c.moveTo(5, -88); c.lineTo(18, -91); c.moveTo(5, -86); c.lineTo(18, -84); c.stroke(); + }, + + // D-DUB — small aardvark, pink dress, hair bow + dw(c) { + const skin = '#eec9a2', dress = '#f06292'; + ell(c, -10, -3, 9, 5, '#d81b60'); ell(c, 12, -3, 9, 5, '#d81b60'); + rr(c, -14, -28, 10, 26, 4, skin); rr(c, 4, -28, 10, 26, 4, skin); + c.fillStyle = dress; + c.beginPath(); c.moveTo(-26, -28); c.lineTo(26, -28); c.lineTo(15, -78); c.lineTo(-15, -78); c.closePath(); c.fill(); + rr(c, -28, -74, 9, 30, 5, dress); rr(c, 19, -74, 9, 30, 5, dress); + ell(c, -24, -42, 5.5, 5.5, skin); ell(c, 24, -42, 5.5, 5.5, skin); + ell(c, 0, -78, 13, 6, '#fff'); + ell(c, -12, -114, 7, 8, skin); ell(c, 12, -114, 7, 8, skin); + ell(c, -12, -114, 3.5, 4.5, '#d9a87c'); ell(c, 12, -114, 3.5, 4.5, '#d9a87c'); + ell(c, 0, -94, 23, 22, skin); + c.fillStyle = '#ec407a'; + c.beginPath(); c.moveTo(14, -112); c.lineTo(24, -118); c.lineTo(22, -106); c.closePath(); c.fill(); + c.beginPath(); c.moveTo(14, -112); c.lineTo(6, -119); c.lineTo(8, -106); c.closePath(); c.fill(); + ell(c, 14, -112, 3, 3, '#c2185b'); + eyes(c, 2, -96, 8, 3.5); + ell(c, 1, -86, 3.5, 2.5, '#8d6e63'); + smile(c, 0, -84, 6); + }, + + // BIG RED — enormous crimson canine + clifford(c) { + const red = '#e53935', dark = '#b71c1c'; + c.strokeStyle = red; c.lineWidth = 8; c.lineCap = 'round'; + c.beginPath(); c.moveTo(-32, -50); c.quadraticCurveTo(-48, -62, -44, -80); c.stroke(); + rr(c, -34, -28, 17, 26, 7, red); rr(c, 16, -28, 17, 26, 7, red); + ell(c, -25, -3, 11, 5, dark); ell(c, 25, -3, 11, 5, dark); + ell(c, 0, -56, 37, 33, red); + ell(c, 6, -105, 30, 27, red); + ell(c, -20, -108, 8, 16, dark); + ell(c, 26, -122, 8, 14, dark); + ell(c, 28, -97, 14, 11, red); + ell(c, 39, -98, 6.5, 5.5, '#212121'); + eyes(c, 6, -112, 9, 4); + smile(c, 26, -92, 7, '#7f0000'); + }, + + // JORGE — curious brown monkey + george(c) { + const fur = '#795548', face = '#e7c39a'; + ell(c, -11, -3, 10, 5, fur); ell(c, 13, -3, 10, 5, fur); + rr(c, -17, -28, 13, 26, 5, fur); rr(c, 4, -28, 13, 26, 5, fur); + rr(c, -33, -76, 10, 38, 5, fur); rr(c, 23, -76, 10, 38, 5, fur); + ell(c, -28, -37, 6, 6, face); ell(c, 28, -37, 6, 6, face); + rr(c, -22, -78, 44, 52, 14, fur); + ell(c, 0, -52, 14, 18, face); + ell(c, -24, -100, 8, 9, fur); ell(c, 24, -100, 8, 9, fur); + ell(c, -24, -100, 4, 5, face); ell(c, 24, -100, 4, 5, face); + ell(c, 0, -98, 25, 23, fur); + ell(c, 2, -93, 18, 16, face); + eyes(c, 2, -99, 7, 3.5); + ell(c, 0, -89, 1.5, 1.2, '#4e342e'); ell(c, 5, -89, 1.5, 1.2, '#4e342e'); + smile(c, 2, -89, 9, '#4e342e'); + }, + + // THESAURA — caped superhero with a star emblem + wordgirl(c) { + const skin = '#9c6b3f', suit = '#d32f2f', accent = '#fbc02d', capeC = '#b71c1c'; + c.fillStyle = capeC; + c.beginPath(); c.moveTo(-16, -80); c.lineTo(-32, -14); c.lineTo(6, -22); c.lineTo(14, -80); c.closePath(); c.fill(); + rr(c, -16, -30, 12, 24, 4, suit); rr(c, 4, -30, 12, 24, 4, suit); + rr(c, -17, -10, 14, 9, 4, capeC); rr(c, 3, -10, 14, 9, 4, capeC); + rr(c, -30, -76, 9, 34, 5, suit); rr(c, 21, -76, 9, 34, 5, suit); + ell(c, -25, -40, 5.5, 5.5, skin); ell(c, 25, -40, 5.5, 5.5, skin); + rr(c, -20, -80, 40, 52, 10, suit); + rr(c, -20, -39, 40, 8, 3, accent); + star(c, 0, -62, 10, accent); + ell(c, 0, -98, 22, 22, skin); + c.fillStyle = '#1b1b1b'; + c.beginPath(); c.arc(0, -100, 23, Math.PI, 0); c.closePath(); c.fill(); + ell(c, -19, -90, 6, 11, '#1b1b1b'); + eyes(c, 3, -95, 7.5, 3.5); + smile(c, 2, -88, 6, '#4e342e'); + }, + + // GIGABYTE — cybernetic bird with a giant beak + digit(c) { + const body = '#ab47bc', belly = '#ce93d8', beak = '#fb8c00', metal = '#26c6da'; + c.fillStyle = body; + c.beginPath(); c.moveTo(-22, -50); c.lineTo(-42, -64); c.lineTo(-38, -42); c.closePath(); c.fill(); + rr(c, -11, -22, 5, 22, 2, beak); rr(c, 7, -22, 5, 22, 2, beak); + ell(c, -7, -2, 9, 3.5, beak); ell(c, 11, -2, 9, 3.5, beak); + ell(c, 0, -52, 26, 31, body); + ell(c, 3, -47, 15, 21, belly); + ell(c, -17, -55, 10, 17, metal); + ell(c, 2, -94, 21, 19, body); + c.strokeStyle = metal; c.lineWidth = 2.5; + c.beginPath(); c.moveTo(0, -110); c.lineTo(-5, -126); c.stroke(); + ell(c, -5, -128, 4.5, 4.5, metal); + eyes(c, 4, -98, 8, 4.5); + c.fillStyle = beak; + c.beginPath(); c.moveTo(15, -96); c.lineTo(43, -90); c.lineTo(15, -84); c.closePath(); c.fill(); + c.fillStyle = '#e65100'; + c.beginPath(); c.moveTo(15, -83); c.lineTo(34, -82); c.lineTo(15, -77); c.closePath(); c.fill(); + }, + + // BALDWIN — bald kid, yellow shirt, perpetually upset + caillou(c) { + const skin = '#ffd6b0', shirt = '#fdd835', trim = '#e53935', shorts = '#3f51b5'; + ell(c, -11, -3, 10, 5, '#fafafa'); ell(c, 13, -3, 10, 5, '#fafafa'); + rr(c, -16, -28, 12, 26, 4, skin); rr(c, 4, -28, 12, 26, 4, skin); + rr(c, -18, -46, 36, 19, 6, shorts); + rr(c, -29, -74, 9, 32, 5, shirt); rr(c, 20, -74, 9, 32, 5, shirt); + ell(c, -24, -40, 5.5, 5.5, skin); ell(c, 24, -40, 5.5, 5.5, skin); + rr(c, -20, -80, 40, 40, 10, shirt); + rr(c, -20, -80, 40, 7, 3, trim); + ell(c, 0, -102, 24, 23, skin); + ell(c, -23, -100, 4, 6, skin); ell(c, 23, -100, 4, 6, skin); + c.strokeStyle = 'rgba(255,255,255,0.7)'; c.lineWidth = 3; c.lineCap = 'round'; + c.beginPath(); c.arc(-2, -104, 17, 1.25 * Math.PI, 1.6 * Math.PI); c.stroke(); + eyes(c, 2, -102, 7.5, 3.5); + c.strokeStyle = '#5d4037'; c.lineWidth = 2.5; + c.beginPath(); c.moveTo(-9, -112); c.lineTo(-2, -109); c.moveTo(13, -112); c.lineTo(6, -109); c.stroke(); + c.beginPath(); c.arc(2, -84, 6, 1.15 * Math.PI, 1.85 * Math.PI); c.stroke(); + }, + }; +})(); + +// Pre-render each fighter to an offscreen canvas for UI and indicators +const spriteCache = {}; +function buildSpriteCache() { + for (const ch of CHARACTERS) { + const cv = document.createElement('canvas'); + cv.width = 130; cv.height = 185; + const c = cv.getContext('2d'); + c.translate(65, 178); + SPRITES[ch.id](c); + spriteCache[ch.id] = cv; + } +} +buildSpriteCache(); + // ---------- STAGES ---------- const W = 1280, H = 720; const STAGES = [ @@ -108,6 +327,13 @@ let scale = 1, offsetX = 0, offsetY = 0; let p1Char = null, p2Char = null, nextPick = 1; let selectedStage = 0; let game = null; +let vsCPU = false; + +function setMode(cpu) { + vsCPU = cpu; + document.getElementById('p2Heading').textContent = cpu ? 'CPU' : 'Player 2'; + showScreen('characterSelect'); +} const GRAVITY = 0.75; const FRICTION = 0.82; @@ -172,7 +398,8 @@ function buildCharacterSelect() { const cell = document.createElement('div'); cell.className = 'char-cell'; cell.id = 'cell-' + c.id; - cell.innerHTML = `${c.emoji}${c.name}`; + cell.title = c.tagline; + cell.innerHTML = `${c.name}${c.name}`; cell.onclick = () => pickCharacter(i); grid.appendChild(cell); }); @@ -194,14 +421,15 @@ function buildCharacterSelect() { function pickCharacter(i) { const c = CHARACTERS[i]; + const img = `${c.name}`; if (nextPick === 1) { p1Char = i; - document.getElementById('p1Preview').textContent = c.emoji; + document.getElementById('p1Preview').innerHTML = img; document.getElementById('p1Name').textContent = c.name; nextPick = 2; } else { p2Char = i; - document.getElementById('p2Preview').textContent = c.emoji; + document.getElementById('p2Preview').innerHTML = img; document.getElementById('p2Name').textContent = c.name; nextPick = 1; } @@ -215,10 +443,12 @@ function pickCharacter(i) { // ---------- FIGHTER ---------- class Fighter { - constructor(charIndex, playerNum, x) { + constructor(charIndex, playerNum, x, cpu = false) { this.char = CHARACTERS[charIndex]; this.playerNum = playerNum; this.keys = playerNum === 1 ? P1_KEYS : P2_KEYS; + this.cpu = cpu; + this.aiInput = { left: false, right: false, jump: false, attack: false, special: false }; this.w = 56; this.h = 64; this.spawnX = x; @@ -227,6 +457,69 @@ class Fighter { this.damage = 0; } + readInput() { + if (this.cpu) return this.aiInput; + return { + left: !!keys[this.keys.left], + right: !!keys[this.keys.right], + jump: !!keys[this.keys.jump], + attack: !!keys[this.keys.attack], + special: !!keys[this.keys.special], + }; + } + + cpuThink(g) { + const inp = { left: false, right: false, jump: false, attack: false, special: false }; + const foe = g.fighters.find(f => f !== this); + if (this.dead || !foe || foe.dead) { this.aiInput = inp; return; } + + const mains = STAGES[selectedStage].platforms.filter(p => p.main); + const left = Math.min(...mains.map(p => p.x)); + const right = Math.max(...mains.map(p => p.x + p.w)); + const mainY = mains[0].y; + const dx = foe.x - this.x; + const dy = foe.y - this.y; + + const offStage = this.x < left - 5 || this.x > right + 5 || this.y > mainY + 40; + if (offStage) { + // recovery: head back to center, burn jumps while falling + const cx = (left + right) / 2; + if (cx < this.x) inp.left = true; else inp.right = true; + if (this.vy > 1 && this.jumpsLeft > 0 && Math.random() < 0.35) inp.jump = true; + } else { + // approach until in melee range + if (Math.abs(dx) > 55) { + if (dx > 0) inp.right = true; else inp.left = true; + } else if (Math.abs(dx) < 25 && Math.random() < 0.04) { + // occasionally create space + if (dx > 0) inp.left = true; else inp.right = true; + } + // chase upward + if (dy < -90 && this.onGround && Math.random() < 0.1) inp.jump = true; + if (Math.random() < 0.004) inp.jump = true; + // swing when in range (turn toward foe so the hit lands) + if (Math.abs(dx) < 75 && Math.abs(dy) < 65 && Math.random() < 0.12) { + inp.attack = true; + if (dx > 0) inp.right = true; else if (dx < 0) inp.left = true; + } + // specials: projectiles from range, bursts up close + if (this.specialCooldown <= 0 && Math.random() < 0.03) { + const ranged = ['book', 'carrot', 'banana', 'zap'].includes(this.char.special); + const ok = ranged ? Math.abs(dx) > 110 && Math.abs(dy) < 80 : Math.abs(dx) < 150; + if (ok) { + inp.special = true; + if (dx > 0) inp.right = true; else if (dx < 0) inp.left = true; + } + } + // don't walk off the edge unless edge-guarding a nearby foe + const nextX = this.x + (inp.right ? 35 : inp.left ? -35 : 0); + if (this.onGround && (nextX < left + 15 || nextX > right - 15) && Math.abs(dx) > 90) { + inp.left = inp.right = false; + } + } + this.aiInput = inp; + } + respawn(initial = false) { this.x = this.spawnX; this.y = initial ? 300 : 100; @@ -274,13 +567,15 @@ class Fighter { if (inHitstun) this.hitstun--; // --- movement input --- + if (this.cpu) this.cpuThink(g); + const inp = this.readInput(); if (!inHitstun && this.dashTimer <= 0) { const spd = this.char.speed; - if (keys[this.keys.left]) { + if (inp.left) { this.vx -= this.onGround ? spd * 0.3 : spd * 0.12; this.facing = -1; } - if (keys[this.keys.right]) { + if (inp.right) { this.vx += this.onGround ? spd * 0.3 : spd * 0.12; this.facing = 1; } @@ -288,7 +583,7 @@ class Fighter { this.vx = Math.max(-maxSpd, Math.min(maxSpd, this.vx)); // jump (with edge detection so holding doesn't multi-jump) - if (keys[this.keys.jump]) { + if (inp.jump) { if (!this.jumpHeld && this.jumpsLeft > 0) { this.vy = -this.char.jump * (this.jumpsLeft === 2 ? 1 : 0.88); this.jumpsLeft--; @@ -302,13 +597,13 @@ class Fighter { } // attack - if (keys[this.keys.attack] && this.attackCooldown <= 0) { + if (inp.attack && this.attackCooldown <= 0) { this.attackTimer = 10; this.attackCooldown = 24; } // special - if (keys[this.keys.special] && this.specialCooldown <= 0) { + if (inp.special && this.specialCooldown <= 0) { this.doSpecial(g); } } @@ -449,36 +744,13 @@ class Fighter { const px = offsetX + this.x * scale; const py = offsetY + this.y * scale; + // shadow + player ring ctx.save(); ctx.translate(px, py); - - // shadow ctx.fillStyle = 'rgba(0,0,0,0.25)'; ctx.beginPath(); ctx.ellipse(0, 2 * scale, 26 * scale * this.squash, 6 * scale, 0, 0, Math.PI * 2); ctx.fill(); - - // spin rotation for tantrum - if (this.spinTimer > 0) { - ctx.rotate((this.spinTimer * 0.8) % (Math.PI * 2)); - } - - // dash trail - if (this.dashTimer > 0) { - ctx.shadowColor = this.char.color; - ctx.shadowBlur = 30 * scale; - } - - // body (emoji) - ctx.scale(this.facing * (1 / this.squash) * 0.9 + this.facing * 0.1, this.squash); - ctx.font = `${56 * scale}px serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'bottom'; - - // player ring indicator - ctx.restore(); - ctx.save(); - ctx.translate(px, py); ctx.strokeStyle = this.playerNum === 1 ? '#4fc3f7' : '#ff6f00'; ctx.lineWidth = 3 * scale; ctx.beginPath(); @@ -486,14 +758,22 @@ class Fighter { ctx.stroke(); ctx.restore(); + // body sprite (squash stretches height, widens on landing) ctx.save(); - ctx.translate(px, py - (this.h / 2) * scale * this.squash); - if (this.spinTimer > 0) ctx.rotate(this.spinTimer * 0.6); - ctx.scale(this.facing, 1); - ctx.font = `${58 * scale * (2 - this.squash)}px serif`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillText(this.char.emoji, 0, 0); + if (this.spinTimer > 0) { + ctx.translate(px, py - 48 * scale); + ctx.rotate(this.spinTimer * 0.6); + ctx.translate(0, 48 * scale); + } else { + ctx.translate(px, py); + } + if (this.dashTimer > 0) { + ctx.shadowColor = this.char.color; + ctx.shadowBlur = 30 * scale; + } + const sz = 0.48 * this.char.size; + ctx.scale(scale * sz * this.facing * (2 - this.squash), scale * sz * this.squash); + SPRITES[this.char.id](ctx); ctx.restore(); // attack swing visual @@ -577,7 +857,7 @@ class Game { constructor() { this.fighters = [ new Fighter(p1Char, 1, W * 0.32), - new Fighter(p2Char, 2, W * 0.68), + new Fighter(p2Char, 2, W * 0.68, vsCPU), ]; this.projectiles = []; this.particles = []; @@ -589,7 +869,7 @@ class Game { this.countdown = 180; // 3..2..1..GO document.getElementById('hudP1Name').textContent = this.fighters[0].char.name; - document.getElementById('hudP2Name').textContent = this.fighters[1].char.name; + document.getElementById('hudP2Name').textContent = this.fighters[1].char.name + (vsCPU ? ' (CPU)' : ''); this.updateHUD(); } @@ -639,7 +919,7 @@ class Game { const disp = document.getElementById('winnerDisplay'); if (winner) { txt.textContent = `${winner.char.name} WINS!`; - disp.textContent = winner.char.emoji; + disp.innerHTML = `${winner.char.name}`; disp.title = winner.char.taunt; } else { txt.textContent = 'SUDDEN TIE!'; @@ -816,8 +1096,8 @@ class Game { ctx.font = `${30 * scale}px serif`; ctx.textAlign = 'center'; ctx.fillText('🔻', ix, offsetY + 40 * scale); - ctx.font = `${22 * scale}px serif`; - ctx.fillText(f.char.emoji, ix, offsetY + 70 * scale); + const iw = 34 * scale, ih = iw * (185 / 130); + ctx.drawImage(spriteCache[f.char.id], ix - iw / 2, offsetY + 48 * scale, iw, ih); } } } diff --git a/pbs-smash/index.html b/pbs-smash/index.html index 725f824..d95f992 100644 --- a/pbs-smash/index.html +++ b/pbs-smash/index.html @@ -16,10 +16,11 @@

PBS SMASH!

A Very Educational Brawl

- + @@ -56,7 +57,7 @@

Player 1

-

Player 2

+

Player 2

???
diff --git a/pbs-smash/style.css b/pbs-smash/style.css index 12508dc..441784e 100644 --- a/pbs-smash/style.css +++ b/pbs-smash/style.css @@ -141,9 +141,11 @@ body { position: absolute; bottom: 30px; color: #7c4dff; - font-size: 0.9rem; - letter-spacing: 3px; + font-size: 0.8rem; + letter-spacing: 1px; opacity: 0.7; + text-align: center; + line-height: 1.6; } /* HOW TO PLAY */ @@ -292,6 +294,21 @@ kbd { box-shadow: 0 0 20px rgba(171,71,188,0.4); } +.char-cell img { + height: 58px; + filter: drop-shadow(0 3px 4px rgba(0,0,0,0.4)); +} + +.char-preview img { + height: 130px; + filter: drop-shadow(0 4px 8px rgba(0,0,0,0.5)); +} + +.winner-display img { + height: 160px; + filter: drop-shadow(0 6px 12px rgba(0,0,0,0.5)); +} + .char-cell-name { font-size: 0.55rem; font-weight: 900;