save progress

This commit is contained in:
2026-05-06 10:01:40 +09:00
parent 155e25099d
commit 1535ed975a
27 changed files with 879 additions and 96 deletions

77
package-lock.json generated
View File

@@ -8,9 +8,8 @@
"name": "svelte-app", "name": "svelte-app",
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"p5": "^1.6.0", "p5": "^1.11.4",
"p5-svelte": "^3.1.2", "p5play": "^3.22.0",
"p5play": "^3.8.14",
"sirv-cli": "^2.0.0", "sirv-cli": "^2.0.0",
"svelte-spa-router": "^3.3.0" "svelte-spa-router": "^3.3.0"
}, },
@@ -194,12 +193,6 @@
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
"dev": true "dev": true
}, },
"node_modules/@types/p5": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@types/p5/-/p5-1.6.2.tgz",
"integrity": "sha512-iy1B7DBMc9e5j0DrWnIjSJh+Ro9UkKxPHspbx/GvIeGFTOIJF0H2lICnIN4go1OZEAQSHBinLd7t+oe8lrrLvA==",
"peer": true
},
"node_modules/@types/resolve": { "node_modules/@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -641,26 +634,30 @@
"dev": true "dev": true
}, },
"node_modules/p5": { "node_modules/p5": {
"version": "1.6.0", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.6.0.tgz", "resolved": "https://registry.npmjs.org/p5/-/p5-1.11.4.tgz",
"integrity": "sha512-RowF+RxfVUhJm/YKXL5TCFzTqnwAIwK6W1VGs9LAqSf3PCmLz9Igbxzlf0Ry5IMV71L42wipCdH/bDiNsqAstA==" "integrity": "sha512-N7tM2XYSmuNX8S295RvgHoJS7kpYLYxLjVFeySkwkbxwVrGnrwY8yAwciTxlonBjP422W7WW9pihpUVP8bAVgg==",
}, "license": "LGPL-2.1"
"node_modules/p5-svelte": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/p5-svelte/-/p5-svelte-3.1.2.tgz",
"integrity": "sha512-lcfWh+cJ1/wRdIXHnjpYmDgj2h3TCy1QJVQnf/cBcFWS8CSkvyAN5F8u8H2U8qBUtZ4XaD3nd+1NoYUMHaMExQ==",
"dependencies": {
"p5": "^1.4.1"
},
"peerDependencies": {
"@types/p5": "^1.4.2",
"p5": "^1.4.0"
}
}, },
"node_modules/p5play": { "node_modules/p5play": {
"version": "3.8.14", "version": "3.35.4",
"resolved": "https://registry.npmjs.org/p5play/-/p5play-3.8.14.tgz", "resolved": "https://registry.npmjs.org/p5play/-/p5play-3.35.4.tgz",
"integrity": "sha512-z2TjIIJ4td9KGIubsyftaJJsh9FXxI+Wl/JDXWfshwJyErYl9wjQpMHdeWxlC+zrg40PHXeYzxG6Gq939bVe4A==" "integrity": "sha512-5C0QobV0a36JhFacV0rrMvgeJNFWYtIpS1EcvHYptmzGXFRt6x9/mvEegiWPqNq5LqRf30XeD3g1JJLfoHYzwQ==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/q5play"
},
{
"type": "ko-fi",
"url": "https://ko-fi.com/q5play"
},
{
"type": "github",
"url": "https://github.com/sponsors/quinton-ashley"
}
],
"license": "p5play Personal License"
}, },
"node_modules/path-parse": { "node_modules/path-parse": {
"version": "1.0.7", "version": "1.0.7",
@@ -1142,12 +1139,6 @@
"integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==", "integrity": "sha512-LG4opVs2ANWZ1TJoKc937iMmNstM/d0ae1vNbnBvBhqCSezgVUOzcLCqbI5elV8Vy6WKwKjaqR+zO9VKirBBCA==",
"dev": true "dev": true
}, },
"@types/p5": {
"version": "1.6.2",
"resolved": "https://registry.npmjs.org/@types/p5/-/p5-1.6.2.tgz",
"integrity": "sha512-iy1B7DBMc9e5j0DrWnIjSJh+Ro9UkKxPHspbx/GvIeGFTOIJF0H2lICnIN4go1OZEAQSHBinLd7t+oe8lrrLvA==",
"peer": true
},
"@types/resolve": { "@types/resolve": {
"version": "1.20.2", "version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz", "resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -1478,22 +1469,14 @@
"dev": true "dev": true
}, },
"p5": { "p5": {
"version": "1.6.0", "version": "1.11.4",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.6.0.tgz", "resolved": "https://registry.npmjs.org/p5/-/p5-1.11.4.tgz",
"integrity": "sha512-RowF+RxfVUhJm/YKXL5TCFzTqnwAIwK6W1VGs9LAqSf3PCmLz9Igbxzlf0Ry5IMV71L42wipCdH/bDiNsqAstA==" "integrity": "sha512-N7tM2XYSmuNX8S295RvgHoJS7kpYLYxLjVFeySkwkbxwVrGnrwY8yAwciTxlonBjP422W7WW9pihpUVP8bAVgg=="
},
"p5-svelte": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/p5-svelte/-/p5-svelte-3.1.2.tgz",
"integrity": "sha512-lcfWh+cJ1/wRdIXHnjpYmDgj2h3TCy1QJVQnf/cBcFWS8CSkvyAN5F8u8H2U8qBUtZ4XaD3nd+1NoYUMHaMExQ==",
"requires": {
"p5": "^1.4.1"
}
}, },
"p5play": { "p5play": {
"version": "3.8.14", "version": "3.35.4",
"resolved": "https://registry.npmjs.org/p5play/-/p5play-3.8.14.tgz", "resolved": "https://registry.npmjs.org/p5play/-/p5play-3.35.4.tgz",
"integrity": "sha512-z2TjIIJ4td9KGIubsyftaJJsh9FXxI+Wl/JDXWfshwJyErYl9wjQpMHdeWxlC+zrg40PHXeYzxG6Gq939bVe4A==" "integrity": "sha512-5C0QobV0a36JhFacV0rrMvgeJNFWYtIpS1EcvHYptmzGXFRt6x9/mvEegiWPqNq5LqRf30XeD3g1JJLfoHYzwQ=="
}, },
"path-parse": { "path-parse": {
"version": "1.0.7", "version": "1.0.7",

View File

@@ -19,9 +19,9 @@
"svelte": "^3.55.0" "svelte": "^3.55.0"
}, },
"dependencies": { "dependencies": {
"p5": "^1.6.0", "p5": "^1.11.4",
"p5-svelte": "^3.1.2",
"p5play": "^3.8.14", "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"
} }

