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": { "dependencies": {
"p5": "^1.11.4", "p5": "^1.11.4",
"p5play": "^3.22.0", "p5play": "^3.22.0",
"sirv-cli": "^2.0.0", "sirv-cli": "^2.0.0",
"svelte-spa-router": "^3.3.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 LevelSelect from './routes/LevelSelect.svelte';
import Game from './routes/Game.svelte'; import Game from './routes/Game.svelte';
import GameOver from './routes/GameOver.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 = { const routes = {
'/': Home, '/': Home,
'/levelselect': LevelSelect, '/levelselect': LevelSelect,
'/game': Game, '/game': Game,
'/gameover': GameOver, '/gameover': GameOver,
'/win': Win,
}; };
</script> </script>

View File

@@ -1,81 +1,127 @@
<script> <script>
import {onMount, onDestroy} from 'svelte'; import { onMount, onDestroy } from 'svelte';
import {Player} from '../game/Player.js'; import { Player } from '../game/Player.js';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import {lives, colorOpacity, levelColor, onFragmentCollection, resetLevel, completeLevel} from '../stores/colorStore.js'; import { lives, colorOpacity, levelColor, onFragmentCollection, resetLevel, completeLevel } from '../stores/colorStore.js';
import {push} from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
import {Enemy} from '../game/Enemy.js'; import { Enemy } from '../game/Enemy.js';
import {TarPuddle} from '../game/TarPuddle.js'; import { TarPuddle } from '../game/TarPuddle.js';
import {getLevel} from '../game/levelData.js'; import { getLevel } from '../game/levelData.js';
import {Fragment} from '../game/Fragment.js'; import { Fragment } from '../game/Fragment.js';
// level to load - passed in from Game.svelte
export let levelNumber = 1; export let levelNumber = 1;
let canvasContainer; let canvasContainer; // the div p5 will draw into
let p5Instance; 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(() => { onMount(() => {
// IMPORTANT: parameter is named "p" not "p5" // IMPORTANT: parameter is named "p" not "p5"
// "p5" is the global constructor from the CDN script // "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) => { const sketch = (p) => {
let player; let player;
let platforms; let fragments = [];
let fragments;
let bgImg;
let splatImg;
let enemies = []; let enemies = [];
let tarPuddles = []; let tarPuddles = [];
let gameState = 'playing'; let splats = []; // paint splash effects on collection
let splats = []; let levelData; // declared at sketch level so all functions can access it
let levelData; // declared once here at sketch level 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) { function onFragmentCollected(hexColor, x, y) {
player.collectFragment(hexColor); player.collectFragment(hexColor);
onFragmentCollection(fragments.length, hexColor); onFragmentCollection(fragments.length, hexColor); // update the color store
splats.push({x, y, alpha: 200, size: 30, color: hexColor}); splats.push({ x, y, alpha: 200, size: 30 }); // add paint splash
if (fragments.every(f => f.collected)) { if (fragments.every(f => f.collected)) {
gameState = 'levelcomplete'; gameState = 'levelcomplete';
completeLevel(levelData.color); completeLevel(levelData.color);
setTimeout(() => push('/levelselect'), 2500); const dest = levelNumber === LAST_LEVEL ? '/win' : '/levelselect';
setTimeout(() => push(dest), 2500);
} }
} }
p.preload = () => { 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'); splatImg = p.loadImage('/assets/splat.png');
}; };
p.setup = () => { p.setup = () => {
// create canvas inside the container div
const canvas = p.createCanvas(800, 450); const canvas = p.createCanvas(800, 450);
canvas.parent(canvasContainer); 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); levelData = getLevel(levelNumber);
// reset color store for this level
resetLevel(levelNumber, levelData.color); resetLevel(levelNumber, levelData.color);
// gravity must be set before creating sprites // create player at the level spawn point
p.world.gravity.y = 10;
player = new Player(p, levelData.spawnX, levelData.spawnY); 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) { for (const platData of levelData.platforms) {
const plat = new p.Sprite(platData.x, platData.y, platData.w, platData.h); const plat = new p.Sprite(platData.x, platData.y, platData.w, platData.h);
plat.collider = 'static'; plat.collider = 'static';
plat.image = '/assets/tile.png'; const g = p.createGraphics(platData.w, platData.h);
platforms.add(plat); 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 = []; fragments = [];
for (const fragData of levelData.fragments) { for (const fragData of levelData.fragments) {
fragments.push(new Fragment(p, fragData.x, fragData.y, fragData.color)); fragments.push(new Fragment(p, fragData.x, fragData.y, fragData.color));
} }
// create enemies from level data
enemies = []; enemies = [];
for (const enemyData of levelData.enemies) { for (const enemyData of levelData.enemies) {
const e = new Enemy(p, enemyData.x, enemyData.y); const e = new Enemy(p, enemyData.x, enemyData.y);
@@ -83,6 +129,7 @@
enemies.push(e); enemies.push(e);
} }
// create tar puddles from level data
tarPuddles = []; tarPuddles = [];
for (const tarData of levelData.tar) { for (const tarData of levelData.tar) {
tarPuddles.push(new TarPuddle(p, tarData.x, tarData.y)); tarPuddles.push(new TarPuddle(p, tarData.x, tarData.y));
@@ -90,107 +137,114 @@
}; };
p.draw = () => { p.draw = () => {
p.background(30);
// draw background image if loaded
if (bgImg) { if (bgImg) {
p.image(bgImg, 0, 0, p.width, p.height); 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 reveal = get(colorOpacity);
const hex = get(levelColor); const hex = get(levelColor);
if (reveal > 0 && hex) { if (reveal > 0 && hex) {
const col = p.color(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 r = p.lerp(128, p.red(col), reveal);
const g = p.lerp(128, p.green(col), reveal); const g = p.lerp(128, p.green(col), reveal);
const b = p.lerp(128, p.blue(col), reveal); const b = p.lerp(128, p.blue(col), reveal);
p.noStroke(); 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); p.rect(0, 0, p.width, p.height);
} }
// fragments // ── 3. PLAYER INPUT ───────────────────────────────────────────
for (const frag of fragments) { // update runs AFTER allSprites.update() so touching.bottom is accurate
frag.drawGlow();
frag.update(player.sprite, onFragmentCollected);
}
// player glow then update
player.drawGlow();
player.update(keysDown); 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) { for (const enemy of enemies) {
enemy.update(); enemy.update(); // patrol movement
if (enemy.overlapsPlayer(player.sprite)) { // p5play overlap check — true when sprites are touching
const dead = player.loseLife(); if (player.sprite.overlaps(enemy.sprite)) {
lives.set(player.lives); const dead = player.loseLife(lives);
if (dead) { if (dead && gameState === 'playing') {
gameState = 'gameover'; 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) { for (const tar of tarPuddles) {
if (tar.overlapsPlayer(player.sprite)) { if (player.sprite.overlaps(tar.sprite)) {
const dead = player.loseLife(); const dead = player.loseLife(lives);
lives.set(player.lives); if (dead && gameState === 'playing') {
if (dead) {
gameState = 'gameover'; gameState = 'gameover';
setTimeout(() => push('/gameover'), 1000); setTimeout(() => push(`/gameover?level=${levelNumber}`), 500);
} }
} }
} }
// splat effects // ── 7. SPLAT EFFECTS ──────────────────────────────────────────
splats = splats.filter(s => s.alpha > 0); // 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) { for (const s of splats) {
if (!splatImg) continue;
p.push(); 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.image(splatImg, s.x - s.size, s.y - s.size, s.size * 2, s.size * 2);
p.pop(); p.pop();
s.alpha -= 4; s.alpha -= 4; // fade speed
s.size += 1.2; s.size += 1.2; // grow speed
} }
// fell off screen // ── 8. FELL OFF SCREEN ────────────────────────────────────────
if (player.sprite.y > p.height + 100) { // if player falls below the canvas, lose a life and respawn
player.loseLife(); if (player.sprite.y > p.height + 100 && gameState === 'playing') {
lives.set(player.lives); 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.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 = () => { // p5 here is the global constructor from the CDN script
if (p.keyCode === p.LEFT_ARROW || p.key === 'a') keysDown.left = false; // NOT the sketch parameter named "p"
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
p5Instance = new p5(sketch); p5Instance = new p5(sketch);
}); });
onDestroy(() => { onDestroy(() => {
if (p5Instance) p5Instance.remove(); if (p5Instance) p5Instance.remove();
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
}); });
</script> </script>
<!-- the div that p5 draws the canvas into -->
<div bind:this={canvasContainer} class="canvas-wrap"></div> <div bind:this={canvasContainer} class="canvas-wrap"></div>
<style> <style>

View File

@@ -26,8 +26,4 @@ export class Enemy{
this.sprite.mirror.x = 1; // flip image back 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 // check if player overlap with fragment
if (playerSprite.overlaps(this.sprite)){ if (playerSprite.overlaps(this.sprite)){
//console.log('Player collected fragment');
this.collect(onCollect); this.collect(onCollect);
} }
} }

View File

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

View File

@@ -1,81 +1,441 @@
// level config will be in one array // level config — one entry per level
// index 0 = level 1, etc // x,y = center w,h = dimensions
// edit level change calculations here // fragment colors match the level color (updated hex values)
// x,y = center position. w, h = width and height of the platform // bg → put your PNG in public/backgrounds/levelN.png
// playerImg → put your PNG in public/assets/player_levelN.png
export const LEVELS = [ export const LEVELS = [
// ── LEVEL 1: CRIMSON ──────────────────────────────────────────────────────
// platforms and enemies unchanged from original design
{ {
id: 1, id: 1,
name: 'The Gray Beginning', name: 'Eruption',
color: '#FF4136', color: '#970505',
//bgFar: '/backgrounds/level1_far/png', bg: '/backgrounds/level1.png',
//bgMid: '/backgrounds/level1_mid.png', playerImg: '/assets/player_level1.png',
spawnX: 80, spawnX: 60,
spawnY: 380, spawnY: 400,
// each platform: {x,y,w,h}
platforms: [ platforms: [
{x: 400, y: 440, w: 800, h: 20}, // ground { x: 400, y: 440, w: 800, h: 12 }, // ground
{x: 220, y: 360, w: 160, h: 16}, { x: 170, y: 370, w: 160, h: 14 }, // left starter
{x: 430, y: 300, w: 140, h: 16 }, { x: 390, y: 308, w: 150, h: 14 }, // middle step
{x: 640, y: 240, w: 160, h: 16 }, { x: 610, y: 248, w: 150, h: 14 }, // upper right
{x: 320, y: 210, w: 120, h: 16 }, { x: 395, y: 188, w: 140, h: 14 }, // top center
], ],
// each fragment: {x,y,color}
fragments: [ fragments: [
{x: 220, y: 330, color: '#FF4136'}, { x: 170, y: 338, color: '#970505' },
{x: 430, y: 270, color: '#FF4136'}, { x: 390, y: 278, color: '#970505' },
{x: 640, y: 210, color: '#FF4136'}, { x: 610, y: 216, color: '#970505' },
{ x: 395, y: 156, color: '#970505' },
], ],
// each enemy: {x,y, patrol}
enemies: [ enemies: [
{x: 430, y: 280, patrol: 50}, { x: 390, y: 283, patrol: 50 },
], ],
// each puddle: {x, y}
tar: [], tar: [],
}, },
// ── LEVEL 2: AMBER ────────────────────────────────────────────────────────
// platforms and enemies unchanged from original design
{ {
id: 2, id: 2,
name: 'Warmer skies', name: 'Sunset',
color: '#FF851B', color: '#CF8917',
bgFar: '/backgrounds/level2_far.png', bg: '/backgrounds/level2.png',
bgMid: '/backgrounds/level2_mid.png', playerImg: '/assets/player_level2.png',
spawnX: 80, spawnX: 60,
spawnY: 380, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 20 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 180, y: 370, w: 140, h: 16 }, { x: 160, y: 375, w: 150, h: 14 }, // left low
{ x: 380, y: 310, w: 120, h: 16 }, { x: 360, y: 318, w: 140, h: 14 }, // center
{ x: 560, y: 250, w: 120, h: 16 }, { x: 560, y: 260, w: 130, h: 14 }, // right mid
{ x: 700, y: 330, w: 100, h: 16 }, { x: 710, y: 330, w: 110, h: 14 }, // right island
{ x: 300, y: 200, w: 100, h: 16 }, { x: 280, y: 238, w: 110, h: 14 }, // upper left — backtrack
{ x: 478, y: 188, w: 110, h: 14 }, // top center
], ],
fragments: [ fragments: [
{ x: 180, y: 340, color: '#FF851B' }, { x: 160, y: 343, color: '#CF8917' },
{ x: 380, y: 280, color: '#FF851B' }, { x: 710, y: 298, color: '#CF8917' },
{ x: 300, y: 170, color: '#FF851B' }, { x: 280, y: 206, color: '#CF8917' },
{ x: 700, y: 300, color: '#FF851B' }, { x: 478, y: 156, color: '#CF8917' },
], ],
enemies: [ enemies: [
{ x: 380, y: 290, patrol: 45 }, { x: 360, y: 293, patrol: 45 },
{ x: 560, y: 230, patrol: 40 }, { x: 560, y: 235, patrol: 40 },
], ],
tar: [ 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
]; ];
export function getLevel(id) {
// get level by ID (indexed) 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> <script>
import {push} from 'svelte-spa-router'; import { push, querystring } from 'svelte-spa-router';
import {querystring} from 'svelte-spa-router'; import { resetLevel } from '../stores/colorStore.js';
import {resetLevel} from '../stores/colorStore.js'; import { getLevel } from '../game/levelData.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); const level = getLevel(levelNum);
resetLevel(levelNum, level.color); resetLevel(levelNum, level.color);
push(`/game/${levelNum}`); push(`/game?level=${levelNum}`);
} }
</script> </script>
@@ -23,7 +24,7 @@
</div> </div>
<style> <style>
.screen{ .screen {
width: 800px; width: 800px;
height: 450px; height: 450px;
margin: 0 auto; margin: 0 auto;
@@ -35,33 +36,31 @@
justify-content: center; justify-content: center;
gap: 16px; gap: 16px;
} }
h1{ h1 {
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 48px; font-size: 48px;
font-weight: 400; font-weight: 400;
color: white; color: white;
} }
p{ p {
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 20px; font-size: 20px;
opacity: .9; opacity: 0.6;
} }
.buttons{ .buttons {
display: flex; display: flex;
gap: 16px; gap: 16px;
margin-top: 8px margin-top: 8px;
} }
button{ button {
padding: 10px 32px; padding: 10px 32px;
border-radius: 24px; border-radius: 24px;
cursor: pointer; cursor: pointer;
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 20px; font-size: 20px;
background: rgba(255,255,255,0.08); background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2); border: 1px solid rgba(255,255,255,0.2);
color: white; color: white;
} }
button:hover{ button:hover { background: rgba(255,255,255,0.18); }
background: rgba(255,255,255,0.18);
}
</style> </style>

View File

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