Files
timmy-home/luna/sketch.js
Timmy-Sprint 9cb38d7cec
Some checks failed
Self-Healing Smoke / self-healing-smoke (pull_request) Failing after 19s
Agent PR Gate / gate (pull_request) Failing after 41s
Smoke Test / smoke (pull_request) Failing after 19s
Agent PR Gate / report (pull_request) Successful in 11s
Implement LUNA-2 character controller: jump, gravity, gallop with dust, trail sparkles
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
2026-05-05 09:11:09 -04:00

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
});
}
}