Features added: - Jump with spacebar (tap key event) - Gravity for vertical movement - Gallop detection: distance-based speed boost - Dust particles from hooves during gallop - Trail particles following movement path - Proper ground collision handling - Update index.html title to reflect LUNA-2 Resolves: timmy-home#969
380 lines
9.2 KiB
JavaScript
380 lines
9.2 KiB
JavaScript
/**
|
|
* LUNA-2: Character controller — run, jump, gallop with particle effects
|
|
* Implements:
|
|
* - Jump with gravity
|
|
* - Gallop (speed boost) with dust trail
|
|
* - Trail particles (sparkles) following movement path
|
|
*
|
|
* Builds on LUNA-1 scaffold + LUNA-3 islands and crystals
|
|
*/
|
|
|
|
let particles = [];
|
|
let trailParticles = [];
|
|
let unicornX, unicornY;
|
|
let targetX, targetY;
|
|
let verticalVelocity = 0;
|
|
let isJumping = false;
|
|
let isGalloping = false;
|
|
const GRAVITY = 0.5;
|
|
const JUMP_FORCE = -12;
|
|
const GROUND_Y_FLOOR = height - 40; // will be set after canvas
|
|
let gallopCooldown = 0;
|
|
|
|
// Platforms: floating islands at various heights with horizontal ranges
|
|
const islands = [
|
|
{ x: 100, y: 350, w: 150, h: 20, color: [100, 200, 150] },
|
|
{ x: 350, y: 280, w: 120, h: 20, color: [120, 180, 200] },
|
|
{ x: 550, y: 320, w: 140, h: 20, color: [200, 180, 100] },
|
|
{ x: 200, y: 180, w: 180, h: 20, color: [180, 140, 200] },
|
|
{ x: 500, y: 120, w: 100, h: 20, color: [140, 220, 180] },
|
|
];
|
|
|
|
// Collectible crystals on islands
|
|
const crystals = [];
|
|
islands.forEach((island, i) => {
|
|
const count = 2 + floor(random(2));
|
|
for (let j = 0; j < count; j++) {
|
|
crystals.push({
|
|
x: island.x + 30 + random(island.w - 60),
|
|
y: island.y - 30 - random(20),
|
|
size: 8 + random(6),
|
|
hue: random(280, 340),
|
|
collected: false,
|
|
islandIndex: i
|
|
});
|
|
}
|
|
});
|
|
|
|
let collectedCount = 0;
|
|
const TOTAL_CRYSTALS = crystals.length;
|
|
|
|
// Pink/unicorn palette
|
|
const PALETTE = {
|
|
background: [255, 210, 230],
|
|
unicorn: [255, 182, 193],
|
|
horn: [255, 215, 0],
|
|
mane: [255, 105, 180],
|
|
eye: [255, 20, 147],
|
|
sparkle: [255, 105, 180],
|
|
island: [100, 200, 150],
|
|
dust: [255, 220, 200],
|
|
};
|
|
|
|
function setup() {
|
|
const container = document.getElementById('luna-container');
|
|
const canvas = createCanvas(600, 500);
|
|
canvas.parent('luna-container');
|
|
unicornX = width / 2;
|
|
unicornY = height - 60;
|
|
targetX = unicornX;
|
|
targetY = unicornY;
|
|
noStroke();
|
|
addTapHint();
|
|
// Set ground reference
|
|
GROUND_Y_FLOOR = height - 40;
|
|
}
|
|
|
|
function draw() {
|
|
// Gradient sky background
|
|
for (let y = 0; y < height; y++) {
|
|
const t = y / height;
|
|
const r = lerp(26, 15, t);
|
|
const g = lerp(26, 52, t);
|
|
const b = lerp(46, 96, t);
|
|
stroke(r, g, b);
|
|
line(0, y, width, y);
|
|
}
|
|
|
|
// Draw islands
|
|
islands.forEach(island => {
|
|
push();
|
|
fill(0, 0, 0, 40);
|
|
ellipse(island.x + island.w/2 + 5, island.y + 5, island.w + 10, island.h + 6);
|
|
fill(island.color[0], island.color[1], island.color[2]);
|
|
ellipse(island.x + island.w/2, island.y, island.w, island.h);
|
|
fill(255, 255, 255, 60);
|
|
ellipse(island.x + island.w/2, island.y - island.h/3, island.w * 0.6, island.h * 0.3);
|
|
pop();
|
|
});
|
|
|
|
// Draw crystals
|
|
crystals.forEach(c => {
|
|
if (c.collected) return;
|
|
push();
|
|
translate(c.x, c.y);
|
|
const glow = color(`hsla(${c.hue}, 80%, 70%, 0.4)`);
|
|
noStroke();
|
|
fill(glow);
|
|
ellipse(0, 0, c.size * 2.2, c.size * 2.2);
|
|
const ccol = color(`hsl(${c.hue}, 90%, 75%)`);
|
|
fill(ccol);
|
|
beginShape();
|
|
vertex(0, -c.size);
|
|
vertex(c.size * 0.6, 0);
|
|
vertex(0, c.size);
|
|
vertex(-c.size * 0.6, 0);
|
|
endShape(CLOSE);
|
|
fill(255, 255, 255, 180);
|
|
ellipse(0, 0, c.size * 0.5, c.size * 0.5);
|
|
pop();
|
|
});
|
|
|
|
// Determine gallop: distance-based speed boost
|
|
const distToTarget = dist(unicornX, unicornY, targetX, targetY);
|
|
isGalloping = distToTarget > 80;
|
|
|
|
// Smooth horizontal movement with gallop speed factor
|
|
const baseLerp = 0.08;
|
|
const gallopLerp = isGalloping ? 0.18 : baseLerp;
|
|
unicornX = lerp(unicornX, targetX, gallopLerp);
|
|
|
|
// Vertical movement with jump/gravity
|
|
if (isJumping) {
|
|
unicornY += verticalVelocity;
|
|
verticalVelocity += GRAVITY;
|
|
} else {
|
|
// Apply gravity even when grounded to handle walking off platforms
|
|
// but only if not on ground
|
|
if (unicornY < GROUND_Y_FLOOR) {
|
|
unicornY += verticalVelocity;
|
|
verticalVelocity += GRAVITY;
|
|
if (unicornY >= GROUND_Y_FLOOR) {
|
|
unicornY = GROUND_Y_FLOOR;
|
|
verticalVelocity = 0;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Constrain to screen bounds
|
|
unicornX = constrain(unicornX, 40, width - 40);
|
|
// Vertical bounds (fallback ground)
|
|
if (unicornY > GROUND_Y_FLOOR) {
|
|
unicornY = GROUND_Y_FLOOR;
|
|
verticalVelocity = 0;
|
|
isJumping = false;
|
|
}
|
|
|
|
// Spawn trail particles when moving (and galloping creates more)
|
|
const speed = dist(unicornX, unicornY, targetX, targetY);
|
|
if (speed > 1) {
|
|
const trailCount = isGalloping ? 3 : 1;
|
|
for (let i = 0; i < trailCount; i++) {
|
|
trailParticles.push({
|
|
x: unicornX + random(-10, 10),
|
|
y: unicornY - 20 + random(-5, 5),
|
|
life: isGalloping ? 40 : 25,
|
|
maxLife: isGalloping ? 40 : 25,
|
|
size: isGalloping ? random(4, 8) : random(2, 5),
|
|
hue: random(320, 360),
|
|
vx: random(-0.5, 0.5),
|
|
vy: random(-1, -0.2)
|
|
});
|
|
}
|
|
}
|
|
|
|
// Spawn dust particles when galloping (ground contact assumed)
|
|
if (isGalloping && !isJumping) {
|
|
for (let i = 0; i < 2; i++) {
|
|
particles.push({
|
|
x: unicornX + random(-15, 15),
|
|
y: GROUND_Y_FLOOR + 5,
|
|
vx: random(-1, 1),
|
|
vy: random(-2, -0.5),
|
|
life: 15,
|
|
color: 'rgba(200, 150, 100, 0.6)',
|
|
size: random(3, 6)
|
|
});
|
|
}
|
|
}
|
|
|
|
// Draw trail particles
|
|
for (let i = trailParticles.length - 1; i >= 0; i--) {
|
|
let p = trailParticles[i];
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.life--;
|
|
const alpha = map(p.life, 0, p.maxLife, 0, 200);
|
|
push();
|
|
stroke(`hsla(${p.hue}, 90%, 70%, ${alpha/255})`);
|
|
strokeWeight(p.size);
|
|
point(p.x, p.y);
|
|
pop();
|
|
if (p.life <= 0) trailParticles.splice(i, 1);
|
|
}
|
|
|
|
// Draw sparkles (now redundant but keep complementarily)
|
|
drawSparkles();
|
|
|
|
// Draw the unicorn
|
|
drawUnicorn(unicornX, unicornY);
|
|
|
|
// Update standard particles
|
|
updateParticles();
|
|
|
|
// Collection detection
|
|
for (let c of crystals) {
|
|
if (c.collected) continue;
|
|
const d = dist(unicornX, unicornY, c.x, c.y);
|
|
if (d < 35) {
|
|
c.collected = true;
|
|
collectedCount++;
|
|
createCollectionBurst(c.x, c.y, c.hue);
|
|
}
|
|
}
|
|
|
|
// HUD
|
|
document.getElementById('score').textContent = `Crystals: ${collectedCount}/${TOTAL_CRYSTALS}`;
|
|
document.getElementById('position').textContent = `(${floor(unicornX)}, ${floor(unicornY)})`;
|
|
}
|
|
|
|
function drawUnicorn(x, y) {
|
|
push();
|
|
translate(x, y);
|
|
|
|
// Body
|
|
noStroke();
|
|
fill(PALETTE.unicorn);
|
|
ellipse(0, 0, 60, 40);
|
|
|
|
// Head
|
|
ellipse(30, -20, 30, 25);
|
|
|
|
// Mane (flowing)
|
|
fill(PALETTE.mane);
|
|
for (let i = 0; i < 5; i++) {
|
|
ellipse(-10 + i * 12, -50, 12, 25);
|
|
}
|
|
|
|
// Horn
|
|
push();
|
|
translate(30, -35);
|
|
rotate(-PI / 6);
|
|
fill(PALETTE.horn);
|
|
triangle(0, 0, -8, -35, 8, -35);
|
|
pop();
|
|
|
|
// Eye
|
|
fill(PALETTE.eye);
|
|
ellipse(38, -22, 8, 8);
|
|
|
|
// Legs (simple)
|
|
stroke(PALETTE.unicorn[0] - 40);
|
|
strokeWeight(6);
|
|
line(-20, 20, -20, 45);
|
|
line(20, 20, 20, 45);
|
|
|
|
pop();
|
|
}
|
|
|
|
function drawSparkles() {
|
|
if (abs(targetX - unicornX) > 1 || abs(targetY - unicornY) > 1) {
|
|
for (let i = 0; i < 3; i++) {
|
|
let angle = random(TWO_PI);
|
|
let r = random(20, 50);
|
|
let sx = unicornX + cos(angle) * r;
|
|
let sy = unicornY + sin(angle) * r;
|
|
stroke(PALETTE.sparkle[0], PALETTE.sparkle[1], PALETTE.sparkle[2], 150);
|
|
strokeWeight(2);
|
|
point(sx, sy);
|
|
}
|
|
}
|
|
}
|
|
|
|
function createCollectionBurst(x, y, hue) {
|
|
for (let i = 0; i < 20; i++) {
|
|
let angle = random(TWO_PI);
|
|
let speed = random(2, 6);
|
|
particles.push({
|
|
x: x,
|
|
y: y,
|
|
vx: cos(angle) * speed,
|
|
vy: sin(angle) * speed,
|
|
life: 60,
|
|
color: `hsl(${hue + random(-20, 20)}, 90%, 70%)`,
|
|
size: random(3, 6)
|
|
});
|
|
}
|
|
for (let i = 0; i < 12; i++) {
|
|
let angle = random(TWO_PI);
|
|
particles.push({
|
|
x: x,
|
|
y: y,
|
|
vx: cos(angle) * 4,
|
|
vy: sin(angle) * 4,
|
|
life: 40,
|
|
color: 'rgba(255, 215, 0, 0.9)',
|
|
size: 4
|
|
});
|
|
}
|
|
}
|
|
|
|
function updateParticles() {
|
|
for (let i = particles.length - 1; i >= 0; i--) {
|
|
let p = particles[i];
|
|
p.x += p.vx;
|
|
p.y += p.vy;
|
|
p.vy += 0.1;
|
|
p.life--;
|
|
p.vx *= 0.95;
|
|
p.vy *= 0.95;
|
|
if (p.life <= 0) {
|
|
particles.splice(i, 1);
|
|
continue;
|
|
}
|
|
push();
|
|
stroke(p.color);
|
|
strokeWeight(p.size);
|
|
point(p.x, p.y);
|
|
pop();
|
|
}
|
|
}
|
|
|
|
// Input handling
|
|
function mousePressed() {
|
|
// If mobile, this also handles touch due to p5.js compatibility
|
|
targetX = mouseX;
|
|
targetY = mouseY;
|
|
addPulseAt(targetX, targetY);
|
|
}
|
|
|
|
function keyPressed() {
|
|
if (key === ' ' || keyCode === 32) { // spacebar
|
|
if (!isJumping) {
|
|
// Start jump if currently grounded (roughly)
|
|
if (verticalVelocity === 0) {
|
|
isJumping = true;
|
|
verticalVelocity = JUMP_FORCE;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function addTapHint() {
|
|
for (let i = 0; i < 5; i++) {
|
|
particles.push({
|
|
x: random(width),
|
|
y: random(height),
|
|
vx: random(-0.5, 0.5),
|
|
vy: random(-0.5, 0.5),
|
|
life: 200,
|
|
color: 'rgba(233, 69, 96, 0.5)',
|
|
size: 3
|
|
});
|
|
}
|
|
}
|
|
|
|
function addPulseAt(x, y) {
|
|
for (let i = 0; i < 12; i++) {
|
|
let angle = (TWO_PI / 12) * i;
|
|
particles.push({
|
|
x: x,
|
|
y: y,
|
|
vx: cos(angle) * 3,
|
|
vy: sin(angle) * 3,
|
|
life: 30,
|
|
color: 'rgba(233, 69, 96, 0.7)',
|
|
size: 3
|
|
});
|
|
}
|
|
}
|