Angry Birds, one level, built by Gemini 4 Argon
Hostedvia DaBenchOpenRouter
105tok/s
First token included
38.5Ktokens
6m 7s
$0.19
Show the code the model wroteHide the code
<!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>Angry Slingshot — Physics Fort</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/matter-js/0.19.0/matter.min.js"></script>
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
user-select: none;
-webkit-user-select: none;
}
html, body {
width: 100%;
height: 100%;
overflow: hidden;
background: #5aa9e6;
font-family: 'Trebuchet MS', 'Fredoka One', 'Segoe UI', Impact, sans-serif;
touch-action: none;
}
canvas {
display: block;
width: 100vw;
height: 100vh;
cursor: grab;
}
canvas:active {
cursor: grabbing;
}
/* HUD */
#hud {
position: fixed;
top: 16px;
left: 18px;
right: 18px;
display: flex;
justify-content: space-between;
align-items: flex-start;
pointer-events: none;
z-index: 10;
}
.hud-group {
display: flex;
align-items: center;
gap: 12px;
pointer-events: auto;
}
.btn-icon {
width: 48px;
height: 48px;
border-radius: 50%;
border: 3px solid #4a2810;
background: linear-gradient(180deg, #ffcf4b 0%, #f59f00 100%);
box-shadow: 0 4px 0 #4a2810, inset 0 2px 0 rgba(255,255,255,0.65);
color: #4a2810;
font-size: 22px;
font-weight: 900;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.1s ease;
}
.btn-icon:hover {
transform: scale(1.06);
}
.btn-icon:active {
transform: translateY(2px) scale(0.97);
}
.score-plaque {
background: linear-gradient(180deg, #9c5a25 0%, #6e3b14 100%);
border: 3px solid #3c1e06;
border-radius: 14px;
padding: 8px 18px;
box-shadow: 0 5px 0 #3c1e06, inset 0 2px 0 rgba(255,220,160,0.4);
color: #fff8e7;
text-align: right;
min-width: 155px;
}
.score-label {
font-size: 11px;
letter-spacing: 1.5px;
text-transform: uppercase;
color: #ffd97d;
font-weight: 800;
}
.score-value {
font-size: 26px;
font-weight: 900;
color: #ffffff;
text-shadow: 0 2px 0 #3c1e06;
line-height: 1.05;
}
/* Demo Banner */
#demo-banner {
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: rgba(35, 22, 10, 0.82);
border: 2.5px solid #ffd452;
color: #fff6d6;
padding: 8px 20px;
border-radius: 999px;
font-size: 15px;
font-weight: 800;
letter-spacing: 0.4px;
box-shadow: 0 4px 14px rgba(0,0,0,0.3);
pointer-events: none;
z-index: 11;
transition: opacity 0.3s ease, transform 0.3s ease;
white-space: nowrap;
}
#demo-banner.hidden {
opacity: 0;
transform: translateX(-50%) translateY(-20px);
}
#demo-banner span {
color: #ffd452;
}
/* End-of-Level Modal Overlay */
#overlay {
position: fixed;
inset: 0;
background: rgba(15, 24, 38, 0.58);
backdrop-filter: blur(4px);
display: flex;
align-items: center;
justify-content: center;
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity 0.35s ease;
}
#overlay.visible {
opacity: 1;
pointer-events: auto;
}
.modal-card {
background: linear-gradient(180deg, #fff7df 0%, #f4dfb4 100%);
border: 5px solid #4a2810;
border-radius: 28px;
padding: 30px 42px 32px;
width: min(440px, 90vw);
text-align: center;
box-shadow: 0 12px 0 #4a2810, 0 24px 48px rgba(0,0,0,0.45);
transform: scale(0.85);
transition: transform 0.35s cubic-bezier(0.22, 1.35, 0.5, 1);
}
#overlay.visible .modal-card {
transform: scale(1);
}
.modal-title {
font-size: 38px;
font-weight: 900;
color: #fff;
-webkit-text-stroke: 2px #4a2810;
text-shadow: 0 4px 0 #4a2810;
margin-bottom: 14px;
letter-spacing: 0.5px;
}
.modal-title.win {
color: #ffcf33;
}
.modal-title.lose {
color: #ff6b57;
}
.stars-row {
display: flex;
justify-content: center;
gap: 14px;
margin: 14px 0 18px;
height: 76px;
align-items: center;
}
.star-svg {
width: 66px;
height: 66px;
filter: drop-shadow(0 4px 0 #4a2810);
transform: scale(0);
transition: transform 0.45s cubic-bezier(0.25, 1.6, 0.5, 1);
}
.star-svg.show {
transform: scale(1);
}
.star-svg:nth-child(2).show {
transform: scale(1.18) translateY(-6px);
}
.modal-score-box {
background: rgba(74, 40, 16, 0.1);
border: 2px dashed rgba(74, 40, 16, 0.3);
border-radius: 16px;
padding: 12px 20px;
margin-bottom: 24px;
}
.modal-score-label {
font-size: 13px;
font-weight: 800;
text-transform: uppercase;
color: #7c4d25;
letter-spacing: 1px;
}
.modal-score-num {
font-size: 34px;
font-weight: 900;
color: #4a2810;
}
.btn-restart {
background: linear-gradient(180deg, #68d33b 0%, #3da617 100%);
border: 4px solid #23570b;
border-radius: 999px;
padding: 13px 38px;
font-size: 22px;
font-weight: 900;
color: #ffffff;
text-shadow: 0 2px 0 #23570b;
box-shadow: 0 6px 0 #23570b, inset 0 3px 0 rgba(255,255,255,0.45);
cursor: pointer;
transition: transform 0.1s ease;
}
.btn-restart:hover {
transform: scale(1.05);
}
.btn-restart:active {
transform: translateY(3px) scale(0.98);
box-shadow: 0 3px 0 #23570b;
}
</style>
</head>
<body>
<div id="hud">
<div class="hud-group">
<button class="btn-icon" id="hud-reset-btn" title="Restart Level">↻</button>
</div>
<div class="score-plaque">
<div class="score-label">Score</div>
<div class="score-value" id="hud-score">0</div>
</div>
</div>
<div id="demo-banner">
<span>AUTOPLAY DEMO</span> — Grab the bird in the slingshot to play!
</div>
<div id="overlay">
<div class="modal-card">
<div class="modal-title win" id="modal-title">Level cleared</div>
<div class="stars-row" id="modal-stars">
<svg class="star-svg" id="star-1" viewBox="0 0 64 64">
<polygon points="32,4 40.5,21.5 60,24.2 45.8,37.8 49.3,57 32,47.8 14.7,57 18.2,37.8 4,24.2 23.5,21.5" stroke="#4a2810" stroke-width="4" stroke-linejoin="round"/>
</svg>
<svg class="star-svg" id="star-2" viewBox="0 0 64 64">
<polygon points="32,4 40.5,21.5 60,24.2 45.8,37.8 49.3,57 32,47.8 14.7,57 18.2,37.8 4,24.2 23.5,21.5" stroke="#4a2810" stroke-width="4" stroke-linejoin="round"/>
</svg>
<svg class="star-svg" id="star-3" viewBox="0 0 64 64">
<polygon points="32,4 40.5,21.5 60,24.2 45.8,37.8 49.3,57 32,47.8 14.7,57 18.2,37.8 4,24.2 23.5,21.5" stroke="#4a2810" stroke-width="4" stroke-linejoin="round"/>
</svg>
</div>
<div class="modal-score-box">
<div class="modal-score-label">Final Score</div>
<div class="modal-score-num" id="modal-score">0</div>
</div>
<button class="btn-restart" id="modal-restart-btn">Restart</button>
</div>
</div>
<canvas id="game"></canvas>
<script>
(() => {
const { Engine, World, Bodies, Body, Vector, Events, Sleeping, Composite } = Matter;
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const hudScoreEl = document.getElementById('hud-score');
const hudResetBtn = document.getElementById('hud-reset-btn');
const demoBannerEl = document.getElementById('demo-banner');
const overlayEl = document.getElementById('overlay');
const modalTitleEl = document.getElementById('modal-title');
const modalScoreEl = document.getElementById('modal-score');
const modalRestartBtn = document.getElementById('modal-restart-btn');
const starEls = [
document.getElementById('star-1'),
document.getElementById('star-2'),
document.getElementById('star-3')
];
// =========================================================================
// SOUND SYNTHESIS (Web Audio API - No external files)
// =========================================================================
let audioCtx = null;
function ensureAudio() {
if (!audioCtx) {
const AC = window.AudioContext || window.webkitAudioContext;
if (AC) audioCtx = new AC();
}
if (audioCtx && audioCtx.state === 'suspended') {
audioCtx.resume().catch(() => {});
}
}
function playSound(type, intensity = 1) {
if (!audioCtx || audioCtx.state !== 'running') return;
const now = audioCtx.currentTime;
try {
if (type === 'launch') {
// Slingshot twang + bird battle cry
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(140, now);
osc.frequency.exponentialRampToValueAtTime(520, now + 0.14);
osc.frequency.exponentialRampToValueAtTime(280, now + 0.32);
gain.gain.setValueAtTime(0.22, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.34);
osc.connect(gain).connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.35);
} else if (type === 'wood') {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'square';
osc.frequency.setValueAtTime(160 + Math.random() * 50, now);
osc.frequency.exponentialRampToValueAtTime(55, now + 0.09);
gain.gain.setValueAtTime(Math.min(0.24, 0.08 * intensity), now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.1);
osc.connect(gain).connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.11);
} else if (type === 'stone') {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(110 + Math.random() * 30, now);
osc.frequency.exponentialRampToValueAtTime(42, now + 0.12);
gain.gain.setValueAtTime(Math.min(0.28, 0.1 * intensity), now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.13);
osc.connect(gain).connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.14);
} else if (type === 'pop') {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sine';
osc.frequency.setValueAtTime(260, now);
osc.frequency.exponentialRampToValueAtTime(680, now + 0.08);
osc.frequency.exponentialRampToValueAtTime(180, now + 0.18);
gain.gain.setValueAtTime(0.28, now);
gain.gain.exponentialRampToValueAtTime(0.001, now + 0.19);
osc.connect(gain).connect(audioCtx.destination);
osc.start(now);
osc.stop(now + 0.2);
} else if (type === 'win') {
[523.25, 659.25, 783.99, 1046.5].forEach((freq, idx) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'triangle';
osc.frequency.setValueAtTime(freq, now + idx * 0.11);
gain.gain.setValueAtTime(0.18, now + idx * 0.11);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.11 + 0.35);
osc.connect(gain).connect(audioCtx.destination);
osc.start(now + idx * 0.11);
osc.stop(now + idx * 0.11 + 0.36);
});
} else if (type === 'lose') {
[320, 290, 260, 210].forEach((freq, idx) => {
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = 'sawtooth';
osc.frequency.setValueAtTime(freq, now + idx * 0.16);
gain.gain.setValueAtTime(0.12, now + idx * 0.16);
gain.gain.exponentialRampToValueAtTime(0.001, now + idx * 0.16 + 0.28);
osc.connect(gain).connect(audioCtx.destination);
osc.start(now + idx * 0.16);
osc.stop(now + idx * 0.16 + 0.3);
});
}
} catch (e) {}
}
// =========================================================================
// WORLD CONSTANTS & GEOMETRY
// =========================================================================
const GROUND_Y = 600;
const SLING_X = 285;
const SLING_Y = GROUND_Y - 128; // 472 (fork pouch center)
const MAX_PULL = 132;
const MIN_PULL = 18;
const LAUNCH_POWER = 0.228; // maps pull vector to Matter.js velocity
const BIRD_RADIUS = 22;
const FIXED_DT = 1000 / 60;
// Camera state in virtual world space
// Default view covers x = 55 .. 1445 (width 1390), height ~ 740
const camera = {
x: 750,
y: 355,
viewWidth: 1410,
targetX: 750,
targetY: 355,
targetViewWidth: 1410,
scale: 1,
offsetX: 0,
offsetY: 0
};
let dpr = 1;
let screenW = window.innerWidth;
let screenH = window.innerHeight;
function resizeCanvas() {
dpr = Math.min(window.devicePixelRatio || 1, 2);
screenW = window.innerWidth;
screenH = window.innerHeight;
canvas.width = Math.floor(screenW * dpr);
canvas.height = Math.floor(screenH * dpr);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
function updateCameraTransform() {
camera.x += (camera.targetX - camera.x) * 0.08;
camera.y += (camera.targetY - camera.y) * 0.08;
camera.viewWidth += (camera.targetViewWidth - camera.viewWidth) * 0.08;
const scaleX = screenW / camera.viewWidth;
const scaleY = screenH / 730;
camera.scale = Math.min(scaleX, scaleY);
camera.offsetX = screenW * 0.5 - camera.x * camera.scale;
camera.offsetY = screenH * 0.54 - camera.y * camera.scale;
// Clamp so ground is always nicely near the bottom of the screen
const groundScreenY = GROUND_Y * camera.scale + camera.offsetY;
const minGroundScreenY = screenH * 0.80;
const maxGroundScreenY = screenH * 0.88;
if (groundScreenY < minGroundScreenY) {
camera.offsetY += (minGroundScreenY - groundScreenY);
} else if (groundScreenY > maxGroundScreenY) {
camera.offsetY += (maxGroundScreenY - groundScreenY);
}
}
function screenToWorld(sx, sy) {
return {
x: (sx - camera.offsetX) / camera.scale,
y: (sy - camera.offsetY) / camera.scale
};
}
// =========================================================================
// DECORATIVE BACKGROUND ELEMENTS
// =========================================================================
const clouds = [
{ x: 180, y: 110, s: 1.1, speed: 0.14 },
{ x: 520, y: 75, s: 0.85, speed: 0.1 },
{ x: 920, y: 125, s: 1.25, speed: 0.16 },
{ x: 1340, y: 85, s: 0.95, speed: 0.12 },
{ x: 1700, y: 115, s: 1.05, speed: 0.15 }
];
const grassTufts = [];
for (let gx = -200; gx < 1900; gx += 26) {
grassTufts.push({
x: gx + (Math.sin(gx * 0.7) * 8),
h: 9 + (Math.abs(Math.cos(gx * 1.3)) * 8),
lean: Math.sin(gx * 0.3) * 4
});
}
// =========================================================================
// GAME & PHYSICS STATE
// =========================================================================
let engine = null;
let world = null;
let groundBody = null;
let fortBlocks = [];
let pigs = [];
let activeBird = null;
let launchedBirds = [];
let waitingBirds = []; // { x, y, hopPhase, hopOffset, targetX }
// Slingshot interaction state
let slingState = 'aiming'; // 'aiming' | 'flying' | 'settling' | 'gameover'
let isDragging = false;
let pullPos = { x: SLING_X, y: SLING_Y };
let fortAwake = false;
let initialFortPose = new Map();
// Score & progression
let score = 0;
let displayedScore = 0;
let birdsRemaining = 3; // includes the one currently in the slingshot
let settleTimer = 0;
let flightTimer = 0;
let birdHasHit = false;
// Particles & trails
let flightTrail = [];
let particles = [];
let floatingScores = [];
// Demo mode state
let isDemo = true;
let demoTimer = 0;
let demoShotIndex = 0;
const demoShots = [
// Shot 1: Hits the lower-left/center junction for a dramatic collapse
{ dx: -116, dy: 52 },
// Shot 2: High arc into the upper center/right tower
{ dx: -124, dy: 66 },
// Shot 3: Direct strike at the right bay
{ dx: -128, dy: 58 }
];
// =========================================================================
// FORT & LEVEL INITIALIZATION
// =========================================================================
function createWoodGrainSeed(w, h) {
const lines = [];
const count = Math.max(2, Math.floor(Math.min(w, h) / 7));
for (let i = 0; i < count; i++) {
lines.push({
offset: ((i + 0.5) / count - 0.5) * 0.78,
wave: (Math.random() - 0.5) * 3
});
}
return {
lines,
knot: Math.random() > 0.35 ? {
u: (Math.random() - 0.5) * 0.5,
v: (Math.random() - 0.5) * 0.4,
r: 2.5 + Math.random() * 2.5
} : null
};
}
function addBlock(x, y, w, h, material) {
const isStone = material === 'stone';
const maxHp = isStone ? 165 : 58;
const body = Bodies.rectangle(x, y, w, h, {
density: isStone ? 0.0042 : 0.0018,
friction: isStone ? 0.78 : 0.68,
frictionStatic: isStone ? 0.92 : 0.85,
restitution: isStone ? 0.06 : 0.10,
frictionAir: 0.006,
slop: 0.01
});
body.gameType = 'block';
body.material = material;
body.w = w;
body.h = h;
body.hp = maxHp;
body.maxHp = maxHp;
body.woodData = !isStone ? createWoodGrainSeed(w, h) : null;
body.preVel = { x: 0, y: 0 };
Sleeping.set(body, true);
initialFortPose.set(body.id, { x, y, angle: 0 });
fortBlocks.push(body);
World.add(world, body);
return body;
}
function addPig(x, y, r, role = 'minion') {
const maxHp = role === 'boss' ? 34 : 24;
const body = Bodies.circle(x, y, r, {
density: 0.0016,
friction: 0.55,
frictionStatic: 0.75,
restitution: 0.22,
frictionAir: 0.006,
slop: 0.01
});
body.gameType = 'pig';
body.r = r;
body.role = role;
body.hp = maxHp;
body.maxHp = maxHp;
body.blinkTimer = Math.random() * 180;
body.scaredTimer = 0;
body.preVel = { x: 0, y: 0 };
Sleeping.set(body, true);
initialFortPose.set(body.id, { x, y, angle: 0 });
pigs.push(body);
World.add(world, body);
return body;
}
function buildFort() {
fortBlocks = [];
pigs = [];
initialFortPose.clear();
fortAwake = false;
// Exact flush vertical stacking starting from GROUND_Y = 600:
// 1. Stone foundation plinths (y: 600 -> 564, h = 36, center = 582)
addBlock(975, 582, 132, 36, 'stone');
addBlock(1120, 582, 150, 36, 'stone');
addBlock(1265, 582, 132, 36, 'stone');
// 2. First floor vertical pillars (y: 564 -> 444, h = 120, center = 504)
addBlock(926, 504, 22, 120, 'wood');
addBlock(1024, 504, 22, 120, 'wood');
addBlock(1062, 504, 24, 120, 'stone');
addBlock(1120, 504, 22, 120, 'wood'); // central column supporting upper tower
addBlock(1178, 504, 24, 120, 'stone');
addBlock(1216, 504, 22, 120, 'wood');
addBlock(1314, 504, 22, 120, 'wood');
// 3. First floor ceiling planks (y: 444 -> 422, h = 22, center = 433)
addBlock(975, 433, 132, 22, 'wood');
addBlock(1120, 433, 148, 22, 'wood');
addBlock(1265, 433, 132, 22, 'wood');
// 4. Second floor side weights & braces (resting on y = 422)
// Left roof blocks (y: 422 -> 382, h = 40, center = 402)
addBlock(952, 402, 38, 40, 'stone');
addBlock(1002, 402, 28, 40, 'wood');
// Left pediment plank (y: 382 -> 364, h = 18, center = 373)
addBlock(977, 373, 94, 18, 'wood');
// Right roof blocks (y: 422 -> 382, h = 40, center = 402)
addBlock(1238, 402, 28, 40, 'wood');
addBlock(1288, 402, 38, 40, 'stone');
// Right pediment plank (y: 382 -> 364, h = 18, center = 373)
addBlock(1263, 373, 94, 18, 'wood');
// 5. Center Tower Second Floor (y: 422 -> 312, h = 110, center = 367)
addBlock(1066, 367, 22, 110, 'wood');
addBlock(1174, 367, 22, 110, 'wood');
// 6. Center Tower Roof Plank (y: 312 -> 290, h = 22, center = 301)
addBlock(1120, 301, 144, 22, 'wood');
// 7. Heavy Stone Cap Blocks on Center Roof (y: 290 -> 252, h = 38, center = 271)
addBlock(1082, 271, 38, 38, 'stone');
addBlock(1158, 271, 38, 38, 'stone');
// 8. Top Finial Wood Beam (y: 252 -> 232, h = 20, center = 242)
addBlock(1120, 242, 122, 20, 'wood');
// 9. Three Pigs Inside the Fort!
// Pig 1: Left Bay (sits on y = 564, r = 24 -> center y = 540)
addPig(975, 540, 24, 'minion');
// Pig 2: Right Bay (sits on y = 564, r = 24 -> center y = 540)
addPig(1265, 540, 24, 'minion');
// Pig 3: Boss Pig in Center Second-Floor Throne Room (sits on y = 422, r = 29 -> center y = 393)
addPig(1120, 393, 29, 'boss');
}
function wakeFort() {
if (fortAwake) return;
fortAwake = true;
for (const b of fortBlocks) {
Sleeping.set(b, false);
}
for (const p of pigs) {
Sleeping.set(p, false);
}
}
function enforceFortStillnessBeforeHit() {
if (fortAwake) return;
for (const b of fortBlocks) {
const pose = initialFortPose.get(b.id);
if (pose) {
Body.setPosition(b, { x: pose.x, y: pose.y });
Body.setAngle(b, pose.angle);
Body.setVelocity(b, { x: 0, y: 0 });
Body.setAngularVelocity(b, 0);
Sleeping.set(b, true);
}
}
for (const p of pigs) {
const pose = initialFortPose.get(p.id);
if (pose) {
Body.setPosition(p, { x: pose.x, y: pose.y });
Body.setAngle(p, pose.angle);
Body.setVelocity(p, { x: 0, y: 0 });
Body.setAngularVelocity(p, 0);
Sleeping.set(p, true);
}
}
// Check if the flying bird is about to touch any fort block or pig
if (activeBird && slingState === 'flying') {
const bx = activeBird.position.x + activeBird.velocity.x;
const by = activeBird.position.y + activeBird.velocity.y;
const br = BIRD_RADIUS + 8;
for (const b of fortBlocks) {
const dx = Math.abs(bx - b.position.x);
const dy = Math.abs(by - b.position.y);
if (dx <= b.w * 0.5 + br && dy <= b.h * 0.5 + br) {
wakeFort();
return;
}
}
for (const p of pigs) {
const dist = Math.hypot(bx - p.position.x, by - p.position.y);
if (dist <= p.r + br) {
wakeFort();
return;
}
}
}
}
// =========================================================================
// LEVEL RESET & SETUP
// =========================================================================
function initLevel() {
overlayEl.classList.remove('visible');
starEls.forEach(el => el.classList.remove('show'));
engine = Engine.create({
enableSleeping: true,
positionIterations: 10,
velocityIterations: 8
});
world = engine.world;
engine.gravity.y = 1;
engine.gravity.scale = 0.001;
// Ground & invisible left barrier
groundBody = Bodies.rectangle(800, GROUND_Y + 60, 4000, 120, {
isStatic: true,
friction: 0.9,
restitution: 0.1
});
groundBody.gameType = 'ground';
World.add(world, groundBody);
buildFort();
score = 0;
displayedScore = 0;
hudScoreEl.textContent = '0';
birdsRemaining = 3;
launchedBirds = [];
activeBird = null;
pullPos = { x: SLING_X, y: SLING_Y };
isDragging = false;
slingState = 'aiming';
settleTimer = 0;
flightTimer = 0;
birdHasHit = false;
flightTrail = [];
particles = [];
floatingScores = [];
demoTimer = 0;
// Two waiting birds on the ground behind the slingshot
waitingBirds = [
{ x: 185, y: GROUND_Y - BIRD_RADIUS, phase: 0, jumping: false, jumpT: 0, startX: 185, startY: GROUND_Y - BIRD_RADIUS },
{ x: 125, y: GROUND_Y - BIRD_RADIUS, phase: 2.1, jumping: false, jumpT: 0, startX: 125, startY: GROUND_Y - BIRD_RADIUS }
];
camera.targetX = 750;
camera.targetY = 355;
camera.targetViewWidth = 1410;
setupCollisionHandling();
}
// =========================================================================
// COLLISION & DAMAGE HANDLING
// =========================================================================
function setupCollisionHandling() {
Events.on(engine, 'beforeUpdate', () => {
enforceFortStillnessBeforeHit();
const allDynamic = [...fortBlocks, ...pigs, ...launchedBirds];
for (const b of allDynamic) {
b.preVel = { x: b.velocity.x, y: b.velocity.y };
}
});
Events.on(engine, 'collisionStart', (event) => {
const pairs = event.pairs;
const blocksToRemove = new Set();
const pigsToPop = new Set();
for (const pair of pairs) {
const A = pair.bodyA;
const B = pair.bodyB;
const involvesBird = (A.gameType === 'bird' || B.gameType === 'bird');
const involvesFort = (A.gameType === 'block' || B.gameType === 'block' || A.gameType === 'pig' || B.gameType === 'pig');
if (involvesBird && involvesFort && !fortAwake) {
wakeFort();
}
if (involvesBird) {
const bird = A.gameType === 'bird' ? A : B;
if (!bird.hasImpacted) {
bird.hasImpacted = true;
bird.frictionAir = 0.008;
birdHasHit = true;
spawnFeathers(bird.position.x, bird.position.y, 7);
}
}
const vA = A.preVel || A.velocity || { x: 0, y: 0 };
const vB = B.preVel || B.velocity || { x: 0, y: 0 };
const relVx = vA.x - vB.x;
const relVy = vA.y - vB.y;
const relSpeed = Math.hypot(relVx, relVy);
if (relSpeed < 1.35) continue;
const contact = (pair.activeContacts && pair.activeContacts[0] && pair.activeContacts[0].vertex)
? pair.activeContacts[0].vertex
: { x: (A.position.x + B.position.x) * 0.5, y: (A.position.y + B.position.y) * 0.5 };
if (relSpeed > 2.2) {
spawnDust(contact.x, contact.y, Math.min(6, Math.floor(relSpeed)));
}
// Apply damage to blocks & pigs
applyImpactDamage(A, B, relSpeed, contact, blocksToRemove, pigsToPop);
applyImpactDamage(B, A, relSpeed, contact, blocksToRemove, pigsToPop);
}
// Process destroyed blocks
for (const block of blocksToRemove) {
destroyBlock(block);
}
// Process popped pigs
for (const pig of pigsToPop) {
popPig(pig);
}
});
}
function applyImpactDamage(victim, hitter, relSpeed, contact, blocksToRemove, pigsToPop) {
if (!victim || (victim.gameType !== 'block' && victim.gameType !== 'pig')) return;
// Calculate mass factor of the hitter
let massFactor = 1.0;
if (hitter.isStatic) {
massFactor = 1.35; // hitting the ground hard
} else if (hitter.gameType === 'bird') {
massFactor = 2.8; // birds pack a strong punch
} else if (hitter.material === 'stone') {
massFactor = 2.2; // heavy falling stone crushes wood & pigs
} else if (hitter.material === 'wood') {
massFactor = 1.15;
}
// Scare nearby pigs
for (const p of pigs) {
if (Math.hypot(p.position.x - contact.x, p.position.y - contact.y) < 180) {
p.scaredTimer = 70;
}
}
if (victim.gameType === 'block') {
const minThreshold = victim.material === 'stone' ? 2.4 : 1.7;
if (relSpeed < minThreshold) return;
const dmg = (relSpeed - minThreshold + 0.5) * 9.5 * massFactor;
victim.hp -= dmg;
playSound(victim.material, relSpeed * 0.3);
if (dmg > 10) {
spawnSplinters(contact.x, contact.y, victim.material, Math.min(5, Math.ceil(dmg / 12)));
}
if (victim.hp <= 0) {
blocksToRemove.add(victim);
// If the bird smashed through a wooden block, preserve a portion of the bird's pre-impact momentum!
if (hitter.gameType === 'bird' && victim.material === 'wood' && hitter.preVel) {
Body.setVelocity(hitter, {
x: hitter.preVel.x * 0.68,
y: hitter.preVel.y * 0.68
});
}
}
} else if (victim.gameType === 'pig') {
const minThreshold = 1.5;
if (relSpeed < minThreshold) return;
const dmg = (relSpeed - minThreshold + 0.6) * 8.5 * massFactor;
victim.hp -= dmg;
victim.scaredTimer = 90;
if (victim.hp <= 0) {
pigsToPop.add(victim);
}
}
}
function destroyBlock(block) {
const idx = fortBlocks.indexOf(block);
if (idx === -1) return;
fortBlocks.splice(idx, 1);
World.remove(world, block);
const pts = block.material === 'stone' ? 800 : 500;
addScore(pts, block.position.x, block.position.y, block.material === 'stone' ? '#d9e5f0' : '#ffcf56');
spawnSplinters(block.position.x, block.position.y, block.material, 11);
spawnDust(block.position.x, block.position.y, 7);
playSound(block.material, 1.5);
}
function popPig(pig) {
const idx = pigs.indexOf(pig);
if (idx === -1) return;
pigs.splice(idx, 1);
World.remove(world, pig);
addScore(5000, pig.position.x, pig.position.y - 10, '#80ff52', true);
spawnPigPopParticles(pig.position.x, pig.position.y, pig.r);
playSound('pop', 1.5);
}
function addScore(amount, x, y, color = '#ffdd44', big = false) {
score += amount;
floatingScores.push({
text: `+${amount}`,
x,
y,
vy: -1.6,
life: 1.0,
color,
big
});
}
// =========================================================================
// PARTICLE SYSTEM (Feathers, Dust, Splinters, Pig Pops)
// =========================================================================
function spawnFeathers(x, y, count = 6) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 1.5 + Math.random() * 4.5;
particles.push({
type: 'feather',
x,
y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd - 1.5,
rot: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.16,
size: 7 + Math.random() * 5,
color: Math.random() > 0.3 ? '#e52521' : '#b31217',
life: 1.0,
decay: 0.014 + Math.random() * 0.01
});
}
}
function spawnDust(x, y, count = 5) {
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 0.6 + Math.random() * 2.6;
particles.push({
type: 'dust',
x: x + (Math.random() - 0.5) * 14,
y: y + (Math.random() - 0.5) * 14,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd - 0.5,
r: 7 + Math.random() * 10,
color: 'rgba(235, 224, 202, 0.7)',
life: 1.0,
decay: 0.028 + Math.random() * 0.018
});
}
}
function spawnSplinters(x, y, material, count = 8) {
const isStone = material === 'stone';
for (let i = 0; i < count; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 2 + Math.random() * 6.5;
particles.push({
type: 'shard',
x: x + (Math.random() - 0.5) * 18,
y: y + (Math.random() - 0.5) * 18,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd - 2.0,
w: isStone ? (6 + Math.random() * 7) : (10 + Math.random() * 12),
h: isStone ? (6 + Math.random() * 6) : (3.5 + Math.random() * 3.5),
rot: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.28,
color: isStone
? (Math.random() > 0.5 ? '#8d99a6' : '#6c7682')
: (Math.random() > 0.5 ? '#d88b38' : '#a8621c'),
life: 1.0,
decay: 0.02 + Math.random() * 0.015
});
}
}
function spawnPigPopParticles(x, y, r) {
// Big cartoon cloud ring
for (let i = 0; i < 14; i++) {
const angle = (i / 14) * Math.PI * 2;
const spd = 2.2 + Math.random() * 3.2;
particles.push({
type: 'dust',
x: x + Math.cos(angle) * (r * 0.4),
y: y + Math.sin(angle) * (r * 0.4),
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd,
r: 12 + Math.random() * 14,
color: i % 3 === 0 ? 'rgba(165, 242, 110, 0.85)' : 'rgba(255, 255, 255, 0.9)',
life: 1.0,
decay: 0.025 + Math.random() * 0.015
});
}
// Little stars
for (let i = 0; i < 8; i++) {
const angle = Math.random() * Math.PI * 2;
const spd = 3 + Math.random() * 5;
particles.push({
type: 'star',
x,
y,
vx: Math.cos(angle) * spd,
vy: Math.sin(angle) * spd - 1.5,
rot: Math.random() * Math.PI * 2,
vRot: (Math.random() - 0.5) * 0.2,
size: 7 + Math.random() * 5,
life: 1.0,
decay: 0.022
});
}
}
// =========================================================================
// LAUNCH & TRAJECTORY PREDICTION
// =========================================================================
function getLaunchVelocity(pullX, pullY) {
const dx = SLING_X - pullX;
const dy = SLING_Y - pullY;
return {
x: dx * LAUNCH_POWER,
y: dy * LAUNCH_POWER
};
}
// Exact step-by-step Verlet simulation matching Matter.js Body.update
function computeTrajectoryPoints(pullX, pullY, numSteps = 75) {
const v0 = getLaunchVelocity(pullX, pullY);
const dtSq = FIXED_DT * FIXED_DT;
const gravAccY = engine.gravity.y * engine.gravity.scale * dtSq;
const airFactor = 1 - 0.0012; // matches bird frictionAir in free flight
let x = pullX;
let y = pullY;
let vx = v0.x;
let vy = v0.y;
const pts = [];
for (let step = 1; step <= numSteps; step++) {
vx = vx * airFactor;
vy = vy * airFactor + gravAccY;
x += vx;
y += vy;
if (step % 3 === 0) {
pts.push({ x, y });
}
if (y > GROUND_Y) break;
}
return pts;
}
function fireBird(pullX, pullY) {
const v0 = getLaunchVelocity(pullX, pullY);
const bird = Bodies.circle(pullX, pullY, BIRD_RADIUS, {
density: 0.0046,
friction: 0.5,
restitution: 0.38,
frictionAir: 0.0012,
slop: 0.01
});
bird.gameType = 'bird';
bird.hasImpacted = false;
bird.preVel = { x: v0.x, y: v0.y };
World.add(world, bird);
Body.setVelocity(bird, v0);
activeBird = bird;
launchedBirds.push(bird);
birdsRemaining--;
slingState = 'flying';
flightTimer = 0;
settleTimer = 0;
birdHasHit = false;
flightTrail = [{ x: pullX, y: pullY, r: 4.5 }];
spawnFeathers(pullX, pullY, 5);
playSound('launch');
}
// =========================================================================
// PLAYER INPUT (GRAB SLINGSHOT & TAKE OVER FROM DEMO)
// =========================================================================
function clampPull(wx, wy) {
let dx = wx - SLING_X;
let dy = wy - SLING_Y;
const dist = Math.hypot(dx, dy);
if (dist > MAX_PULL) {
dx = (dx / dist) * MAX_PULL;
dy = (dy / dist) * MAX_PULL;
}
// Prevent dragging the bird into the ground below GROUND_Y - BIRD_RADIUS
const clampedY = Math.min(SLING_Y + dy, GROUND_Y - BIRD_RADIUS - 4);
return {
x: SLING_X + dx,
y: clampedY
};
}
function enterPlayerModeAndGrab(worldPt) {
ensureAudio();
if (isDemo) {
isDemo = false;
demoBannerEl.classList.add('hidden');
// If the demo already launched a bird or modified the level, reset cleanly!
if (slingState !== 'aiming' || birdsRemaining < 3 || fortAwake) {
initLevel();
// Snap camera immediately for responsive player control
camera.x = camera.targetX;
camera.y = camera.targetY;
camera.viewWidth = camera.targetViewWidth;
updateCameraTransform();
}
}
if (slingState === 'aiming' && birdsRemaining > 0) {
isDragging = true;
pullPos = clampPull(worldPt.x, worldPt.y);
}
}
function onPointerDown(e) {
if (overlayEl.classList.contains('visible')) return;
ensureAudio();
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const worldPt = screenToWorld(clientX, clientY);
// Check if the player grabbed near the slingshot / bird (generous radius in world or screen left)
const distToPouch = Math.hypot(worldPt.x - pullPos.x, worldPt.y - pullPos.y);
const distToSling = Math.hypot(worldPt.x - SLING_X, worldPt.y - SLING_Y);
const isLeftSideGrab = isDemo && clientX < screenW * 0.42;
if (distToPouch < 85 || distToSling < 110 || isLeftSideGrab) {
enterPlayerModeAndGrab(worldPt);
}
}
function onPointerMove(e) {
if (!isDragging) return;
const clientX = e.touches ? e.touches[0].clientX : e.clientX;
const clientY = e.touches ? e.touches[0].clientY : e.clientY;
const worldPt = screenToWorld(clientX, clientY);
pullPos = clampPull(worldPt.x, worldPt.y);
}
function onPointerUp() {
if (!isDragging) return;
isDragging = false;
const pullDist = Math.hypot(pullPos.x - SLING_X, pullPos.y - SLING_Y);
if (pullDist >= MIN_PULL && slingState === 'aiming') {
fireBird(pullPos.x, pullPos.y);
pullPos = { x: SLING_X, y: SLING_Y };
} else {
pullPos = { x: SLING_X, y: SLING_Y };
}
}
canvas.addEventListener('mousedown', onPointerDown);
window.addEventListener('mousemove', onPointerMove);
window.addEventListener('mouseup', onPointerUp);
canvas.addEventListener('touchstart', (e) => { e.preventDefault(); onPointerDown(e); }, { passive: false });
window.addEventListener('touchmove', (e) => { if (isDragging) e.preventDefault(); onPointerMove(e); }, { passive: false });
window.addEventListener('touchend', onPointerUp);
hudResetBtn.addEventListener('click', () => {
ensureAudio();
isDemo = false;
demoBannerEl.classList.add('hidden');
initLevel();
});
modalRestartBtn.addEventListener('click', () => {
ensureAudio();
isDemo = false;
demoBannerEl.classList.add('hidden');
initLevel();
});
// =========================================================================
// END-OF-LEVEL MODAL ("Level cleared" / "Try again")
// =========================================================================
function showEndScreen(cleared) {
slingState = 'gameover';
if (isDemo) {
// In demo mode, pause briefly to let the viewer admire the collapse, then loop!
setTimeout(() => {
if (isDemo) {
demoShotIndex = 0;
initLevel();
}
}, 1700);
return;
}
if (cleared) {
playSound('win');
modalTitleEl.textContent = 'Level cleared';
modalTitleEl.className = 'modal-title win';
// Star calculation:
// 3 stars: >= 28,000 (e.g. cleared with 1-2 birds + good fort destruction)
// 2 stars: >= 20,000
// 1 star: cleared all 3 pigs
let stars = 1;
if (score >= 28000 || birdsRemaining >= 2) stars = 3;
else if (score >= 20000 || birdsRemaining >= 1) stars = 2;
starEls.forEach((star, i) => {
const poly = star.querySelector('polygon');
if (i < stars) {
poly.setAttribute('fill', '#ffd426');
setTimeout(() => star.classList.add('show'), 220 + i * 240);
} else {
poly.setAttribute('fill', '#b8ab93');
setTimeout(() => star.classList.add('show'), 220 + i * 240);
}
});
} else {
playSound('lose');
modalTitleEl.textContent = 'Try again';
modalTitleEl.className = 'modal-title lose';
starEls.forEach((star) => {
const poly = star.querySelector('polygon');
poly.setAttribute('fill', '#b8ab93');
star.classList.add('show');
});
}
modalScoreEl.textContent = score.toLocaleString();
overlayEl.classList.add('visible');
}
// =========================================================================
// GAME UPDATE LOOP
// =========================================================================
function isWorldCalm() {
for (const b of fortBlocks) {
if (!b.isSleeping && (Math.hypot(b.velocity.x, b.velocity.y) > 0.35 || Math.abs(b.angularVelocity) > 0.02)) {
return false;
}
}
for (const p of pigs) {
if (!p.isSleeping && Math.hypot(p.velocity.x, p.velocity.y) > 0.35) {
return false;
}
}
if (activeBird) {
const bv = Math.hypot(activeBird.velocity.x, activeBird.velocity.y);
if (bv > 0.65 && activeBird.position.x < 1550 && activeBird.position.x > -100) {
return false;
}
}
return true;
}
function advanceToNextTurnOrEnd() {
// Did we clear all three pigs?
if (pigs.length === 0) {
// Award unused bird bonuses!
if (birdsRemaining > 0) {
for (let i = 0; i < waitingBirds.length; i++) {
const wb = waitingBirds[i];
addScore(10000, wb.x, wb.y - 25, '#ffd93b', true);
}
// Also if a bird was still in the slingshot (unlikely here, but safe)
const extraInSling = birdsRemaining - waitingBirds.length;
for (let i = 0; i < extraInSling; i++) {
addScore(10000, SLING_X, SLING_Y - 30, '#ffd93b', true);
}
birdsRemaining = 0;
}
showEndScreen(true);
return;
}
// Pigs still remain: do we have birds left?
if (birdsRemaining > 0) {
// Animate next waiting bird hopping into the slingshot
if (waitingBirds.length > 0) {
const nextBird = waitingBirds.shift();
nextBird.jumping = true;
nextBird.jumpT = 0;
// Shift remaining waiting bird forward
if (waitingBirds.length > 0) {
waitingBirds[0].startX = waitingBirds[0].x;
}
}
slingState = 'aiming';
pullPos = { x: SLING_X, y: SLING_Y };
demoTimer = 0;
camera.targetX = 750;
camera.targetY = 355;
camera.targetViewWidth = 1410;
} else {
// Out of birds and pigs still alive!
showEndScreen(false);
}
}
function updateDemoAI() {
if (!isDemo || slingState !== 'aiming' || isDragging) return;
demoTimer++;
const waitFrames = 45;
const pullFrames = 75;
const holdFrames = 22;
const shot = demoShots[demoShotIndex % demoShots.length];
if (demoTimer < waitFrames) {
pullPos = { x: SLING_X, y: SLING_Y };
} else if (demoTimer < waitFrames + pullFrames) {
const t = (demoTimer - waitFrames) / pullFrames;
// Smooth ease-in-out
const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2;
pullPos = clampPull(SLING_X + shot.dx * ease, SLING_Y + shot.dy * ease);
} else if (demoTimer < waitFrames + pullFrames + holdFrames) {
pullPos = clampPull(SLING_X + shot.dx, SLING_Y + shot.dy);
} else {
demoShotIndex++;
fireBird(pullPos.x, pullPos.y);
pullPos = { x: SLING_X, y: SLING_Y };
}
}
function updateGame() {
// Step physics engine at fixed 60Hz delta
Engine.update(engine, FIXED_DT);
// Pop any pigs or remove blocks that fell off the world edge
for (let i = pigs.length - 1; i >= 0; i--) {
const p = pigs[i];
if (p.position.x > 1650 || p.position.x < -100 || p.position.y > GROUND_Y + 40) {
popPig(p);
}
}
// Update clouds
for (const c of clouds) {
c.x += c.speed;
if (c.x > 1950) c.x = -250;
}
// Animate HUD score counter smoothly
if (displayedScore < score) {
const diff = score - displayedScore;
displayedScore += Math.max(25, Math.ceil(diff * 0.15));
if (displayedScore > score) displayedScore = score;
hudScoreEl.textContent = displayedScore.toLocaleString();
}
// Update waiting birds idle bobbing
for (const wb of waitingBirds) {
wb.phase += 0.06;
if (wb.x < 185) {
wb.x += (185 - wb.x) * 0.08;
}
}
// Update Demo AI if active
updateDemoAI();
// Update Flying Bird, Trail & Camera Tracking
if (slingState === 'flying' && activeBird) {
flightTimer++;
// Record dotted trail while the bird is before/near first impact
if (!birdHasHit && flightTimer % 3 === 0 && flightTrail.length < 45) {
flightTrail.push({
x: activeBird.position.x,
y: activeBird.position.y,
r: (flightTrail.length % 2 === 0) ? 4.5 : 3.2
});
}
// Camera smoothly follows the bird into the fort!
const bx = Math.max(350, Math.min(1140, activeBird.position.x + 160));
camera.targetX = bx;
camera.targetY = 365;
camera.targetViewWidth = 1120;
// Check if round should settle
const allPigsGone = pigs.length === 0;
const calm = isWorldCalm();
if (allPigsGone) {
settleTimer++;
if (settleTimer > 75 || (calm && settleTimer > 40)) {
advanceToNextTurnOrEnd();
}
} else if (flightTimer > 55 && (calm || flightTimer > 310)) {
settleTimer++;
if (settleTimer > 42 || flightTimer > 320) {
advanceToNextTurnOrEnd();
}
} else {
settleTimer = 0;
}
} else if (slingState === 'aiming') {
camera.targetX = 750;
camera.targetY = 355;
camera.targetViewWidth = 1410;
}
// Update particles
for (let i = particles.length - 1; i >= 0; i--) {
const pt = particles[i];
pt.x += pt.vx;
pt.y += pt.vy;
pt.life -= pt.decay;
if (pt.type === 'feather') {
pt.vy += 0.06;
pt.vx *= 0.96;
pt.vy *= 0.96;
pt.rot += pt.vRot + Math.sin(pt.life * 12) * 0.03;
} else if (pt.type === 'shard') {
pt.vy += 0.25;
pt.rot += pt.vRot;
if (pt.y > GROUND_Y - 3) {
pt.y = GROUND_Y - 3;
pt.vy *= -0.35;
pt.vx *= 0.7;
}
} else if (pt.type === 'dust') {
pt.r += 0.35;
pt.vx *= 0.94;
pt.vy *= 0.94;
} else if (pt.type === 'star') {
pt.vy += 0.14;
pt.rot += pt.vRot;
}
if (pt.life <= 0) {
particles.splice(i, 1);
}
}
// Update floating score popups
for (let i = floatingScores.length - 1; i >= 0; i--) {
const fs = floatingScores[i];
fs.y += fs.vy;
fs.vy *= 0.96;
fs.life -= 0.018;
if (fs.life <= 0) {
floatingScores.splice(i, 1);
}
}
updateCameraTransform();
}
// =========================================================================
// CARTOON RENDERING (100% Drawn in Code)
// =========================================================================
function drawSkyAndParallax() {
// 1. Sky gradient (screen space)
const skyGrad = ctx.createLinearGradient(0, 0, 0, screenH);
skyGrad.addColorStop(0, '#3e92eb');
skyGrad.addColorStop(0.58, '#8ed2ff');
skyGrad.addColorStop(0.88, '#dcf3ff');
skyGrad.addColorStop(1, '#f7fcff');
ctx.fillStyle = skyGrad;
ctx.fillRect(0, 0, screenW, screenH);
// Save and enter world coordinates with parallax factors
ctx.save();
ctx.translate(camera.offsetX, camera.offsetY);
ctx.scale(camera.scale, camera.scale);
// 2. Fluffy Cartoon Clouds (parallax 0.25)
const pCloud = (camera.x - 750) * 0.72;
ctx.save();
ctx.translate(pCloud, 0);
for (const c of clouds) {
drawCartoonCloud(c.x, c.y, c.s);
}
ctx.restore();
// 3. Distant Mountain Range / Mesas (parallax 0.55)
const pMount = (camera.x - 750) * 0.52;
ctx.save();
ctx.translate(pMount, 0);
ctx.fillStyle = '#9ac7e3';
ctx.beginPath();
ctx.moveTo(-600, GROUND_Y);
ctx.lineTo(-420, 340);
ctx.lineTo(-180, 460);
ctx.lineTo(140, 290);
ctx.lineTo(430, 470);
ctx.lineTo(760, 310);
ctx.lineTo(1090, 450);
ctx.lineTo(1410, 275);
ctx.lineTo(1780, 440);
ctx.lineTo(2100, GROUND_Y);
ctx.closePath();
ctx.fill();
// Snow/highlight caps on peaks
ctx.fillStyle = 'rgba(255, 255, 255, 0.45)';
for (const pk of [{ x: 140, y: 290 }, { x: 760, y: 310 }, { x: 1410, y: 275 }]) {
ctx.beginPath();
ctx.moveTo(pk.x, pk.y);
ctx.lineTo(pk.x - 52, pk.y + 52);
ctx.lineTo(pk.x - 18, pk.y + 42);
ctx.lineTo(pk.x + 8, pk.y + 56);
ctx.lineTo(pk.x + 54, pk.y + 50);
ctx.closePath();
ctx.fill();
}
ctx.restore();
// 4. Midground Rolling Hills (parallax 0.3)
const pHill1 = (camera.x - 750) * 0.32;
ctx.save();
ctx.translate(pHill1, 0);
ctx.fillStyle = '#78be68';
ctx.strokeStyle = '#559446';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-600, GROUND_Y);
ctx.bezierCurveTo(-250, 430, 80, 420, 420, 520);
ctx.bezierCurveTo(720, 410, 1080, 400, 1460, 515);
ctx.bezierCurveTo(1720, 440, 1950, 450, 2200, GROUND_Y);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Distant pine/cypress trees on hills
for (const tx of [-120, 25, 680, 890, 930, 1580]) {
drawDistantTree(tx, 475);
}
ctx.restore();
// 5. Foreground Lush Hills (parallax 0.14)
const pHill2 = (camera.x - 750) * 0.14;
ctx.save();
ctx.translate(pHill2, 0);
ctx.fillStyle = '#5da942';
ctx.strokeStyle = '#3f7d29';
ctx.lineWidth = 4;
ctx.beginPath();
ctx.moveTo(-600, GROUND_Y);
ctx.bezierCurveTo(-150, 495, 220, 470, 590, 560);
ctx.bezierCurveTo(860, 480, 1220, 475, 1620, 555);
ctx.bezierCurveTo(1820, 505, 2000, 515, 2200, GROUND_Y);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.restore();
ctx.restore();
}
function drawCartoonCloud(x, y, s) {
ctx.save();
ctx.translate(x, y);
ctx.scale(s, s);
// Soft bottom shadow
ctx.fillStyle = '#d3ecfa';
ctx.beginPath();
ctx.roundRect(-78, -6, 156, 32, 16);
ctx.fill();
// Main white puffs
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(-44, 0, 26, 0, Math.PI * 2);
ctx.arc(-10, -14, 36, 0, Math.PI * 2);
ctx.arc(32, -6, 29, 0, Math.PI * 2);
ctx.arc(56, 6, 20, 0, Math.PI * 2);
ctx.roundRect(-70, -4, 140, 26, 13);
ctx.fill();
ctx.restore();
}
function drawDistantTree(x, baseY) {
ctx.save();
ctx.translate(x, baseY);
ctx.fillStyle = '#488537';
ctx.beginPath();
ctx.moveTo(0, -52);
ctx.lineTo(-18, 0);
ctx.lineTo(18, 0);
ctx.closePath();
ctx.fill();
ctx.restore();
}
function drawGround() {
// Deep earth fill
const soilGrad = ctx.createLinearGradient(0, GROUND_Y, 0, GROUND_Y + 320);
soilGrad.addColorStop(0, '#7c4722');
soilGrad.addColorStop(0.25, '#5e3214');
soilGrad.addColorStop(1, '#3b1d09');
ctx.fillStyle = soilGrad;
ctx.fillRect(-1000, GROUND_Y, 3800, 500);
// Decorative soil stones
ctx.fillStyle = '#683918';
for (let sx = -200; sx < 1900; sx += 110) {
ctx.beginPath();
ctx.ellipse(sx, GROUND_Y + 42 + (sx % 3) * 14, 18, 9, 0.2, 0, Math.PI * 2);
ctx.fill();
}
// Lush green grass turf band
ctx.fillStyle = '#65c434';
ctx.fillRect(-1000, GROUND_Y - 4, 3800, 18);
// Top bright grass highlight
ctx.fillStyle = '#8be04e';
ctx.fillRect(-1000, GROUND_Y - 4, 3800, 6);
// Dark cartoon outline along ground surface
ctx.strokeStyle = '#2d5714';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.moveTo(-1000, GROUND_Y - 4);
ctx.lineTo(2800, GROUND_Y - 4);
ctx.stroke();
// Grass blades
ctx.strokeStyle = '#4ea623';
ctx.lineWidth = 3;
ctx.lineCap = 'round';
for (const g of grassTufts) {
ctx.beginPath();
ctx.moveTo(g.x, GROUND_Y - 3);
ctx.quadraticCurveTo(g.x + g.lean * 0.5, GROUND_Y - 3 - g.h * 0.6, g.x + g.lean, GROUND_Y - 3 - g.h);
ctx.stroke();
}
}
// =========================================================================
// SLINGSHOT RENDERING (Split Back & Front Layers)
// =========================================================================
const BACK_FORK = { x: SLING_X + 14, y: SLING_Y - 4 };
const FRONT_FORK = { x: SLING_X - 13, y: SLING_Y + 3 };
function getActivePouchPosition() {
if (slingState === 'aiming' && birdsRemaining > 0) {
return { x: pullPos.x, y: pullPos.y };
}
return { x: SLING_X, y: SLING_Y };
}
function drawSlingshotBack() {
// Wooden pedestal shadow on ground
ctx.fillStyle = 'rgba(0, 0, 0, 0.22)';
ctx.beginPath();
ctx.ellipse(SLING_X, GROUND_Y + 2, 38, 8, 0, 0, Math.PI * 2);
ctx.fill();
// Back wooden branch of the Y-fork
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#3b1d08';
ctx.lineWidth = 22;
ctx.beginPath();
ctx.moveTo(SLING_X + 2, SLING_Y + 42);
ctx.quadraticCurveTo(SLING_X + 18, SLING_Y + 18, BACK_FORK.x, BACK_FORK.y);
ctx.stroke();
ctx.strokeStyle = '#7d441b';
ctx.lineWidth = 15;
ctx.beginPath();
ctx.moveTo(SLING_X + 2, SLING_Y + 42);
ctx.quadraticCurveTo(SLING_X + 18, SLING_Y + 18, BACK_FORK.x, BACK_FORK.y);
ctx.stroke();
// Back elastic band
if (slingState === 'aiming' && birdsRemaining > 0) {
const pouch = getActivePouchPosition();
const angle = Math.atan2(pouch.y - SLING_Y, pouch.x - SLING_X);
const backAttachX = pouch.x - Math.cos(angle) * (BIRD_RADIUS * 0.7);
const backAttachY = pouch.y - Math.sin(angle) * (BIRD_RADIUS * 0.7);
ctx.strokeStyle = '#2d1507';
ctx.lineWidth = 11;
ctx.beginPath();
ctx.moveTo(BACK_FORK.x, BACK_FORK.y);
ctx.lineTo(backAttachX, backAttachY);
ctx.stroke();
ctx.strokeStyle = '#572911';
ctx.lineWidth = 6.5;
ctx.beginPath();
ctx.moveTo(BACK_FORK.x, BACK_FORK.y);
ctx.lineTo(backAttachX, backAttachY);
ctx.stroke();
}
ctx.restore();
}
function drawSlingshotFront() {
ctx.save();
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
// Main trunk + Front branch outline
ctx.strokeStyle = '#3b1d08';
ctx.lineWidth = 25;
ctx.beginPath();
ctx.moveTo(SLING_X, GROUND_Y - 4);
ctx.lineTo(SLING_X, SLING_Y + 42);
ctx.quadraticCurveTo(SLING_X - 16, SLING_Y + 20, FRONT_FORK.x, FRONT_FORK.y);
ctx.stroke();
// Main trunk + Front branch wood fill
ctx.strokeStyle = '#a35d28';
ctx.lineWidth = 17;
ctx.beginPath();
ctx.moveTo(SLING_X, GROUND_Y - 4);
ctx.lineTo(SLING_X, SLING_Y + 42);
ctx.quadraticCurveTo(SLING_X - 16, SLING_Y + 20, FRONT_FORK.x, FRONT_FORK.y);
ctx.stroke();
// Warm rim highlight on front fork
ctx.strokeStyle = '#cc8143';
ctx.lineWidth = 5;
ctx.beginPath();
ctx.moveTo(SLING_X - 4, GROUND_Y - 10);
ctx.lineTo(SLING_X - 4, SLING_Y + 42);
ctx.quadraticCurveTo(SLING_X - 18, SLING_Y + 22, FRONT_FORK.x - 3, FRONT_FORK.y);
ctx.stroke();
// Front elastic band + leather pouch when aiming
if (slingState === 'aiming' && birdsRemaining > 0) {
const pouch = getActivePouchPosition();
const dx = pouch.x - SLING_X;
const dy = pouch.y - SLING_Y;
const dist = Math.hypot(dx, dy);
const angle = dist > 2 ? Math.atan2(dy, dx) : Math.PI;
const attachX = pouch.x + Math.cos(angle) * (BIRD_RADIUS * 0.78);
const attachY = pouch.y + Math.sin(angle) * (BIRD_RADIUS * 0.78);
// Leather pouch cradling the back of the bird
ctx.save();
ctx.translate(attachX, attachY);
ctx.rotate(angle);
ctx.fillStyle = '#47220d';
ctx.strokeStyle = '#241004';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(-8, -13, 15, 26, 6);
ctx.fill();
ctx.stroke();
ctx.restore();
// Front rubber band
ctx.strokeStyle = '#2d1507';
ctx.lineWidth = 11.5;
ctx.beginPath();
ctx.moveTo(FRONT_FORK.x, FRONT_FORK.y);
ctx.lineTo(attachX, attachY);
ctx.stroke();
ctx.strokeStyle = '#6c3417';
ctx.lineWidth = 7;
ctx.beginPath();
ctx.moveTo(FRONT_FORK.x, FRONT_FORK.y);
ctx.lineTo(attachX, attachY);
ctx.stroke();
} else {
// Relaxed band hanging between forks after launch
ctx.strokeStyle = '#47220d';
ctx.lineWidth = 6.5;
ctx.beginPath();
ctx.moveTo(BACK_FORK.x, BACK_FORK.y);
ctx.quadraticCurveTo(SLING_X, SLING_Y + 28, FRONT_FORK.x, FRONT_FORK.y);
ctx.stroke();
}
// Band tie wraps around the fork tips
for (const fk of [FRONT_FORK, BACK_FORK]) {
ctx.fillStyle = '#4a2410';
ctx.strokeStyle = '#241004';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.roundRect(fk.x - 10, fk.y - 4, 20, 9, 4);
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
// =========================================================================
// TRAJECTORY PREVIEW ARC & FLIGHT TRAIL
// =========================================================================
function drawTrajectoryAndTrail() {
// 1. Persistent trail of the last launched bird
for (let i = 0; i < flightTrail.length; i++) {
const pt = flightTrail[i];
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)';
ctx.strokeStyle = 'rgba(60, 40, 20, 0.55)';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.arc(pt.x, pt.y, pt.r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// 2. Dotted aiming trajectory arc when dragging the bird back
if (slingState === 'aiming' && birdsRemaining > 0) {
const pullDist = Math.hypot(pullPos.x - SLING_X, pullPos.y - SLING_Y);
if (pullDist >= MIN_PULL) {
const arcPts = computeTrajectoryPoints(pullPos.x, pullPos.y, 75);
for (let i = 0; i < arcPts.length; i++) {
const p = arcPts[i];
const fade = Math.max(0.15, 1 - i / arcPts.length);
const r = (i % 2 === 0 ? 5.2 : 3.6) * (0.65 + 0.35 * fade);
ctx.save();
ctx.globalAlpha = fade;
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#3c2410';
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.arc(p.x, p.y, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.restore();
}
}
}
}
// =========================================================================
// CARTOON RED BIRD WITH ANGRY EYEBROWS
// =========================================================================
function drawCartoonBird(x, y, angle = 0, screaming = false) {
const r = BIRD_RADIUS;
ctx.save();
ctx.translate(x, y);
ctx.rotate(angle);
// Three black tail feathers sticking out the back
ctx.fillStyle = '#1f1a17';
ctx.strokeStyle = '#1f1a17';
ctx.lineWidth = 2;
for (const ta of [-0.35, 0, 0.35]) {
ctx.save();
ctx.rotate(ta);
ctx.beginPath();
ctx.moveTo(-r + 3, 0);
ctx.lineTo(-r - 12, -3.5);
ctx.lineTo(-r - 11, 3.5);
ctx.closePath();
ctx.fill();
ctx.restore();
}
// Two top head crest feathers
ctx.fillStyle = '#e52521';
ctx.strokeStyle = '#40110d';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.ellipse(-2, -r - 5, 8, 5, -0.45, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.beginPath();
ctx.ellipse(-9, -r - 2, 6.5, 4, -0.8, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Main round red body
ctx.fillStyle = '#e52521';
ctx.strokeStyle = '#40110d';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
// Darker red lower shading
ctx.save();
ctx.beginPath();
ctx.arc(0, 0, r - 1.5, 0, Math.PI * 2);
ctx.clip();
ctx.fillStyle = '#bd1714';
ctx.beginPath();
ctx.arc(-4, 5, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#e52521';
ctx.beginPath();
ctx.arc(3, -3, r * 0.92, 0, Math.PI * 2);
ctx.fill();
// Cream belly patch at bottom
ctx.fillStyle = '#f5e2c8';
ctx.beginPath();
ctx.ellipse(2, r * 0.78, r * 0.68, r * 0.44, 0, 0, Math.PI * 2);
ctx.fill();
// Cute darker red cheek spots
ctx.fillStyle = '#b31217';
ctx.beginPath();
ctx.ellipse(-6, 3, 3.5, 2.5, 0, 0, Math.PI * 2);
ctx.ellipse(-12, 1, 2.5, 2, 0, 0, Math.PI * 2);
ctx.fill();
// Glossy top-left highlight
ctx.fillStyle = 'rgba(255, 190, 180, 0.45)';
ctx.beginPath();
ctx.ellipse(-6, -10, 7, 4, -0.5, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
// Outer body outline on top
ctx.strokeStyle = '#40110d';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.stroke();
// Eyes (two side-by-side white ovals facing right)
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#2b120e';
ctx.lineWidth = 2.5;
// Left/back eye
ctx.beginPath();
ctx.ellipse(4, -2, 5.2, 5.8, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Right/front eye
ctx.beginPath();
ctx.ellipse(12.5, -2, 5.0, 5.6, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Pupils glaring forward
ctx.fillStyle = '#140c0a';
ctx.beginPath();
ctx.arc(5.6, -2, 2.1, 0, Math.PI * 2);
ctx.arc(13.8, -2, 2.1, 0, Math.PI * 2);
ctx.fill();
// Eye catchlights
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(4.8, -3.2, 0.9, 0, Math.PI * 2);
ctx.arc(13.0, -3.2, 0.9, 0, Math.PI * 2);
ctx.fill();
// Thick, sharply angled V-shaped ANGRY EYEBROWS
ctx.fillStyle = '#1a110f';
ctx.beginPath();
ctx.moveTo(-3, -10);
ctx.lineTo(9, -4.5);
ctx.lineTo(19, -9.5);
ctx.lineTo(17.5, -13.5);
ctx.lineTo(9, -8.5);
ctx.lineTo(-1, -14.2);
ctx.closePath();
ctx.fill();
// Golden-yellow beak
ctx.lineJoin = 'round';
if (screaming) {
// Open battle-cry beak
ctx.fillStyle = '#ff9d00';
ctx.strokeStyle = '#3b1c06';
ctx.lineWidth = 2.5;
ctx.beginPath();
ctx.moveTo(8, 3);
ctx.lineTo(22, 0);
ctx.lineTo(9, 8);
ctx.closePath();
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#ffbe2e';
ctx.beginPath();
ctx.moveTo(7, 2);
ctx.lineTo(24, -1);
ctx.lineTo(10, -5);
ctx.closePath();
ctx.fill();
ctx.stroke();
} else {
// Lower beak
ctx.fillStyle = '#e88b00';
ctx.strokeStyle = '#3b1c06';
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.moveTo(7, 3.5);
ctx.lineTo(20, 4.5);
ctx.lineTo(10, 9.5);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Upper sharp beak
ctx.fillStyle = '#ffbe26';
ctx.beginPath();
ctx.moveTo(7, 3.5);
ctx.lineTo(23, 2.2);
ctx.lineTo(10, -3.8);
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
// =========================================================================
// GREEN PIGS WITH EXPRESSIONS & BOSS CROWN
// =========================================================================
function drawCartoonPig(pig) {
const { x, y } = pig.position;
const r = pig.r;
const isBoss = pig.role === 'boss';
const damaged = pig.hp < pig.maxHp * 0.65;
const scared = pig.scaredTimer > 0;
if (pig.scaredTimer > 0) pig.scaredTimer--;
pig.blinkTimer = (pig.blinkTimer + 1) % 220;
const blinking = pig.blinkTimer > 210 && !scared;
ctx.save();
ctx.translate(x, y);
ctx.rotate(pig.angle);
// Round Pig Ears
ctx.fillStyle = '#6ec926';
ctx.strokeStyle = '#224a08';
ctx.lineWidth = 3;
for (const side of [-1, 1]) {
ctx.save();
ctx.rotate(side * 0.65);
ctx.beginPath();
ctx.ellipse(0, -r - 2, 6, 7.5, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#488f12';
ctx.beginPath();
ctx.ellipse(0, -r - 2, 3, 4, 0, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#6ec926';
ctx.restore();
}
// Main green pig body
const bodyGrad = ctx.createRadialGradient(-r * 0.25, -r * 0.3, r * 0.1, 0, 0, r);
bodyGrad.addColorStop(0, '#9cf04a');
bodyGrad.addColorStop(0.7, '#6ec926');
bodyGrad.addColorStop(1, '#52a314');
ctx.fillStyle = bodyGrad;
ctx.strokeStyle = '#224a08';
ctx.lineWidth = 3.5;
ctx.beginPath();
ctx.arc(0, 0, r, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Bruised eye patch if damaged
if (damaged) {
ctx.fillStyle = 'rgba(110, 55, 145, 0.55)';
ctx.beginPath();
ctx.arc(-r * 0.44, -r * 0.08, r * 0.32, 0, Math.PI * 2);
ctx.fill();
}
// Wide-set Eyes
for (const side of [-1, 1]) {
const ex = side * r * 0.46;
const ey = -r * 0.12;
const er = r * 0.23;
ctx.fillStyle = '#ffffff';
ctx.strokeStyle = '#224a08';
ctx.lineWidth = 2.4;
ctx.beginPath();
ctx.arc(ex, ey, er, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
if (blinking) {
ctx.fillStyle = '#6ec926';
ctx.beginPath();
ctx.arc(ex, ey, er - 1, 0, Math.PI * 2);
ctx.fill();
ctx.strokeStyle = '#224a08';
ctx.beginPath();
ctx.moveTo(ex - er, ey);
ctx.lineTo(ex + er, ey);
ctx.stroke();
} else {
// Pupil
const pr = scared ? er * 0.32 : er * 0.44;
const lookX = side * -1.2;
ctx.fillStyle = '#152905';
ctx.beginPath();
ctx.arc(ex + lookX, ey, pr, 0, Math.PI * 2);
ctx.fill();
// Half-eyelid if bruised
if (damaged && side === -1) {
ctx.fillStyle = '#638035';
ctx.beginPath();
ctx.arc(ex, ey, er, Math.PI, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
}
}
// Mouth / Expression under snout
ctx.strokeStyle = '#224a08';
ctx.lineWidth = 2.4;
if (scared) {
ctx.fillStyle = '#33120c';
ctx.beginPath();
ctx.arc(0, r * 0.48, r * 0.22, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else {
ctx.beginPath();
ctx.arc(0, r * 0.32, r * 0.26, 0.25, Math.PI - 0.25);
ctx.stroke();
}
// Boss Pig Moustache
if (isBoss) {
ctx.fillStyle = '#994b16';
ctx.strokeStyle = '#3b1a04';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.ellipse(-r * 0.28, r * 0.36, r * 0.34, r * 0.14, -0.35, 0, Math.PI * 2);
ctx.ellipse(r * 0.28, r * 0.36, r * 0.34, r * 0.14, 0.35, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
}
// Large Green Oval Snout in Center
ctx.fillStyle = '#8de838';
ctx.strokeStyle = '#224a08';
ctx.lineWidth = 2.8;
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.38, r * 0.29, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
// Two dark nostrils
ctx.fillStyle = '#1b4005';
ctx.beginPath();
ctx.ellipse(-r * 0.14, 0, r * 0.08, r * 0.12, -0.15, 0, Math.PI * 2);
ctx.ellipse(r * 0.14, 0, r * 0.08, r * 0.12, 0.15, 0, Math.PI * 2);
ctx.fill();
// Band-aid on forehead if damaged
if (damaged) {
ctx.save();
ctx.translate(r * 0.25, -r * 0.55);
ctx.rotate(0.4);
ctx.fillStyle = '#f5d1a6';
ctx.strokeStyle = '#5c3b1c';
ctx.lineWidth = 1.8;
ctx.beginPath();
ctx.roundRect(-9, -4, 18, 8, 3);
ctx.fill();
ctx.stroke();
ctx.restore();
}
// Golden Crown on Boss Pig!
if (isBoss) {
ctx.save();
ctx.translate(0, -r + 2);
ctx.fillStyle = '#ffcf26';
ctx.strokeStyle = '#4a2b05';
ctx.lineWidth = 2.6;
ctx.lineJoin = 'round';
ctx.beginPath();
ctx.moveTo(-15, 0);
ctx.lineTo(-18, -16);
ctx.lineTo(-8, -8);
ctx.lineTo(0, -20);
ctx.lineTo(8, -8);
ctx.lineTo(18, -16);
ctx.lineTo(15, 0);
ctx.closePath();
ctx.fill();
ctx.stroke();
// Red crown jewel
ctx.fillStyle = '#e52521';
ctx.beginPath();
ctx.arc(0, -7, 3.2, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
ctx.restore();
}
// =========================================================================
// WOOD PLANKS (WITH WOOD GRAIN) & STONE BLOCKS (WITH CRACKS)
// =========================================================================
function drawFortBlock(block) {
const { x, y } = block.position;
const { w, h, material, hp, maxHp } = block;
const hw = w * 0.5;
const hh = h * 0.5;
const dmgRatio = 1 - Math.max(0, hp / maxHp);
ctx.save();
ctx.translate(x, y);
ctx.rotate(block.angle);
if (material === 'wood') {
// Base wood fill
const woodGrad = ctx.createLinearGradient(-hw, -hh, hw, hh);
woodGrad.addColorStop(0, '#e39a44');
woodGrad.addColorStop(0.5, '#cf802b');
woodGrad.addColorStop(1, '#b3681b');
ctx.fillStyle = woodGrad;
ctx.strokeStyle = '#4a2608';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, 3);
ctx.fill();
// Top/Left warm bevel highlight
ctx.fillStyle = 'rgba(255, 224, 166, 0.35)';
ctx.fillRect(-hw + 2, -hh + 2, w - 4, 4);
// Wood grain lines along the longer axis
ctx.strokeStyle = 'rgba(92, 46, 10, 0.42)';
ctx.lineWidth = 1.6;
const horizontal = w >= h;
if (block.woodData) {
for (const ln of block.woodData.lines) {
ctx.beginPath();
if (horizontal) {
const ly = ln.offset * h;
ctx.moveTo(-hw + 4, ly);
ctx.quadraticCurveTo(0, ly + ln.wave, hw - 4, ly);
} else {
const lx = ln.offset * w;
ctx.moveTo(lx, -hh + 4);
ctx.quadraticCurveTo(lx + ln.wave, 0, lx, hh - 4);
}
ctx.stroke();
}
if (block.woodData.knot) {
const k = block.woodData.knot;
ctx.beginPath();
ctx.ellipse(k.u * w, k.v * h, k.r * (horizontal ? 1.5 : 0.9), k.r * (horizontal ? 0.9 : 1.5), 0, 0, Math.PI * 2);
ctx.stroke();
}
}
// Corner nail heads on planks
ctx.fillStyle = '#5c3110';
const inset = 5.5;
if (w >= 45 || h >= 45) {
for (const sx of [-1, 1]) {
for (const sy of [-1, 1]) {
ctx.beginPath();
ctx.arc(sx * (hw - inset), sy * (hh - inset), 1.8, 0, Math.PI * 2);
ctx.fill();
}
}
}
// Outer border
ctx.strokeStyle = '#4a2608';
ctx.lineWidth = 3;
ctx.strokeRect(-hw, -hh, w, h);
} else {
// Heavy Stone Block
const stoneGrad = ctx.createLinearGradient(-hw, -hh, hw, hh);
stoneGrad.addColorStop(0, '#a6b2bf');
stoneGrad.addColorStop(0.55, '#8693a1');
stoneGrad.addColorStop(1, '#687482');
ctx.fillStyle = stoneGrad;
ctx.beginPath();
ctx.roundRect(-hw, -hh, w, h, 4);
ctx.fill();
// Chiseled inner bevel
ctx.fillStyle = 'rgba(235, 245, 255, 0.35)';
ctx.fillRect(-hw + 3, -hh + 3, w - 6, 4.5);
ctx.fillStyle = 'rgba(30, 38, 48, 0.28)';
ctx.fillRect(-hw + 3, hh - 7.5, w - 6, 4.5);
// Subtle stone speckles
ctx.fillStyle = 'rgba(50, 60, 72, 0.32)';
ctx.beginPath();
ctx.arc(-hw * 0.4, -hh * 0.2, 2.2, 0, Math.PI * 2);
ctx.arc(hw * 0.35, hh * 0.25, 2.6, 0, Math.PI * 2);
ctx.arc(hw * 0.1, -hh * 0.3, 1.8, 0, Math.PI * 2);
ctx.fill();
// Stone border
ctx.strokeStyle = '#2d3540';
ctx.lineWidth = 3.2;
ctx.strokeRect(-hw, -hh, w, h);
}
// Dynamic damage cracks when hit
if (dmgRatio > 0.22) {
ctx.strokeStyle = material === 'stone' ? '#1f252d' : '#361904';
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.moveTo(-hw * 0.3, -hh);
ctx.lineTo(-hw * 0.05, -hh * 0.2);
ctx.lineTo(-hw * 0.25, hh * 0.3);
if (dmgRatio > 0.55) {
ctx.moveTo(hw * 0.25, hh);
ctx.lineTo(hw * 0.05, 0);
ctx.lineTo(hw * 0.35, -hh * 0.4);
}
ctx.stroke();
}
ctx.restore();
}
// =========================================================================
// PARTICLES & FLOATING SCORE POPUPS RENDERING
// =========================================================================
function drawParticlesAndScores() {
for (const pt of particles) {
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, pt.life));
ctx.translate(pt.x, pt.y);
if (pt.type === 'dust') {
ctx.fillStyle = pt.color;
ctx.beginPath();
ctx.arc(0, 0, pt.r, 0, Math.PI * 2);
ctx.fill();
} else if (pt.type === 'shard') {
ctx.rotate(pt.rot);
ctx.fillStyle = pt.color;
ctx.strokeStyle = '#2b1808';
ctx.lineWidth = 1.6;
ctx.fillRect(-pt.w * 0.5, -pt.h * 0.5, pt.w, pt.h);
ctx.strokeRect(-pt.w * 0.5, -pt.h * 0.5, pt.w, pt.h);
} else if (pt.type === 'feather') {
ctx.rotate(pt.rot);
ctx.fillStyle = pt.color;
ctx.strokeStyle = '#3b0e0b';
ctx.lineWidth = 1.5;
ctx.beginPath();
ctx.ellipse(0, 0, pt.size, pt.size * 0.42, 0, 0, Math.PI * 2);
ctx.fill();
ctx.stroke();
} else if (pt.type === 'star') {
ctx.rotate(pt.rot);
ctx.fillStyle = '#ffdf38';
ctx.strokeStyle = '#4a2810';
ctx.lineWidth = 1.8;
ctx.beginPath();
for (let i = 0; i < 5; i++) {
const a1 = (i * 2 * Math.PI) / 5 - Math.PI / 2;
const a2 = a1 + Math.PI / 5;
ctx.lineTo(Math.cos(a1) * pt.size, Math.sin(a1) * pt.size);
ctx.lineTo(Math.cos(a2) * (pt.size * 0.45), Math.sin(a2) * (pt.size * 0.45));
}
ctx.closePath();
ctx.fill();
ctx.stroke();
}
ctx.restore();
}
// Floating score popups
for (const fs of floatingScores) {
ctx.save();
const scale = fs.life > 0.8 ? 1 + (1 - fs.life) * 1.4 : 1;
ctx.globalAlpha = Math.max(0, Math.min(1, fs.life * 1.4));
ctx.translate(fs.x, fs.y);
ctx.scale(scale, scale);
ctx.font = `900 ${fs.big ? 32 : 22}px "Trebuchet MS", Impact, sans-serif`;
ctx.textAlign = 'center';
ctx.lineJoin = 'round';
ctx.strokeStyle = '#2d1606';
ctx.lineWidth = fs.big ? 6 : 4.5;
ctx.strokeText(fs.text, 0, 0);
ctx.fillStyle = fs.color;
ctx.fillText(fs.text, 0, 0);
ctx.restore();
}
}
// =========================================================================
// MAIN RENDER LOOP
// =========================================================================
function render() {
ctx.save();
ctx.scale(dpr, dpr);
// 1. Sky & Parallax Hills
drawSkyAndParallax();
// 2. Enter World Coordinate Space
ctx.save();
ctx.translate(camera.offsetX, camera.offsetY);
ctx.scale(camera.scale, camera.scale);
// 3. Ground & Grass
drawGround();
// 4. Trajectory Arc & Flight Trail (behind slingshot & fort)
drawTrajectoryAndTrail();
// 5. Slingshot Back Fork & Back Elastic Band
drawSlingshotBack();
// 6. Waiting Birds on the Ground (left of slingshot)
for (const wb of waitingBirds) {
const bobY = wb.y - Math.abs(Math.sin(wb.phase)) * 4;
// Ground shadow
ctx.fillStyle = 'rgba(0,0,0,0.2)';
ctx.beginPath();
ctx.ellipse(wb.x, GROUND_Y + 1, 18, 5, 0, 0, Math.PI * 2);
ctx.fill();
drawCartoonBird(wb.x, bobY, 0, false);
}
// 7. Bird in Slingshot Pouch (when aiming)
if (slingState === 'aiming' && birdsRemaining > 0) {
const pullDist = Math.hypot(pullPos.x - SLING_X, pullPos.y - SLING_Y);
const aimAngle = pullDist > 8 ? Math.atan2(SLING_Y - pullPos.y, SLING_X - pullPos.x) : 0;
drawCartoonBird(pullPos.x, pullPos.y, aimAngle, pullDist > 60);
}
// 8. Launched Birds in Flight / Tumbled on Ground
for (const b of launchedBirds) {
const spd = Math.hypot(b.velocity.x, b.velocity.y);
const screaming = !b.hasImpacted && spd > 4;
const angle = !b.hasImpacted && spd > 2
? Math.atan2(b.velocity.y, b.velocity.x)
: b.angle;
drawCartoonBird(b.position.x, b.position.y, angle, screaming);
}
// 9. Slingshot Front Elastic Band, Pouch & Front Fork
drawSlingshotFront();
// 10. Fort Blocks (Wood Planks & Stone Blocks)
for (const block of fortBlocks) {
drawFortBlock(block);
}
// 11. Green Pigs Inside the Fort
for (const pig of pigs) {
drawCartoonPig(pig);
}
// 12. Particles & Score Pops
drawParticlesAndScores();
ctx.restore(); // exit world space
ctx.restore(); // exit dpr scale
}
function frameLoop() {
updateGame();
render();
requestAnimationFrame(frameLoop);
}
// Boot the level and start the loop!
initLevel();
updateCameraTransform();
requestAnimationFrame(frameLoop);
})();
</script>
</body>
</html>Same prompt, other models
One email when a new model lands on the board or in the showcase. Unsubscribe anytime.