1816 lines
53 KiB
JavaScript
1816 lines
53 KiB
JavaScript
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();
|
||
}
|