BIN
public/assets/Fragment.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

BIN
public/assets/enemy.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 769 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 726 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 32 KiB

BIN
public/assets/player.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
public/assets/splat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

BIN
public/assets/tar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 477 B

BIN
public/assets/tile.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 687 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

View File

@@ -2,24 +2,22 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset='utf-8'> <meta charset='utf-8'>
<meta name='viewport' content='width=device-width,initial-scale=1'> <meta name='viewport' content='width=device-width,initial-scale=1'>
<title>Svelte app</title> <title>ColorQuest</title>
<link rel='icon' type='image/png' href='/favicon.png'> <link rel='icon' type='image/png' href='/favicon.png'>
<link rel='stylesheet' href='/global.css'> <link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/build/bundle.css'> <link rel='stylesheet' href='/build/bundle.css'>
<!-- p5 -->
<script src="https://cdn.jsdelivr.net/npm/p5@1/lib/p5.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/p5@1/lib/addons/p5.sound.min.js"></script>
<!-- p5play -->
<script src="https://p5play.org/v3/planck.min.js"></script>
<script src="https://p5play.org/v3/p5play.js"></script>
<script src="https://cdn.jsdelivr.net/npm/p5@1.11.4/lib/p5.js"></script>
<script src="https://cdn.jsdelivr.net/npm/p5@1.11.4/lib/addons/p5.sound.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/planck@1.0.0/dist/planck.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/p5play@3.22.0/p5play.js"></script>
</head> </head>
<body> <body>
<script defer src='/build/bundle.js'></script> <script src='/build/bundle.js'></script>
</body </html> </body>
</html>

View File

@@ -7,6 +7,8 @@ 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';
// this is the application of the svelte-spa-router // this is the application of the svelte-spa-router
// e.g. /#/game will show the Game component // e.g. /#/game will show the Game component
// this is kind of like switching between HTML pages but we switch components instead // this is kind of like switching between HTML pages but we switch components instead
@@ -18,7 +20,6 @@ const routes = {
}; };
</script> </script>
<!-- the router swaps components based on URL -->
<Router {routes} /> <Router {routes} />

