Compare commits
4 Commits
bb24d5df44
...
3b019607ec
| Author | SHA1 | Date | |
|---|---|---|---|
| 3b019607ec | |||
| 73130dd8ce | |||
| 708fa4e9ab | |||
| b13342f47e |
46
README.md
46
README.md
@@ -1,10 +1,48 @@
|
||||
# svelte-P5 Play
|
||||
|
||||
A simple template to get you started with p5 svelte and the [p5play](https://p5play.org/index.html) library.
|
||||
# Nubzuki Jump
|
||||
- Name: Tomas Horsky
|
||||
- ID: 20256426
|
||||
- Email: tomashorsky@kaist.ac.kr
|
||||
- Git Repository: https://git.prototyping.id/20256426/NubzukiJump
|
||||
- Demo Video:
|
||||
|
||||
## How to use
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
## 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 a character named 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, the types of platforms are:
|
||||
|
||||
|
||||
1. **Basic platform**
|
||||
2. **Moving platform** – a platform that moves horizontally
|
||||
3. **Spring platform** – boosts the player's jump
|
||||
4. **One-time platform** – disappears after one jump
|
||||
|
||||
The player can move only left and right, the jumping is done automatically whenever player lands on platform. Game is controlled either by using the **arrow keys**, or by enabling **camera control** and moving head left or right.
|
||||
|
||||
## Code Organization
|
||||
The code is divided into two main parts, the Svelte app and the game logic inside of `src/game`. The Svelte app is responsible for layout of the page, displaying the leaderboard and handling the game state (start, end, restart). It also contains one component `CameraControl.svelte` which is responsible for enabling camera control and showing reading of head movement.
|
||||
The game logic is divided into several files:
|
||||
1. `game.js` - the main file which initializes the game and handles the game loop
|
||||
2. `player.js` - contains the logic related to the player character, such as movement and player state
|
||||
3. `platforms.js` - contains logic for creating and managing platforms
|
||||
4. `platformTypes.js` - handles the creation and behavior of of platforms by type
|
||||
5. `cameraControl.js` - contains the logic for enabling camera control and reading head movement
|
||||
6. `constants.js` - contains constants for the game
|
||||
|
||||
How that different parts of the code are conected and are communicating 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>
|
||||
|
||||
## Issues
|
||||
The issue I was not able to solve is that when using camera control, the game becomes slower and a little bit laggy. I improved it by lowering the camera resolution and reducing the detection frequency, however its still not perfect but playeble. Other then that, the colisons between player and edges of platform are not always perfect, but nothing terrible.
|
||||
|
||||
## Resources and Acknowledgements
|
||||
- [P5play tutorial](https://p5play.org/learn/sprite) and [P5play documentation](https://p5play.org/docs) - to help me with p5play
|
||||
- **Github copilot** - to help me write code faster
|
||||
- **ChatGPT** - mainly for camera controls and for finding bugs in my code
|
||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,13 +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": {
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
"p5": "1.11.4",
|
||||
"p5-svelte": "^3.1.2",
|
||||
"p5play": "^3.8.14",
|
||||
@@ -85,6 +86,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",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "svelte-app",
|
||||
"name": "nubzuki-jump",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
@@ -19,6 +19,7 @@
|
||||
"svelte": "^5.55.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mediapipe/tasks-vision": "^0.10.35",
|
||||
"p5": "1.11.4",
|
||||
"p5-svelte": "^3.1.2",
|
||||
"p5play": "^3.8.14",
|
||||
|
||||
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 |
BIN
public/assets/organization_diagram.png
Normal file
BIN
public/assets/organization_diagram.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 3.1 KiB |
@@ -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'>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
147
src/CameraControl.svelte
Normal file
147
src/CameraControl.svelte
Normal file
@@ -0,0 +1,147 @@
|
||||
<script>
|
||||
import {
|
||||
initCameraControl,
|
||||
updateCameraControl,
|
||||
cameraInput,
|
||||
} 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;
|
||||
}
|
||||
}
|
||||
|
||||
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 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: 340px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
89
src/game/cameraControl.js
Normal file
89
src/game/cameraControl.js
Normal file
@@ -0,0 +1,89 @@
|
||||
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 = 60;
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -44,13 +46,15 @@ export function updatePlayerPosition(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;
|
||||
} else {
|
||||
player.vel.x *= 0.5;
|
||||
}
|
||||
|
||||
|
||||
// Wrap horizontally
|
||||
if (player.x > width) {
|
||||
player.x = 0;
|
||||
|
||||
Reference in New Issue
Block a user