working draft

This commit is contained in:
2026-05-09 13:41:33 +09:00
parent 1535ed975a
commit 7c370ce507
27 changed files with 756 additions and 265 deletions

View File

@@ -20,7 +20,6 @@
},
"dependencies": {
"p5": "^1.11.4",
"p5play": "^3.22.0",
"sirv-cli": "^2.0.0",
"svelte-spa-router": "^3.3.0"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 61 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -6,17 +6,14 @@ import Home from './routes/Home.svelte';
import LevelSelect from './routes/LevelSelect.svelte';
import Game from './routes/Game.svelte';
import GameOver from './routes/GameOver.svelte';
import Win from './routes/Win.svelte';
// this is the application of the svelte-spa-router
// e.g. /#/game will show the Game component
// this is kind of like switching between HTML pages but we switch components instead
const routes = {
'/': Home,
'/levelselect': LevelSelect,
'/game': Game,
'/gameover': GameOver,
'/win': Win,
};
</script>

View File

@@ -1,81 +1,127 @@
<script>
import {onMount, onDestroy} from 'svelte';
import {Player} from '../game/Player.js';
import { onMount, onDestroy } from 'svelte';
import { Player } from '../game/Player.js';
import { get } from 'svelte/store';
import {lives, colorOpacity, levelColor, onFragmentCollection, resetLevel, completeLevel} from '../stores/colorStore.js';
import {push} from 'svelte-spa-router';
import {Enemy} from '../game/Enemy.js';
import {TarPuddle} from '../game/TarPuddle.js';
import {getLevel} from '../game/levelData.js';
import {Fragment} from '../game/Fragment.js';
import { lives, colorOpacity, levelColor, onFragmentCollection, resetLevel, completeLevel } from '../stores/colorStore.js';
import { push } from 'svelte-spa-router';
import { Enemy } from '../game/Enemy.js';
import { TarPuddle } from '../game/TarPuddle.js';
import { getLevel } from '../game/levelData.js';
import { Fragment } from '../game/Fragment.js';
// level to load - passed in from Game.svelte
export let levelNumber = 1;
let canvasContainer;
let canvasContainer; // the div p5 will draw into
let p5Instance;
const keysDown = {left: false, right: false, jump: false};
const keysDown = { left: false, right: false, jump: false };
const onKeyDown = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'a') { keysDown.left = true; e.preventDefault(); }
if (e.key === 'ArrowRight' || e.key === 'd') { keysDown.right = true; e.preventDefault(); }
// e.repeat blocks the browser auto-repeat from re-queuing a jump while held
if ((e.key === 'ArrowUp' || e.key === 'w' || e.key === ' ') && !e.repeat) {
keysDown.jump = true;
e.preventDefault();
}
};
const onKeyUp = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'a') keysDown.left = false;
if (e.key === 'ArrowRight' || e.key === 'd') keysDown.right = false;
// jump is not cleared here — player.update() consumes it so quick taps aren't lost
};
// clear all keys when the window loses focus so nothing stays stuck
const onBlur = () => {
keysDown.left = false;
keysDown.right = false;
keysDown.jump = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
onMount(() => {
// IMPORTANT: parameter is named "p" not "p5"
// "p5" is the global constructor from the CDN script
// if you name the parameter "p5" it shadows the global and breaks new p5(sketch)
// naming it "p5" would shadow the global and break new p5(sketch)
const sketch = (p) => {
let player;
let platforms;
let fragments;
let bgImg;
let splatImg;
let fragments = [];
let enemies = [];
let tarPuddles = [];
let gameState = 'playing';
let splats = [];
let levelData; // declared once here at sketch level
let splats = []; // paint splash effects on collection
let levelData; // declared at sketch level so all functions can access it
let gameState = 'playing'; // 'playing' | 'levelcomplete' | 'gameover'
let bgImg;
let playerImgLoaded;
let splatImg;
// fragment collection callback
// defined here at sketch level so both setup and draw can see it
const LAST_LEVEL = 10;
// onFragmentCollected lives here so both setup and draw can see it
function onFragmentCollected(hexColor, x, y) {
player.collectFragment(hexColor);
onFragmentCollection(fragments.length, hexColor);
splats.push({x, y, alpha: 200, size: 30, color: hexColor});
onFragmentCollection(fragments.length, hexColor); // update the color store
splats.push({ x, y, alpha: 200, size: 30 }); // add paint splash
if (fragments.every(f => f.collected)) {
gameState = 'levelcomplete';
completeLevel(levelData.color);
setTimeout(() => push('/levelselect'), 2500);
const dest = levelNumber === LAST_LEVEL ? '/win' : '/levelselect';
setTimeout(() => push(dest), 2500);
}
}
p.preload = () => {
bgImg = p.loadImage('/backgrounds/temp-background.png');
const data = getLevel(levelNumber);
if (data?.bg) bgImg = p.loadImage(data.bg);
if (data?.playerImg) playerImgLoaded = p.loadImage(data.playerImg);
splatImg = p.loadImage('/assets/splat.png');
};
p.setup = () => {
// create canvas inside the container div
const canvas = p.createCanvas(800, 450);
canvas.parent(canvasContainer);
// assign to the outer levelData, no "const" here
// gravity — makes sprites fall downward
// must be set before creating sprites
p.world.gravity.y = 30;
// load level data
levelData = getLevel(levelNumber);
// reset color store for this level
resetLevel(levelNumber, levelData.color);
// gravity must be set before creating sprites
p.world.gravity.y = 10;
// create player at the level spawn point
player = new Player(p, levelData.spawnX, levelData.spawnY);
if (playerImgLoaded) player.sprite.img = playerImgLoaded;
platforms = new p.Group();
// create platforms — rounded-corner graphic is drawn onto an offscreen canvas
// and used as the sprite image so physics box stays rectangular
for (const platData of levelData.platforms) {
const plat = new p.Sprite(platData.x, platData.y, platData.w, platData.h);
plat.collider = 'static';
plat.image = '/assets/tile.png';
platforms.add(plat);
const g = p.createGraphics(platData.w, platData.h);
g.clear();
g.noStroke();
g.fill('#888888');
g.rect(0, 0, platData.w, platData.h, 6);
plat.img = g;
}
// create fragments from level data
fragments = [];
for (const fragData of levelData.fragments) {
fragments.push(new Fragment(p, fragData.x, fragData.y, fragData.color));
}
// create enemies from level data
enemies = [];
for (const enemyData of levelData.enemies) {
const e = new Enemy(p, enemyData.x, enemyData.y);
@@ -83,6 +129,7 @@
enemies.push(e);
}
// create tar puddles from level data
tarPuddles = [];
for (const tarData of levelData.tar) {
tarPuddles.push(new TarPuddle(p, tarData.x, tarData.y));
@@ -90,107 +137,114 @@
};
p.draw = () => {
p.background(30);
// draw background image if loaded
if (bgImg) {
p.image(bgImg, 0, 0, p.width, p.height);
} else {
p.background(30);
}
// color tint overlay based on fragments collected
// ── 1. PHYSICS FIRST ──────────────────────────────────────────
// p5play needs this every frame to run gravity and collision
// touching.bottom is only accurate AFTER this runs
p.allSprites.update();
// ── 2. COLOR TINT OVERLAY ─────────────────────────────────────
// as fragments are collected, the world gradually gains color
// reveal goes from 0.0 (gray) to 1.0 (full color)
const reveal = get(colorOpacity);
const hex = get(levelColor);
if (reveal > 0 && hex) {
const col = p.color(hex);
// lerp blends between gray (128) and the level color
const r = p.lerp(128, p.red(col), reveal);
const g = p.lerp(128, p.green(col), reveal);
const b = p.lerp(128, p.blue(col), reveal);
p.noStroke();
p.fill(r, g, b, reveal * 120);
p.fill(r, g, b, reveal * 120); // max alpha 120 so its a subtle tint
p.rect(0, 0, p.width, p.height);
}
// fragments
for (const frag of fragments) {
frag.drawGlow();
frag.update(player.sprite, onFragmentCollected);
}
// player glow then update
player.drawGlow();
// ── 3. PLAYER INPUT ───────────────────────────────────────────
// update runs AFTER allSprites.update() so touching.bottom is accurate
player.update(keysDown);
player.sprite.visible = player.isVisible();
player.drawGlow();
player.sprite.visible = player.isVisible(); // handles blink when invincible
// enemies
// ── 4. FRAGMENTS ──────────────────────────────────────────────
for (const frag of fragments) {
frag.drawGlow(); // colored glow under the fragment
frag.update(player.sprite, onFragmentCollected); // checks overlap with player
}
// ── 5. ENEMIES ────────────────────────────────────────────────
for (const enemy of enemies) {
enemy.update();
if (enemy.overlapsPlayer(player.sprite)) {
const dead = player.loseLife();
lives.set(player.lives);
if (dead) {
enemy.update(); // patrol movement
// p5play overlap check — true when sprites are touching
if (player.sprite.overlaps(enemy.sprite)) {
const dead = player.loseLife(lives);
if (dead && gameState === 'playing') {
gameState = 'gameover';
setTimeout(() => push('/gameover'), 1000);
// short delay so player sees what happened
setTimeout(() => push(`/gameover?level=${levelNumber}`), 500);
}
}
}
// tar puddles
// ── 6. TAR PUDDLES ────────────────────────────────────────────
for (const tar of tarPuddles) {
if (tar.overlapsPlayer(player.sprite)) {
const dead = player.loseLife();
lives.set(player.lives);
if (dead) {
if (player.sprite.overlaps(tar.sprite)) {
const dead = player.loseLife(lives);
if (dead && gameState === 'playing') {
gameState = 'gameover';
setTimeout(() => push('/gameover'), 1000);
setTimeout(() => push(`/gameover?level=${levelNumber}`), 500);
}
}
}
// splat effects
splats = splats.filter(s => s.alpha > 0);
// ── 7. SPLAT EFFECTS ──────────────────────────────────────────
// paint splashes that appear at fragment collection sites
// they fade out and grow over time
splats = splats.filter(s => s.alpha > 0); // remove faded splats
for (const s of splats) {
if (!splatImg) continue;
p.push();
p.tint(255, s.alpha);
p.tint(255, s.alpha); // fade out using tint alpha
p.image(splatImg, s.x - s.size, s.y - s.size, s.size * 2, s.size * 2);
p.pop();
s.alpha -= 4;
s.size += 1.2;
s.alpha -= 4; // fade speed
s.size += 1.2; // grow speed
}
// fell off screen
if (player.sprite.y > p.height + 100) {
player.loseLife();
lives.set(player.lives);
// ── 8. FELL OFF SCREEN ────────────────────────────────────────
// if player falls below the canvas, lose a life and respawn
if (player.sprite.y > p.height + 100 && gameState === 'playing') {
const dead = player.loseLife(lives);
if (dead) {
gameState = 'gameover';
setTimeout(() => push(`/gameover?level=${levelNumber}`), 500);
}
}
p.allSprites.update();
// ── 9. DRAW SPRITES LAST ──────────────────────────────────────
// p5play renders all sprites after everything else is drawn
p.allSprites.draw();
};
p.keyPressed = () => {
if (p.keyCode === p.LEFT_ARROW || p.key === 'a') keysDown.left = true;
if (p.keyCode === p.RIGHT_ARROW || p.key === 'd') keysDown.right = true;
if (p.keyCode === p.UP_ARROW || p.key === 'w' || p.key === ' ') keysDown.jump = true;
};
p.keyReleased = () => {
if (p.keyCode === p.LEFT_ARROW || p.key === 'a') keysDown.left = false;
if (p.keyCode === p.RIGHT_ARROW || p.key === 'd') keysDown.right = false;
if (p.keyCode === p.UP_ARROW || p.key === 'w' || p.key === ' ') keysDown.jump = false;
};
};
// p5 here refers to the global from the CDN script, not the sketch parameter
// p5 here is the global constructor from the CDN script
// NOT the sketch parameter named "p"
p5Instance = new p5(sketch);
});
onDestroy(() => {
if (p5Instance) p5Instance.remove();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
});
</script>
<!-- the div that p5 draws the canvas into -->
<div bind:this={canvasContainer} class="canvas-wrap"></div>
<style>

View File

@@ -26,8 +26,4 @@ export class Enemy{
this.sprite.mirror.x = 1; // flip image back
}
}
overlapsPlayer(playerSprite){
return playerSprite.overlaps(this.sprite);
}
}

View File

@@ -27,6 +27,7 @@ export class Fragment {
// check if player overlap with fragment
if (playerSprite.overlaps(this.sprite)){
//console.log('Player collected fragment');
this.collect(onCollect);
}
}

View File

@@ -1,118 +1,119 @@
export class Player {
// p5 = the instance, x and y are start position
constructor(p5, x, y){
this.p5 = p5;
// p = the p5 instance, x and y are start position
constructor(p, x, y) {
this.p = p;
this.spawnX = x;
this.spawnY = y;
// p5play sprite - this is the body with physics
this.sprite = new p5.Sprite(x,y,28,28);
// p5play sprite - this is the physics body
this.sprite = new p.Sprite(x, y, 28, 28);
// get the character image
this.sprite.image = '/assets/player.png';
// p5play uses .img not .image for direct path strings
this.sprite.img = '/assets/player.png';
// we dont want player to tip over
// lock rotation
this.sprite.rotationLock = true;
// how slidey the movement is
// 0 is not friction 1 is very high friction
this.sprite.friction = 0; // we change later based on vibes
// we dont want the player to be bouncy... for now at least
// we dont want the player to be bouncy
this.sprite.bounciness = 0;
// game state
this.lives = 3;
this.collectedColors = []; // hex color strings
this.isInvincible = false;
this.invincibleTimer = 0;
}
// this will be called every frame from the game loop
// KeysDwon is an object we can use it is {left, right, jump}
update(keysDown){
const SPEED = 4; // we can change this later
// called every frame from the game loop
// keysDown is an object: {left, right, jump}
update(keysDown) {
const SPEED = 5;
if (keysDown.left) this.sprite.vel.x = -SPEED;
if (keysDown.right) this.sprite.vel.x = SPEED;
// If no key is pressed, slow down
if (!keysDown.left && !keysDown.right){
this.sprite.vel.x *= 0.7;
// if no key is pressed, slow down (friction)
if (!keysDown.left && !keysDown.right) {
this.sprite.vel.x *= 0.78;
}
// jump but only if the character is on the ground
// p5play built in = sprite.touching.bottom
if (keysDown.jump && this.sprite.touching && this.sprite.touching.bottom){
this.sprite.vel.y = -11; // negative is up in p5
keysDown.jump = false;
const onGround = this.sprite.vel.y > -0.5 && this.sprite.vel.y < 1.5;
if (keysDown.jump && onGround) {
this.sprite.vel.y = -11;
keysDown.jump = false; // consume so it can't re-fire until the next keydown
}
// for when the player is invincible
if (this.isInvincible){
// count down invincibility frames
if (this.isInvincible) {
this.invincibleTimer--;
if (this.invincibleTimer <= 0){
if (this.invincibleTimer <= 0) {
this.isInvincible = false;
}
}
}
// if player hits tar or enemym they lose a life
/// if no lives left, game over
loseLife(){
// call when player hits an enemy or tar puddle
// returns true if no lives left (game over)
loseLife(livesStore) {
if (this.isInvincible) return false;
this.lives--;
this.isInvincible = true; // wont gain damage a little bit after losign a life
this.invincibleTimer = 90; // 1.5 seconds if we are at 60fps
// update the store directly instead of a local variable
livesStore.update(n => n - 1);
// grace period so player cant take damage immediately again
this.isInvincible = true;
this.invincibleTimer = 90; // 1.5 seconds at 60fps
this.respawn();
return this.lives <=0; // if true = game over
// read the current store value to check if game over
let current;
livesStore.subscribe(v => current = v)();
return current <= 0; // true = game over
}
// respawn at level start spawn point and be static
respawn(){
// send player back to level start
respawn() {
this.sprite.x = this.spawnX;
this.sprite.y = this.spawnY;
this.sprite.vel.x = 0;
this.sprite.vel.y = 0;
}
// player walks into a frament
collectFragment(hexColor){
// called when player walks into a fragment
collectFragment(hexColor) {
this.collectedColors.push(hexColor);
// we start getting color!
// color gets added to the glow effect
}
//glow effect around the player
// this should be called before drawing character so its under it
drawGlow(){
// draw the glow effect around the player
// call this BEFORE drawing the sprite so glow appears underneath
drawGlow() {
if (this.collectedColors.length === 0) return;
// we want the last collected color to be the glow
const c = this.collectedColors[this.collectedColors.length -1];
// use the most recently collected color as the glow color
const c = this.collectedColors[this.collectedColors.length - 1];
this.p5.push();
this.p5.noStroke();
this.p.push();
this.p.noStroke();
// draw a gradiet with circles that lessen in opacity
// fake flow effect lol
// measurements based on 64 by 64 character
for (let radius = 60; radius > 0; radius -= 8){
const alpha = (radius / 60) * 40; // max is 40
const col = this.p5.color(c);
// draw concentric circles getting more transparent outward
// this fakes a soft glow without any extra library
for (let radius = 60; radius > 0; radius -= 8) {
const alpha = (radius / 60) * 40; // max alpha is 40
const col = this.p.color(c);
col.setAlpha(alpha);
this.p5.fill(col);
this.p5.circle(this.sprite.x, this.sprite.y, radius*2);
this.p.fill(col);
this.p.circle(this.sprite.x, this.sprite.y, radius * 2);
}
this.p5.pop();
this.p.pop();
}
// flickery effect for invincibility
isVisible(){
// blink effect while invincible
// returns false every 6 frames to hide the sprite
isVisible() {
if (!this.isInvincible) return true;
// show/hide every 6 frames so it blinks
// show/hide every 6 frames for a blink effect
return Math.floor(this.invincibleTimer / 6) % 2 === 0;
}
}

View File

@@ -7,8 +7,4 @@ export class TarPuddle {
this.sprite.collider = 'none'; // no physics needed just check for overlap
this.sprite.rotationLock = true;
}
overlapsPlayer(playerSprite){
return playerSprite.overlaps(this.sprite);
}
}

View File

@@ -1,81 +1,441 @@
// level config will be in one array
// index 0 = level 1, etc
// edit level change calculations here
// x,y = center position. w, h = width and height of the platform
// level config — one entry per level
// x,y = center w,h = dimensions
// fragment colors match the level color (updated hex values)
// bg → put your PNG in public/backgrounds/levelN.png
// playerImg → put your PNG in public/assets/player_levelN.png
export const LEVELS = [
// ── LEVEL 1: CRIMSON ──────────────────────────────────────────────────────
// platforms and enemies unchanged from original design
{
id: 1,
name: 'The Gray Beginning',
color: '#FF4136',
//bgFar: '/backgrounds/level1_far/png',
//bgMid: '/backgrounds/level1_mid.png',
spawnX: 80,
spawnY: 380,
// each platform: {x,y,w,h}
name: 'Eruption',
color: '#970505',
bg: '/backgrounds/level1.png',
playerImg: '/assets/player_level1.png',
spawnX: 60,
spawnY: 400,
platforms: [
{x: 400, y: 440, w: 800, h: 20}, // ground
{x: 220, y: 360, w: 160, h: 16},
{x: 430, y: 300, w: 140, h: 16 },
{x: 640, y: 240, w: 160, h: 16 },
{x: 320, y: 210, w: 120, h: 16 },
{ x: 400, y: 440, w: 800, h: 12 }, // ground
{ x: 170, y: 370, w: 160, h: 14 }, // left starter
{ x: 390, y: 308, w: 150, h: 14 }, // middle step
{ x: 610, y: 248, w: 150, h: 14 }, // upper right
{ x: 395, y: 188, w: 140, h: 14 }, // top center
],
// each fragment: {x,y,color}
fragments: [
{x: 220, y: 330, color: '#FF4136'},
{x: 430, y: 270, color: '#FF4136'},
{x: 640, y: 210, color: '#FF4136'},
{ x: 170, y: 338, color: '#970505' },
{ x: 390, y: 278, color: '#970505' },
{ x: 610, y: 216, color: '#970505' },
{ x: 395, y: 156, color: '#970505' },
],
// each enemy: {x,y, patrol}
enemies: [
{x: 430, y: 280, patrol: 50},
{ x: 390, y: 283, patrol: 50 },
],
// each puddle: {x, y}
tar: [],
},
// ── LEVEL 2: AMBER ────────────────────────────────────────────────────────
// platforms and enemies unchanged from original design
{
id: 2,
name: 'Warmer skies',
color: '#FF851B',
bgFar: '/backgrounds/level2_far.png',
bgMid: '/backgrounds/level2_mid.png',
spawnX: 80,
spawnY: 380,
name: 'Sunset',
color: '#CF8917',
bg: '/backgrounds/level2.png',
playerImg: '/assets/player_level2.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 20 },
{ x: 180, y: 370, w: 140, h: 16 },
{ x: 380, y: 310, w: 120, h: 16 },
{ x: 560, y: 250, w: 120, h: 16 },
{ x: 700, y: 330, w: 100, h: 16 },
{ x: 300, y: 200, w: 100, h: 16 },
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 160, y: 375, w: 150, h: 14 }, // left low
{ x: 360, y: 318, w: 140, h: 14 }, // center
{ x: 560, y: 260, w: 130, h: 14 }, // right mid
{ x: 710, y: 330, w: 110, h: 14 }, // right island
{ x: 280, y: 238, w: 110, h: 14 }, // upper left — backtrack
{ x: 478, y: 188, w: 110, h: 14 }, // top center
],
fragments: [
{ x: 180, y: 340, color: '#FF851B' },
{ x: 380, y: 280, color: '#FF851B' },
{ x: 300, y: 170, color: '#FF851B' },
{ x: 700, y: 300, color: '#FF851B' },
{ x: 160, y: 343, color: '#CF8917' },
{ x: 710, y: 298, color: '#CF8917' },
{ x: 280, y: 206, color: '#CF8917' },
{ x: 478, y: 156, color: '#CF8917' },
],
enemies: [
{ x: 380, y: 290, patrol: 45 },
{ x: 560, y: 230, patrol: 40 },
{ x: 360, y: 293, patrol: 45 },
{ x: 560, y: 235, patrol: 40 },
],
tar: [
{ x: 150, y: 432 },
{ x: 490, y: 432 },
],
},
// ── LEVEL 3: YELLOW ───────────────────────────────────────────────────────
// two wide wings — left and right — converging at the upper center
{
id: 3,
name: 'Golden',
color: '#E3D214',
bg: '/backgrounds/level3.png',
playerImg: '/assets/player_level3.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 140, y: 372, w: 150, h: 14 }, // left start
{ x: 575, y: 365, w: 150, h: 14 }, // right start (enemy guards)
{ x: 280, y: 302, w: 120, h: 14 }, // left mid
{ x: 510, y: 295, w: 120, h: 14 }, // right mid
{ x: 155, y: 238, w: 110, h: 14 }, // upper left
{ x: 420, y: 228, w: 130, h: 14 }, // upper center (wider, enemy)
{ x: 660, y: 220, w: 110, h: 14 }, // upper right
],
fragments: [
{ x: 280, y: 270, color: '#E3D214' }, // left mid — easy
{ x: 660, y: 188, color: '#E3D214' }, // upper right
{ x: 155, y: 206, color: '#E3D214' }, // upper left
{ x: 420, y: 196, color: '#E3D214' }, // upper center — guarded
],
enemies: [
{ x: 575, y: 340, patrol: 52 },
{ x: 420, y: 203, patrol: 45 },
],
tar: [
{ x: 370, y: 432 },
{ x: 700, y: 432 },
],
},
// ── LEVEL 4: GREEN ────────────────────────────────────────────────────────
// two vertical columns with connecting bridges — forest canopy feel
{
id: 4,
name: 'Greenery',
color: '#39BD1C',
bg: '/backgrounds/level4.png',
playerImg: '/assets/player_level4.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 140, y: 372, w: 130, h: 14 }, // left start (enemy guards)
{ x: 665, y: 365, w: 130, h: 14 }, // right — separated by tar
{ x: 270, y: 305, w: 120, h: 14 }, // center-left mid (enemy guards)
{ x: 510, y: 298, w: 120, h: 14 }, // center-right mid
{ x: 165, y: 242, w: 110, h: 14 }, // upper left
{ x: 380, y: 232, w: 120, h: 14 }, // upper center
{ x: 610, y: 225, w: 110, h: 14 }, // upper right (enemy guards)
{ x: 290, y: 175, w: 95, h: 14 }, // top left
{ x: 520, y: 168, w: 95, h: 14 }, // top right — hardest
],
fragments: [
{ x: 270, y: 273, color: '#39BD1C' }, // center-left mid
{ x: 510, y: 266, color: '#39BD1C' }, // center-right mid
{ x: 380, y: 200, color: '#39BD1C' }, // upper center
{ x: 520, y: 136, color: '#39BD1C' }, // top right — hardest
],
enemies: [
{ x: 140, y: 347, patrol: 40 },
{ x: 270, y: 280, patrol: 38 },
{ x: 610, y: 200, patrol: 36 },
],
tar: [
{ x: 395, y: 432 },
{ x: 610, y: 432 },
],
},
// ── LEVEL 5: CYAN ─────────────────────────────────────────────────────────
// zigzag flow — dips and rises like tide pools
{
id: 5,
name: 'Tidal',
color: '#12B6C8',
bg: '/backgrounds/level5.png',
playerImg: '/assets/player_level5.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 145, y: 378, w: 140, h: 14 }, // left start
{ x: 355, y: 395, w: 115, h: 14 }, // dips down — zigzag
{ x: 540, y: 368, w: 125, h: 14 }, // center right
{ x: 705, y: 348, w: 115, h: 14 }, // far right
{ x: 235, y: 308, w: 110, h: 14 }, // upper left
{ x: 445, y: 295, w: 110, h: 14 }, // upper center (enemy)
{ x: 640, y: 278, w: 115, h: 14 }, // upper right
{ x: 125, y: 248, w: 95, h: 14 }, // high far left — isolated
{ x: 365, y: 235, w: 90, h: 14 }, // high center
{ x: 590, y: 220, w: 90, h: 14 }, // high right (enemy)
{ x: 755, y: 202, w: 80, h: 14 }, // top far right — narrow
],
fragments: [
{ x: 235, y: 276, color: '#12B6C8' }, // upper left
{ x: 640, y: 246, color: '#12B6C8' }, // upper right
{ x: 125, y: 216, color: '#12B6C8' }, // high far left — isolated
{ x: 365, y: 203, color: '#12B6C8' }, // high center
{ x: 755, y: 170, color: '#12B6C8' }, // top far right — hardest
],
enemies: [
{ x: 355, y: 370, patrol: 35 },
{ x: 445, y: 270, patrol: 35 },
{ x: 590, y: 195, patrol: 28 },
],
tar: [
{ x: 265, y: 432 },
{ x: 480, y: 432 },
{ x: 680, y: 432 },
],
},
// ── LEVEL 6: DEEP BLUE ────────────────────────────────────────────────────
// cold, sparse — wider gaps, deliberate platform placement
{
id: 6,
name: 'The Abyss',
color: '#170CB7',
bg: '/backgrounds/level6.png',
playerImg: '/assets/player_level6.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 130, y: 382, w: 125, h: 14 }, // left start
{ x: 675, y: 370, w: 130, h: 14 }, // far right — requires commitment
{ x: 308, y: 355, w: 108, h: 14 }, // center step
{ x: 510, y: 385, w: 105, h: 14 }, // dip right
{ x: 220, y: 302, w: 100, h: 14 }, // upper left
{ x: 455, y: 290, w: 100, h: 14 }, // upper center
{ x: 660, y: 275, w: 110, h: 14 }, // upper right (enemy)
{ x: 105, y: 245, w: 88, h: 14 }, // high far left — isolated
{ x: 358, y: 232, w: 85, h: 14 }, // high center
{ x: 580, y: 215, w: 85, h: 14 }, // high right
{ x: 745, y: 198, w: 80, h: 14 }, // narrow top right
{ x: 268, y: 175, w: 80, h: 14 }, // top left
{ x: 480, y: 165, w: 80, h: 14 }, // very top — hardest
],
fragments: [
{ x: 308, y: 323, color: '#170CB7' }, // center step — warmup
{ x: 220, y: 270, color: '#170CB7' }, // upper left
{ x: 660, y: 243, color: '#170CB7' }, // upper right
{ x: 358, y: 200, color: '#170CB7' }, // high center
{ x: 480, y: 133, color: '#170CB7' }, // very top — hardest
],
enemies: [
{ x: 510, y: 360, patrol: 33 },
{ x: 455, y: 265, patrol: 30 },
{ x: 660, y: 250, patrol: 37 },
{ x: 358, y: 207, patrol: 25 },
],
tar: [
{ x: 230, y: 432 },
{ x: 460, y: 432 },
{ x: 640, y: 432 },
],
},
// ── LEVEL 7: PURPLE ───────────────────────────────────────────────────────
// narrow platforms spiraling up — precision required
{
id: 7,
name: 'Twilight Spire',
color: '#6613BA',
bg: '/backgrounds/level7.png',
playerImg: '/assets/player_level7.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 130, y: 385, w: 115, h: 14 }, // left start
{ x: 308, y: 365, w: 100, h: 14 }, // small step
{ x: 483, y: 390, w: 100, h: 14 }, // dip
{ x: 652, y: 358, w: 115, h: 14 }, // right mid
{ x: 768, y: 300, w: 75, h: 14 }, // narrow far right
{ x: 578, y: 272, w: 88, h: 14 }, // upper right
{ x: 400, y: 295, w: 85, h: 14 }, // upper center
{ x: 220, y: 285, w: 90, h: 14 }, // upper left
{ x: 90, y: 248, w: 80, h: 14 }, // narrow far left
{ x: 320, y: 232, w: 80, h: 14 }, // high center
{ x: 518, y: 215, w: 80, h: 14 }, // high right
{ x: 698, y: 190, w: 78, h: 14 }, // top right — narrow
],
fragments: [
{ x: 652, y: 326, color: '#6613BA' }, // right mid
{ x: 220, y: 253, color: '#6613BA' }, // upper left
{ x: 320, y: 200, color: '#6613BA' }, // high center
{ x: 518, y: 183, color: '#6613BA' }, // high right
{ x: 698, y: 158, color: '#6613BA' }, // top right — hardest
],
enemies: [
{ x: 308, y: 340, patrol: 28 },
{ x: 400, y: 270, patrol: 25 },
{ x: 578, y: 247, patrol: 26 },
{ x: 90, y: 223, patrol: 22 },
],
tar: [
{ x: 200, y: 432 },
{ x: 408, y: 432 },
{ x: 618, y: 432 },
],
},
// ── LEVEL 8: MAGENTA ──────────────────────────────────────────────────────
// energetic grid-like layout with strategic gaps
{
id: 8,
name: 'Neon Bloom',
color: '#C71287',
bg: '/backgrounds/level8.png',
playerImg: '/assets/player_level8.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 155, y: 380, w: 130, h: 14 },
{ x: 338, y: 360, w: 118, h: 14 },
{ x: 513, y: 380, w: 110, h: 14 }, // dip
{ x: 688, y: 358, w: 120, h: 14 },
{ x: 165, y: 308, w: 100, h: 14 }, // upper left
{ x: 365, y: 300, w: 90, h: 14 }, // upper center
{ x: 553, y: 308, w: 100, h: 14 }, // upper right
{ x: 728, y: 278, w: 78, h: 14 }, // narrow far right
{ x: 260, y: 248, w: 90, h: 14 }, // high left
{ x: 448, y: 238, w: 90, h: 14 }, // high center
{ x: 636, y: 222, w: 90, h: 14 }, // high right
{ x: 368, y: 178, w: 80, h: 14 }, // near top
{ x: 553, y: 165, w: 80, h: 14 }, // top — hardest
],
fragments: [
{ x: 165, y: 276, color: '#C71287' }, // upper left
{ x: 728, y: 246, color: '#C71287' }, // narrow far right
{ x: 448, y: 206, color: '#C71287' }, // high center
{ x: 368, y: 146, color: '#C71287' }, // near top
{ x: 553, y: 133, color: '#C71287' }, // top — hardest
],
enemies: [
{ x: 338, y: 335, patrol: 38 },
{ x: 688, y: 333, patrol: 40 },
{ x: 553, y: 283, patrol: 32 },
{ x: 365, y: 275, patrol: 25 },
],
tar: [
{ x: 178, y: 432 },
{ x: 378, y: 432 },
{ x: 558, y: 432 },
{ x: 738, y: 432 },
],
},
// ── LEVEL 9: BROWN ────────────────────────────────────────────────────────
// dense cave — most enemies, hardest single-color level
{
id: 9,
name: 'Deep Caves',
color: '#753F16',
bg: '/backgrounds/level9.png',
playerImg: '/assets/player_level9.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
{ x: 135, y: 382, w: 128, h: 14 },
{ x: 315, y: 365, w: 112, h: 14 },
{ x: 496, y: 388, w: 108, h: 14 }, // dip
{ x: 660, y: 358, w: 118, h: 14 },
{ x: 200, y: 322, w: 100, h: 14 },
{ x: 400, y: 312, w: 93, h: 14 },
{ x: 578, y: 305, w: 100, h: 14 },
{ x: 738, y: 288, w: 78, h: 14 }, // narrow
{ x: 118, y: 262, w: 88, h: 14 },
{ x: 310, y: 255, w: 88, h: 14 },
{ x: 492, y: 245, w: 88, h: 14 },
{ x: 658, y: 232, w: 88, h: 14 },
{ x: 232, y: 195, w: 78, h: 14 },
{ x: 425, y: 185, w: 78, h: 14 },
{ x: 608, y: 178, w: 78, h: 14 },
],
fragments: [
{ x: 578, y: 273, color: '#753F16' },
{ x: 310, y: 223, color: '#753F16' },
{ x: 658, y: 200, color: '#753F16' },
{ x: 425, y: 153, color: '#753F16' },
{ x: 608, y: 146, color: '#753F16' },
],
enemies: [
{ x: 315, y: 340, patrol: 35 },
{ x: 660, y: 333, patrol: 40 },
{ x: 400, y: 287, patrol: 28 },
{ x: 492, y: 220, patrol: 28 },
{ x: 738, y: 263, patrol: 22 },
],
tar: [
{ x: 152, y: 432 },
{ x: 348, y: 432 },
{ x: 540, y: 432 },
{ x: 712, y: 432 },
],
},
// ── LEVEL 10: THE COLOR REALM — final ────────────────────────────────────
// one fragment per level color — completing this leads to the win screen
{
id: 10,
name: 'The Color Realm',
color: '#FFD700',
bg: '/backgrounds/level10.png',
playerImg: '/assets/player_level10.png',
spawnX: 60,
spawnY: 400,
platforms: [
{ x: 400, y: 440, w: 800, h: 12 },
// row 1 — low
{ x: 120, y: 380, w: 140, h: 14 },
{ x: 310, y: 362, w: 120, h: 14 },
{ x: 510, y: 382, w: 120, h: 14 },
{ x: 700, y: 365, w: 130, h: 14 },
// row 2 — mid
{ x: 210, y: 305, w: 120, h: 14 },
{ x: 420, y: 298, w: 110, h: 14 },
{ x: 628, y: 288, w: 120, h: 14 },
// row 3 — high
{ x: 115, y: 248, w: 100, h: 14 },
{ x: 315, y: 238, w: 95, h: 14 },
{ x: 515, y: 228, w: 95, h: 14 },
{ x: 715, y: 215, w: 90, h: 14 },
// row 4 — top
{ x: 215, y: 178, w: 90, h: 14 },
{ x: 415, y: 168, w: 90, h: 14 },
{ x: 615, y: 160, w: 90, h: 14 },
// row 5 — very top (brown fragment)
{ x: 415, y: 128, w: 80, h: 14 },
],
fragments: [
{ x: 310, y: 330, color: '#970505' }, // crimson
{ x: 700, y: 333, color: '#CF8917' }, // amber
{ x: 210, y: 273, color: '#E3D214' }, // yellow
{ x: 628, y: 256, color: '#39BD1C' }, // green
{ x: 315, y: 206, color: '#12B6C8' }, // cyan
{ x: 715, y: 183, color: '#170CB7' }, // deep blue
{ x: 215, y: 146, color: '#6613BA' }, // purple
{ x: 615, y: 128, color: '#C71287' }, // magenta
{ x: 415, y: 96, color: '#753F16' }, // brown — very top
],
enemies: [
{ x: 120, y: 355, patrol: 45 },
{ x: 510, y: 357, patrol: 40 },
{ x: 420, y: 273, patrol: 35 },
{ x: 628, y: 263, patrol: 42 },
{ x: 515, y: 203, patrol: 30 },
],
tar: [
{ x: 225, y: 432 },
{ x: 390, y: 432 },
{ x: 540, y: 432 },
{ x: 665, y: 432 },
{ x: 760, y: 432 },
],
},
// Levels 35 will follow the same pattern
];
// get level by ID (indexed)
export function getLevel(id){
return LEVELS.find(level => level.id === id)
export function getLevel(id) {
return LEVELS.find(level => level.id === id);
}

View File

@@ -1,15 +1,16 @@
<script>
import {push} from 'svelte-spa-router';
import {querystring} from 'svelte-spa-router';
import {resetLevel} from '../stores/colorStore.js';
import {getLevel} from '../game/levelData.js';
import { push, querystring } from 'svelte-spa-router';
import { resetLevel } from '../stores/colorStore.js';
import { getLevel } from '../game/levelData.js';
$: levelNum = parseInt(new URLSearchParams($querystring).get('level')) || 1;
$: levelNum = $querystring
? parseInt(new URLSearchParams($querystring).get('level')) || 1
: 1;
function retry(){
function retry() {
const level = getLevel(levelNum);
resetLevel(levelNum, level.color);
push(`/game/${levelNum}`);
push(`/game?level=${levelNum}`);
}
</script>
@@ -23,7 +24,7 @@
</div>
<style>
.screen{
.screen {
width: 800px;
height: 450px;
margin: 0 auto;
@@ -35,33 +36,31 @@
justify-content: center;
gap: 16px;
}
h1{
font-family:'Courier New', Courier, monospace;
h1 {
font-family: 'Courier New', Courier, monospace;
font-size: 48px;
font-weight: 400;
color: white;
}
p{
font-family:'Courier New', Courier, monospace;
p {
font-family: 'Courier New', Courier, monospace;
font-size: 20px;
opacity: .9;
opacity: 0.6;
}
.buttons{
.buttons {
display: flex;
gap: 16px;
margin-top: 8px
margin-top: 8px;
}
button{
button {
padding: 10px 32px;
border-radius: 24px;
cursor: pointer;
font-family:'Courier New', Courier, monospace;
font-family: 'Courier New', Courier, monospace;
font-size: 20px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: white;
}
button:hover{
background: rgba(255,255,255,0.18);
}
button:hover { background: rgba(255,255,255,0.18); }
</style>

View File

@@ -48,18 +48,21 @@
justify-content: center;
gap: 24px;
}
h1{
font-family:'Courier New', Courier, monospace;
font-size: 42px;
h1 {
font-family: 'Courier New', Courier, monospace;
font-size: 34px;
font-weight: 400;
}
.level-grid{
.level-grid {
display: flex;
gap: 16px;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
max-width: 560px;
}
.level-card{
width: 120px;
height: 140px;
.level-card {
width: 100px;
height: 118px;
background: #222;
border: 1px solid #444;
border-radius: 12px;
@@ -68,24 +71,32 @@
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
gap: 8px;
color: white;
transition: transform 0.15s, border-color 0.15s;
}
.level-card:hover:not(.locked){
.level-card:hover:not(.locked) {
transform: translateY(-4px);
border-color: var(--c);
}
.level-card.locked{
.level-card.locked {
opacity: 0.4;
cursor: not-allowed;
}
.level-card.locked .swatch{
.swatch {
width: 44px;
height: 44px;
border-radius: 50%;
background: var(--c);
}
.level-card.locked .swatch {
background: #555;
}
.name{
font-family:'Courier New', Courier, monospace;
font-size: 18px;
.name {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
text-align: center;
padding: 0 4px;
}
.back{
background: none;

77
src/routes/Win.svelte Normal file
View File

@@ -0,0 +1,77 @@
<script>
import { push } from 'svelte-spa-router';
</script>
<div class="screen">
<h1 class="title">color restored</h1>
<p class="line1">every fragment found. every hue returned to the world.</p>
<p class="line2">the little painter has done it.</p>
<button on:click={() => push('/')}>back to home</button>
</div>
<style>
.screen {
width: 800px;
height: 450px;
margin: 0 auto;
background: #0a0a0a;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 14px;
}
.title {
font-family: 'Courier New', Courier, monospace;
font-size: 52px;
font-weight: 400;
background: linear-gradient(
90deg,
#FF4136, #FF851B, #FFDC00, #2ECC40,
#0074D9, #B10DC9, #FF69B4, #FF4136
);
background-size: 200% auto;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
animation: shimmer 4s linear infinite;
}
@keyframes shimmer {
from { background-position: 0% center; }
to { background-position: 200% center; }
}
.line1 {
font-family: 'Courier New', Courier, monospace;
font-size: 18px;
color: #ccc;
text-align: center;
}
.line2 {
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
color: #666;
text-align: center;
margin-top: -4px;
}
button {
margin-top: 20px;
padding: 10px 36px;
border-radius: 24px;
cursor: pointer;
font-family: 'Courier New', Courier, monospace;
font-size: 18px;
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.2);
color: white;
transition: background 0.15s;
}
button:hover {
background: rgba(255, 255, 255, 0.14);
}
</style>