View File

@@ -0,0 +1,203 @@
<script>
import {onMount, onDestroy} from 'svelte';
import {Player} from '../game/Player.js';
import { get } from 'svelte/store';
import {lives, colorOpacity, levelColor, onFragmentCollection, resetLevel, completeLevel} from '../stores/colorStore.js';
import {push} from 'svelte-spa-router';
import {Enemy} from '../game/Enemy.js';
import {TarPuddle} from '../game/TarPuddle.js';
import {getLevel} from '../game/levelData.js';
import {Fragment} from '../game/Fragment.js';
export let levelNumber = 1;
let canvasContainer;
let p5Instance;
const keysDown = {left: false, right: false, jump: false};
onMount(() => {
// IMPORTANT: parameter is named "p" not "p5"
// "p5" is the global constructor from the CDN script
// if you name the parameter "p5" it shadows the global and breaks new p5(sketch)
const sketch = (p) => {
let player;
let platforms;
let fragments;
let bgImg;
let splatImg;
let enemies = [];
let tarPuddles = [];
let gameState = 'playing';
let splats = [];
let levelData; // declared once here at sketch level
// onFragmentCollected lives here so both setup and draw can see it
function onFragmentCollected(hexColor, x, y) {
player.collectFragment(hexColor);
onFragmentCollection(fragments.length, hexColor);
splats.push({x, y, alpha: 200, size: 30, color: hexColor});
if (fragments.every(f => f.collected)) {
gameState = 'levelcomplete';
completeLevel(levelData.color);
setTimeout(() => push('/levelselect'), 2500);
}
}
p.preload = () => {
bgImg = p.loadImage('/backgrounds/temp-background.png');
splatImg = p.loadImage('/assets/splat.png');
};
p.setup = () => {
const canvas = p.createCanvas(800, 450);
canvas.parent(canvasContainer);
// assign to the outer levelData, no "const" here
levelData = getLevel(levelNumber);
resetLevel(levelNumber, levelData.color);
// gravity must be set before creating sprites
p.world.gravity.y = 10;
player = new Player(p, levelData.spawnX, levelData.spawnY);
platforms = new p.Group();
for (const platData of levelData.platforms) {
const plat = new p.Sprite(platData.x, platData.y, platData.w, platData.h);
plat.collider = 'static';
plat.image = '/assets/tile.png';
platforms.add(plat);
}
fragments = [];
for (const fragData of levelData.fragments) {
fragments.push(new Fragment(p, fragData.x, fragData.y, fragData.color));
}
enemies = [];
for (const enemyData of levelData.enemies) {
const e = new Enemy(p, enemyData.x, enemyData.y);
e.patrolDistance = enemyData.patrol;
enemies.push(e);
}
tarPuddles = [];
for (const tarData of levelData.tar) {
tarPuddles.push(new TarPuddle(p, tarData.x, tarData.y));
}
};
p.draw = () => {
p.background(30);
// draw background image if loaded
if (bgImg) {
p.image(bgImg, 0, 0, p.width, p.height);
}
// color tint overlay based on fragments collected
const reveal = get(colorOpacity);
const hex = get(levelColor);
if (reveal > 0 && hex) {
const col = p.color(hex);
const r = p.lerp(128, p.red(col), reveal);
const g = p.lerp(128, p.green(col), reveal);
const b = p.lerp(128, p.blue(col), reveal);
p.noStroke();
p.fill(r, g, b, reveal * 120);
p.rect(0, 0, p.width, p.height);
}
// fragments
for (const frag of fragments) {
frag.drawGlow();
frag.update(player.sprite, onFragmentCollected);
}
// player glow then update
player.drawGlow();
player.update(keysDown);
player.sprite.visible = player.isVisible();
// enemies
for (const enemy of enemies) {
enemy.update();
if (enemy.overlapsPlayer(player.sprite)) {
const dead = player.loseLife();
lives.set(player.lives);
if (dead) {
gameState = 'gameover';
setTimeout(() => push('/gameover'), 1000);
}
}
}
// tar puddles
for (const tar of tarPuddles) {
if (tar.overlapsPlayer(player.sprite)) {
const dead = player.loseLife();
lives.set(player.lives);
if (dead) {
gameState = 'gameover';
setTimeout(() => push('/gameover'), 1000);
}
}
}
// splat effects
splats = splats.filter(s => s.alpha > 0);
for (const s of splats) {
if (!splatImg) continue;
p.push();
p.tint(255, s.alpha);
p.image(splatImg, s.x - s.size, s.y - s.size, s.size * 2, s.size * 2);
p.pop();
s.alpha -= 4;
s.size += 1.2;
}
// fell off screen
if (player.sprite.y > p.height + 100) {
player.loseLife();
lives.set(player.lives);
}
p.allSprites.update();
p.allSprites.draw();
};
p.keyPressed = () => {
if (p.keyCode === p.LEFT_ARROW || p.key === 'a') keysDown.left = true;
if (p.keyCode === p.RIGHT_ARROW || p.key === 'd') keysDown.right = true;
if (p.keyCode === p.UP_ARROW || p.key === 'w' || p.key === ' ') keysDown.jump = true;
};
p.keyReleased = () => {
if (p.keyCode === p.LEFT_ARROW || p.key === 'a') keysDown.left = false;
if (p.keyCode === p.RIGHT_ARROW || p.key === 'd') keysDown.right = false;
if (p.keyCode === p.UP_ARROW || p.key === 'w' || p.key === ' ') keysDown.jump = false;
};
};
// p5 here refers to the global from the CDN script, not the sketch parameter
p5Instance = new p5(sketch);
});
onDestroy(() => {
if (p5Instance) p5Instance.remove();
});
</script>
<div bind:this={canvasContainer} class="canvas-wrap"></div>
<style>
.canvas-wrap {
width: 800px;
height: 450px;
margin: 0 auto;
position: relative;
}
</style>

