Compare commits

...

13 Commits

40 changed files with 1299 additions and 323 deletions

167
README.md
View File

@@ -1,5 +1,172 @@
**Name**: Samantha Lopez **Name**: Samantha Lopez
**ID**: 20266142 **ID**: 20266142
**Email**: samantha@kaist.ac.kr **Email**: samantha@kaist.ac.kr
**Gittea Repo**: [https://git.prototyping.id/20266142/The-Full-Hue](https://git.prototyping.id/20266142/The-Full-Hue) **Gittea Repo**: [https://git.prototyping.id/20266142/The-Full-Hue](https://git.prototyping.id/20266142/The-Full-Hue)
**Video Demo**: [https://youtu.be/UBSl3wiXSGM](https://youtu.be/UBSl3wiXSGM)
# Game Description
_The Full Hue_ is a 2D platformer game based around the themes of color and emotion. The game begins completely gray. Each level is themed around a specific color and its corresponding meaning: orange is warmth and creativity, yellow is joy and anxiety, blue is depth, and so on. The goal is to collect the color fragments scattered across each levels platforms, restoring color to the world one hue at a time.
### How to play:
Move utilizing the arrow keys or WASD
| Key | Action |
|--------|-----|
| `← →` or `A D` | Move left/right |
| `Space`, ` ↑`, or `w`| Jump|
**Collecting Fragments:** small glowing fragments are placed on the platforms across each level, to collect them simply walk into them. As they are picked up a small quote appears at the top of the screen and the level's background is gradually tinted to its corresponding color.
**Avoid Hazards:** Enemies walk back and forth on platform. Tar puddles sit on the ground floor. Coming into contact with either costs the player a life, there are 3 lives per level. Taking damage triggers a short invincibility period with a blinking effect so that the player can recover and reset.
**Completing the level:** You successfully complete a level by collecting every fragment at that stage. An overlay with a short reflection will be shown upon level completion which will allow you to move to the next level.
**Game progress:** Levels unlock sequentially as they are completed. Completing a level permanently unlock the next one, making it visible on the level select screen. Locked levels show a `?` symbol.
### After beating all levels
Once all 10 levels are completed the game enters a free explore mode. The home screen changes to a new message inviting the player to go through the game again now in full color and without obstacles. Re-entering the game levels removes the tar puddles and enemies and the backgrounds are shown in full color. The game becomes a sort of gallery inviting the player to walk through each level again appreciating the art and reading the messages at a more leisurely
pace.
### Game features
**Free-explore mode**: After all 10 levels are beat, re-entering levels removes tar and enemies, and the full color backgrounds are shown. The home screen also changes to a new message with a rainbow animated title.
**Level visual identities**: I illustrated a background image for each level (created grayscale and full color versions), the enemies, tar puddles, heart icon for lives, and the player. Specific color HEX codes where chosen for HUD, quotes, and overlays.
**Color tint**: The background of each level changes from gray to full color as each fragment is collected, giving the player a visual feedback on their progress.
**Browser background**: The area outside of the game canvas has animated dots in the game's 10 hex codes. They blink at randomized rates and phases so that the browser doesn't feel empty, I wanted the dots to mimic the twinkling of stars in a night sky.
**fragment animations**: The fragments bob up and down based on a sine wave (they are offset with a random phase so that they aren't synchronized), spin, and emit a colored glow.
**Invincibility**: when a player loses a life they gain 1.5s of invincibility. The sprite is hidden every 6 frames to create a blinking feedback without having to animate anything.
### Asset creation
- I illustrated all the game assets myself to give the game a more indie feel. Please access and view all the art time lapses and final products via this notion link.
- [https://www.notion.so/Asset-Creation-The-Full-Hue-34ff941b5e51808da8b1c6204cc0e0ce?source=copy_link](https://www.notion.so/Asset-Creation-The-Full-Hue-34ff941b5e51808da8b1c6204cc0e0ce?source=copy_link)
# Code organization
### The file structure
My files are organized as illustrated below:
![file organization](public/documentation/File%20organization.png)
### Data Flow
![Data flow](public/documentation/flow%20of%20data.png)
### UML Diagrams
![UML Diagrams](public/documentation/UML%20diagrams.png)
### Game Screen
using `Game.svelte` I layer four components in a single `800x450` container. The organization is as follows:
| component | contents | z-index|
|-----------|----------|--------|
| GameCanvas | p5 canvas | 0 |
| HUD overlay | lives and fragment info display | 10 |
| QuoteToast | quote displayed on fragment collection | 20 |
| LevelCompleteOverlay | Final level quote & next level button | 30 |
### How GameCanvas.svelte runs the game loop
The p5 `draw()` function is going at 60fps and doing the following each frame:
- Draw the background image
- `allSprites.update()` - checks for collisions & handles p5play physics
- updates the color tint overlay
- `fragment.drawGlow() and update()` - fragment animation and callback in case of collection
- `enemy.update()` and overlap check - enemy animation and updates lives in case of overlap
- tar overlap check - updates lives on overlap
- splat effect called when triggered
- fall-off-screen check for avatar
- `allSprites.draw()` - render all the sprites using p5play
### LevelData.js Organization
```
{
id,
name,
color,
bg,
bgcomplete,
playerImg,
spawnX,
spawnY,
platforms: [{x,y,w,h},...],
fragments: [{x,y,color},...],
enemies: [{x,y,patrol},...],
tar: [{x,y},...],
fragmentQuotes: [{'...','...'}],
completeQuote: '...',
}
```
`gameCanvas.setup()` reads this information and draws the game objects from it. This makes it easier to create new levels, change platform positions, text, etc. by only having to update the information on this file. This was especially useful as one of the more tedious parts of making the game was positioning all the platforms, enemies, and puddles in a way that made sense and was playable. I had to play test my levels a lot to make sure they were playable, not too easy but also not too hard. While my level design is simple it required purposeful positioning of each element within the level and that is someting which this structure made simple enough for me to make bulk and quick changes.
### Svelte stores = shared states
**colorStore.js**
| Function/variable | purpose |
|----------|---------|
| `lives` | player lives (0-3)|
| `colorOpacity` | controls tint overlay based on fraction of fragments collected |
| `levelColor` | the hex color of the current level |
| `fragmentsCollected` | keeps count of the collected fragment number |
| `unlockedColors` | array of unlocked hex colors per session |
| `gameCompleted` | `true` when all colors are in `unlockedColors` controls some free-explore mode specifications |
| `resetLevel()` | resets the level state after 3 lives are gone |
| `completeLevel()` | pushes level color into `unlockedColors`|
**quoteStore.js**
| Function/variable | purpose |
|-|-|
|`fragmentQuote` | controls `QuoteToast` information|
| `showFragmentQuote()` | controls visibility of `QuoteToast` |
| `completedQuoteData` | controls `LevelCompleteOverlay` information|
|`showCOmpletedQuote()/clearCompleteQuote()` | controls overlay visibility |
### Coding Patterns used
| Pattern | Where | what it does |
| --- | --- | --- |
| **Observer / Stores** | `colorStore`, `quoteStore` → UI | When something changes, every part of the screen that uses that information updates automatically|
| **Builder** | `levelData.js` | All 10 levels are written in one big list of settings. The game reads that list to build each level|
| **State Machine** | `_canJump/_peakReached` in Player, `gameState` in GameCanvas | only allows certain changes in order, the game tracks what is happening so impossible things can't occur, like jumping forever|
# Areas where improvement can be made
- the game progress is tracked by session so refreshing the browser resets things like `unlockedColors` to empty. Essentially, refreshing resets all game progress.
- The tint effect on each level uses a rectangle over the canvas that changes in opacity as fragments are collected. This is not ideal as it simply tints the background image instead of revealing the original background colors, however, it still gets the point across. Ideally an animation of some sort or different approach would look more aesthetically pleasing, due to time I could not work on this further.
Resource Acknowledgement: The following were utilized in the creation of this project
- Course notes (ID 30011)
- Resources from w3schools.com
- p5.js
- p5play
- svelte
- copilot (coding assistance)
- claude (coding assistance and spell checker) - I jotted some notes and documentation via a notion page which can be accessed here [https://www.notion.so/Midterm-Documentation-My-notes-and-AI-conversations-34bf941b5e51803cbc26f62c0da8a94f?source=copy_link](https://www.notion.so/Midterm-Documentation-My-notes-and-AI-conversations-34bf941b5e51803cbc26f62c0da8a94f?source=copy_link)
- Procreate (illustration)
- Figma (illustration)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 477 B

After

Width:  |  Height:  |  Size: 429 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 85 KiB

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 235 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 64 KiB

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 180 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 47 KiB

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

View File

@@ -1,15 +1,9 @@
html, html,
body { body {
position: relative;
width: 100%;
height: 100%;
}
body {
color: #333;
margin: 0; margin: 0;
padding: 8px; padding: 0;
width: 100%;
min-height: 100%;
background: #000;
box-sizing: border-box; box-sizing: border-box;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
Oxygen-Sans, Ubuntu, Cantarell, 'Helvetica Neue', sans-serif;
} }

View File

@@ -1,23 +1,117 @@
<script> <script>
import { onMount } from 'svelte';
import Router from 'svelte-spa-router'; import Router from 'svelte-spa-router';
// importing all the different screens import Home from './routes/Home.svelte';
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'; import Win from './routes/Win.svelte';
const routes = { const routes = {
'/': Home, '/': Home,
'/levelselect': LevelSelect, '/levelselect': LevelSelect,
'/game': Game, '/game': Game,
'/gameover': GameOver, '/gameover': GameOver,
'/win': Win, '/win': Win,
}; };
const COLORS = [
'#970505', '#CF8917', '#E3D214', '#39BD1C',
'#12B6C8', '#170CB7', '#6613BA', '#C71287',
'#753F16', '#FFD700',
];
let stars = [];
onMount(() => {
stars = Array.from({ length: 90 }, () => ({
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 2 + 1,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
delay: -(Math.random() * 6), // negative = start mid-animation so they don't all blink at once
duration: Math.random() * 3 + 2,
}));
});
</script> </script>
<Router {routes} /> <div class="page">
<!-- star background -->
<div class="starfield" aria-hidden="true">
{#each stars as s}
<span
class="star"
style="
left: {s.x}%;
top: {s.y}%;
width: {s.size}px;
height: {s.size}px;
background: {s.color};
box-shadow: 0 0 {s.size * 2}px {s.color};
animation-duration: {s.duration}s;
animation-delay: {s.delay}s;
"
></span>
{/each}
</div>
<!-- content column -->
<div class="stage">
<p class="game-title">the full hue</p>
<Router {routes} />
</div>
</div>
<style>
.page {
min-height: 100vh;
background: #000;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
}
/* ── starfield ── */
.starfield {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 0;
}
.star {
position: absolute;
border-radius: 50%;
animation: blink linear infinite;
}
@keyframes blink {
0%,100% { opacity: 0.9; }
50% { opacity: 0.08; }
}
/* ── content ── */
.stage {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
}
.game-title {
margin: 0;
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
letter-spacing: 0.35em;
text-transform: lowercase;
color: rgba(255, 255, 255, 0.28);
}
</style>

View File

@@ -2,7 +2,8 @@
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, gameCompleted } from '../stores/colorStore.js';
import { showFragmentQuote, showCompleteQuote } from '../stores/quoteStore.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';
@@ -18,6 +19,7 @@
const keysDown = { left: false, right: false, jump: false }; const keysDown = { left: false, right: false, jump: false };
const onKeyDown = (e) => { const onKeyDown = (e) => {
if (e.key === 'Escape' || e.key === 'e' || e.key === 'E') { push('/levelselect'); return; }
if (e.key === 'ArrowLeft' || e.key === 'a') { keysDown.left = true; e.preventDefault(); } if (e.key === 'ArrowLeft' || e.key === 'a') { keysDown.left = true; e.preventDefault(); }
if (e.key === 'ArrowRight' || e.key === 'd') { keysDown.right = 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 // e.repeat blocks the browser auto-repeat from re-queuing a jump while held
@@ -60,26 +62,37 @@
let playerImgLoaded; let playerImgLoaded;
let splatImg; let splatImg;
// fragment collection callback
// defined here at sketch level so both setup and draw can see it
const LAST_LEVEL = 10; const LAST_LEVEL = 10;
let collectedCount = 0; // tracks which quote to show next
function onFragmentCollected(hexColor, x, y) { function onFragmentCollected(hexColor, x, y) {
player.collectFragment(hexColor); player.collectFragment(hexColor);
onFragmentCollection(fragments.length, hexColor); // update the color store onFragmentCollection(fragments.length, hexColor);
splats.push({ x, y, alpha: 200, size: 30 }); // add paint splash splats.push({ x, y, alpha: 200, size: 30 });
if (fragments.every(f => f.collected)) {
const isLast = fragments.every(f => f.collected);
// skip the toast on the last fragment — the level-complete overlay takes over
if (!isLast) {
showFragmentQuote(levelData.fragmentQuotes?.[collectedCount], hexColor);
}
collectedCount++;
if (isLast) {
gameState = 'levelcomplete'; gameState = 'levelcomplete';
completeLevel(levelData.color); completeLevel(levelData.color);
const dest = levelNumber === LAST_LEVEL ? '/win' : '/levelselect'; const dest = levelNumber === LAST_LEVEL ? '/win' : `/game?level=${levelNumber + 1}`;
setTimeout(() => push(dest), 2500); showCompleteQuote(levelData.completeQuote, levelData.color, dest);
} }
} }
p.preload = () => { p.preload = () => {
const data = getLevel(levelNumber); const data = getLevel(levelNumber);
if (data?.bg) bgImg = p.loadImage(data.bg); const isComplete = get(gameCompleted);
if (data?.playerImg) playerImgLoaded = p.loadImage(data.playerImg); const bgPath = isComplete && data?.bgComplete ? data.bgComplete : data?.bg;
if (bgPath) bgImg = p.loadImage(bgPath);
const playerPath = isComplete ? '/assets/player_level10.png' : data?.playerImg;
if (playerPath) playerImgLoaded = p.loadImage(playerPath);
splatImg = p.loadImage('/assets/splat.png'); splatImg = p.loadImage('/assets/splat.png');
}; };
@@ -121,18 +134,19 @@
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 // create enemies and tar only when the game hasn't been completed —
// after beating all levels the player can explore freely
enemies = []; enemies = [];
for (const enemyData of levelData.enemies) {
const e = new Enemy(p, enemyData.x, enemyData.y);
e.patrolDistance = enemyData.patrol;
enemies.push(e);
}
// create tar puddles from level data
tarPuddles = []; tarPuddles = [];
for (const tarData of levelData.tar) { if (!get(gameCompleted)) {
tarPuddles.push(new TarPuddle(p, tarData.x, tarData.y)); for (const enemyData of levelData.enemies) {
const e = new Enemy(p, enemyData.x, enemyData.y);
e.patrolDistance = enemyData.patrol;
enemies.push(e);
}
for (const tarData of levelData.tar) {
tarPuddles.push(new TarPuddle(p, tarData.x, tarData.y));
}
} }
}; };
@@ -149,19 +163,19 @@
p.allSprites.update(); p.allSprites.update();
// ── 2. COLOR TINT OVERLAY ───────────────────────────────────── // ── 2. COLOR TINT OVERLAY ─────────────────────────────────────
// as fragments are collected, the world gradually gains color // skipped after game completion — full-color backgrounds need no tint
// reveal goes from 0.0 (gray) to 1.0 (full color) if (!get(gameCompleted)) {
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); }
} }
// ── 3. PLAYER INPUT ─────────────────────────────────────────── // ── 3. PLAYER INPUT ───────────────────────────────────────────
@@ -215,12 +229,15 @@
} }
// ── 8. FELL OFF SCREEN ──────────────────────────────────────── // ── 8. FELL OFF SCREEN ────────────────────────────────────────
// if player falls below the canvas, lose a life and respawn
if (player.sprite.y > p.height + 100 && gameState === 'playing') { if (player.sprite.y > p.height + 100 && gameState === 'playing') {
const dead = player.loseLife(lives); if (get(gameCompleted)) {
if (dead) { player.respawn(); // NG+: no damage, just put them back
gameState = 'gameover'; } else {
setTimeout(() => push(`/gameover?level=${levelNumber}`), 500); const dead = player.loseLife(lives);
if (dead) {
gameState = 'gameover';
setTimeout(() => push(`/gameover?level=${levelNumber}`), 500);
}
} }
} }

