Files
kakamora-game/sketch.js
2026-05-09 21:58:54 +09:00

1816 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = []; // 0100, 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`));
// char27: 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();
}