View File

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

View File

@@ -0,0 +1,33 @@
export class Enemy{
constructor(p5, x, y) {
this.sprite = new p5.Sprite(x,y,36,36);
this.sprite.image = '/assets/enemy.png';
this.sprite.collider = 'dynamic';
this.sprite.rotationLock = true;
this.sprite.bounciness = 0;
this.speed = 1.5;
this.direction = 1 // 1 is right, -1 is left
this.startX = x;
this.patrolDisctance = 100; // how far it can walk
}
update(){
// move in current direction
this.sprite.vel.x = this.speed * this.direction;
// flip direction once it reaches patrol distance
if (this.sprite.x > this.startX + this.patrolDistance){
this.direction = -1;
this.sprite.mirror.x = -1; // we flip the image
}
if (this.sprite.x < this.startX - this.patrolDistance){
this.direction = 1;
this.sprite.mirror.x = 1; // flip image back
}
}
overlapsPlayer(playerSprite){
return playerSprite.overlaps(this.sprite);
}
}

View File

@@ -0,0 +1,62 @@
export class Fragment {
constructor(p5, x, y, hexColor){
this.p5 = p5;
this.hexColor = hexColor;
this.collected = false;
this.baseY = y // this Y will be used for bobbing math
this.bobAngle = p5.random(0, 360); // so they are not all in sync
// not a solid body so player can pass through
this.sprite = new p5.Sprite(x,y,20,20);
this.sprite.image = '/assets/Fragment.png';
this.sprite.collider = 'none';
this.sprite.rotationLock = true;
}
// call it every frame
// callback function when picked up
update(playerSprite, onCollect){
if (this.collected) return;
// bob up and down based on sine wave
this.bobAngle += 2;
this.sprite.y = this.baseY + Math.sin(this.bobAngle * 0.04) * 7;
// slow spin for funsies
this.sprite.rotation += 1;
// check if player overlap with fragment
if (playerSprite.overlaps(this.sprite)){
this.collect(onCollect);
}
}
collect(onCollect) {
this.collected = true;
// save position BEFORE removing the sprite
const x = this.sprite.x;
const y = this.sprite.y;
this.sprite.remove();
onCollect(this.hexColor, x, y);
}
// draw color glow around fragment
// before drawing the sprite
drawGlow(){
if (this.collected) return;
this.p5.push();
this.p5.noStroke();
// glow pulsates based on sine wave
const pulse = Math.sin(this.bobAngle * 0.08) * 8;
const glowSize = 45 + pulse;
const col = this.p5.color(this.hexColor);
col.setAlpha(60);
this.p5.fill(col);
this.p5.circle(this.sprite.x, this.sprite.y, glowSize);
this.p5.pop();
}
}

