Compare commits

...

9 Commits

Author SHA1 Message Date
05aba527db updated readme 2026-05-06 13:15:17 +09:00
287681092f add video 2026-05-06 13:15:04 +09:00
81cf7d238e last polishing of code 2026-05-06 13:14:50 +09:00
0952b33a33 updated imports 2026-05-05 12:35:34 +09:00
085e2b4134 add disable camera button 2026-05-05 12:11:08 +09:00
3b019607ec add README 2026-04-29 23:52:04 +09:00
73130dd8ce optimazed camera controll again
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 23:09:07 +09:00
708fa4e9ab optimazed version 2026-04-29 19:27:19 +09:00
b13342f47e add camera controll 2026-04-29 15:59:34 +09:00
17 changed files with 395 additions and 97 deletions

View File

@@ -1,10 +1,73 @@
# svelte-P5 Play
A simple template to get you started with p5 svelte and the [p5play](https://p5play.org/index.html) library.
## How to use
# Nubzuki Jump
- Name: Tomas Horsky
- ID: 20256426
- Email: tomashorsky@kaist.ac.kr
- Git Repository: https://git.prototyping.id/20256426/NubzukiJump
- Demo Video: https://www.youtube.com/watch?v=E3_a1b5RLrk
## How to use and play
```bash
npm install
npm run dev
```
Then play the game either by using the arrow keys (smoother), or enable camera controls and move by moving your head.
## Description of the game
<img src="./public/assets/nubzuki.png" width="140" align="right" style="margin-left: 30px;" />
Nubzuki Jump is a simple 2D platformer game where the player controls Nubzuki. The goal is to jump on platforms and reach as high as possible without falling down. The game features four different types of platforms, which are introduced as the player progresses:
1. **Basic platform**
2. **Moving platform** moves horizontally
3. **Spring platform** boosts the player's jump
4. **One-time platform** disappears after one jump
The player can only move left and right; jumping happens automatically whenever the player lands on a platform. The game can be controlled either using the **arrow keys** or by enabling **camera control** and moving the head left or right.
## Code Organization
The code is divided into two main parts: the Svelte app (`App.svelte` and `CameraControl.svelte`) and the game logic inside `src/game`. `App.svelte` is responsible for the page layout, displaying the leaderboard, and handling the game state (start, playing, game over).
The `CameraControl.svelte` component handles enabling camera control and visualizing the detected head movement.
The game logic is split into several files:
1. `game.js` initializes the game and handles the main game loop
2. `player.js` contains logic related to the player character (movement, state)
3. `platforms.js` handles creation and management of platforms
4. `platformTypes.js` defines behavior and visuals of different platform types
5. `cameraControl.js` handles camera input and head movement detection
6. `constants.js` stores game constants and configuration
The way different parts of the application are connected and communicate can be seen in the following diagram:
<div style="text-align: left;">
<img src="./public/assets/organization_diagram.png"
alt="Organization diagram"
width="500">
</div>
## Implementation
<img src="./public/assets/p5play.webp" width="400" align="right" />
I used **Svelte 5** with runes in combination with **p5** and **p5play**. At first, I thought I would use Svelte a lot, but since the game is basically just a p5 canvas, I used Svelte only for the layout outside of the game. I used **p5play** to help me with the physics, the player and platforms are **Sprites** (game objects from the **p5play** library) which simplifies collision handling and jumping. The world movement could also have been implemented p5play (utiliazing camera movement feature), but I decided to implement it on my own.
I implemented the game basically on my own, without prompting AI to generate code for me, but I used Copilot to help me code faster. However, I used AI for the Svelte layout part and the camera control. Making the camera control work was quite a long process, because the head movement detection is quite performance-heavy and difficult to make smooth in real time. I tried a few different libraries and landed on MediaPipe FaceLandmarker, which, in combination with lowering the video quality and detection frequency, works quite well.
The main functions of the game are:
- `updateGame()` handles the game loop by updating the player (`movePlayer()`), moving the world (`moveWorld()`), and checking if the player has fallen
- `movePlayer()` processes player movement and triggers jumps when landing on platforms
- `moveWorld()` gradually moves the world upward and generates new platforms when needed (`generateNewPlatforms()`)
- `generateNewPlatforms()` fills empty space with platforms, types are chosen based on player elevation and randomness
- `updateCameraControl()` processes camera input by detecting head (nose) position (left, center, right)
## Issues
The main issue I was not able to fully solve is that when using camera control, the game becomes slower and slightly laggy. I improved it by lowering the camera resolution and reducing the detection frequency, but it is still not perfect. Additionally, I encountered circular dependency issue from Sveltes internal modules which I was not able to resolve, but it does not affect the game in anyway.
## Resources and Acknowledgements
- [P5play tutorial](https://p5play.org/learn/sprite) and [P5play documentation](https://p5play.org/docs) helped me learn and use the P5play library
- **GitHub Copilot** helped me code faster
- **ChatGPT** helped me with implementing camera controls, svelte layout and with debugging

60
package-lock.json generated
View File

@@ -1,16 +1,14 @@
{
"name": "svelte-app",
"name": "nubzuki-jump",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "svelte-app",
"name": "nubzuki-jump",
"version": "1.0.0",
"dependencies": {
"p5": "1.11.4",
"p5-svelte": "^3.1.2",
"p5play": "^3.8.14",
"@mediapipe/tasks-vision": "^0.10.35",
"sirv-cli": "^2.0.0"
},
"devDependencies": {
@@ -85,6 +83,12 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mediapipe/tasks-vision": {
"version": "0.10.35",
"resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.35.tgz",
"integrity": "sha512-HOvadwVRE6JC+45nyYhmnywnr5h/J8KZvOeUNVOG9q/0875pZgItznFB9bRTvLc264YSJqiZ1NsIpCStJw/egg==",
"license": "Apache-2.0"
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@@ -595,13 +599,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/p5": {
"version": "1.7.7",
"resolved": "https://registry.npmjs.org/@types/p5/-/p5-1.7.7.tgz",
"integrity": "sha512-WFuP7jqc5CkkMtCK/NphgvMnJz1Qi9CMuK7t6xLu/tuXkRdGQA4q4AD0dUYcChC0Oibe8PE8gbKSFPNF0BqVNw==",
"license": "MIT",
"peer": true
},
"node_modules/@types/resolve": {
"version": "1.20.2",
"resolved": "https://registry.npmjs.org/@types/resolve/-/resolve-1.20.2.tgz",
@@ -1092,45 +1089,6 @@
"dev": true,
"license": "BSD-2-Clause"
},
"node_modules/p5": {
"version": "1.11.4",
"resolved": "https://registry.npmjs.org/p5/-/p5-1.11.4.tgz",
"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==",
"license": "MIT",
"dependencies": {
"p5": "^1.4.1"
},
"peerDependencies": {
"@types/p5": "^1.4.2",
"p5": "^1.4.0"
}
},
"node_modules/p5play": {
"version": "3.35.4",
"resolved": "https://registry.npmjs.org/p5play/-/p5play-3.35.4.tgz",
"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": {
"version": "1.0.7",
"resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz",

View File

@@ -1,5 +1,5 @@
{
"name": "svelte-app",
"name": "nubzuki-jump",
"version": "1.0.0",
"private": true,
"type": "module",
@@ -19,9 +19,7 @@
"svelte": "^5.55.5"
},
"dependencies": {
"p5": "1.11.4",
"p5-svelte": "^3.1.2",
"p5play": "^3.8.14",
"@mediapipe/tasks-vision": "^0.10.35",
"sirv-cli": "^2.0.0"
}
}

BIN
public/assets/demoVideo.mp4 Normal file

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 131 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

BIN
public/assets/p5play.webp Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -7,7 +7,7 @@
<title>Svelte app</title>
<link rel='icon' type='image/png' href='/favicon.png'>
<link rel='icon' type='image/png' href='/assets/nubzuki.png'>
<link rel='stylesheet' href='/global.css'>
<link rel='stylesheet' href='/build/bundle.css'>

View File

@@ -1,17 +1,17 @@
<script>
import CameraControl from "./CameraControl.svelte";
import {
initializeGame,
updateGame,
getScore,
isGameOver,
resetGame
} from './game/game.js';
resetGame,
} from "./game/game.js";
let game;
let score = $state(0);
let highScore = $state(0);
let state = $state('start'); // start | playing | gameover
let state = $state("start"); // start | playing | gameover
window.setup = () => {
createCanvas(400, 800).parent(game);
@@ -24,7 +24,7 @@
score = Math.floor(getScore());
if (isGameOver()) {
state = 'gameover';
state = "gameover";
if (score > highScore) {
highScore = score;
}
@@ -35,7 +35,7 @@
function playGame() {
resetGame();
score = 0;
state = 'playing';
state = "playing";
loop();
}
@@ -56,18 +56,22 @@
<div class="game-wrapper">
<div bind:this={game}></div>
{#if state !== 'playing'}
{#if state !== "playing"}
<div class="overlay">
<div class="overlay-card">
<h2>{state === 'gameover' ? 'Game over!' : 'Start new game'}</h2>
{#if state !== "start"} <p>Score: {score}</p> {/if }
<h2>{state === "gameover" ? "Game over!" : "Start new game"}</h2>
{#if state !== "start"}
<p>Score: {score}</p>
{/if}
<button onclick={playGame}>Play</button>
</div>
</div>
{/if}
</div>
<aside class="right-panel">
<CameraControl />
</aside>
<aside class="right-panel"></aside>
</div>
</main>
@@ -155,4 +159,4 @@
.right-panel {
width: 220px;
}
</style>
</style>

176
src/CameraControl.svelte Normal file
View File

@@ -0,0 +1,176 @@
<script>
import {
initCameraControl,
updateCameraControl,
cameraInput,
resetCameraInput
} from "./game/cameraControl.js";
let videoContainer;
let enabled = $state(false);
let loading = $state(false);
let zone = $state("center");
let error = $state("");
async function enableCamera() {
try {
loading = true;
error = "";
const video = await initCameraControl();
videoContainer.innerHTML = "";
videoContainer.appendChild(video);
await video.play();
enabled = true;
loading = false;
requestAnimationFrame(updateLoop);
} catch (e) {
console.error(e);
error = "Camera could not be started";
loading = false;
}
}
function disableCamera() {
if (videoContainer) {
const video = videoContainer.querySelector("video");
if (video && video.srcObject) {
const tracks = video.srcObject.getTracks();
tracks.forEach((track) => track.stop());
}
videoContainer.innerHTML = "";
}
enabled = false;
resetCameraInput();
}
async function updateLoop() {
if (!enabled) return;
await updateCameraControl();
zone = cameraInput.zone;
requestAnimationFrame(updateLoop);
}
</script>
<div class="camera-card">
<h2>Camera Control</h2>
<p>
Enable camera and move your head to control the game, or use the arrows.
</p>
<div class="camera-view">
<div class="video-holder" bind:this={videoContainer}></div>
{#if !enabled}
<button onclick={enableCamera} disabled={loading}>
{loading ? "Loading..." : "Enable camera"}
</button>
{/if}
<div class="zones">
<div class:active={zone === "left"}>GO LEFT</div>
<div class:active={zone === "center"}>STAY</div>
<div class:active={zone === "right"}>GO RIGHT</div>
</div>
</div>
{#if enabled}
<button class="disable-btn" onclick={disableCamera}>
Disable camera control
</button>
{/if}
{#if error}
<p class="error">{error}</p>
{/if}
</div>
<style>
.camera-card h2 {
margin: 0;
font-size: 28px;
font-weight: bold;
}
.camera-card {
background: rgba(255, 255, 255, 0.12);
border-radius: 16px;
padding: 16px;
color: white;
width: 320px;
height: 380px;
}
.camera-view {
position: relative;
width: 320px;
height: 240px;
background: #111;
border-radius: 16px;
margin-top: 0;
overflow: hidden;
}
.video-holder :global(video) {
width: 100%;
height: 100%;
}
.video-holder :global(video) {
width: 100%;
height: 100%;
object-fit: cover;
transform: scaleX(-1);
}
.camera-view button {
position: absolute;
inset: 50% auto auto 50%;
transform: translate(-50%, -50%);
z-index: 5;
padding: 10px 16px;
border: none;
border-radius: 999px;
cursor: pointer;
font-weight: bold;
}
.zones {
position: absolute;
inset: auto 0 0 0;
display: grid;
grid-template-columns: repeat(3, 1fr);
font-size: 11px;
font-weight: bold;
z-index: 4;
}
.zones div {
padding: 6px 0;
background: rgba(0, 0, 0, 0.45);
}
.zones .active {
background: rgba(255, 255, 255, 0.8);
color: #111;
}
.error {
color: #ffb3b3;
font-size: 13px;
}
.disable-btn {
margin-top: 15px;
padding: 8px 14px;
border: none;
border-radius: 999px;
cursor: pointer;
font-weight: bold;
}
</style>

100
src/game/cameraControl.js Normal file
View File

@@ -0,0 +1,100 @@
import { FaceLandmarker, FilesetResolver } from '@mediapipe/tasks-vision';
export const cameraInput = {
left: false,
right: false,
active: false,
zone: 'center'
};
let video;
let faceLandmarker;
let lastTime = 0;
const DETECTION_INTERVAL = 80;
export async function initCameraControl() {
if (cameraInput.active)
return video;
video = document.createElement('video');
video.autoplay = true;
video.playsInline = true;
video.muted = true;
// Set low resolution and frame rate for better performance
const stream = await navigator.mediaDevices.getUserMedia({
video: {
width: { ideal: 160 },
height: { ideal: 120 },
frameRate: { ideal: 30 }
}
});
video.srcObject = stream;
//load models for face landmark detection
const vision = await FilesetResolver.forVisionTasks(
'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision/wasm'
);
faceLandmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: {
modelAssetPath:
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/latest/face_landmarker.task',
},
runningMode: 'VIDEO',
numFaces: 1
});
cameraInput.active = true;
return video;
}
export function updateCameraControl() {
const now = performance.now();
// Limit detection frequency to improve performance
if (now - lastTime < DETECTION_INTERVAL)
return;
lastTime = now;
if (!faceLandmarker || !video || video.readyState < 2)
return;
const result = faceLandmarker.detectForVideo(video, now);
// If no face is detected, reset to center
if (!result.faceLandmarks.length) {
cameraInput.left = false;
cameraInput.right = false;
cameraInput.zone = 'center';
return;
}
// look at the nose and determine zone
const x = result.faceLandmarks[0][1].x;
if (x < 0.4) {
cameraInput.left = false;
cameraInput.right = true;
cameraInput.zone = 'right';
} else if (x > 0.6) {
cameraInput.left = true;
cameraInput.right = false;
cameraInput.zone = 'left';
} else {
cameraInput.left = false;
cameraInput.right = false;
cameraInput.zone = 'center';
}
}
export function resetCameraInput() {
cameraInput.left = false;
cameraInput.right = false;
cameraInput.active = false;
cameraInput.zone = 'center';
video = null;
faceLandmarker = null;
lastTime = 0;
}

View File

@@ -16,4 +16,4 @@ export const PLAT_TYPE = {
MOVING: 'moving',
SPRING: 'spring',
ONE_TIME: 'one-time'
};
};

View File

@@ -1,4 +1,4 @@
import { updatePlayerPosition, createPlayer } from './player.js';
import { createPlayer, movePlayer } from './player.js';
import { initPlatforms, moveWorld } from './platforms.js';
import { GAME_COLORS, PLATFORMS_GAP } from './constants.js';
@@ -8,12 +8,9 @@ let platforms;
let pendingWorldMove = 0;
let gameOver = false;
export function getScore() {
return (player?.elevation ?? 0) / PLATFORMS_GAP * 10;
}
export function isGameOver() {
return gameOver;
export function initializeGame() {
world.gravity.y = 10;
resetGame();
}
export function resetGame() {
@@ -24,11 +21,6 @@ export function resetGame() {
player = createPlayer();
}
export function initializeGame() {
world.gravity.y = 10;
resetGame();
}
export function updateGame() {
clear();
background(GAME_COLORS.background);
@@ -37,9 +29,10 @@ export function updateGame() {
return;
}
pendingWorldMove += updatePlayerPosition(player, platforms);
pendingWorldMove += movePlayer(player, platforms);
player.rotation = player.vel.x * 1.5;
// move
if (pendingWorldMove > PLATFORMS_GAP) {
const move = Math.min(pendingWorldMove, 5);
pendingWorldMove -= move;
@@ -57,3 +50,12 @@ export function updateGame() {
textAlign(RIGHT);
text(getScore(), width - 20, 50);
}
export function getScore() {
return (player?.elevation ?? 0) / PLATFORMS_GAP * 10;
}
export function isGameOver() {
return gameOver;
}

View File

@@ -14,7 +14,6 @@ function drawSpringPlatform() {
noStroke();
textAlign(CENTER, CENTER);
textSize(15);
//textStyle(BOLD);
text('⮝ ⮝ ⮝', 0, 2);
textStyle(NORMAL);
}
@@ -51,8 +50,6 @@ function addMovingBehavior(platform) {
};
}
export function addTypeSpecifics(platform, type) {
switch (platform.type) {
case PLAT_TYPE.MOVING:

View File

@@ -1,15 +1,17 @@
import { PLAT_TYPE } from './constants.js';
import { cameraInput } from './cameraControl.js';
export function createPlayer() {
const player = new Sprite();
player.scale = 0.20;
player.scale = 0.13;
player.img = "assets/nubzuki.png";
player.bounciness = 0;
player.rotationLock = true;
player.w = 20;
player.w = 25;
player.h = 20;
player.offset.y = 25;
player.offset.y = 30;
player.elevation = 0;
player.x = width / 2;
player.y = height - 80;
@@ -18,7 +20,6 @@ export function createPlayer() {
function handleJump(player, platform) {
let elevationGain = 0;
if (player.elevation < platform.elevation) {
elevationGain = platform.elevation - player.elevation;
@@ -40,14 +41,13 @@ function handleJump(player, platform) {
return elevationGain;
}
export function updatePlayerPosition(player, platforms) {
export function movePlayer(player, platforms) {
// Controls
player.vel.x = 0;
if (keyIsDown(LEFT_ARROW)) {
if (keyIsDown(LEFT_ARROW) || cameraInput.left) {
player.vel.x = -5;
}
if (keyIsDown(RIGHT_ARROW)) {
} else if (keyIsDown(RIGHT_ARROW) || cameraInput.right) {
player.vel.x = 5;
}