const DEV_SAILING = false; // false = normal game flow let trail = []; let velocities = []; let particles = []; let flashAlpha = 0; let score = 0; let bgBlue = 0; let beachImg, sailBgImg; let moanaFont; let beachSound, sliceSound, strikesSound, drumsSound, shipSound, owSound, bombSound; let charFrames = []; let battleFrames = []; let slotChars = new Array(10).fill(-1); let slotOffsets = new Array(10).fill(0); const ANI_FPS = 18; const BATTLE_ANI_FPS = 5; // faster for battle sprites const CHAR_SCALES = [0.9, 1.5, 1.2, 1.5, 1.5, 1.5, 1.5]; let charOrder = []; let charOrderIdx = 0; // ====== GAME STATE ====== let gameState = 'name'; // 'name' | 'intro' | 'playing' | 'departure' let playerName = ''; let nameInput, nameConfirmBtn; const COCONUT_TIME_MS = 5000; let coconutTimerEnd = 0; // millis() when current coconut expires let slotPowers = []; // 0–100, speed bonus per slot let coconut = { x: 0, y: 0, r: 0, state: 0, slices: 0, shakeX: 0, shakeY: 0, shakeTimer: 0, baseAngle: 0, flying: false, flyVX: 0, flyVY: 0, flyAlpha: 255, flyRotation: 0, flyRotSpeed: 0, bounces: 0, squish: 1, entering: false, enterT: 0, enterFromX: 0, enterFromY: 0 }; let coconutImgs = []; let flyingKakamoraList = []; let knifeImg; let effectImgs = []; let imgParticles = []; let slashLingerAlpha = 0; let sailBgOffset = 0; let wakeParticles = []; let warriors = []; let rocks = []; let battleHits = []; let battleStarted = false; let battleOver = false; let battleResult = ''; let rockSpawnCd = 0; let lingerTrail = []; let lingerVelocities = []; let hitBursts = []; let deflectedRocks = []; let scorePopups = []; let activeWarriorIdx = 0; let screenFlash = 0; let battleScore = 0; let attackKeyPressed = false; let pCanvas; let proton, fireEmitter; // ====== PRELOAD ====== function preload() { for (let i = 0; i < 5; i++) { coconutImgs[i] = loadImage(`assets/coconut/coconut_${i}.png`); } // char1 charFrames[0] = []; for (let i = 1; i <= 5; i++) charFrames[0].push(loadImage(`assets/kakamora-characters/char1/co_ani_${i}.png`)); // char2–7: Frame N.png const charRanges = [[6,10],[11,15],[16,21],[22,26],[27,31],[32,36]]; for (let c = 0; c < charRanges.length; c++) { let [start, end] = charRanges[c]; charFrames[c + 1] = []; for (let n = start; n <= end; n++) charFrames[c + 1].push(loadImage(`assets/kakamora-characters/char${c + 2}/Frame ${n}.png`)); } // battle animations: char1-2 ~ char7-2 const battleFrameNums = [ [45, 61, 62, 63, 64], [46, 47, 48, 49, 50], [51, 52, 53, 54, 55], [56, 57, 58, 59, 60], [65, 66, 67], [68, 69, 70, 71, 72], [73, 74, 75], ]; for (let c = 0; c < 7; c++) { battleFrames[c] = []; for (let n of battleFrameNums[c]) { battleFrames[c].push(loadImage(`assets/kakamora-characters/char${c + 1}-2/Frame ${n}.png`)); } } beachImg = loadImage('assets/beach.png'); sailBgImg = loadImage('assets/image.png'); moanaFont = loadFont('assets/fonts/Moanas.ttf'); knifeImg = loadImage('assets/knife.png'); for (let i = 0; i < 3; i++) effectImgs.push(loadImage(`assets/effect/particles${i}.png`)); } // ====== SETUP ====== function setup() { pCanvas = createCanvas(windowWidth, windowHeight); resetCoconutHome(); window.addEventListener('keydown', function(e) { if (e.code === 'Space' || e.code === 'KeyZ') { attackKeyPressed = true; e.preventDefault(); } }); world.gravity.y = 0; shuffleCharOrder(); Howler.autoSuspend = false; beachSound = new Howl({ src: ['assets/sound/beach sound.wav'], loop: true, volume: 0.45 }); sliceSound = new Howl({ src: ['assets/sound/mixkit-knife-fast-hit-2184.wav'], volume: 0.75 }); strikesSound = new Howl({ src: ['assets/sound/strikes.wav'], volume: 0.6 }); drumsSound = new Howl({ src: ['assets/sound/mixkit-drums-of-war-call-2780.wav'], loop: true, volume: 0.55 }); shipSound = new Howl({ src: ['assets/sound/mixkit-wooden-ship-on-the-sea-1187.wav'], loop: true, volume: 0.5 }); owSound = new Howl({ src: ['assets/sound/mixkit-ow-exclamation-of-pain-2204.wav'], volume: 2.0 }); bombSound = new Howl({ src: ['assets/sound/mixkit-bomb-explosion-in-battle-2800.wav'], volume: 0.6 }); // HTML name input nameInput = createInput(''); nameInput.attribute('placeholder', 'Your name, brave one...'); nameInput.attribute('maxlength', '16'); nameInput.style('font-size', '18px'); nameInput.style('font-family', 'Nunito, sans-serif'); nameInput.style('background', 'rgba(14, 20, 36, 0.92)'); nameInput.style('color', '#e8f4ff'); nameInput.style('border', '2px solid rgba(90, 185, 255, 0.55)'); nameInput.style('border-radius', '2px'); nameInput.style('padding', '10px 16px'); nameInput.style('width', '260px'); nameInput.style('outline', 'none'); nameInput.style('letter-spacing', '1px'); nameInput.style('position', 'absolute'); nameInput.elt.addEventListener('keydown', e => { if (e.key === 'Enter') confirmName(); }); positionNameUI(); if (DEV_SAILING) { nameInput.hide(); score = 7; for (let i = 0; i < 7; i++) { slotChars[i] = i; slotOffsets[i] = floor(random(1000)); slotPowers[i] = floor(random(40, 100)); } battleStarted = false; gameState = 'sailing'; } } function positionNameUI() { let pw = min(windowWidth * 0.52, 500); let ph = 300; let px = (windowWidth - pw) / 2; let py = (windowHeight - ph) / 2; let inputW = pw * 0.72; let inputX = px + (pw - inputW) / 2; nameInput.position(inputX, py + 168); nameInput.style('width', inputW + 'px'); } function shuffleCharOrder() { charOrder = [0, 1, 2, 3, 4, 5, 6]; for (let i = charOrder.length - 1; i > 0; i--) { let j = floor(random(i + 1)); [charOrder[i], charOrder[j]] = [charOrder[j], charOrder[i]]; } charOrderIdx = 0; } function windowResized() { resizeCanvas(windowWidth, windowHeight); resetCoconutHome(); if (gameState === 'name') positionNameUI(); } function resetCoconutHome() { coconut.r = min(windowWidth, windowHeight) * 0.13; coconut.x = width / 2; coconut.y = height * 0.42; } // ====== DRAW ====== function draw() { // Dev shortcut — uses p5play kb so it works regardless of other listeners if (kb.presses('s') && gameState !== 'playing') { score = 7; for (let i = 0; i < 7; i++) { slotChars[i] = i; slotOffsets[i] = floor(random(1000)); slotPowers[i] = floor(random(40, 100)); } battleStarted = false; gameState = 'sailing'; if (!shipSound.playing()) shipSound.play(); } if (gameState === 'sailing') { drawSailingScene(); return; } imageMode(CORNER); image(beachImg, 0, 0, width, height); if (bgBlue > 0) { fill(25, 90, 200, bgBlue * 0.4); noStroke(); rect(0, 0, width, height); bgBlue = max(0, bgBlue - 4); } if (gameState === 'name') { drawNameEntry(); return; } if (gameState === 'playing') { checkCoconutTimer(); updateShake(); updateEnter(); drawCoconut(); drawCoconutTimer(); drawTrail(); updateImgParticles(); drawImgParticles(); } drawParticles(); drawFlash(); if (gameState !== 'intro' && gameState !== 'name') { drawKakamoraSlots(); updateFlyingKakamoraList(); drawFlyingKakamoraList(); } if (gameState === 'intro') drawIntroQuest(); if (gameState === 'departure') drawDepartureQuest(); } // ====== COCONUT ====== function drawCoconut() { let alpha = coconut.entering ? (coconut.enterT < 1 ? lerp(50, 220, coconut.enterT) : 255) : 255; tint(255, alpha); push(); translate(coconut.x + coconut.shakeX, coconut.y + coconut.shakeY); rotate(coconut.baseAngle); scale(coconut.squish, 1 / coconut.squish); imageMode(CENTER); image(coconutImgs[coconut.state], 0, 0, coconut.r * 2, coconut.r * 2); pop(); noTint(); } function updateEnter() { if (!coconut.entering) return; coconut.enterT += 0.10; // phase 0→1 = fall, 1→2 = bounce up let groundY = height * 0.80; let startX = width + coconut.r; let startY = height * 0.22; let midX = width * 0.58; // where it hits ground let endX = width / 2; let endY = height * 0.42; coconut.baseAngle += 0.08; // spin while flying in if (coconut.enterT < 1) { // Phase 1: arc down from right to ground let t = coconut.enterT; coconut.x = lerp(startX, midX, t); coconut.y = lerp(startY, groundY, t * t); // ease-in (accelerate downward) } else if (coconut.enterT < 1.05) { // Squash on impact coconut.squish = 1.7; coconut.x = midX; coconut.y = groundY; } else if (coconut.enterT < 2) { // Phase 2: bounce from ground up to center let t = coconut.enterT - 1; // 0 → 1 coconut.squish += (1 - coconut.squish) * 0.2; // spring back coconut.x = lerp(midX, endX, t); // Parabola: starts at groundY, arcs up, lands at endY let yLinear = lerp(groundY, endY, t); let yArc = sin(t * PI) * (groundY - endY) * 0.55; coconut.y = yLinear - yArc; } else { coconut.entering = false; coconut.squish = 1; coconut.x = endX; coconut.y = endY; } } function startCoconutEntrance() { coconut.entering = true; coconut.enterT = 0; coconut.squish = 1; coconut.x = width + coconut.r; coconut.y = height * 0.22; coconut.baseAngle = random(TWO_PI); } function updateShake() { if (coconut.shakeTimer > 0) { coconut.shakeX = sin(coconut.shakeTimer * 2.4) * coconut.shakeTimer * 0.7; coconut.shakeY = cos(coconut.shakeTimer * 3.1) * coconut.shakeTimer * 0.4; coconut.shakeTimer--; } else { coconut.shakeX = 0; coconut.shakeY = 0; } } function updateFlyAway() { if (!coconut.flying) return; coconut.x += coconut.flyVX; coconut.y += coconut.flyVY; coconut.flyVY += 0.6; coconut.flyRotation += coconut.flyRotSpeed; // Squish springs back toward 1 coconut.squish += (1 - coconut.squish) * 0.22; // Ground bounce let groundY = height * 0.82; if (coconut.y >= groundY && coconut.flyVY > 0 && coconut.bounces < 3) { coconut.y = groundY; coconut.flyVY *= -0.48; // bounce up with damping coconut.flyVX *= 0.80; // friction coconut.flyRotSpeed *= 0.55; coconut.squish = 1.65; // squash on impact coconut.bounces++; } // Fade only after 2nd bounce if (coconut.bounces >= 2) coconut.flyAlpha -= 11; if (coconut.flyAlpha <= 0 || coconut.y > height + 150) { if (score < 10) launchKakamoraToSlot(score); score++; coconut.state = 0; coconut.slices = 0; coconut.flying = false; coconut.flyAlpha = 255; coconut.flyRotation = 0; coconut.shakeX = 0; coconut.shakeY = 0; coconut.shakeTimer = 0; coconut.bounces = 0; coconut.squish = 1; coconutTimerEnd = millis() + COCONUT_TIME_MS; if (score >= 7 && gameState === 'playing') { gameState = 'departure'; } else { startCoconutEntrance(); } } } // ====== TRAIL INPUT ====== function mouseDragged() { if (gameState !== 'playing') return; let pos = createVector(mouseX, mouseY); trail.push(pos); let dx = 0, dy = 0; if (trail.length > 1) { let prev = trail[trail.length - 2]; dx = pos.x - prev.x; dy = pos.y - prev.y; velocities.push(sqrt(dx * dx + dy * dy)); } else { velocities.push(0); } if (dist(mouseX, mouseY, coconut.x, coconut.y) < coconut.r && !coconut.entering) { spawnParticles(mouseX, mouseY); spawnImgParticles(mouseX, mouseY, dx, dy); coconut.shakeTimer = max(coconut.shakeTimer, 18); } if (trail.length > 22) trail.shift(); if (velocities.length > 22) velocities.shift(); } function mouseReleased() { if (gameState !== 'playing') { trail = []; velocities = []; return; } let hits = trail.filter(p => dist(p.x, p.y, coconut.x, coconut.y) < coconut.r).length; if (hits >= 2 && !coconut.entering) { sliceSound.play(); coconut.slices++; coconut.state = floor(coconut.slices / 2); flashAlpha = 25; if (coconut.slices >= 8) { let power = floor(max(0, (coconutTimerEnd - millis()) / COCONUT_TIME_MS) * 100); slotPowers[score] = power; flashAlpha = 80; bgBlue = 255; if (score < 7) launchKakamoraToSlot(score); score++; coconut.state = 0; coconut.slices = 0; coconut.shakeX = 0; coconut.shakeY = 0; coconut.shakeTimer = 0; coconut.squish = 1; coconutTimerEnd = millis() + COCONUT_TIME_MS; if (score >= 7 && gameState === 'playing') { gameState = 'departure'; } else { startCoconutEntrance(); } } } // linger the slash line briefly lingerTrail = trail.slice(); lingerVelocities = velocities.slice(); slashLingerAlpha = 255; trail = []; velocities = []; } // ====== TRAIL DRAW ====== function drawSlashSegments(pts, vels, masterAlpha) { if (pts.length < 2) return; noFill(); for (let i = 1; i < pts.length; i++) { let t = i / pts.length; // 0=tail, 1=head let speed = vels[i] || 0; let a = t * masterAlpha; // Outer glow strokeWeight(map(speed, 0, 40, 6, 22)); stroke(255, 240, 180, a * 0.18); line(pts[i-1].x, pts[i-1].y, pts[i].x, pts[i].y); // Mid glow strokeWeight(map(speed, 0, 40, 3, 12)); stroke(255, 255, 220, a * 0.45); line(pts[i-1].x, pts[i-1].y, pts[i].x, pts[i].y); // Sharp core strokeWeight(map(speed, 0, 40, 1, 4)); stroke(255, 255, 255, a); line(pts[i-1].x, pts[i-1].y, pts[i].x, pts[i].y); } noStroke(); } function drawTrail() { drawSlashSegments(trail, velocities, 255); // Linger fade after release if (slashLingerAlpha > 0) { drawSlashSegments(lingerTrail, lingerVelocities, slashLingerAlpha); slashLingerAlpha -= 28; if (slashLingerAlpha <= 0) { lingerTrail = []; lingerVelocities = []; } } } // ====== SLOT LAYOUT (horizontal top-left) ====== const S_MARGIN = 14; const S_HDRH = 30; const S_PAD = 12; const S_GAP = 10; function getSlotSize() { // fit 7 slots between left margin and timer area (right ~220px) let available = width - S_MARGIN * 2 - S_PAD * 2 - 6 * S_GAP - 220; return constrain(floor(available / 7), 60, 92); } function getSlotPos(i) { let sz = getSlotSize(); return { x: S_MARGIN + S_PAD + sz / 2 + i * (sz + S_GAP), y: S_MARGIN + S_HDRH + S_PAD + sz / 2, size: sz }; } // ====== KAKAMORA SLOTS ====== function drawKakamoraSlots() { let sz = getSlotSize(); let panelW = S_PAD * 2 + 7 * sz + 6 * S_GAP; let pwrH = 20; let panelH = S_HDRH + S_PAD + sz + pwrH + S_PAD; let px = S_MARGIN; let py = S_MARGIN; // Panel background fill(12, 16, 26, 210); noStroke(); rect(px, py, panelW, panelH, 2); // Header bar fill(35, 42, 60, 255); noStroke(); rect(px, py, panelW, S_HDRH, 2, 2, 0, 0); fill(255, 195, 55); textAlign(LEFT, CENTER); textFont(moanaFont); textSize(13); text('WARRIOR CREW', px + S_PAD, py + S_HDRH / 2); // score counter right side textAlign(RIGHT, CENTER); fill(200, 210, 230); textSize(12); text(`${score} / 7`, px + panelW - S_PAD, py + S_HDRH / 2); textFont('Nunito, sans-serif'); for (let i = 0; i < 7; i++) { let { x, y, size } = getSlotPos(i); let filled = i < score; if (filled && slotChars[i] >= 0) { let frames = charFrames[slotChars[i]]; let frameIdx = floor((frameCount + slotOffsets[i]) / ANI_FPS) % frames.length; let img = frames[frameIdx]; let sc = min(size / img.width, size / img.height) * CHAR_SCALES[slotChars[i]]; drawingContext.save(); drawingContext.beginPath(); drawingContext.arc(x, y, size / 2, 0, Math.PI * 2); drawingContext.clip(); imageMode(CENTER); image(img, x, y, img.width * sc, img.height * sc); drawingContext.restore(); // Power display — threshold at 55: pwr >= 55 = full green let pwr = slotPowers[i] !== undefined ? slotPowers[i] : 0; let pwrT = min(pwr / 55, 1); let pwrCol = lerpColor(color(220, 50, 50), color(50, 215, 85), pwrT); // Glow ring (outer) — pulses slightly based on power noFill(); strokeWeight(7); stroke(red(pwrCol), green(pwrCol), blue(pwrCol), 55 + pwrT * 50); ellipse(x, y, size + 11, size + 11); // Sharp ring (inner) strokeWeight(2.5); stroke(pwrCol); ellipse(x, y, size + 2, size + 2); noStroke(); // Power badge below circle let pillW = floor(size * 0.86); let pillH = 23; let pillX = x - pillW / 2; let pillY = y + size / 2 + 6; // shadow fill(0, 0, 0, 150); rect(pillX + 1, pillY + 1, pillW, pillH, 12); // dark background fill(8, 10, 20, 240); rect(pillX, pillY, pillW, pillH, 12); // progress fill — bar shows true pwr/100, color uses lowered threshold fill(red(pwrCol), green(pwrCol), blue(pwrCol), 210); let barW = max(0, (pillW - 4) * (pwr / 100)); if (barW > 0) rect(pillX + 2, pillY + 2, barW, pillH - 4, 10); // label: "PWR XX" fill(255, 255, 255, 245); textAlign(CENTER, CENTER); textFont('Nunito, sans-serif'); textStyle(BOLD); textSize(12); text(`PWR ${pwr}`, x, pillY + pillH / 2); textStyle(NORMAL); textFont('Nunito, sans-serif'); } else { fill(22, 28, 42); noStroke(); ellipse(x, y, size, size); stroke(50, 58, 78); strokeWeight(1.5); noFill(); ellipse(x, y, size, size); fill(55, 65, 88); noStroke(); textAlign(CENTER, CENTER); textSize(13); text(i + 1, x, y); } } } // ====== FLYING KAKAMORA ====== function launchKakamoraToSlot(slotIdx) { let { x: tx, y: ty, size } = getSlotPos(slotIdx); let charIdx = charOrder[charOrderIdx % 7]; charOrderIdx++; slotChars[slotIdx] = charIdx; slotOffsets[slotIdx] = floor(random(1000)); flyingKakamoraList.push({ startX: width / 2, startY: height * 0.42, targetX: tx, targetY: ty, t: 0, charIdx, questShown: false, size }); } function updateFlyingKakamoraList() { for (let k of flyingKakamoraList) k.t += 0.032; flyingKakamoraList = flyingKakamoraList.filter(k => k.t <= 1); } function drawFlyingKakamoraList() { for (let k of flyingKakamoraList) { let ease = 1 - pow(1 - min(k.t, 1), 2); let x = lerp(k.startX, k.targetX, ease); let y = lerp(k.startY, k.targetY, ease) - sin(k.t * PI) * height * 0.3; let sz = lerp(k.size * 1.8, k.size, ease); let frames = charFrames[k.charIdx]; let frameIdx = floor(frameCount / ANI_FPS) % frames.length; let img = frames[frameIdx]; let sc = min(sz / img.width, sz / img.height) * CHAR_SCALES[k.charIdx]; drawingContext.save(); drawingContext.beginPath(); drawingContext.arc(x, y, sz / 2, 0, Math.PI * 2); drawingContext.clip(); imageMode(CENTER); image(img, x, y, img.width * sc, img.height * sc); drawingContext.restore(); } } // ====== PARTICLES ====== class Particle { constructor(x, y) { this.pos = createVector(x, y); // 전방향 랜덤 퍼짐 (드래그 방향 편향 없이 360도) let baseAngle = random(TWO_PI); let spd = random(2, 6); this.vel = createVector(cos(baseAngle) * spd, sin(baseAngle) * spd); this.life = 255; this.r = random(1.5, 3.5); this.isHusk = random() > 0.4; } update() { this.pos.add(this.vel); this.vel.y += 0.22; this.vel.mult(0.91); this.life -= 9; } draw() { noStroke(); if (this.isHusk) fill(158, 108, 52, this.life); else fill(242, 224, 188, this.life); ellipse(this.pos.x, this.pos.y, this.r, this.r * 0.55); } } function spawnParticles(x, y) { for (let i = 0; i < 12; i++) particles.push(new Particle(x, y)); } function drawParticles() { for (let i = particles.length - 1; i >= 0; i--) { particles[i].update(); particles[i].draw(); if (particles[i].life <= 0) particles.splice(i, 1); } } // ====== FLASH ====== function drawFlash() { if (flashAlpha > 0) { fill(255, flashAlpha); rect(0, 0, width, height); flashAlpha -= 20; } } // ====== COCONUT TIMER ====== function checkCoconutTimer() { if (coconut.flying || coconutTimerEnd === 0) return; if (millis() > coconutTimerEnd) { coconut.state = 0; coconut.slices = 0; coconutTimerEnd = millis() + COCONUT_TIME_MS; } } function drawCoconutTimer() { if (coconut.flying || coconutTimerEnd === 0) return; let fraction = max(0, (coconutTimerEnd - millis()) / COCONUT_TIME_MS); let secs = (fraction * (COCONUT_TIME_MS / 1000)).toFixed(1); let c = lerpColor(color(255, 60, 60), color(80, 220, 100), fraction); // Top-right pill let tx = width - 18; let ty = 18; let label = `TIME ${secs}s`; textFont('Nunito, sans-serif'); textSize(26); let tw = textWidth(label) + 36; let th = 54; fill(12, 16, 26, 220); noStroke(); rect(tx - tw, ty, tw, th, 2); // Progress bar fill(30, 37, 54); rect(tx - tw + 8, ty + th - 12, tw - 16, 6, 2); fill(c); rect(tx - tw + 8, ty + th - 12, (tw - 16) * fraction, 6, 2); fill(c); textAlign(RIGHT, TOP); text(label, tx - 10, ty + 10); textFont('Nunito, sans-serif'); } // ====== SHARED DIALOG HELPER ====== function drawDialogBox(dx, dy, dw, dh, speakerName, lines, btnLabel) { fill(0, 0, 0, 90); noStroke(); rect(0, 0, width, height); fill(28, 32, 45, 185); noStroke(); rect(dx, dy, dw, dh, 2); // Name banner — sharp corners textFont(moanaFont); textSize(20); let bw = textWidth(speakerName) + 44; let bh = 36; fill(210, 105, 20); stroke(255, 175, 70); strokeWeight(2); rect(dx + 22, dy - bh / 2, bw, bh, 2); noStroke(); fill(255); textAlign(LEFT, CENTER); text(speakerName, dx + 34, dy + 2); // Quest lines — centered textFont('Nunito, sans-serif'); textSize(14); fill(210, 210, 215); textAlign(CENTER, CENTER); let lineSpacing = 30; let totalH = lines.length * lineSpacing; let startY = dy + 46 + (dh - 46 - 56 - totalH) / 2 + lineSpacing / 2; for (let i = 0; i < lines.length; i++) { text(lines[i], dx + dw / 2, startY + i * lineSpacing); } // Button row let btnW = 180; let btnH = 40; let btnX = dx + dw / 2 - btnW / 2; let btnY = dy + dh - btnH - 16; let over = mouseX >= btnX && mouseX <= btnX + btnW && mouseY >= btnY && mouseY <= btnY + btnH; fill(over ? color(255, 200, 60) : color(200, 140, 30)); stroke(255, 230, 120); strokeWeight(2); rect(btnX, btnY, btnW, btnH, 10); noStroke(); fill(30, 15, 5); textFont('Nunito, sans-serif'); textStyle(BOLD); textAlign(CENTER, CENTER); textSize(18); text(btnLabel, dx + dw / 2, btnY + btnH / 2 + 1); textStyle(NORMAL); } // ====== INTRO QUEST ====== function drawIntroQuest() { let dw = min(width * 0.54, 580); let dh = 230; let dx = (width - dw) / 2; let dy = (height - dh) / 2; drawDialogBox(dx, dy, dw, dh, 'Chief Tamatoa', [ 'Crack each coconut to free a warrior. You have 5 seconds.', 'Slash fast — the quicker the strike, the stronger they fight.', 'Claim all 7. The ocean waits for no one.' ], 'BEGIN'); } // ====== DEPARTURE QUEST ====== function drawDepartureQuest() { let dw = min(width * 0.54, 580); let dh = 190; let dx = (width - dw) / 2; let dy = (height - dh) / 2; drawDialogBox(dx, dy, dw, dh, 'Chief Tamatoa', [ 'Your crew is assembled. The Kakamora won\'t wait.', 'Deflect their rocks. Protect your warriors.', 'Hold the line until the last one standing.' ], 'SET SAIL'); } // ====== NAME ENTRY ====== function drawNameEntry() { fill(0, 0, 0, 130); noStroke(); rect(0, 0, width, height); let pw = min(width * 0.52, 500); let ph = 300; let px = (width - pw) / 2; let py = (height - ph) / 2; // Panel background fill(14, 20, 36, 230); noStroke(); rect(px, py, pw, ph, 6); // Header bar fill(22, 32, 54); rect(px, py, pw, 54, 6, 6, 0, 0); // Title — large, gold textFont(moanaFont); textAlign(CENTER, CENTER); textSize(26); fill(255, 215, 80); text('THE OCEAN IS CALLING', width / 2, py + 27); textFont('Nunito, sans-serif'); // Sub-headline — medium, bright white textSize(16); textStyle(BOLD); fill(220, 240, 255); text('A warrior without a name cannot sail.', width / 2, py + 88); textStyle(NORMAL); // Flavour lines — smaller, muted textSize(13); fill(120, 155, 200); text('The Kakamora fear those who are named.', width / 2, py + 116); text('Chief Tamatoa demands to know yours.', width / 2, py + 136); // Canvas-drawn JOIN THE CREW button let bw = 200, bh = 44; let bx = width / 2 - bw / 2, by = py + ph - bh - 20; let over = mouseX >= bx && mouseX <= bx + bw && mouseY >= by && mouseY <= by + bh; fill(over ? color(255, 200, 60) : color(200, 140, 30)); stroke(255, 230, 120); strokeWeight(2); rect(bx, by, bw, bh, 10); noStroke(); fill(30, 15, 5); textFont('Nunito, sans-serif'); textStyle(BOLD); textSize(17); textAlign(CENTER, CENTER); text('JOIN THE CREW', width / 2, by + bh / 2 + 1); textStyle(NORMAL); } function confirmName() { let val = nameInput.value().trim(); if (val.length === 0) return; playerName = val; nameInput.hide(); gameState = 'intro'; } // ====== IMAGE PARTICLES ====== class ImgParticle { constructor(x, y, dx, dy) { this.pos = createVector(x, y); let baseAngle = (dx !== 0 || dy !== 0) ? atan2(dy, dx) + PI + random(-0.6, 0.6) : random(TWO_PI); let spd = random(1.5, 4.5); this.vel = createVector(cos(baseAngle) * spd, sin(baseAngle) * spd); this.life = 255; this.size = random(20, 40); this.img = random(effectImgs); this.rot = random(TWO_PI); this.rotSpeed = random(-0.18, 0.18); } update() { this.pos.add(this.vel); this.vel.y += 0.14; this.vel.mult(0.91); this.life -= 14; this.rot += this.rotSpeed; } draw() { push(); translate(this.pos.x, this.pos.y); rotate(this.rot); tint(255, this.life); imageMode(CENTER); image(this.img, 0, 0, this.size, this.size); noTint(); pop(); } } function spawnImgParticles(x, y, dx, dy) { for (let i = 0; i < 4; i++) imgParticles.push(new ImgParticle(x, y, dx, dy)); } function updateImgParticles() { for (let i = imgParticles.length - 1; i >= 0; i--) { imgParticles[i].update(); if (imgParticles[i].life <= 0) imgParticles.splice(i, 1); } } function drawImgParticles() { for (let p of imgParticles) p.draw(); } // ====== KEYBOARD HANDLER ====== function keyPressed() {} // ====== MOUSE HANDLER ====== function mousePressed() { if (gameState === 'name') { let ph = 300; let py = (height - ph) / 2; let bw = 200, bh = 44; let bx = width / 2 - bw / 2, by = py + ph - bh - 20; if (mouseX >= bx && mouseX <= bx + bw && mouseY >= by && mouseY <= by + bh) confirmName(); return; } if (gameState === 'intro') { gameState = 'playing'; coconutTimerEnd = millis() + COCONUT_TIME_MS; startCoconutEntrance(); beachSound.play(); drumsSound.play(); return; } if (gameState === 'departure') { let b = getSailBtnBounds(); if (mouseX >= b.x && mouseX <= b.x + b.w && mouseY >= b.y && mouseY <= b.y + b.h) { drumsSound.stop(); beachSound.stop(); battleStarted = false; gameState = 'sailing'; shipSound.play(); } return; } if (gameState === 'sailing' && battleOver && battleResult === 'defeat') { let bw = 180, bh = 46; let bx = width/2 - bw/2, by = height/2 + 120; if (mouseX >= bx && mouseX <= bx + bw && mouseY >= by && mouseY <= by + bh) { // Reset battle deflectedRocks = []; scorePopups = []; hitBursts = []; screenFlash = 0; initBattle(); } return; } } // Returns SET SAIL button hit rect (mirrors drawDepartureQuest layout) function getSailBtnBounds() { let dw = min(width * 0.54, 580); let dh = 190; let dx = (width - dw) / 2; let dy = (height - dh) / 2; return { x: dx + dw / 2 - 90, y: dy + dh - 36 - 16, w: 180, h: 36 }; } // Dev shortcut: press 'S' from any non-playing screen to jump to sailing // ====== BATTLE SYSTEM ====== class Rock { constructor(tx, ty) { // Always launch from screen center (background enemy position) this.vpx = width / 2 + random(-width * 0.08, width * 0.08); this.vpy = height * 0.32 + random(-30, 30); this.tx = tx + random(-30, 30); this.ty = ty; // Arc height: how high above the straight line the peak rises this.arcH = random(height * 0.22, height * 0.38); this.t = 0; this.spd = random(0.020, 0.032); this.r0 = random(5, 9); this.r1 = random(60, 90); this.x = this.vpx; this.y = this.vpy; this.size = this.r0; this.rot = random(TWO_PI); this.rotSpd = random(-0.18, 0.18); this.hp = floor(random(2, 4)); this.maxHp = this.hp; this.hit = 0; this.targetWarrior = null; // homing reference this.trail = []; this.verts = []; for (let i = 0; i < 9; i++) { let a = (TWO_PI / 9) * i + random(-0.3, 0.3); this.verts.push({ a, r: random(0.65, 1.25) }); } } update() { // Soft homing toward active warrior x if (this.targetWarrior && !this.targetWarrior.dead && this.t < 0.70) { this.tx = lerp(this.tx, this.targetWarrior.x, 0.025 * this.t); } this.trail.push({ x: this.x, y: this.y, size: this.size }); if (this.trail.length > 10) this.trail.shift(); this.t = min(this.t + this.spd, 1); this.x = lerp(this.vpx, this.tx, this.t); // Parabolic arc: linear interpolation minus upward arc peak at t=0.5 this.y = lerp(this.vpy, this.ty, this.t) - this.arcH * sin(this.t * PI); this.size = lerp(this.r0, this.r1, this.t * this.t); this.rot += this.rotSpd * (1 + this.t * 2); if (this.hit > 0) this.hit--; } draw() { // Fire trail for (let i = 0; i < this.trail.length; i++) { let tp = this.trail[i]; let frac = i / this.trail.length; let ta = frac * 0.6; let ts = tp.size * frac * 0.85; noStroke(); // Trail fades from orange-red at head to dark red at tail fill(lerpColor(color(255, 40, 0, ta * 220), color(180, 0, 0, ta * 80), 1 - frac)); ellipse(tp.x, tp.y, ts * 2.0, ts * 2.0); } let flashHit = this.hit > 0; let pulse = 0.5 + 0.5 * sin(frameCount * 0.5); // pulsing glow push(); translate(this.x, this.y); rotate(this.rot); // Wide outer fire glow (additive-like, very transparent) let glowR = this.size * (1.7 + pulse * 0.25); noStroke(); fill(255, 60, 0, 35 + pulse * 30); beginShape(); for (let v of this.verts) vertex(cos(v.a) * v.r * glowR, sin(v.a) * v.r * glowR); endShape(CLOSE); // Mid glow — orange fill(255, 120, 0, 80 + pulse * 40); let midR = this.size * (1.25 + pulse * 0.1); beginShape(); for (let v of this.verts) vertex(cos(v.a) * v.r * midR, sin(v.a) * v.r * midR); endShape(CLOSE); // Main body — deep red to orange-red let c = flashHit ? color(255, 240, 80) : lerpColor(color(200, 20, 0), color(255, 80, 10), pulse); fill(c); beginShape(); for (let v of this.verts) vertex(cos(v.a) * v.r * this.size, sin(v.a) * v.r * this.size); endShape(CLOSE); // Hot white-yellow core highlight stroke(255, flashHit ? 255 : 180, flashHit ? 100 : 40, flashHit ? 255 : 140 + pulse * 80); strokeWeight(3); noFill(); beginShape(); for (let v of this.verts) vertex(cos(v.a) * v.r * this.size * 0.72, sin(v.a) * v.r * this.size * 0.72); endShape(CLOSE); pop(); noStroke(); } } function initBattle() { warriors = []; activeWarriorIdx = 0; battleScore = 0; let frontY = height * 0.80; let backY = height * 0.88; let backXs = [0.05, 0.19, 0.33, 0.55, 0.72, 0.88]; for (let i = 0; i < 7; i++) { let pwr = slotPowers[i] !== undefined ? slotPowers[i] : 50; let maxHp = max(2, floor(pwr / 35) + 1); let px = i === 0 ? width / 2 : width * backXs[i - 1]; let py = i === 0 ? frontY : backY + random(-15, 15); warriors.push({ charIdx: slotChars[i] >= 0 ? slotChars[i] : i % 7, offset: slotOffsets[i] || 0, power: pwr, maxHp, hp: maxHp, dead: false, deathT: 0, hitFlash: 0, x: px, y: py, jumpT: 0, jumpH: 0, dashCd: 0, bobRate: random(0.04, 0.09), bobPhase: random(TWO_PI) }); } rocks = []; battleHits = []; hitBursts = []; battleOver = false; battleResult = ''; rockSpawnCd = 40; initFireEffect(); } function initFireEffect() { if (!window.Proton) return; if (proton) { proton.destroy(); proton = null; } proton = new Proton(); fireEmitter = new Proton.Emitter(); // Start with rate=0; updated each frame based on dead count fireEmitter.rate = new Proton.Rate(new Proton.Span(0, 0), 0.05); fireEmitter.addInitialize(new Proton.Radius(6, 20)); fireEmitter.addInitialize(new Proton.Life(0.7, 1.8)); fireEmitter.addInitialize( new Proton.Velocity(new Proton.Span(1.5, 3.5), new Proton.Span(260, 280, true), 'polar') ); fireEmitter.addBehaviour(new Proton.Color('#ff2200', '#ffcc00')); fireEmitter.addBehaviour(new Proton.Alpha(0.85, 0)); fireEmitter.addBehaviour(new Proton.Scale(1.4, 0)); fireEmitter.p.x = width / 2; fireEmitter.p.y = height + 5; fireEmitter.emit(); proton.addEmitter(fireEmitter); proton.addRenderer(new Proton.CanvasRenderer(pCanvas.elt)); } function spawnBurst(x, y, big, redRing = false) { let count = big ? 32 : 18; for (let b = 0; b < count; b++) { let a = random(TWO_PI); let spd = big ? random(4, 14) : random(3, 9); let col = b < count * 0.25 ? [255, 255, 200] : b < count * 0.55 ? [255, floor(random(120, 200)), 20] : [220, 55, 20]; hitBursts.push({ x, y, vx: cos(a) * spd, vy: sin(a) * spd - (big ? 3.5 : 2), life: floor(random(big ? 28 : 20, big ? 55 : 38)), maxLife: big ? 55 : 38, r: random(big ? 8 : 4, big ? 22 : 13), ring: false, col }); } let rings = big ? 3 : 2; for (let ri = 0; ri < rings; ri++) { let life = 22 - ri * 5; hitBursts.push({ x, y, ring: true, redRing, life, maxLife: life, r: big ? 14 - ri * 3 : 8 - ri * 2 }); } if (big) screenFlash = 12; } function updateBattle() { if (battleOver) return; if (screenFlash > 0) screenFlash--; let aw = warriors[activeWarriorIdx]; // ── Player controls active warrior ── if (aw && !aw.dead) { if (kb.pressing('left')) aw.x = max(width * 0.06, aw.x - 13); if (kb.pressing('right')) aw.x = min(width * 0.94, aw.x + 13); if (aw.jumpT > 0) aw.jumpT = max(0, aw.jumpT - 0.07); if (aw.hitFlash > 0) aw.hitFlash--; // Space or Z = attack swing if (attackKeyPressed) { attackKeyPressed = false; aw.jumpT = 1.0; aw.jumpH = 60; strikesSound.play(); let hit = false; // Find closest rock in generous range let closest = null, closestD = Infinity; for (let r of rocks) { let d = dist(aw.x, aw.y, r.x, r.y); if (d < r.size + 100 && d < closestD) { closestD = d; closest = r; } } if (closest) { closest.hp -= 2; closest.hit = 12; battleHits.push({ x1: aw.x, y1: aw.y - 30, x2: closest.x, y2: closest.y, life: 14, type: 'slash' }); if (closest.hp <= 0) { // Deflect: rock flies away let dvx = (closest.x - aw.x) * 0.22 + random(-8, 8); let dvy = random(-22, -14); deflectedRocks.push({ x: closest.x, y: closest.y, vx: dvx, vy: dvy, size: closest.size * 1.15, rot: closest.rot, rotSpd: (dvx > 0 ? 1 : -1) * random(0.32, 0.65), verts: closest.verts, alpha: 255, trail: [] }); spawnBurst(closest.x, closest.y, true, true); let pts = max(1, floor(aw.power / 20)); battleScore += pts; scorePopups.push({ x: aw.x + random(-20, 20), y: aw.y - 80, val: '+' + pts, life: 55, maxLife: 55 }); rocks.splice(rocks.indexOf(closest), 1); } else { // Partial hit — still give 1 point battleScore += 1; scorePopups.push({ x: aw.x + random(-20, 20), y: aw.y - 80, val: '+1', life: 40, maxLife: 40 }); } hit = true; } if (!hit) battleHits.push({ x1: aw.x - 70, y1: aw.y - 20, x2: aw.x + 70, y2: aw.y - 40, life: 10, type: 'slash' }); } } // ── Spawn rocks — irregular timing, burst or trickle ── rockSpawnCd--; if (rockSpawnCd <= 0 && aw && !aw.dead) { let baseRate = max(10, 30 - floor(battleScore * 0.4)); let maxVolley = min(4, 1 + floor(battleScore / 8)); // Random pattern each volley: single, quick double, or full burst let pattern = random(); let count, nextCd; if (pattern < 0.35) { // Single rock, short wait count = 1; nextCd = floor(random(baseRate * 0.6, baseRate * 1.1)); } else if (pattern < 0.70) { // Quick 2-rock burst count = min(2, maxVolley); nextCd = floor(random(baseRate * 0.9, baseRate * 1.5)); } else { // Full volley, medium breather count = maxVolley; nextCd = floor(random(baseRate * 1.2, baseRate * 2.0)); } for (let v = 0; v < count; v++) { let targetX = random() < 0.6 ? aw.x + random(-width * 0.22, width * 0.22) : random(width * 0.05, width * 0.95); let r = new Rock(targetX, aw.y); r.targetWarrior = aw; // Each rock in a volley gets a slightly different speed so they don't land together r.spd = random(0.018, 0.042) + battleScore * 0.0008; rocks.push(r); } rockSpawnCd = nextCd; } // ── Update rocks, check collision ── for (let i = rocks.length - 1; i >= 0; i--) { let r = rocks[i]; r.update(); if (r.t >= 1) { // Only damage if warrior is near landing spot and NOT jumping let hitRadius = r.r1 * 0.55 + 35; let inAir = aw && aw.jumpH >= 130 && aw.jumpT > 0.15; let actuallyHit = aw && !aw.dead && !inAir && dist(r.tx, r.ty, aw.x, aw.y) < hitRadius; if (actuallyHit) { aw.hp--; aw.hitFlash = 28; aw.power = max(5, aw.power - 8); owSound.play(); spawnBurst(aw.x, aw.y, true); battleHits.push({ x1: r.x, y1: r.y, x2: aw.x, y2: aw.y, life: 18, type: 'impact' }); if (aw.hp <= 0) { aw.dead = true; let next = activeWarriorIdx + 1; while (next < warriors.length && warriors[next].dead) next++; activeWarriorIdx = next; // Reposition next warrior to front if (activeWarriorIdx < warriors.length) { warriors[activeWarriorIdx].x = width / 2; warriors[activeWarriorIdx].y = height * 0.80; } } } else { bombSound.play(); } rocks.splice(i, 1); } } // Dead warriors tick deathT for (let w of warriors) { if (w.dead) w.deathT++; } for (let i = battleHits.length - 1; i >= 0; i--) { if (--battleHits[i].life <= 0) battleHits.splice(i, 1); } if (activeWarriorIdx >= warriors.length) { battleOver = true; battleResult = 'defeat'; shipSound.stop(); } } function drawWarriorsOnDeck() { for (let i = 0; i < warriors.length; i++) { let w = warriors[i]; let isActive = (i === activeWarriorIdx); let sz = isActive ? 110 : 85; let frames = (isActive ? battleFrames[w.charIdx] : charFrames[w.charIdx]) || charFrames[w.charIdx]; if (!frames) continue; if (!w.dead) { let bobY = sin(frameCount * w.bobRate + w.bobPhase) * (isActive ? 5 : 3); let jumpY = sin(w.jumpT * PI) * w.jumpH; let drawY = w.y + bobY - jumpY; let fps = isActive ? BATTLE_ANI_FPS : 32; // waiting warriors animate slowly let fi = floor((frameCount + w.offset) / fps) % frames.length; let img = frames[fi]; let sc = min(sz / img.width, sz / img.height) * CHAR_SCALES[w.charIdx]; // Waiting warriors: dimmer if (!isActive) tint(180, 180, 200, 190); if (w.hitFlash > 0 && w.hitFlash % 4 < 2) tint(255, 70, 70); imageMode(CENTER); image(img, w.x, drawY, img.width * sc, img.height * sc); noTint(); // Active warrior glow ring if (isActive) { noFill(); stroke(100, 220, 255, 80 + sin(frameCount * 0.1) * 40); strokeWeight(5); ellipse(w.x, drawY, sz * 1.2, sz * 1.2); noStroke(); } // Shadow — scales with jump height let shadowScale = map(jumpY, 0, 130, 1, 0.15); noStroke(); fill(0, 0, 0, 55 * shadowScale); ellipse(w.x, w.y + sz * 0.52, sz * 0.9 * shadowScale, sz * 0.18 * shadowScale); // HP bar (active only shows full; waiting shows compact) if (isActive) { let hpF = w.hp / w.maxHp; let barW = sz + 20; let barH = 8; let barX = w.x - barW / 2; let barY = w.y + sz * 0.65; noStroke(); fill(20, 20, 20, 200); rect(barX, barY, barW, barH, 4); fill(lerpColor(color(220, 50, 50), color(55, 210, 85), min(hpF / 0.55, 1))); rect(barX, barY, barW * hpF, barH, 4); // Power label let pwrT = min(w.power / 55, 1); let pwrCol = lerpColor(color(220, 80, 80), color(80, 220, 100), pwrT); noStroke(); fill(0, 0, 0, 140); rect(w.x - 26, drawY - sz * 0.68 - 14, 52, 17, 4); fill(pwrCol); textAlign(CENTER, CENTER); textFont('Nunito, sans-serif'); textStyle(BOLD); textSize(11); text(`PWR ${w.power}`, w.x, drawY - sz * 0.68 - 6); textStyle(NORMAL); textFont('Nunito, sans-serif'); } } else if (w.deathT < 50) { let img = frames[0]; let sc = min(sz / img.width, sz / img.height) * CHAR_SCALES[w.charIdx]; tint(200, 70, 70, max(0, 220 - w.deathT * 5)); imageMode(CENTER); image(img, w.x, w.y + w.deathT * 1.6, img.width * sc, img.height * sc); noTint(); } } } function drawBattleHits() { for (let h of battleHits) { let t = h.life / 16; // 1→0 let a = t * 255; if (h.type === 'slash') { // Wide glowing slash arc — 3 layered strokes let cx = (h.x1 + h.x2) / 2; let cy = (h.y1 + h.y2) / 2; let ang = atan2(h.y2 - h.y1, h.x2 - h.x1); let len = dist(h.x1, h.y1, h.x2, h.y2); // Outer glow (very wide, faint orange-white) stroke(255, 210, 100, a * 0.4); strokeWeight(55 * t); push(); noFill(); translate(cx, cy); rotate(ang); arc(0, 0, len * 1.5, len * 0.9, -PI * 0.6, PI * 0.6); pop(); // Mid glow (yellow-white) stroke(255, 245, 160, a * 0.7); strokeWeight(26 * t); push(); noFill(); translate(cx, cy); rotate(ang); arc(0, 0, len * 1.25, len * 0.7, -PI * 0.55, PI * 0.55); pop(); // Sharp bright core (white) stroke(255, 255, 255, a); strokeWeight(6 * t); push(); noFill(); translate(cx, cy); rotate(ang); arc(0, 0, len * 1.0, len * 0.55, -PI * 0.5, PI * 0.5); pop(); // Radial spark lines at impact point let sp = min(h.life, 8) / 8; let nSpk = 10; stroke(255, 240, 120, sp * 240); strokeWeight(3 * sp); for (let k = 0; k < nSpk; k++) { let sa = ang + map(k, 0, nSpk - 1, -PI * 0.6, PI * 0.6); let r0 = 14; let r1 = 14 + sp * random(35, 70); line(h.x2 + cos(sa) * r0, h.y2 + sin(sa) * r0, h.x2 + cos(sa) * r1, h.y2 + sin(sa) * r1); } } else { // Impact hit — orange blast ring let a2 = map(h.life, 0, 16, 0, 255); noFill(); stroke(255, 140, 40, a2 * 0.7); strokeWeight(8 * t); ellipse((h.x1 + h.x2) / 2, (h.y1 + h.y2) / 2, 50 * (1 - t + 0.3), 50 * (1 - t + 0.3)); stroke(255, 200, 100, a2); strokeWeight(2.5 * t); ellipse((h.x1 + h.x2) / 2, (h.y1 + h.y2) / 2, 28 * (1 - t + 0.3), 28 * (1 - t + 0.3)); } } noStroke(); } // ====== WAKE PARTICLES ====== class WakeParticle { constructor(x, y) { this.x = x + random(-22, 22); this.y = y + random(-5, 8); this.vx = random(1.8, 4.5); // drifts right as boat moves left this.vy = random(-0.3, 0.7); this.life = random(140, 240); this.decay = random(3.5, 6); this.r = random(3, 10); } update() { this.x += this.vx; this.y += this.vy; this.vx *= 0.97; this.life -= this.decay; } draw() { noStroke(); fill(255, 255, 255, this.life * 0.6); ellipse(this.x, this.y, this.r, this.r * 0.42); } } // ====== SAILING SCENE ====== function drawSailingScene() { if (!battleStarted) { initBattle(); battleStarted = true; if (!shipSound.playing()) shipSound.play(); } // Fill canvas first so rotation gaps never show white background(10, 18, 35); let t = millis() / 1000; let tiltDeg = sin(t * 0.22) * 10 + sin(t * 0.57) * 2.5; let tilt = radians(tiltDeg); // Scale image to cover the full canvas (contain = no crop, cover = no gap) let imgW = sailBgImg.width; let imgH = sailBgImg.height; let scaleX = width / imgW; let scaleY = height / imgH; // Use the larger scale so the image covers the canvas (no gaps), then +extra for rotation let sc = max(scaleX, scaleY) * 1.42; let drawW = imgW * sc; let drawH = imgH * sc; push(); translate(width / 2, height / 2); rotate(tilt); imageMode(CENTER); image(sailBgImg, 0, 0, drawW, drawH); pop(); // Battle logic + rendering updateBattle(); for (let r of rocks) r.draw(); drawWarriorsOnDeck(); drawBattleHits(); // Hit burst particles noStroke(); for (let i = hitBursts.length - 1; i >= 0; i--) { let b = hitBursts[i]; b.life--; if (b.life <= 0) { hitBursts.splice(i, 1); continue; } let t = b.life / b.maxLife; if (b.ring) { let radius = map(t, 1, 0, 10, 90); push(); noFill(); if (b.redRing) stroke(255, 40, 40, t * 255); else stroke(255, 200, 80, t * 220); strokeWeight(4 * t); ellipse(b.x, b.y, radius * 2, radius * 2); pop(); } else if (b.ghost) { // Dash afterimage — wide horizontal streak noStroke(); fill(180, 230, 255, t * 140); rect(b.x - 30, b.y - 40, 60, 80, 8); } else { b.x += b.vx; b.y += b.vy; b.vy += 0.28; b.vx *= 0.92; fill(b.col[0], b.col[1], b.col[2], t * 230); ellipse(b.x, b.y, b.r * t + 1, b.r * t + 1); } } noStroke(); // Deflected rocks flying away for (let i = deflectedRocks.length - 1; i >= 0; i--) { let d = deflectedRocks[i]; d.trail.push({ x: d.x, y: d.y, rot: d.rot, alpha: d.alpha }); if (d.trail.length > 8) d.trail.shift(); d.x += d.vx; d.y += d.vy; d.vy += 0.6; d.vx *= 0.98; d.rot += d.rotSpd; d.alpha -= 4; if (d.alpha <= 0 || d.y > height + 120) { deflectedRocks.splice(i, 1); continue; } // Motion trail (ghost copies fading out) for (let ti = 0; ti < d.trail.length; ti++) { let tr = d.trail[ti]; let ta = (ti / d.trail.length) * d.alpha * 0.4; let ts = d.size * (0.55 + 0.45 * (ti / d.trail.length)); push(); translate(tr.x, tr.y); rotate(tr.rot); fill(200, 170, 120, ta); noStroke(); beginShape(); for (let v of d.verts) vertex(cos(v.a) * v.r * ts, sin(v.a) * v.r * ts); endShape(CLOSE); pop(); } // Main rock push(); translate(d.x, d.y); rotate(d.rot); // Glowing orange outline at first if (d.alpha > 160) { stroke(255, 140, 40, (d.alpha - 160) * 2); strokeWeight(3); } else { noStroke(); } fill(160, 140, 110, d.alpha); beginShape(); for (let v of d.verts) vertex(cos(v.a) * v.r * d.size, sin(v.a) * v.r * d.size); endShape(CLOSE); noStroke(); pop(); } // Score popups for (let i = scorePopups.length - 1; i >= 0; i--) { let p = scorePopups[i]; p.life--; if (p.life <= 0) { scorePopups.splice(i, 1); continue; } let t = p.life / p.maxLife; let py = p.y - (1 - t) * 70; // floats upward let sz = t > 0.75 ? map(t, 0.75, 1, 38, 52) : map(t, 0, 0.75, 14, 38); // pop then shrink textAlign(CENTER, CENTER); textFont('Nunito, sans-serif'); textStyle(BOLD); textSize(sz); // Shadow fill(80, 40, 0, t * 200); text(p.val, p.x + 2, py + 2); // Main text fill(255, 240, 60, t * 255); stroke(200, 80, 0, t * 220); strokeWeight(3); text(p.val, p.x, py); textStyle(NORMAL); noStroke(); } // Wake particles if (frameCount % 2 === 0) wakeParticles.push(new WakeParticle(width * 0.78, height * 0.73)); if (frameCount % 5 === 0) { let p = new WakeParticle(width * 0.22, height * 0.69); p.vx = random(-1.5, 0.5); p.vy = random(-2.5, -0.8); p.decay = 9; wakeParticles.push(p); } for (let i = wakeParticles.length - 1; i >= 0; i--) { wakeParticles[i].update(); wakeParticles[i].draw(); if (wakeParticles[i].life <= 0) wakeParticles.splice(i, 1); } // Screen flash on hit if (screenFlash > 0) { noStroke(); fill(255, 120, 30, screenFlash * 14); rect(0, 0, width, height); } // Fire effect at bottom — scales with dead warriors if (proton && fireEmitter && warriors.length > 0) { let deadCount = warriors.filter(w => w.dead).length; let n = deadCount * 5; fireEmitter.rate = new Proton.Rate(new Proton.Span(n, n + 3), 0.05); // Spread emitter x across bottom based on dead count fireEmitter.p.x = width / 2 + sin(millis() * 0.001) * (deadCount * 30); fireEmitter.p.y = height + 5; drawingContext.save(); drawingContext.globalCompositeOperation = 'lighter'; proton.update(); drawingContext.restore(); } // Top status bar drawBattleStatusBar(); // Score + controls HUD if (!battleOver) { // Score pill top-right let aw = warriors[activeWarriorIdx]; noStroke(); fill(8, 12, 22, 200); rect(width - 160, 10, 150, 52, 6); textFont('Nunito, sans-serif'); textSize(11); fill(160, 190, 220); textAlign(LEFT, TOP); text('SCORE', width - 148, 18); textStyle(BOLD); textSize(26); fill(255, 220, 80); text(battleScore, width - 148, 32); textStyle(NORMAL); // Power loss warning flash if (aw && aw.hitFlash > 0) { textSize(13); fill(255, 80, 80); textAlign(CENTER, TOP); text('-PWR', width - 83, 20); } // Controls textFont('Nunito, sans-serif'); textSize(12); fill(255, 255, 255, 100); textAlign(CENTER, BOTTOM); text('← → MOVE SPACE / Z ATTACK', width / 2, height - 12); textFont('Nunito, sans-serif'); } // Game over if (battleOver && battleResult === 'defeat') { fill(0, 0, 0, 160); noStroke(); rect(0, 0, width, height); textFont(moanaFont); textAlign(CENTER, CENTER); textSize(80); fill(220, 55, 55); text('DEFEAT', width/2, height/2 - 30); textFont('Nunito, sans-serif'); textSize(20); fill(200, 175, 155); text('The crew has fallen.', width/2, height/2 + 40); textSize(26); fill(255, 215, 80); text(`SCORE: ${battleScore}`, width/2, height/2 + 80); // Try Again button let bw = 180, bh = 46; let bx = width/2 - bw/2, by = height/2 + 120; let over = mouseX >= bx && mouseX <= bx + bw && mouseY >= by && mouseY <= by + bh; fill(over ? color(255, 200, 60) : color(200, 140, 30)); stroke(255, 230, 120); strokeWeight(2); rect(bx, by, bw, bh, 10); noStroke(); fill(30, 15, 5); textFont('Nunito, sans-serif'); textStyle(BOLD); textSize(20); text('TRY AGAIN', width/2, by + bh/2 + 1); textStyle(NORMAL); } } function drawBattleStatusBar() { let n = warriors.length; if (n === 0) return; let iconSz = 36; let gap = 8; let barW = n * (iconSz + gap) - gap + 24; let barH = iconSz + 22; let bx = (width - barW) / 2; let by = 10; // Panel noStroke(); fill(8, 12, 22, 200); rect(bx, by, barW, barH, 6); for (let i = 0; i < n; i++) { let w = warriors[i]; let cx = bx + 12 + i * (iconSz + gap) + iconSz / 2; let cy = by + barH / 2; let frames = battleFrames[w.charIdx] || charFrames[w.charIdx]; if (!frames) continue; if (w.dead) { // Dark slot + X mark fill(40, 10, 10, 220); ellipse(cx, cy, iconSz, iconSz); stroke(200, 50, 50, 200); strokeWeight(2.5); let r = iconSz * 0.28; line(cx - r, cy - r, cx + r, cy + r); line(cx + r, cy - r, cx - r, cy + r); noStroke(); } else { // Alive — small portrait clipped to circle let fi = floor((frameCount + w.offset) / ANI_FPS) % frames.length; let img = frames[fi]; let sc = min(iconSz / img.width, iconSz / img.height) * CHAR_SCALES[w.charIdx]; drawingContext.save(); drawingContext.beginPath(); drawingContext.arc(cx, cy, iconSz / 2, 0, Math.PI * 2); drawingContext.clip(); imageMode(CENTER); image(img, cx, cy, img.width * sc, img.height * sc); drawingContext.restore(); // HP ring let hpF = w.hp / w.maxHp; let hpCol = lerpColor(color(220, 50, 50), color(55, 210, 85), min(hpF / 0.55, 1)); noFill(); stroke(hpCol); strokeWeight(2.5); arc(cx, cy, iconSz + 3, iconSz + 3, -HALF_PI, -HALF_PI + TWO_PI * hpF); noStroke(); } } // Dead count label let deadCount = warriors.filter(w => w.dead).length; if (deadCount > 0) { fill(220, 60, 60); textAlign(RIGHT, CENTER); textFont(moanaFont); textSize(13); text(`${deadCount} FALLEN`, bx + barW - 6, by + barH / 2); textFont('Nunito, sans-serif'); } } function drawWarriorCircle(charIdx, offset, x, y, sz) { let frames = charFrames[charIdx]; let frameIdx = floor((frameCount + offset) / ANI_FPS) % frames.length; let img = frames[frameIdx]; let sc = min(sz / img.width, sz / img.height) * CHAR_SCALES[charIdx]; drawingContext.save(); drawingContext.beginPath(); drawingContext.arc(x, y, sz / 2, 0, Math.PI * 2); drawingContext.clip(); imageMode(CENTER); image(img, x, y, img.width * sc, img.height * sc); drawingContext.restore(); noFill(); strokeWeight(2.5); stroke(255, 255, 255, 190); ellipse(x, y, sz, sz); noStroke(); }