View File

@@ -0,0 +1,118 @@
export class Player {
// p5 = the instance, x and y are start position
constructor(p5, x, y){
this.p5 = p5;
this.spawnX = x;
this.spawnY = y;
// p5play sprite - this is the body with physics
this.sprite = new p5.Sprite(x,y,28,28);
// get the character image
this.sprite.image = '/assets/player.png';
// we dont want player to tip over
// lock rotation
this.sprite.rotationLock = true;
// how slidey the movement is
// 0 is not friction 1 is very high friction
this.sprite.friction = 0; // we change later based on vibes
// we dont want the player to be bouncy... for now at least
this.sprite.bounciness = 0;
// game state
this.lives = 3;
this.collectedColors = []; // hex color strings
this.isInvincible = false;
this.invincibleTimer = 0;
}
// this will be called every frame from the game loop
// KeysDwon is an object we can use it is {left, right, jump}
update(keysDown){
const SPEED = 4; // we can change this later
if (keysDown.left) this.sprite.vel.x = -SPEED;
if (keysDown.right) this.sprite.vel.x = SPEED;
// If no key is pressed, slow down
if (!keysDown.left && !keysDown.right){
this.sprite.vel.x *= 0.7;
}
// jump but only if the character is on the ground
// p5play built in = sprite.touching.bottom
if (keysDown.jump && this.sprite.touching && this.sprite.touching.bottom){
this.sprite.vel.y = -11; // negative is up in p5
keysDown.jump = false;
}
// for when the player is invincible
if (this.isInvincible){
this.invincibleTimer--;
if (this.invincibleTimer <= 0){
this.isInvincible = false;
}
}
}
// if player hits tar or enemym they lose a life
/// if no lives left, game over
loseLife(){
if (this.isInvincible) return false;
this.lives--;
this.isInvincible = true; // wont gain damage a little bit after losign a life
this.invincibleTimer = 90; // 1.5 seconds if we are at 60fps
this.respawn();
return this.lives <=0; // if true = game over
}
// respawn at level start spawn point and be static
respawn(){
this.sprite.x = this.spawnX;
this.sprite.y = this.spawnY;
this.sprite.vel.x = 0;
this.sprite.vel.y = 0;
}
// player walks into a frament
collectFragment(hexColor){
this.collectedColors.push(hexColor);
// we start getting color!
}
//glow effect around the player
// this should be called before drawing character so its under it
drawGlow(){
if (this.collectedColors.length === 0) return;
// we want the last collected color to be the glow
const c = this.collectedColors[this.collectedColors.length -1];
this.p5.push();
this.p5.noStroke();
// draw a gradiet with circles that lessen in opacity
// fake flow effect lol
// measurements based on 64 by 64 character
for (let radius = 60; radius > 0; radius -= 8){
const alpha = (radius / 60) * 40; // max is 40
const col = this.p5.color(c);
col.setAlpha(alpha);
this.p5.fill(col);
this.p5.circle(this.sprite.x, this.sprite.y, radius*2);
}
this.p5.pop();
}
// flickery effect for invincibility
isVisible(){
if (!this.isInvincible) return true;
// show/hide every 6 frames so it blinks
return Math.floor(this.invincibleTimer / 6) % 2 === 0;
}
}

View File

@@ -0,0 +1,14 @@
// the tar puddle is like an enemy
export class TarPuddle {
constructor(p5, x, y) {
this.sprite = new p5.Sprite(x, y, 64, 16);
this.sprite.image = '/assets/tar.png';
this.sprite.collider = 'none'; // no physics needed just check for overlap
this.sprite.rotationLock = true;
}
overlapsPlayer(playerSprite){
return playerSprite.overlaps(this.sprite);
}
}

View File