View File

@@ -1,29 +1,26 @@
<script> <script>
import {lives, fragmentsCollected, levelColor} from '../stores/colorStore.js' import { lives, fragmentsCollected, levelColor } from '../stores/colorStore.js';
import { LEVELS } from '../game/levelData.js' import { LEVELS } from '../game/levelData.js';
export let levelNumber = 1; export let levelNumber = 1;
// get total fragments for the level
$: totalFragments = LEVELS.find(l => l.id === levelNumber)?.fragments.length ?? 0; $: totalFragments = LEVELS.find(l => l.id === levelNumber)?.fragments.length ?? 0;
</script> </script>
<div class="hud"> <div class="hud">
<!-- lives counter displayed on the left--> <div class="lives">
<div class = "lives">
{#each {length: 3} as _, i} {#each {length: 3} as _, i}
<img <img
src={i < $lives ? '/assets/heart_full.png' : '/assets/heart_empty.png'} src={i < $lives ? '/assets/heart_full.png' : '/assets/heart_empty.png'}
alt="life" alt="life"
width="28" width="28"
height="28" height="28"
/> />
{/each} {/each}
</div> </div>
<!-- fragment collections shown on the right-->
<div class="frags" style="color: {$levelColor}"> <div class="frags" style="color: {$levelColor}">
{$fragmentsCollected} / {totalFragments} {$fragmentsCollected} / {totalFragments}
</div> </div>
</div> </div>
@@ -36,8 +33,8 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
pointer-events: none; /* this allows the clicks to pass through to the game */ pointer-events: none;
z-index: 10; /* shows on top */ z-index: 10;
} }
.lives { .lives {
@@ -45,10 +42,11 @@
gap: 6px; gap: 6px;
} }
.frags{ .frags {
font-size: 22px; font-size: 22px;
font-weight: 500; font-weight: 500;
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
text-shadow: 0 1px 4px rgba(0,0,0,0.8); text-shadow: 0 1px 4px rgba(0,0,0,0.8);
} }
</style> </style>