@@ -0,0 +1,81 @@
// level config will be in one array
// index 0 = level 1, etc
// edit level change calculations here
// x,y = center position. w, h = width and height of the platform
export const LEVELS = [
{
id: 1,
name: 'The Gray Beginning',
color: '#FF4136',
//bgFar: '/backgrounds/level1_far/png',
//bgMid: '/backgrounds/level1_mid.png',
spawnX: 80,
spawnY: 380,
// each platform: {x,y,w,h}
platforms: [
{x: 400, y: 440, w: 800, h: 20}, // ground
{x: 220, y: 360, w: 160, h: 16},
{x: 430, y: 300, w: 140, h: 16 },
{x: 640, y: 240, w: 160, h: 16 },
{x: 320, y: 210, w: 120, h: 16 },
],
// each fragment: {x,y,color}
fragments: [
{x: 220, y: 330, color: '#FF4136'},
{x: 430, y: 270, color: '#FF4136'},
{x: 640, y: 210, color: '#FF4136'},
],
// each enemy: {x,y, patrol}
enemies: [
{x: 430, y: 280, patrol: 50},
],
// each puddle: {x, y}
tar: [],
},
{
id: 2,
name: 'Warmer skies',
color: '#FF851B',
bgFar: '/backgrounds/level2_far.png',
bgMid: '/backgrounds/level2_mid.png',
spawnX: 80,
spawnY: 380,
platforms: [
{ x: 400, y: 440, w: 800, h: 20 },
{ x: 180, y: 370, w: 140, h: 16 },
{ x: 380, y: 310, w: 120, h: 16 },
{ x: 560, y: 250, w: 120, h: 16 },
{ x: 700, y: 330, w: 100, h: 16 },
{ x: 300, y: 200, w: 100, h: 16 },
],
fragments: [
{ x: 180, y: 340, color: '#FF851B' },
{ x: 380, y: 280, color: '#FF851B' },
{ x: 300, y: 170, color: '#FF851B' },
{ x: 700, y: 300, color: '#FF851B' },
],
enemies: [
{ x: 380, y: 290, patrol: 45 },
{ x: 560, y: 230, patrol: 40 },
],
tar: [
{ x: 150, y: 432 },
],
},
// Levels 35 will follow the same pattern
];
// get level by ID (indexed)
export function getLevel(id){
return LEVELS.find(level => level.id === id)
}

View File

@@ -1,7 +1,25 @@
<h1 style="color:black; padding:20px"> <script>
ColorQuest - Game (this is a placeholder) import { querystring } from 'svelte-spa-router';
</h1> import GameCanvas from '../components/GameCanvas.svelte';
import HUD from '../components/HUD.svelte';
<a href='#/gameover'> // querystring is the part after ? in the URL e.g. "level=2"
<button>Game over</button> // we parse it safely — if missing, default to level 1
</a> $: levelNum = $querystring
? parseInt(new URLSearchParams($querystring).get('level')) || 1
: 1;
</script>
<div class="game-wrapper">
<GameCanvas levelNumber={levelNum}/>
<HUD levelNumber={levelNum}/>
</div>
<style>
.game-wrapper{
position: relative;
width: 800px;
height: 450px;
margin: 0 auto;
}
</style>

View File

@@ -1,7 +1,67 @@
<h1 style="color:black; padding:20px"> <script>
ColorQuest - Game Over (this is a placeholder) import {push} from 'svelte-spa-router';
</h1> import {querystring} from 'svelte-spa-router';
import {resetLevel} from '../stores/colorStore.js';
import {getLevel} from '../game/levelData.js';
<a href='#/levelselect'> $: levelNum = parseInt(new URLSearchParams($querystring).get('level')) || 1;
<button>Play Again</button>
</a> function retry(){
const level = getLevel(levelNum);
resetLevel(levelNum, level.color);
push(`/game/${levelNum}`);
}
</script>
<div class="screen">
<h1>the light faded...</h1>
<p>the world stays gray a little longer</p>
<div class="buttons">
<button on:click={retry}>try again</button>
<button on:click={() => push('/levelselect')}>level select</button>
</div>
</div>
<style>
.screen{
width: 800px;
height: 450px;
margin: 0 auto;
background: #0a0a0a;
color: #ccc;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
h1{
font-family:'Courier New', Courier, monospace;
font-size: 48px;
font-weight: 400;
color: white;
}
p{
font-family:'Courier New', Courier, monospace;
font-size: 20px;
opacity: .9;
}
.buttons{
display: flex;
gap: 16px;
margin-top: 8px
}
button{
padding: 10px 32px;
border-radius: 24px;
cursor: pointer;
font-family:'Courier New', Courier, monospace;
font-size: 20px;
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.2);
color: white;
}
button:hover{
background: rgba(255,255,255,0.18);
}
</style>

View File