View File

@@ -0,0 +1,76 @@
<script>
import { push } from 'svelte-spa-router';
import { completeQuoteData, clearCompleteQuote } from '../stores/quoteStore.js';
function proceed() {
const dest = $completeQuoteData?.nextDest;
clearCompleteQuote();
if (dest) push(dest);
}
</script>
{#if $completeQuoteData}
<div class="overlay">
<div class="bar" style="background: {$completeQuoteData.color}"></div>
<p class="quote">{$completeQuoteData.text}</p>
<button
class="continue"
style="border-color: {$completeQuoteData.color}; color: {$completeQuoteData.color}"
on:click={proceed}
>
{$completeQuoteData.nextDest === '/win' ? 'finish →' : 'next level →'}
</button>
</div>
{/if}
<style>
.overlay {
position: absolute;
inset: 0;
background: rgba(6, 6, 6, 0.85);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
padding: 0 80px;
z-index: 30;
animation: fadeIn 0.5s ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.bar {
width: 48px;
height: 3px;
border-radius: 2px;
}
.quote {
font-family: 'Courier New', Courier, monospace;
font-size: 17px;
color: #ddd;
text-align: center;
line-height: 1.75;
font-weight: 400;
}
.continue {
margin-top: 8px;
padding: 9px 30px;
background: transparent;
border: 1px solid;
border-radius: 22px;
font-family: 'Courier New', Courier, monospace;
font-size: 15px;
cursor: pointer;
transition: background 0.15s;
}
.continue:hover {
background: rgba(255, 255, 255, 0.07);
}
</style>

View File

@@ -0,0 +1,43 @@
<script>
import { fragmentQuote } from '../stores/quoteStore.js';
</script>
{#if $fragmentQuote}
{#key $fragmentQuote.key}
<div
class="toast"
style="border-color: {$fragmentQuote.color}; --accent: {$fragmentQuote.color}"
>
<p>{$fragmentQuote.text}</p>
</div>
{/key}
{/if}
<style>
.toast {
position: absolute;
top: 30px;
left: 50%;
transform: translateX(-50%);
width: 380px;
padding: 5px 20px;
background: rgba(6, 6, 6, 0.60);
border-bottom: 2px solid var(--accent);
color: rgba(255, 255, 255, 0.85);
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
line-height: 1.5;
text-align: center;
letter-spacing: 0.02em;
pointer-events: none;
animation: toastIn 4.2s ease forwards;
z-index: 20;
}
@keyframes toastIn {
0% { opacity: 0; transform: translateX(-50%) translateY(-8px); }
12% { opacity: 1; transform: translateX(-50%) translateY(0); }
78% { opacity: 1; }
100% { opacity: 0; }
}
</style>

View File

@@ -9,7 +9,7 @@ export class Enemy{
this.speed = 1.5; this.speed = 1.5;
this.direction = 1 // 1 is right, -1 is left this.direction = 1 // 1 is right, -1 is left
this.startX = x; this.startX = x;
this.patrolDisctance = 100; // how far it can walk this.patrolDistance = 100; // how far it can walk
} }
update(){ update(){

View File

@@ -21,6 +21,9 @@ export class Player {
this.collectedColors = []; // hex color strings this.collectedColors = []; // hex color strings
this.isInvincible = false; this.isInvincible = false;
this.invincibleTimer = 0; this.invincibleTimer = 0;
this._canJump = true;
this._peakReached = false;
} }
// called every frame from the game loop // called every frame from the game loop
@@ -36,11 +39,31 @@ export class Player {
this.sprite.vel.x *= 0.78; this.sprite.vel.x *= 0.78;
} }
const onGround = this.sprite.vel.y > -0.5 && this.sprite.vel.y < 1.5; const vel_y = this.sprite.vel.y;
if (keysDown.jump && onGround) { // Once clearly falling, mark that we've left the ground
if (vel_y > 2) this._peakReached = true;
// Landing: restore jump ability only after having fallen back down
if (this._peakReached && vel_y > -0.5 && vel_y < 1.5) {
this._canJump = true;
this._peakReached = false;
}
if (keysDown.jump && this._canJump) {
this.sprite.vel.y = -11; this.sprite.vel.y = -11;
keysDown.jump = false; // consume so it can't re-fire until the next keydown this._canJump = false;
keysDown.jump = false;
}
// limit sprite to canvas left/right edges
const halfW = this.sprite.w / 2;
if (this.sprite.x < halfW) {
this.sprite.x = halfW;
this.sprite.vel.x = 0;
} else if (this.sprite.x > this.p.width - halfW) {
this.sprite.x = this.p.width - halfW;
this.sprite.vel.x = 0;
} }
// count down invincibility frames // count down invincibility frames
@@ -98,7 +121,7 @@ export class Player {
this.p.noStroke(); this.p.noStroke();
// draw concentric circles getting more transparent outward // draw concentric circles getting more transparent outward
// this fakes a soft glow without any extra library // this fakes a glow
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 alpha is 40
const col = this.p.color(c); const col = this.p.color(c);
@@ -113,7 +136,6 @@ export class Player {
// returns false every 6 frames to hide the sprite // returns false every 6 frames to hide the sprite
isVisible() { isVisible() {
if (!this.isInvincible) return true; if (!this.isInvincible) return true;
// 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

@@ -1,27 +1,23 @@
// 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 = [ export const LEVELS = [
// ── LEVEL 1: CRIMSON ────────────────────────────────────────────────────── // LEVEL 1
// platforms and enemies unchanged from original design
{ {
id: 1, id: 1,
name: 'Eruption', name: 'A Burning Heart',
color: '#970505', color: '#970505',
bg: '/backgrounds/level1.png', bg: '/backgrounds/level1.png',
bgComplete: '/backgrounds/level1_complete.png',
playerImg: '/assets/player_level1.png', playerImg: '/assets/player_level1.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, // ground { x: 400, y: 440, w: 800, h: 12 },
{ x: 170, y: 370, w: 160, h: 14 }, // left starter { x: 170, y: 370, w: 160, h: 14 },
{ x: 390, y: 308, w: 150, h: 14 }, // middle step { x: 390, y: 308, w: 150, h: 14 },
{ x: 610, y: 248, w: 150, h: 14 }, // upper right { x: 610, y: 248, w: 150, h: 14 },
{ x: 395, y: 188, w: 140, h: 14 }, // top center { x: 395, y: 188, w: 140, h: 14 },
], ],
fragments: [ fragments: [
{ x: 170, y: 338, color: '#970505' }, { x: 170, y: 338, color: '#970505' },
@@ -33,26 +29,33 @@ export const LEVELS = [
{ x: 390, y: 283, patrol: 50 }, { x: 390, y: 283, patrol: 50 },
], ],
tar: [], tar: [],
fragmentQuotes: [
'Red is the first color you learn to see.',
'You felt it first, before you had words for it',
'To feel intensely is not weakness. It is aliveness.',
'The heart has always beaten in red.',
],
completeQuote: 'Red is the color of being alive. It asks nothing of you except honesty. Red is rage, passion, urgency, and love; The most visceral emotion, it demands to be felt.',
}, },
// ── LEVEL 2: AMBER ──────────────────────────────────────────────────────── // LEVEL 2
// platforms and enemies unchanged from original design
{ {
id: 2, id: 2,
name: 'Sunset', name: 'Warm Hands',
color: '#CF8917', color: '#CF8917',
bg: '/backgrounds/level2.png', bg: '/backgrounds/level2.png',
bgComplete: '/backgrounds/level2_complete.png',
playerImg: '/assets/player_level2.png', playerImg: '/assets/player_level2.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 160, y: 375, w: 150, h: 14 }, // left low { x: 160, y: 375, w: 150, h: 14 },
{ x: 360, y: 318, w: 140, h: 14 }, // center { x: 360, y: 318, w: 140, h: 14 },
{ x: 560, y: 260, w: 130, h: 14 }, // right mid { x: 560, y: 260, w: 130, h: 14 },
{ x: 710, y: 330, w: 110, h: 14 }, // right island { x: 710, y: 330, w: 110, h: 14 },
{ x: 280, y: 238, w: 110, h: 14 }, // upper left — backtrack { x: 280, y: 238, w: 110, h: 14 },
{ x: 478, y: 188, w: 110, h: 14 }, // top center { x: 478, y: 188, w: 110, h: 14 },
], ],
fragments: [ fragments: [
{ x: 160, y: 343, color: '#CF8917' }, { x: 160, y: 343, color: '#CF8917' },
@@ -67,255 +70,298 @@ export const LEVELS = [
tar: [ tar: [
{ x: 490, y: 432 }, { x: 490, y: 432 },
], ],
fragmentQuotes: [
'Not every fire burns, some just keep you warm',
'Warmth is a form of courage.',
'You made something today. That matters.',
'You were built to connect and create.',
],
completeQuote: 'Orange reminds you that making things is an act of hope. It is the color of warmth, creativity, enthusiasm, and connection. It is a choice to stay open. Let yourself be warm.',
}, },
// ── LEVEL 3: YELLOW ─────────────────────────────────────────────────────── // LEVEL 3
// two wide wings — left and right — converging at the upper center
{ {
id: 3, id: 3,
name: 'Golden', name: 'A Bright Ache',
color: '#E3D214', color: '#E3D214',
bg: '/backgrounds/level3.png', bg: '/backgrounds/level3.png',
bgComplete: '/backgrounds/level3_complete.png',
playerImg: '/assets/player_level3.png', playerImg: '/assets/player_level3.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 140, y: 372, w: 150, h: 14 }, // left start { x: 140, y: 372, w: 150, h: 14 },
{ x: 575, y: 365, w: 150, h: 14 }, // right start (enemy guards) { x: 575, y: 365, w: 150, h: 14 },
{ x: 280, y: 302, w: 120, h: 14 }, // left mid { x: 280, y: 302, w: 120, h: 14 },
{ x: 510, y: 295, w: 120, h: 14 }, // right mid { x: 510, y: 295, w: 120, h: 14 },
{ x: 155, y: 238, w: 110, h: 14 }, // upper left { x: 140, y: 238, w: 130, h: 14 },
{ x: 420, y: 228, w: 130, h: 14 }, // upper center (wider, enemy) { x: 420, y: 228, w: 130, h: 14 },
{ x: 660, y: 220, w: 110, h: 14 }, // upper right { x: 660, y: 220, w: 110, h: 14 },
{ x: 290, y: 130, w: 120, h: 14 },
], ],
fragments: [ fragments: [
{ x: 280, y: 270, color: '#E3D214' }, // left mid — easy { x: 280, y: 270, color: '#E3D214' },
{ x: 660, y: 188, color: '#E3D214' }, // upper right { x: 660, y: 188, color: '#E3D214' },
{ x: 155, y: 206, color: '#E3D214' }, // upper left { x: 285, y: 90, color: '#E3D214' },
{ x: 420, y: 196, color: '#E3D214' }, // upper center — guarded { x: 420, y: 196, color: '#E3D214' },
], ],
enemies: [ enemies: [
{ x: 575, y: 340, patrol: 52 }, { x: 575, y: 340, patrol: 52 },
{ x: 420, y: 203, patrol: 45 }, { x: 420, y: 203, patrol: 45 },
{ x: 140, y: 200, patrol: 45 },
], ],
tar: [ tar: [
{ x: 370, y: 432 }, { x: 370, y: 432 },
{ x: 700, y: 432 }, { x: 700, y: 432 },
], ],
fragmentQuotes: [
'Joy is allowed to be simple',
'Clarity costs something, it asks you to really look.',
'Anxiety and curiosity live in the same color.',
'Your mind runs fast because it cares deeply',
],
completeQuote: 'Yellow carries both hope and anxiety in equal measure. It is the color of joy but also of anxiety. Your nervous energy is not a flaw. It is the same thing as your intelligence, it goes hand in hand with your joy.',
}, },
// ── LEVEL 4: GREEN ──────────────────────────────────────────────────────── // LEVEL 4
// two vertical columns with connecting bridges — forest canopy feel
{ {
id: 4, id: 4,
name: 'Greenery', name: 'Growth',
color: '#39BD1C', color: '#39BD1C',
bg: '/backgrounds/level4.png', bg: '/backgrounds/level4.png',
bgComplete: '/backgrounds/level4_complete.png',
playerImg: '/assets/player_level4.png', playerImg: '/assets/player_level4.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 140, y: 372, w: 130, h: 14 }, // left start (enemy guards) { x: 140, y: 372, w: 130, h: 14 },
{ x: 665, y: 365, w: 130, h: 14 }, // right — separated by tar { x: 665, y: 365, w: 130, h: 14 },
{ x: 270, y: 305, w: 120, h: 14 }, // center-left mid (enemy guards) { x: 270, y: 305, w: 120, h: 14 },
{ x: 510, y: 298, w: 120, h: 14 }, // center-right mid { x: 510, y: 298, w: 120, h: 14 },
{ x: 165, y: 242, w: 110, h: 14 }, // upper left { x: 165, y: 242, w: 110, h: 14 },
{ x: 380, y: 232, w: 120, h: 14 }, // upper center { x: 380, y: 232, w: 120, h: 14 },
{ x: 610, y: 225, w: 110, h: 14 }, // upper right (enemy guards) { x: 610, y: 225, w: 110, h: 14 },
{ x: 290, y: 175, w: 95, h: 14 }, // top left { x: 260, y: 175, w: 100, h: 14 },
{ x: 520, y: 168, w: 95, h: 14 }, // top right — hardest { x: 520, y: 168, w: 95, h: 14 },
], ],
fragments: [ fragments: [
{ x: 270, y: 273, color: '#39BD1C' }, // center-left mid { x: 270, y: 273, color: '#39BD1C' },
{ x: 510, y: 266, color: '#39BD1C' }, // center-right mid { x: 510, y: 266, color: '#39BD1C' },
{ x: 380, y: 200, color: '#39BD1C' }, // upper center { x: 260, y: 140, color: '#39BD1C' },
{ x: 520, y: 136, color: '#39BD1C' }, // top right — hardest { x: 520, y: 136, color: '#39BD1C' },
], ],
enemies: [ enemies: [
{ x: 140, y: 347, patrol: 40 }, { x: 140, y: 347, patrol: 40 },
{ x: 270, y: 280, patrol: 38 }, { x: 270, y: 280, patrol: 38 },
{ x: 610, y: 200, patrol: 36 }, { x: 610, y: 200, patrol: 36 },
], { x: 665, y: 350, patrol: 38 },
{ x: 380, y: 200, patrol: 38},
],
tar: [ tar: [
{ x: 395, y: 432 }, { x: 395, y: 432 },
{ x: 610, y: 432 }, { x: 610, y: 432 },
], ],
fragmentQuotes: [
'Growth rarely feels like growth while it\'s happening.',
'Green is the slowest and most stubborn color.',
'Healing is not linear',
'Every root is also a reach.',
],
completeQuote: 'Green is the color of becoming. It does not rush, does not announce itself. It doesn\'t ask you to heal, only to keep growing. Green represents growth, healing, balance, the slow work of becoming. You are allowed to grow quietly, at your own pace.',
}, },
// ── LEVEL 5: CYAN ───────────────────────────────────────────────────────── // LEVEL 5
// zigzag flow — dips and rises like tide pools
{ {
id: 5, id: 5,
name: 'Tidal', name: 'Open Water',
color: '#12B6C8', color: '#12B6C8',
bg: '/backgrounds/level5.png', bg: '/backgrounds/level5.png',
bgComplete: '/backgrounds/level5_complete.png',
playerImg: '/assets/player_level5.png', playerImg: '/assets/player_level5.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 145, y: 378, w: 140, h: 14 }, // left start { x: 145, y: 378, w: 140, h: 14 },
{ x: 355, y: 395, w: 115, h: 14 }, // dips down — zigzag { x: 145, y: 220, w: 140, h: 14 },
{ x: 540, y: 368, w: 125, h: 14 }, // center right { x: 355, y: 395, w: 115, h: 14 },
{ x: 705, y: 348, w: 115, h: 14 }, // far right { x: 540, y: 368, w: 125, h: 14 },
{ x: 235, y: 308, w: 110, h: 14 }, // upper left { x: 235, y: 308, w: 110, h: 14 },
{ x: 445, y: 295, w: 110, h: 14 }, // upper center (enemy) { x: 445, y: 295, w: 110, h: 14 },
{ x: 640, y: 278, w: 115, h: 14 }, // upper right { x: 640, y: 320, w: 115, h: 14 },
{ x: 125, y: 248, w: 95, h: 14 }, // high far left — isolated { x: 50, y: 148, w: 95, h: 14 },
{ x: 365, y: 235, w: 90, h: 14 }, // high center { x: 365, y: 235, w: 90, h: 14 },
{ x: 590, y: 220, w: 90, h: 14 }, // high right (enemy) { x: 590, y: 220, w: 90, h: 14 },
{ x: 755, y: 202, w: 80, h: 14 }, // top far right — narrow { x: 755, y: 202, w: 100, h: 14 },
], ],
fragments: [ fragments: [
{ x: 235, y: 276, color: '#12B6C8' }, // upper left { x: 235, y: 276, color: '#12B6C8' },
{ x: 640, y: 246, color: '#12B6C8' }, // upper right { x: 640, y: 280, color: '#12B6C8' },
{ x: 125, y: 216, color: '#12B6C8' }, // high far left — isolated { x: 50, y: 100, color: '#12B6C8' },
{ x: 365, y: 203, color: '#12B6C8' }, // high center { x: 365, y: 203, color: '#12B6C8' },
{ x: 755, y: 170, color: '#12B6C8' }, // top far right — hardest { x: 755, y: 170, color: '#12B6C8' },
], ],
enemies: [ enemies: [
{ x: 355, y: 370, patrol: 35 }, { x: 355, y: 370, patrol: 35 },
{ x: 445, y: 270, patrol: 35 }, { x: 445, y: 270, patrol: 35 },
{ x: 590, y: 195, patrol: 28 }, { x: 590, y: 195, patrol: 28 },
{ x: 150, y: 350, patrol: 38 },
{ x: 145, y: 200, patrol: 38 },
], ],
tar: [ tar: [
{ x: 265, y: 432 }, { x: 265, y: 432 },
{ x: 480, y: 432 }, { x: 480, y: 432 },
{ x: 680, y: 432 }, { x: 680, y: 432 },
], ],
fragmentQuotes: [
'Clarity comes after, not before',
'Clear water still has a bottom.',
'Calm is not the absence of feeling, it\'s feeling without drowning.',
'To speak honestly is an act of trust.',
'You can be still and still be powerful.',
],
completeQuote: 'Cyan is the color of honest water, clear enough to see through, deep enough to matter, and standing at a calming still. Cyan sits between calmness and clarity. It asks you to say what you mean, and listen with clarity. ',
}, },
// ── LEVEL 6: DEEP BLUE ──────────────────────────────────────────────────── // LEVEL 6
// cold, sparse — wider gaps, deliberate platform placement
{ {
id: 6, id: 6,
name: 'The Abyss', name: 'A Long Quiet',
color: '#170CB7', color: '#170CB7',
bg: '/backgrounds/level6.png', bg: '/backgrounds/level6.png',
bgComplete: '/backgrounds/level6_complete.png',
playerImg: '/assets/player_level6.png', playerImg: '/assets/player_level6.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 130, y: 382, w: 125, h: 14 }, // left start { x: 130, y: 382, w: 125, h: 14 },
{ x: 675, y: 370, w: 130, h: 14 }, // far right — requires commitment { x: 675, y: 370, w: 130, h: 14 },
{ x: 308, y: 355, w: 108, h: 14 }, // center step { x: 308, y: 355, w: 108, h: 14 },
{ x: 510, y: 385, w: 105, h: 14 }, // dip right { x: 510, y: 385, w: 105, h: 14 },
{ x: 220, y: 302, w: 100, h: 14 }, // upper left { x: 220, y: 302, w: 100, h: 14 },
{ x: 455, y: 290, w: 100, h: 14 }, // upper center { x: 455, y: 290, w: 100, h: 14 },
{ x: 660, y: 275, w: 110, h: 14 }, // upper right (enemy) { x: 660, y: 275, w: 120, h: 14 },
{ x: 105, y: 245, w: 88, h: 14 }, // high far left — isolated { x: 105, y: 200, w: 90, h: 14 },
{ x: 358, y: 232, w: 85, h: 14 }, // high center { x: 358, y: 232, w: 90, h: 14 },
{ x: 580, y: 215, w: 85, h: 14 }, // high right //{ x: 580, y: 215, w: 85, h: 14 },
{ x: 745, y: 198, w: 80, h: 14 }, // narrow top right { x: 745, y: 198, w: 80, h: 14 },
{ x: 268, y: 175, w: 80, h: 14 }, // top left { x: 268, y: 100, w: 100, h: 14 },
{ x: 480, y: 165, w: 80, h: 14 }, // very top — hardest { x: 480, y: 165, w: 80, h: 14 },
], ],
fragments: [ fragments: [
{ x: 308, y: 323, color: '#170CB7' }, // center step — warmup { x: 270, y: 70, color: '#170CB7' },
{ x: 220, y: 270, color: '#170CB7' }, // upper left { x: 220, y: 270, color: '#170CB7' },
{ x: 660, y: 243, color: '#170CB7' }, // upper right { x: 660, y: 243, color: '#170CB7' },
{ x: 358, y: 200, color: '#170CB7' }, // high center { x: 358, y: 200, color: '#170CB7' },
{ x: 480, y: 133, color: '#170CB7' }, // very top — hardest { x: 480, y: 133, color: '#170CB7' },
], ],
enemies: [ enemies: [
{ x: 510, y: 360, patrol: 33 }, { x: 510, y: 360, patrol: 33 },
{ x: 455, y: 265, patrol: 30 }, { x: 455, y: 265, patrol: 30 },
{ x: 660, y: 250, patrol: 37 }, { x: 660, y: 250, patrol: 37 },
{ x: 358, y: 207, patrol: 25 }, { x: 358, y: 207, patrol: 25 },
{ x: 100, y: 180, patrol: 20 },
], ],
tar: [ tar: [
{ x: 230, y: 432 }, { x: 230, y: 432 },
{ x: 460, y: 432 }, { x: 460, y: 432 },
{ x: 640, y: 432 }, { x: 640, y: 432 },
], ],
fragmentQuotes: [
'Some feelings don\'t have names, they just have weight',
'There is beauty in melancholy, it means you loved something.',
'Depth and darkness are not the same thing.',
'You are allowed to sit with it.',
'Some truths only surface in the quiet.',
],
completeQuote: 'Blue is the color of depth, sadness, and introspection, the most universally felt emotions. It does not ask you to feel better. It asks you to feel, that is enough. Do not be afraid to go deep. That is where you find out who you actually are.',
}, },
// ── LEVEL 7: PURPLE ─────────────────────────────────────────────────────── // LEVEL 7
// narrow platforms spiraling up — precision required
{ {
id: 7, id: 7,
name: 'Twilight Spire', name: 'In-Between',
color: '#6613BA', color: '#6613BA',
bg: '/backgrounds/level7.png', bg: '/backgrounds/level7.png',
bgComplete: '/backgrounds/level7_complete.png',
playerImg: '/assets/player_level7.png', playerImg: '/assets/player_level7.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 130, y: 385, w: 115, h: 14 }, // left start { x: 130, y: 385, w: 115, h: 14 },
{ x: 308, y: 365, w: 100, h: 14 }, // small step { x: 245, y: 300, w: 115, h: 14 },
{ x: 483, y: 390, w: 100, h: 14 }, // dip { x: 370, y: 232, w: 115, h: 14 },
{ x: 652, y: 358, w: 115, h: 14 }, // right mid { x: 500, y: 170, w: 115, h: 14 },
{ x: 768, y: 300, w: 75, h: 14 }, // narrow far right { x: 650, y: 230, w: 150, h: 14 },
{ 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: [ fragments: [
{ x: 652, y: 326, color: '#6613BA' }, // right mid { x: 160, y: 340, color: '#6613BA' },
{ x: 220, y: 253, color: '#6613BA' }, // upper left { x: 220, y: 260, color: '#6613BA' },
{ x: 320, y: 200, color: '#6613BA' }, // high center { x: 400, y: 190, color: '#6613BA' },
{ x: 518, y: 183, color: '#6613BA' }, // high right { x: 470, y: 130, color: '#6613BA' },
{ x: 698, y: 158, color: '#6613BA' }, // top right — hardest { x: 690, y: 180, color: '#6613BA' },
], ],
enemies: [ enemies: [
{ x: 308, y: 340, patrol: 28 }, { x: 130, y: 380, patrol: 28 },
{ x: 400, y: 270, patrol: 25 }, { x: 240, y: 280, patrol: 28 },
{ x: 578, y: 247, patrol: 26 }, { x: 370, y: 210, patrol: 28 },
{ x: 90, y: 223, patrol: 22 }, { x: 500, y: 160, patrol: 28 },
{ x: 650, y: 220, patrol: 38 },
], ],
tar: [ tar: [
{ x: 200, y: 432 }, { x: 200, y: 432 },
{ x: 408, y: 432 }, { x: 408, y: 432 },
{ x: 618, y: 432 }, { x: 618, y: 432 },
], ],
fragmentQuotes: [
'Not everything needs an explanation.',
'Mystery is an invitation, not a threat.',
'Your contradictions are not flaws, they are complexity.',
'The unknown is not something to fix.',
'Transformation is always a little uncomfortable.',
],
completeQuote: 'Purple is the color of the in-betweens, it represents mystery and intuition. Purple lives in the questions. You do not need everything figured out. Some things are only ever felt, never fully explained.',
}, },
// ── LEVEL 8: MAGENTA ────────────────────────────────────────────────────── // LEVEL 8
// energetic grid-like layout with strategic gaps
{ {
id: 8, id: 8,
name: 'Neon Bloom', name: 'Tenderness',
color: '#C71287', color: '#C71287',
bg: '/backgrounds/level8.png', bg: '/backgrounds/level8.png',
bgComplete: '/backgrounds/level8_complete.png',
playerImg: '/assets/player_level8.png', playerImg: '/assets/player_level8.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 155, y: 380, w: 130, h: 14 }, { x: 155, y: 380, w: 130, h: 14 },
{ x: 338, y: 360, w: 118, h: 14 }, { x: 300, y: 320, w: 120, h: 14 },
{ x: 513, y: 380, w: 110, h: 14 }, // dip { x: 480, y: 380, w: 110, h: 14 },
{ x: 688, y: 358, w: 120, h: 14 }, { x: 680, y: 340, w: 120, h: 14 },
{ x: 165, y: 308, w: 100, h: 14 }, // upper left { x: 540, y: 270, w: 110, h: 14 },
{ x: 365, y: 300, w: 90, h: 14 }, // upper center { x: 720, y: 220, w: 100, h: 14 },
{ x: 553, y: 308, w: 100, h: 14 }, // upper right { x: 180, y: 240, w: 110, h: 14 },
{ x: 728, y: 278, w: 78, h: 14 }, // narrow far right { x: 360, y: 170, w: 110, h: 14 },
{ x: 260, y: 248, w: 90, h: 14 }, // high left { x: 553, y: 130, w: 150, h: 14 },
{ 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: [ fragments: [
{ x: 165, y: 276, color: '#C71287' }, // upper left { x: 320, y: 280, color: '#C71287' },
{ x: 728, y: 246, color: '#C71287' }, // narrow far right { x: 700, y: 300, color: '#C71287' },
{ x: 448, y: 206, color: '#C71287' }, // high center { x: 720, y: 180, color: '#C71287' },
{ x: 368, y: 146, color: '#C71287' }, // near top { x: 380, y: 130, color: '#C71287' },
{ x: 553, y: 133, color: '#C71287' }, // top — hardest { x: 553, y: 90, color: '#C71287' },
], ],
enemies: [ enemies: [
{ x: 338, y: 335, patrol: 38 }, { x: 300, y: 280, patrol: 38 },
{ x: 688, y: 333, patrol: 40 }, { x: 480, y: 370, patrol: 40 },
{ x: 553, y: 283, patrol: 32 }, { x: 540, y: 260, patrol: 32 },
{ x: 365, y: 275, patrol: 25 }, { x: 180, y: 230, patrol: 25 },
{ x: 553, y: 120, patrol: 50 },
], ],
tar: [ tar: [
{ x: 178, y: 432 }, { x: 178, y: 432 },
@@ -323,101 +369,106 @@ export const LEVELS = [
{ x: 558, y: 432 }, { x: 558, y: 432 },
{ x: 738, y: 432 }, { x: 738, y: 432 },
], ],
fragmentQuotes: [
'Pink is strength dressed in softness.',
'Compassion begins with yourself.',
'You would never speak to a friend the way you speak to yourself.',
'Playfulness is not childishness, it is aliveness.',
'You are allowed to bloom loudly.',
],
completeQuote: 'Magenta doesn\'t apologize for being bright. Magenta represents compassion, self-love, and softness. It asks you to be as gentle with yourself as you are with the people you love the most.',
}, },
// ── LEVEL 9: BROWN ──────────────────────────────────────────────────────── // LEVEL 9
// dense cave — most enemies, hardest single-color level
{ {
id: 9, id: 9,
name: 'Deep Caves', name: 'What holds you',
color: '#753F16', color: '#753F16',
bg: '/backgrounds/level9.png', bg: '/backgrounds/level9.png',
bgComplete: '/backgrounds/level9_complete.png',
playerImg: '/assets/player_level9.png', playerImg: '/assets/player_level9.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
{ x: 135, y: 382, w: 128, h: 14 }, //{ x: 135, y: 382, w: 128, h: 14 },
{ x: 315, y: 365, w: 112, h: 14 }, { x: 315, y: 365, w: 120, h: 14 },
{ x: 496, y: 388, w: 108, h: 14 }, // dip { x: 496, y: 388, w: 110, h: 14 },
{ x: 660, y: 358, w: 118, h: 14 }, { x: 660, y: 358, w: 120, h: 14 },
{ x: 200, y: 322, w: 100, h: 14 }, { x: 180, y: 300, w: 100, h: 14 },
{ x: 400, y: 312, w: 93, h: 14 }, { x: 400, y: 312, w: 100, h: 14 },
{ x: 578, y: 305, w: 100, h: 14 }, { x: 660, y: 200, w: 110, h: 14 },
{ x: 738, y: 288, w: 78, h: 14 }, // narrow { x: 550, y: 130, w: 100, h: 14 },
{ x: 118, y: 262, w: 88, h: 14 }, { x: 290, y: 230, w: 100, h: 14 },
{ x: 310, y: 255, w: 88, h: 14 }, { x: 420, y: 190, w: 100, 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: [ fragments: [
{ x: 578, y: 273, color: '#753F16' }, { x: 160, y: 260, color: '#753F16' },
{ x: 310, y: 223, color: '#753F16' }, { x: 310, y: 190, color: '#753F16' },
{ x: 658, y: 200, color: '#753F16' }, { x: 620, y: 320, color: '#753F16' },
{ x: 425, y: 153, color: '#753F16' }, { x: 425, y: 153, color: '#753F16' },
{ x: 608, y: 146, color: '#753F16' }, { x: 660, y: 160, color: '#753F16' },
], ],
enemies: [ enemies: [
{ x: 315, y: 340, patrol: 35 }, { x: 315, y: 340, patrol: 35 },
{ x: 660, y: 333, patrol: 40 }, { x: 660, y: 333, patrol: 40 },
{ x: 400, y: 287, patrol: 28 }, { x: 400, y: 287, patrol: 28 },
{ x: 492, y: 220, patrol: 28 }, { x: 492, y: 360, patrol: 28 },
{ x: 738, y: 263, patrol: 22 }, { x: 550, y: 110, patrol: 28 },
], ],
tar: [ tar: [
{ x: 152, y: 432 }, //{ x: 152, y: 432 },
{ x: 348, y: 432 }, { x: 348, y: 432 },
{ x: 540, y: 432 }, { x: 540, y: 432 },
{ x: 712, y: 432 }, { x: 712, y: 432 },
], ],
fragmentQuotes: [
'Some things stay so that other things can move.',
'Stability is essential.',
'The earth holds everything without complaint.',
'Steadiness is a kind of strength.',
'Your roots are not holding you back.',
],
completeQuote: 'Brown is groundedness and stability. It is the earth beneath everything, often overlooked but everything grows from it. It does not need to be seen to do its work.',
}, },
// ── LEVEL 10: THE COLOR REALM — final ──────────────────────────────────── // LEVEL 10
// one fragment per level color — completing this leads to the win screen
{ {
id: 10, id: 10,
name: 'The Color Realm', name: 'The Whole of You',
color: '#FFD700', color: '#FFD700',
bg: '/backgrounds/level10.png', bg: '/backgrounds/level10.png',
bgComplete: '/backgrounds/level10_complete.png',
playerImg: '/assets/player_level10.png', playerImg: '/assets/player_level10.png',
spawnX: 60, spawnX: 60,
spawnY: 400, spawnY: 400,
platforms: [ platforms: [
{ x: 400, y: 440, w: 800, h: 12 }, { x: 400, y: 440, w: 800, h: 12 },
// row 1 — low
{ x: 120, y: 380, w: 140, h: 14 }, { x: 120, y: 380, w: 140, h: 14 },
{ x: 310, y: 362, w: 120, h: 14 }, { x: 310, y: 362, w: 120, h: 14 },
{ x: 510, y: 382, w: 120, h: 14 }, { x: 510, y: 382, w: 120, h: 14 },
{ x: 700, y: 365, w: 130, h: 14 }, { x: 700, y: 365, w: 130, h: 14 },
// row 2 — mid
{ x: 210, y: 305, w: 120, h: 14 }, { x: 210, y: 305, w: 120, h: 14 },
{ x: 420, y: 298, w: 110, h: 14 }, { x: 420, y: 298, w: 110, h: 14 },
{ x: 628, y: 288, w: 120, h: 14 }, { x: 628, y: 288, w: 120, h: 14 },
// row 3 — high
{ x: 115, y: 248, w: 100, h: 14 }, { x: 115, y: 248, w: 100, h: 14 },
{ x: 315, y: 238, w: 95, h: 14 }, { x: 315, y: 238, w: 100, h: 14 },
{ x: 515, y: 228, w: 95, h: 14 }, { x: 515, y: 228, w: 100, h: 14 },
{ x: 715, y: 215, w: 90, h: 14 }, { x: 715, y: 215, w: 100, h: 14 },
// row 4 — top { x: 215, y: 178, w: 100, h: 14 },
{ x: 215, y: 178, w: 90, h: 14 }, { x: 615, y: 160, w: 100, h: 14 },
{ x: 415, y: 168, w: 90, h: 14 }, { x: 415, y: 128, w: 100, 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: [ fragments: [
{ x: 310, y: 330, color: '#970505' }, // crimson { x: 310, y: 330, color: '#970505' },
{ x: 700, y: 333, color: '#CF8917' }, // amber { x: 700, y: 333, color: '#CF8917' },
{ x: 210, y: 273, color: '#E3D214' }, // yellow { x: 210, y: 273, color: '#E3D214' },
{ x: 628, y: 256, color: '#39BD1C' }, // green { x: 628, y: 256, color: '#39BD1C' },
{ x: 315, y: 206, color: '#12B6C8' }, // cyan { x: 335, y: 206, color: '#12B6C8' },
{ x: 715, y: 183, color: '#170CB7' }, // deep blue { x: 715, y: 183, color: '#170CB7' },
{ x: 215, y: 146, color: '#6613BA' }, // purple { x: 215, y: 146, color: '#6613BA' },
{ x: 615, y: 128, color: '#C71287' }, // magenta { x: 630, y: 128, color: '#C71287' },
{ x: 415, y: 96, color: '#753F16' }, // brown — very top { x: 415, y: 96, color: '#753F16' },
], ],
enemies: [ enemies: [
{ x: 120, y: 355, patrol: 45 }, { x: 120, y: 355, patrol: 45 },
@@ -425,6 +476,10 @@ export const LEVELS = [
{ x: 420, y: 273, patrol: 35 }, { x: 420, y: 273, patrol: 35 },
{ x: 628, y: 263, patrol: 42 }, { x: 628, y: 263, patrol: 42 },
{ x: 515, y: 203, patrol: 30 }, { x: 515, y: 203, patrol: 30 },
{ x: 110, y: 220, patrol: 30 },
{ x: 315, y: 200, patrol: 30 },
{ x: 615, y: 120, patrol: 30 },
], ],
tar: [ tar: [
{ x: 225, y: 432 }, { x: 225, y: 432 },
@@ -433,6 +488,18 @@ export const LEVELS = [
{ x: 665, y: 432 }, { x: 665, y: 432 },
{ x: 760, y: 432 }, { x: 760, y: 432 },
], ],
fragmentQuotes: [
'you burned',
'you created',
'you hoped and worried',
'you grew',
'you let go',
'you felt deeply',
'you wondered',
'you loved',
'you stayed',
],
completeQuote: 'Every emotion is a part of you. You needed all of it. The rage and the tenderness. The confusion and the clarity. The grief and the joy. A world with only one color is not a world at all. Neither are you. You are the whole spectrum. The full Hue.',
}, },
]; ];

View File

@@ -2,6 +2,8 @@
import { querystring } from 'svelte-spa-router'; import { querystring } from 'svelte-spa-router';
import GameCanvas from '../components/GameCanvas.svelte'; import GameCanvas from '../components/GameCanvas.svelte';
import HUD from '../components/HUD.svelte'; import HUD from '../components/HUD.svelte';
import QuoteToast from '../components/QuoteToast.svelte';
import LevelCompleteOverlay from '../components/LevelCompleteOverlay.svelte';
// querystring is the part after ? in the URL e.g. "level=2" // querystring is the part after ? in the URL e.g. "level=2"
// we parse it safely — if missing, default to level 1 // we parse it safely — if missing, default to level 1
@@ -11,8 +13,12 @@
</script> </script>
<div class="game-wrapper"> <div class="game-wrapper">
<GameCanvas levelNumber={levelNum}/> {#key levelNum}
<GameCanvas levelNumber={levelNum}/>
{/key}
<HUD levelNumber={levelNum}/> <HUD levelNumber={levelNum}/>
<QuoteToast />
<LevelCompleteOverlay />
</div> </div>
<style> <style>

View File

@@ -1,39 +1,271 @@
<script> <script>
import {push} from 'svelte-spa-router'; import { onMount, onDestroy } from 'svelte';
import {unlockedColors} from '../stores/colorStore.js'; import { push } from 'svelte-spa-router';
import { gameCompleted, hasSeenControls } from '../stores/colorStore.js';
const COLORS = [
'#970505', '#CF8917', '#E3D214', '#39BD1C',
'#12B6C8', '#170CB7', '#6613BA', '#C71287',
'#753F16', '#FFD700',
];
let sparks = [];
onMount(() => {
sparks = Array.from({ length: 140 }, () => ({
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 3 + 1,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
delay: -(Math.random() * 4),
duration: Math.random() * 2 + 1,
}));
});
let showControls = false;
const miniKeys = { left: false, right: false, jump: false };
function startGame() { function startGame() {
if (!$gameCompleted && !$hasSeenControls) {
showControls = true;
} else {
push('/levelselect');
}
}
function goReady() {
hasSeenControls.set(true);
push('/levelselect'); push('/levelselect');
} }
// controls
const onKeyDown = (e) => {
if (!showControls) return;
if (e.key === 'ArrowLeft' || e.key === 'a') { miniKeys.left = true; e.preventDefault(); }
if (e.key === 'ArrowRight' || e.key === 'd') { miniKeys.right = true; e.preventDefault(); }
if ((e.key === 'ArrowUp' || e.key === 'w' || e.key === ' ') && !e.repeat) {
miniKeys.jump = true;
e.preventDefault();
}
};
const onKeyUp = (e) => {
if (e.key === 'ArrowLeft' || e.key === 'a') miniKeys.left = false;
if (e.key === 'ArrowRight' || e.key === 'd') miniKeys.right = false;
};
const onBlur = () => {
miniKeys.left = false;
miniKeys.right = false;
miniKeys.jump = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
function initMiniCanvas(node) {
const CANVAS_W = 680;
const CANVAS_H = 124;
const PLAT_H = 16;
const sketch = (p) => {
let playerSprite;
let playerImg;
let canJump = true;
let peakReached = false;
p.preload = () => {
playerImg = p.loadImage('/assets/player.png');
};
p.setup = () => {
const canvas = p.createCanvas(CANVAS_W, CANVAS_H);
canvas.parent(node);
p.world.gravity.y = 30;
// Ground platform
const plat = new p.Sprite(CANVAS_W / 2, CANVAS_H - PLAT_H / 2, CANVAS_W, PLAT_H);
plat.collider = 'static';
const g = p.createGraphics(CANVAS_W, PLAT_H);
g.clear();
g.noStroke();
g.fill('#888888');
g.rect(0, 0, CANVAS_W, PLAT_H, 4);
plat.img = g;
// Player centered above platform
playerSprite = new p.Sprite(CANVAS_W / 2, CANVAS_H - PLAT_H - 22, 28, 28);
playerSprite.rotationLock = true;
playerSprite.bounciness = 0;
if (playerImg) playerSprite.img = playerImg;
};
p.draw = () => {
p.background(18);
p.allSprites.update();
if (playerSprite) {
// Jump state machine — mirrors Player.js logic
const vy = playerSprite.vel.y;
if (vy > 2) peakReached = true;
if (peakReached && vy > -0.5 && vy < 1.5) {
canJump = true;
peakReached = false;
}
// Horizontal movement with friction
if (miniKeys.left) playerSprite.vel.x = -5;
if (miniKeys.right) playerSprite.vel.x = 5;
if (!miniKeys.left && !miniKeys.right) playerSprite.vel.x *= 0.78;
// Jump
if (miniKeys.jump && canJump) {
playerSprite.vel.y = -11;
canJump = false;
miniKeys.jump = false;
}
// Clamp to canvas edges
if (playerSprite.x < 14) { playerSprite.x = 14; playerSprite.vel.x = 0; }
if (playerSprite.x > CANVAS_W - 14) { playerSprite.x = CANVAS_W - 14; playerSprite.vel.x = 0; }
// Safety respawn
if (playerSprite.y > CANVAS_H + 40) {
playerSprite.x = CANVAS_W / 2;
playerSprite.y = CANVAS_H - PLAT_H - 22;
playerSprite.vel.x = 0;
playerSprite.vel.y = 0;
}
// Soft white glow under the player
p.push();
p.noStroke();
for (let r = 38; r > 0; r -= 6) {
p.fill(255, 255, 255, (r / 38) * 18);
p.circle(playerSprite.x, playerSprite.y, r * 2);
}
p.pop();
}
p.allSprites.draw();
};
};
const inst = new p5(sketch);
return { destroy() { inst.remove(); } };
}
onDestroy(() => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
});
</script> </script>
<div class="title-screen"> <div class="title-screen">
<!--<img src="/backgrounds/title_bg.png" class="bg" alt="title background"/>-->
<div class="content"> {#if $gameCompleted}
<h1>ColorQuest</h1>
<p>Bring color back to your world</p> <!-- ── NG+ sparkle layer ── -->
<button on:click={startGame}>begin</button> <div class="sparkfield" aria-hidden="true">
</div> {#each sparks as s}
<span
class="spark"
style="
left: {s.x}%;
top: {s.y}%;
width: {s.size}px;
height: {s.size}px;
background: {s.color};
box-shadow: 0 0 {s.size * 2}px {s.color};
animation-duration: {s.duration}s;
animation-delay: {s.delay}s;
"
></span>
{/each}
</div>
<!-- ── NG+ home screen ── -->
<div class="content">
<h1 class="rainbow-title">The Full Hue</h1>
<p class="ng-lead">you've brought all the color back.</p>
<p class="ng-body">
go through the world again, now in full color and without sorrows.<br>
take your time. view the scenery. learn about your hues.
</p>
<button on:click={startGame}>go again</button>
</div>
{:else if showControls}
<!-- ── Controls screen ── -->
<div class="content controls-content">
<h2>how to play</h2>
<p class="ctrl-sub">a short guide before your journey begins</p>
<div class="key-table">
<div class="key-row">
<div class="keys">
<span class="key"></span>
<span class="key"></span>
<span class="or">or</span>
<span class="key">A</span>
<span class="key">D</span>
</div>
<span class="dot">·</span>
<span class="key-desc">move left / right</span>
</div>
<div class="key-row">
<div class="keys">
<span class="key"></span>
<span class="or">or</span>
<span class="key">W</span>
<span class="or">or</span>
<span class="key wide">SPACE</span>
</div>
<span class="dot">·</span>
<span class="key-desc">jump</span>
</div>
<div class="key-row">
<div class="keys">
<span class="key">E</span>
</div>
<span class="dot">·</span>
<span class="key-desc">open level select</span>
</div>
</div>
<p class="try-label">try it out ↓</p>
<div class="mini-canvas-wrap" use:initMiniCanvas></div>
<button on:click={goReady}>i'm ready →</button>
</div>
{:else}
<!-- ── Original home screen ── -->
<div class="content">
<h1>The Full Hue</h1>
<p class="tagline">Bring back your color</p>
<button on:click={startGame}>begin</button>
</div>
{/if}
</div> </div>
<style> <style>
.title-screen{ .title-screen {
width: 800px; width: 800px;
height: 450; height: 450px;
margin: 0 auto; margin: 0 auto;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
background: #111; background: #111;
height: 450px;
} }
.bg{
width: 100%; .content {
height: 100%;
object-fit: cover;
opacity: 0.85;
}
.content{
position: absolute; position: absolute;
inset: 0; inset: 0;
display: flex; display: flex;
@@ -42,31 +274,198 @@
justify-content: center; justify-content: center;
gap: 12px; gap: 12px;
color: white; color: white;
padding: 0 80px;
} }
h1{
font-family:'Courier New', Courier, monospace; /* ── shared heading ── */
h1 {
font-family: 'Courier New', Courier, monospace;
font-size: 64px; font-size: 64px;
font-weight: 400; font-weight: 400;
text-shadow: 0 2px 12px rgba(0,0,0,0.7);
margin: 0; margin: 0;
text-shadow: 0 2px 12px rgba(0,0,0,0.7);
} }
p{
font-family:'Courier New', Courier, monospace; /* ── original tagline ── */
.tagline {
font-family: 'Courier New', Courier, monospace;
font-size: 22px; font-size: 22px;
opacity: 0.8; opacity: 0.8;
margin: 0; margin: 0;
} }
button{
/* ── NG+ rainbow title ── */
.rainbow-title {
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; }
}
/* ── NG+ text ── */
.ng-lead {
font-family: 'Courier New', Courier, monospace;
font-size: 18px;
color: rgba(255, 255, 255, 0.75);
margin: 4px 0 0;
text-align: center;
}
.ng-body {
font-family: 'Courier New', Courier, monospace;
font-size: 14px;
color: rgba(255, 255, 255, 0.45);
line-height: 1.75;
margin: 0;
text-align: center;
}
/* ── shared button ── */
button {
margin-top: 20px; margin-top: 20px;
padding: 12px 40px; padding: 12px 40px;
font-family:'Courier New', Courier, monospace; font-family: 'Courier New', Courier, monospace;
font-size: 22px; font-size: 22px;
background: rgba(255,255,255,0.15); background: rgba(255, 255, 255, 0.15);
color: white; color: white;
border: 1px solid rgba(255,255,255,0.4); border: 1px solid rgba(255, 255, 255, 0.4);
border-radius: 30px; border-radius: 30px;
cursor: pointer; cursor: pointer;
transition: background 0.2s; transition: background 0.2s;
} }
button:hover{background: rgba(255,255,255,0.3);}
button:hover { background: rgba(255, 255, 255, 0.3); }
/* ── NG+ sparkle layer ── */
.sparkfield {
position: absolute;
inset: 0;
pointer-events: none;
}
.spark {
position: absolute;
border-radius: 50%;
animation: sparkle linear infinite;
}
@keyframes sparkle {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.05; transform: scale(0.4); }
}
/* ── controls screen ── */
.controls-content {
gap: 9px;
padding: 0 60px;
}
h2 {
font-family: 'Courier New', Courier, monospace;
font-size: 28px;
font-weight: 400;
margin: 0;
}
.ctrl-sub {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
color: rgba(255, 255, 255, 0.38);
margin: 0;
}
.key-table {
display: flex;
flex-direction: column;
gap: 7px;
margin: 4px 0 2px;
}
.key-row {
display: flex;
align-items: center;
gap: 14px;
}
.keys {
display: flex;
align-items: center;
gap: 4px;
min-width: 218px;
}
.key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 28px;
height: 24px;
padding: 0 7px;
background: rgba(255, 255, 255, 0.09);
border: 1px solid rgba(255, 255, 255, 0.28);
border-bottom: 2px solid rgba(255, 255, 255, 0.28);
border-radius: 5px;
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.82);
box-sizing: border-box;
}
.key.wide {
min-width: 62px;
letter-spacing: 0.04em;
}
.or {
font-family: 'Courier New', Courier, monospace;
font-size: 10px;
color: rgba(255, 255, 255, 0.22);
padding: 0 1px;
}
.dot {
color: rgba(255, 255, 255, 0.18);
font-size: 16px;
line-height: 1;
}
.key-desc {
font-family: 'Courier New', Courier, monospace;
font-size: 13px;
color: rgba(255, 255, 255, 0.58);
}
.try-label {
font-family: 'Courier New', Courier, monospace;
font-size: 11px;
color: rgba(255, 255, 255, 0.22);
margin: 0;
text-align: center;
}
.mini-canvas-wrap {
width: 680px;
height: 124px;
border-radius: 8px;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.07);
}
/* controls-content button gets less top margin */
.controls-content button {
margin-top: 6px;
font-size: 18px;
padding: 10px 36px;
}
</style> </style>

View File

@@ -1,12 +1,52 @@
<script> <script>
import { onMount } from 'svelte';
import { push } from 'svelte-spa-router'; import { push } from 'svelte-spa-router';
const COLORS = [
'#970505', '#CF8917', '#E3D214', '#39BD1C',
'#12B6C8', '#170CB7', '#6613BA', '#C71287',
'#753F16', '#FFD700',
];
let sparks = [];
onMount(() => {
sparks = Array.from({ length: 140 }, () => ({
x: Math.random() * 100,
y: Math.random() * 100,
size: Math.random() * 3 + 1,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
delay: -(Math.random() * 4),
duration: Math.random() * 2 + 1,
}));
const timer = setTimeout(() => push('/'), 5000);
return () => clearTimeout(timer);
});
</script> </script>
<div class="screen"> <div class="screen">
<div class="sparkfield" aria-hidden="true">
{#each sparks as s}
<span
class="spark"
style="
left: {s.x}%;
top: {s.y}%;
width: {s.size}px;
height: {s.size}px;
background: {s.color};
box-shadow: 0 0 {s.size * 2}px {s.color};
animation-duration: {s.duration}s;
animation-delay: {s.delay}s;
"
></span>
{/each}
</div>
<h1 class="title">color restored</h1> <h1 class="title">color restored</h1>
<p class="line1">every fragment found. every hue returned to the world.</p> <p class="line1">every fragment found. every hue returned</p>
<p class="line2">the little painter has done it.</p> <p class="line2">Go forth and experience the world in all its colors.</p>
<button on:click={() => push('/')}>back to home</button>
</div> </div>
<style> <style>
@@ -20,6 +60,25 @@
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 14px; gap: 14px;
position: relative;
overflow: hidden;
}
.sparkfield {
position: absolute;
inset: 0;
pointer-events: none;
}
.spark {
position: absolute;
border-radius: 50%;
animation: sparkle linear infinite;
}
@keyframes sparkle {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.05; transform: scale(0.4); }
} }
.title { .title {
@@ -36,6 +95,8 @@
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
background-clip: text; background-clip: text;
animation: shimmer 4s linear infinite; animation: shimmer 4s linear infinite;
position: relative;
z-index: 1;
} }
@keyframes shimmer { @keyframes shimmer {
@@ -48,6 +109,8 @@
font-size: 18px; font-size: 18px;
color: #ccc; color: #ccc;
text-align: center; text-align: center;
position: relative;
z-index: 1;
} }
.line2 { .line2 {
@@ -56,22 +119,7 @@
color: #666; color: #666;
text-align: center; text-align: center;
margin-top: -4px; margin-top: -4px;
} position: relative;
z-index: 1;
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> </style>

View File

@@ -12,11 +12,15 @@
// -- so these are the global variables ---- // -- so these are the global variables ----
import { writable } from 'svelte/store'; import { writable, derived } from 'svelte/store';
import { LEVELS } from '../game/levelData.js';
// world starts gray so unlockedColors start as an empty array (there is none) // world starts gray so unlockedColors start as an empty array (there is none)
export const unlockedColors = writable([]); export const unlockedColors = writable([]);
// true once the player has seen the controls screen (skip it on future visits)
export const hasSeenControls = writable(false);
// the current level number // the current level number
export const currentLevel = writable(1); export const currentLevel = writable(1);
@@ -33,6 +37,12 @@ export const fragmentsCollected = writable(0);
//player lives //player lives
export const lives = writable(3); export const lives = writable(3);
// true once every level's color has been unlocked (game beaten)
export const gameCompleted = derived(
unlockedColors,
$colors => LEVELS.every(level => $colors.includes(level.color))
);
// --- these are the global functions ----- // --- these are the global functions -----
// for collecting a fragment // for collecting a fragment

35
src/stores/quoteStore.js Normal file
View File

@@ -0,0 +1,35 @@
import { writable } from 'svelte/store';
// ── Fragment quote toast
// Auto-clears after 4 seconds; cancels any previous timer so rapid collection
// always shows the newest quote cleanly.
export const fragmentQuote = writable(null); // { text, color, key } | null
let toastTimer = null;
let quoteKey = 0;
export function showFragmentQuote(text, color) {
if (!text) return;
if (toastTimer) clearTimeout(toastTimer);
quoteKey++;
fragmentQuote.set({ text, color, key: quoteKey });
toastTimer = setTimeout(() => {
fragmentQuote.set(null);
toastTimer = null;
}, 4200);
}
// ── Level-complete overlay
// Shows the color-psychology takeaway before the level-select redirect.
export const completeQuoteData = writable(null); // { text, color } | null
export function showCompleteQuote(text, color, nextDest) {
if (!text) return;
completeQuoteData.set({ text, color, nextDest });
}
export function clearCompleteQuote() {
completeQuoteData.set(null);
}