@@ -1,7 +1,72 @@
<h1 style="color:black; padding:20px"> <script>
ColorQuest - Home (this is a placeholder) import {push} from 'svelte-spa-router';
</h1> import {unlockedColors} from '../stores/colorStore.js';
<a href='#/game'> function startGame() {
<button>Start Game</button> push('/levelselect');
</a> }
</script>
<div class="title-screen">
<!--<img src="/backgrounds/title_bg.png" class="bg" alt="title background"/>-->
<div class="content">
<h1>ColorQuest</h1>
<p>Bring color back to your world</p>
<button on:click={startGame}>begin</button>
</div>
</div>
<style>
.title-screen{
width: 800px;
height: 450;
margin: 0 auto;
position: relative;
overflow: hidden;
background: #111;
height: 450px;
}
.bg{
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0.85;
}
.content{
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: white;
}
h1{
font-family:'Courier New', Courier, monospace;
font-size: 64px;
font-weight: 400;
text-shadow: 0 2px 12px rgba(0,0,0,0.7);
margin: 0;
}
p{
font-family:'Courier New', Courier, monospace;
font-size: 22px;
opacity: 0.8;
margin: 0;
}
button{
margin-top: 20px;
padding: 12px 40px;
font-family:'Courier New', Courier, monospace;
font-size: 22px;
background: rgba(255,255,255,0.15);
color: white;
border: 1px solid rgba(255,255,255,0.4);
border-radius: 30px;
cursor: pointer;
transition: background 0.2s;
}
button:hover{background: rgba(255,255,255,0.3);}
</style>

View File

@@ -1,7 +1,100 @@
<h1 style="color:black; padding:20px"> <script>
ColorQuest - Level Select (this is a placeholder) import {push} from 'svelte-spa-router';
</h1> import {unlockedColors} from '../stores/colorStore.js';
import {LEVELS} from '../game/levelData.js';
<a href='#/game'> function playLevel(id) {
<button>Start Game</button> push(`/game?level=${id}`);
</a> }
// level 1 is always unlocked, other based on prev level completion
function isUnlocked(level){
if (level.id === 1) return true;
const prevLevel = LEVELS.find(l => l.id === level.id -1);
return $unlockedColors.includes(prevLevel.color);
}
</script>
<div class="screen">
<h1>Choose a level</h1>
<div class="level-grid">
{#each LEVELS as level}
<button
class="level-card"
class:locked={!isUnlocked(level)}
style="--c: {level.color}"
on:click={() => isUnlocked(level) && playLevel(level.id)}
>
<div class="swatch"></div>
<span class="name">{isUnlocked(level) ? level.name: '?'}</span>
</button>
{/each}
</div>
<button class="back" on:click={() => push('/')}>back</button>
</div>
<style>
.screen{
width: 800px;
height: 450px;
margin: 0 auto;
background: #111;
color: white;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 24px;
}
h1{
font-family:'Courier New', Courier, monospace;
font-size: 42px;
font-weight: 400;
}
.level-grid{
display: flex;
gap: 16px;
}
.level-card{
width: 120px;
height: 140px;
background: #222;
border: 1px solid #444;
border-radius: 12px;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
color: white;
transition: transform 0.15s, border-color 0.15s;
}
.level-card:hover:not(.locked){
transform: translateY(-4px);
border-color: var(--c);
}
.level-card.locked{
opacity: 0.4;
cursor: not-allowed;
}
.level-card.locked .swatch{
background: #555;
}
.name{
font-family:'Courier New', Courier, monospace;
font-size: 18px;
}
.back{
background: none;
border: 1px solid #555;
color: #aaa;
padding: 8px 24px;
border-radius: 20px;
cursor: pointer;
font-family:'Courier New', Courier, monospace;
font-size: 16px;
}
</style>

View File

@@ -5,17 +5,17 @@
// so I can place my color states here so they are updated everywhere // so I can place my color states here so they are updated everywhere
// a writebale is a box that holds a value // a writable is a box that holds a value
// any svelte comonent can read from within it // any svelte component can read from within it
//autupdated when value changes // autoupdated when value changes
// its like the dollar sign notation // its like the dollar sign notation
// -- so these are the global variables ---- // -- so these are the global variables ----
import {writeable} from 'svelte/store'; import { writable } from 'svelte/store';
// 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([]);
// the current level number // the current level number
export const currentLevel = writable(1); export const currentLevel = writable(1);