mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-21 14:26:42 +02:00
feat(manavoxel): scaffold 2D pixel platform MVP (Phase 0)
Add ManaVoxel — a 2D top-down pixel platform for creating and programming miniature worlds in the browser. This commit includes: - SvelteKit + PixiJS 8 web app with chunk-based tilemap renderer - Game engine: camera (scroll/zoom), input (keyboard/mouse/touch), player with AABB collision, editor/play mode toggle - Pixel editor tools: brush, eraser, flood fill, pipette, box fill, line (Bresenham), undo/redo stack - Shared types package: materials, areas, items, network protocol, inventory - Demo world generator with terrain, buildings, trees - Material palette UI with 15 materials, keyboard shortcuts - Comprehensive design documents (Roblox analysis, tech stack options, voxel resolution analysis, 2D alternative comparison, full project plan) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f4599d1fd9
commit
5589765180
30 changed files with 8969 additions and 0 deletions
57
apps/manavoxel/CLAUDE.md
Normal file
57
apps/manavoxel/CLAUDE.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# ManaVoxel Project Guide
|
||||
|
||||
## Overview
|
||||
|
||||
**ManaVoxel** is a 2D top-down pixel platform where players create detailed miniature worlds, program items with behaviors, and share them — all in the browser.
|
||||
|
||||
| App | Port | URL |
|
||||
|-----|------|-----|
|
||||
| Web App | 5195 | http://localhost:5195 |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
apps/manavoxel/
|
||||
├── apps/
|
||||
│ └── web/ # SvelteKit + PixiJS client (@manavoxel/web)
|
||||
├── packages/
|
||||
│ └── shared/ # Shared types (@manavoxel/shared)
|
||||
├── package.json
|
||||
└── CLAUDE.md
|
||||
```
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
# From monorepo root
|
||||
pnpm dev:manavoxel:web # Start web app (port 5195)
|
||||
|
||||
# From apps/manavoxel
|
||||
pnpm dev # Start all apps
|
||||
pnpm dev:web # Start web only
|
||||
```
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Layer | Technology |
|
||||
|-------|------------|
|
||||
| **Rendering** | PixiJS 8 (WebGL) |
|
||||
| **UI** | SvelteKit 2, Svelte 5 (runes), Tailwind CSS 4 |
|
||||
| **Local-First** | Dexie.js via @manacore/local-store |
|
||||
| **Auth** | Mana Core Auth (JWT) |
|
||||
| **i18n** | svelte-i18n (DE, EN, FR, ES, IT) |
|
||||
|
||||
## Zoom Levels
|
||||
|
||||
| Level | 1 Pixel = | Use |
|
||||
|-------|-----------|-----|
|
||||
| Street | 10cm | Walking, interaction, combat |
|
||||
| Interior | 5cm | Exploring rooms, furniture |
|
||||
| Detail | 1cm | Item/character sprite editing |
|
||||
|
||||
## Core Concepts
|
||||
|
||||
- **Areas**: Streets (10cm) and interiors (5cm) are separate pixel grids connected by portals
|
||||
- **Items**: Pixel sprites (1cm) with properties (sliders) and behaviors (trigger-actions)
|
||||
- **Floors**: Interiors have multiple floors, connected by stairs
|
||||
- **Local-First**: Everything works offline via Dexie.js, syncs via mana-sync
|
||||
57
apps/manavoxel/apps/web/package.json
Normal file
57
apps/manavoxel/apps/web/package.json
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
{
|
||||
"name": "@manavoxel/web",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-pwa": "workspace:*",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0",
|
||||
"vitest": "^4.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-types": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"@manavoxel/shared": "workspace:*",
|
||||
"pixi.js": "^8.17.1",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
}
|
||||
}
|
||||
22
apps/manavoxel/apps/web/src/app.css
Normal file
22
apps/manavoxel/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
@import 'tailwindcss';
|
||||
@import '@manacore/shared-tailwind';
|
||||
|
||||
/* ManaVoxel specific styles */
|
||||
:root {
|
||||
--mv-accent: #10b981;
|
||||
--mv-accent-hover: #059669;
|
||||
--mv-grid: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Prevent text selection during gameplay */
|
||||
.game-canvas {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
/* Pixel-perfect rendering for the canvas */
|
||||
canvas {
|
||||
image-rendering: pixelated;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
14
apps/manavoxel/apps/web/src/app.html
Normal file
14
apps/manavoxel/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no" />
|
||||
<meta name="theme-color" content="#10b981" />
|
||||
<meta name="description" content="ManaVoxel - Create and program detailed pixel worlds in your browser" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
201
apps/manavoxel/apps/web/src/lib/editor/tools.ts
Normal file
201
apps/manavoxel/apps/web/src/lib/editor/tools.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type { TilemapRenderer } from '$lib/engine/tilemap';
|
||||
import { MATERIAL_AIR } from '@manavoxel/shared';
|
||||
|
||||
// ─── Undo/Redo System ───────────────────────────────────────
|
||||
|
||||
export interface PixelChange {
|
||||
x: number;
|
||||
y: number;
|
||||
oldMaterial: number;
|
||||
newMaterial: number;
|
||||
}
|
||||
|
||||
export class UndoStack {
|
||||
private _undoStack: PixelChange[][] = [];
|
||||
private _redoStack: PixelChange[][] = [];
|
||||
private _currentBatch: PixelChange[] = [];
|
||||
private _maxSize = 50;
|
||||
|
||||
/** Start collecting changes for a single user action */
|
||||
beginBatch() {
|
||||
this._currentBatch = [];
|
||||
}
|
||||
|
||||
/** Record a single pixel change within the current batch */
|
||||
record(x: number, y: number, oldMaterial: number, newMaterial: number) {
|
||||
if (oldMaterial === newMaterial) return;
|
||||
this._currentBatch.push({ x, y, oldMaterial, newMaterial });
|
||||
}
|
||||
|
||||
/** Commit the current batch as one undo-able action */
|
||||
commitBatch() {
|
||||
if (this._currentBatch.length === 0) return;
|
||||
this._undoStack.push(this._currentBatch);
|
||||
if (this._undoStack.length > this._maxSize) {
|
||||
this._undoStack.shift();
|
||||
}
|
||||
this._redoStack = []; // Clear redo on new action
|
||||
this._currentBatch = [];
|
||||
}
|
||||
|
||||
get canUndo() {
|
||||
return this._undoStack.length > 0;
|
||||
}
|
||||
get canRedo() {
|
||||
return this._redoStack.length > 0;
|
||||
}
|
||||
|
||||
undo(tilemap: TilemapRenderer) {
|
||||
const batch = this._undoStack.pop();
|
||||
if (!batch) return;
|
||||
for (let i = batch.length - 1; i >= 0; i--) {
|
||||
const c = batch[i];
|
||||
tilemap.setPixel(c.x, c.y, c.oldMaterial);
|
||||
}
|
||||
this._redoStack.push(batch);
|
||||
}
|
||||
|
||||
redo(tilemap: TilemapRenderer) {
|
||||
const batch = this._redoStack.pop();
|
||||
if (!batch) return;
|
||||
for (const c of batch) {
|
||||
tilemap.setPixel(c.x, c.y, c.newMaterial);
|
||||
}
|
||||
this._undoStack.push(batch);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Editor Tools ───────────────────────────────────────────
|
||||
|
||||
export type ToolType = 'brush' | 'eraser' | 'fill' | 'pipette' | 'box' | 'line';
|
||||
|
||||
/**
|
||||
* Place a single pixel (or brush area), recording to undo stack.
|
||||
*/
|
||||
export function brushStroke(
|
||||
tilemap: TilemapRenderer,
|
||||
undo: UndoStack,
|
||||
cx: number,
|
||||
cy: number,
|
||||
material: number,
|
||||
size: number
|
||||
) {
|
||||
const radius = Math.floor(size / 2);
|
||||
for (let dy = -radius; dy <= radius; dy++) {
|
||||
for (let dx = -radius; dx <= radius; dx++) {
|
||||
const x = cx + dx;
|
||||
const y = cy + dy;
|
||||
if (x < 0 || x >= tilemap.worldWidth || y < 0 || y >= tilemap.worldHeight) continue;
|
||||
const old = tilemap.getPixel(x, y);
|
||||
undo.record(x, y, old, material);
|
||||
tilemap.setPixel(x, y, material);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Flood fill from a starting position.
|
||||
* Replaces all connected pixels of the same material with the new material.
|
||||
*/
|
||||
export function floodFill(
|
||||
tilemap: TilemapRenderer,
|
||||
undo: UndoStack,
|
||||
startX: number,
|
||||
startY: number,
|
||||
fillMaterial: number
|
||||
) {
|
||||
const targetMaterial = tilemap.getPixel(startX, startY);
|
||||
if (targetMaterial === fillMaterial) return;
|
||||
|
||||
const stack: [number, number][] = [[startX, startY]];
|
||||
const visited = new Set<string>();
|
||||
const maxIterations = 50_000; // Safety limit
|
||||
let iterations = 0;
|
||||
|
||||
while (stack.length > 0 && iterations < maxIterations) {
|
||||
const [x, y] = stack.pop()!;
|
||||
const key = `${x},${y}`;
|
||||
if (visited.has(key)) continue;
|
||||
visited.add(key);
|
||||
|
||||
if (x < 0 || x >= tilemap.worldWidth || y < 0 || y >= tilemap.worldHeight) continue;
|
||||
if (tilemap.getPixel(x, y) !== targetMaterial) continue;
|
||||
|
||||
undo.record(x, y, targetMaterial, fillMaterial);
|
||||
tilemap.setPixel(x, y, fillMaterial);
|
||||
|
||||
stack.push([x + 1, y], [x - 1, y], [x, y + 1], [x, y - 1]);
|
||||
iterations++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pipette: Pick the material at a position.
|
||||
* Returns the material ID.
|
||||
*/
|
||||
export function pipette(tilemap: TilemapRenderer, x: number, y: number): number {
|
||||
return tilemap.getPixel(x, y);
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a filled rectangle from corner to corner.
|
||||
*/
|
||||
export function boxFill(
|
||||
tilemap: TilemapRenderer,
|
||||
undo: UndoStack,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
material: number
|
||||
) {
|
||||
const minX = Math.max(0, Math.min(x1, x2));
|
||||
const maxX = Math.min(tilemap.worldWidth - 1, Math.max(x1, x2));
|
||||
const minY = Math.max(0, Math.min(y1, y2));
|
||||
const maxY = Math.min(tilemap.worldHeight - 1, Math.max(y1, y2));
|
||||
|
||||
for (let y = minY; y <= maxY; y++) {
|
||||
for (let x = minX; x <= maxX; x++) {
|
||||
const old = tilemap.getPixel(x, y);
|
||||
undo.record(x, y, old, material);
|
||||
tilemap.setPixel(x, y, material);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw a line from (x1,y1) to (x2,y2) using Bresenham's algorithm.
|
||||
*/
|
||||
export function lineDraw(
|
||||
tilemap: TilemapRenderer,
|
||||
undo: UndoStack,
|
||||
x1: number,
|
||||
y1: number,
|
||||
x2: number,
|
||||
y2: number,
|
||||
material: number,
|
||||
brushSize: number
|
||||
) {
|
||||
let dx = Math.abs(x2 - x1);
|
||||
let dy = -Math.abs(y2 - y1);
|
||||
const sx = x1 < x2 ? 1 : -1;
|
||||
const sy = y1 < y2 ? 1 : -1;
|
||||
let err = dx + dy;
|
||||
|
||||
let cx = x1;
|
||||
let cy = y1;
|
||||
|
||||
while (true) {
|
||||
brushStroke(tilemap, undo, cx, cy, material, brushSize);
|
||||
if (cx === x2 && cy === y2) break;
|
||||
const e2 = 2 * err;
|
||||
if (e2 >= dy) {
|
||||
err += dy;
|
||||
cx += sx;
|
||||
}
|
||||
if (e2 <= dx) {
|
||||
err += dx;
|
||||
cy += sy;
|
||||
}
|
||||
}
|
||||
}
|
||||
63
apps/manavoxel/apps/web/src/lib/engine/camera.ts
Normal file
63
apps/manavoxel/apps/web/src/lib/engine/camera.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import type { Container } from 'pixi.js';
|
||||
|
||||
export class Camera {
|
||||
private _container: Container;
|
||||
private _x = 0;
|
||||
private _y = 0;
|
||||
private _scale = 2; // 2x zoom by default (each 10cm pixel = 2 screen pixels)
|
||||
private _minScale = 0.5;
|
||||
private _maxScale = 8;
|
||||
|
||||
get x() {
|
||||
return this._x;
|
||||
}
|
||||
get y() {
|
||||
return this._y;
|
||||
}
|
||||
get scale() {
|
||||
return this._scale;
|
||||
}
|
||||
|
||||
constructor(container: Container) {
|
||||
this._container = container;
|
||||
}
|
||||
|
||||
setPosition(x: number, y: number) {
|
||||
this._x = x;
|
||||
this._y = y;
|
||||
}
|
||||
|
||||
move(dx: number, dy: number) {
|
||||
this._x += dx / this._scale;
|
||||
this._y += dy / this._scale;
|
||||
}
|
||||
|
||||
zoom(factor: number) {
|
||||
const newScale = this._scale * factor;
|
||||
this._scale = Math.max(this._minScale, Math.min(this._maxScale, newScale));
|
||||
}
|
||||
|
||||
setScale(scale: number) {
|
||||
this._scale = Math.max(this._minScale, Math.min(this._maxScale, scale));
|
||||
}
|
||||
|
||||
/** Convert screen coordinates to world coordinates */
|
||||
screenToWorld(
|
||||
screenX: number,
|
||||
screenY: number,
|
||||
screenWidth: number,
|
||||
screenHeight: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: (screenX - screenWidth / 2) / this._scale + this._x,
|
||||
y: (screenY - screenHeight / 2) / this._scale + this._y,
|
||||
};
|
||||
}
|
||||
|
||||
/** Apply camera transform to the world container */
|
||||
update(screenWidth: number, screenHeight: number) {
|
||||
this._container.x = screenWidth / 2 - this._x * this._scale;
|
||||
this._container.y = screenHeight / 2 - this._y * this._scale;
|
||||
this._container.scale.set(this._scale);
|
||||
}
|
||||
}
|
||||
229
apps/manavoxel/apps/web/src/lib/engine/game.ts
Normal file
229
apps/manavoxel/apps/web/src/lib/engine/game.ts
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
import { Application, Container } from 'pixi.js';
|
||||
import { Camera } from './camera';
|
||||
import { InputManager } from './input';
|
||||
import { TilemapRenderer } from './tilemap';
|
||||
import { Player } from './player';
|
||||
import { UndoStack, brushStroke, floodFill, pipette, type ToolType } from '$lib/editor/tools';
|
||||
import { DEFAULT_MATERIALS, MATERIAL_AIR, type Material } from '@manavoxel/shared';
|
||||
|
||||
export class GameEngine {
|
||||
app: Application;
|
||||
camera: Camera;
|
||||
input: InputManager;
|
||||
tilemap: TilemapRenderer;
|
||||
player: Player | null = null;
|
||||
undo: UndoStack;
|
||||
|
||||
private _container: HTMLDivElement;
|
||||
private _worldContainer: Container;
|
||||
private _initialized = false;
|
||||
|
||||
// Editor state
|
||||
private _editing = false;
|
||||
private _selectedMaterial = 1;
|
||||
private _activeTool: ToolType = 'brush';
|
||||
private _brushSize = 1;
|
||||
private _palette: Material[] = DEFAULT_MATERIALS;
|
||||
private _painting = false; // tracks whether we're in a continuous paint stroke
|
||||
|
||||
// Callbacks for UI reactivity
|
||||
onStateChange: (() => void) | null = null;
|
||||
|
||||
get isEditing() {
|
||||
return this._editing;
|
||||
}
|
||||
get selectedMaterial() {
|
||||
return this._selectedMaterial;
|
||||
}
|
||||
get activeTool() {
|
||||
return this._activeTool;
|
||||
}
|
||||
get brushSize() {
|
||||
return this._brushSize;
|
||||
}
|
||||
get palette() {
|
||||
return this._palette;
|
||||
}
|
||||
|
||||
constructor(container: HTMLDivElement) {
|
||||
this._container = container;
|
||||
this.app = new Application();
|
||||
this._worldContainer = new Container();
|
||||
this.undo = new UndoStack();
|
||||
|
||||
this.camera = new Camera(this._worldContainer);
|
||||
this.input = new InputManager(container);
|
||||
this.tilemap = new TilemapRenderer(this._worldContainer, this._palette);
|
||||
|
||||
this._init();
|
||||
}
|
||||
|
||||
private async _init() {
|
||||
await this.app.init({
|
||||
resizeTo: this._container,
|
||||
background: '#1a1a2e',
|
||||
antialias: false,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true,
|
||||
});
|
||||
|
||||
this._container.appendChild(this.app.canvas);
|
||||
this.app.stage.addChild(this._worldContainer);
|
||||
|
||||
// Generate demo world
|
||||
this.tilemap.generateFlatWorld(500, 300);
|
||||
|
||||
// Spawn player in an open area
|
||||
this.player = new Player(this._worldContainer, this.tilemap, 60, 160);
|
||||
|
||||
// Center camera on player
|
||||
this.camera.setPosition(this.player.worldX, this.player.worldY);
|
||||
|
||||
// Game loop
|
||||
this.app.ticker.add((ticker) => this._update(ticker.deltaTime));
|
||||
|
||||
this._initialized = true;
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
private _update(_dt: number) {
|
||||
if (!this._initialized) return;
|
||||
|
||||
if (this._editing) {
|
||||
this._updateEditor();
|
||||
} else {
|
||||
this._updateGame();
|
||||
}
|
||||
|
||||
// Zoom
|
||||
const scrollDelta = this.input.consumeScroll();
|
||||
if (scrollDelta !== 0) {
|
||||
this.camera.zoom(scrollDelta > 0 ? 0.9 : 1.1);
|
||||
}
|
||||
|
||||
// Undo/Redo (Ctrl+Z / Ctrl+Y)
|
||||
if (this.input.isKeyDown('KeyZ') && this.input.isKeyDown('ControlLeft')) {
|
||||
if (this.input.isKeyDown('ShiftLeft')) {
|
||||
this.undo.redo(this.tilemap);
|
||||
} else {
|
||||
this.undo.undo(this.tilemap);
|
||||
}
|
||||
}
|
||||
if (this.input.isKeyDown('KeyY') && this.input.isKeyDown('ControlLeft')) {
|
||||
this.undo.redo(this.tilemap);
|
||||
}
|
||||
|
||||
this.camera.update(this.app.screen.width, this.app.screen.height);
|
||||
}
|
||||
|
||||
private _updateGame() {
|
||||
// Player movement
|
||||
let dx = 0;
|
||||
let dy = 0;
|
||||
if (this.input.isKeyDown('KeyW') || this.input.isKeyDown('ArrowUp')) dy = -1;
|
||||
if (this.input.isKeyDown('KeyS') || this.input.isKeyDown('ArrowDown')) dy = 1;
|
||||
if (this.input.isKeyDown('KeyA') || this.input.isKeyDown('ArrowLeft')) dx = -1;
|
||||
if (this.input.isKeyDown('KeyD') || this.input.isKeyDown('ArrowRight')) dx = 1;
|
||||
|
||||
if (this.player) {
|
||||
this.player.move(dx, dy);
|
||||
// Camera follows player smoothly
|
||||
const lerpSpeed = 0.1;
|
||||
const cx = this.camera.x + (this.player.worldX - this.camera.x) * lerpSpeed;
|
||||
const cy = this.camera.y + (this.player.worldY - this.camera.y) * lerpSpeed;
|
||||
this.camera.setPosition(cx, cy);
|
||||
}
|
||||
}
|
||||
|
||||
private _updateEditor() {
|
||||
// Camera pan with WASD in editor mode
|
||||
const moveSpeed = 4;
|
||||
if (this.input.isKeyDown('KeyW') || this.input.isKeyDown('ArrowUp'))
|
||||
this.camera.move(0, -moveSpeed);
|
||||
if (this.input.isKeyDown('KeyS') || this.input.isKeyDown('ArrowDown'))
|
||||
this.camera.move(0, moveSpeed);
|
||||
if (this.input.isKeyDown('KeyA') || this.input.isKeyDown('ArrowLeft'))
|
||||
this.camera.move(-moveSpeed, 0);
|
||||
if (this.input.isKeyDown('KeyD') || this.input.isKeyDown('ArrowRight'))
|
||||
this.camera.move(moveSpeed, 0);
|
||||
|
||||
// Get world position under cursor
|
||||
const worldPos = this.camera.screenToWorld(
|
||||
this.input.mouseX,
|
||||
this.input.mouseY,
|
||||
this.app.screen.width,
|
||||
this.app.screen.height
|
||||
);
|
||||
const tileX = Math.floor(worldPos.x / this.tilemap.tileSize);
|
||||
const tileY = Math.floor(worldPos.y / this.tilemap.tileSize);
|
||||
|
||||
// Handle mouse actions
|
||||
if (this.input.isMouseDown) {
|
||||
const material =
|
||||
this.input.mouseButton === 2 || this._activeTool === 'eraser'
|
||||
? MATERIAL_AIR
|
||||
: this._selectedMaterial;
|
||||
|
||||
if (!this._painting) {
|
||||
// Start a new paint stroke
|
||||
this._painting = true;
|
||||
this.undo.beginBatch();
|
||||
}
|
||||
|
||||
switch (this._activeTool) {
|
||||
case 'brush':
|
||||
case 'eraser':
|
||||
brushStroke(this.tilemap, this.undo, tileX, tileY, material, this._brushSize);
|
||||
break;
|
||||
case 'fill':
|
||||
// Fill only on initial click (not drag)
|
||||
if (this.input.justPressed) {
|
||||
floodFill(this.tilemap, this.undo, tileX, tileY, material);
|
||||
}
|
||||
break;
|
||||
case 'pipette':
|
||||
if (this.input.justPressed) {
|
||||
const picked = pipette(this.tilemap, tileX, tileY);
|
||||
if (picked !== MATERIAL_AIR) {
|
||||
this._selectedMaterial = picked;
|
||||
this._activeTool = 'brush'; // Switch back to brush after pick
|
||||
this.onStateChange?.();
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (this._painting) {
|
||||
// Mouse released: commit the undo batch
|
||||
this._painting = false;
|
||||
this.undo.commitBatch();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Public API for UI ──────────────────────────────────
|
||||
|
||||
toggleEditor() {
|
||||
this._editing = !this._editing;
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
setMaterial(materialId: number) {
|
||||
this._selectedMaterial = materialId;
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
setTool(tool: ToolType) {
|
||||
this._activeTool = tool;
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
setBrushSize(size: number) {
|
||||
this._brushSize = Math.max(1, Math.min(9, size));
|
||||
this.onStateChange?.();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.player?.destroy();
|
||||
this.input.destroy();
|
||||
this.app.destroy(true);
|
||||
}
|
||||
}
|
||||
95
apps/manavoxel/apps/web/src/lib/engine/input.ts
Normal file
95
apps/manavoxel/apps/web/src/lib/engine/input.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
export class InputManager {
|
||||
private _keys = new Set<string>();
|
||||
private _mouseDown = false;
|
||||
private _mouseButton = 0;
|
||||
private _mouseX = 0;
|
||||
private _mouseY = 0;
|
||||
private _scrollAccumulator = 0;
|
||||
private _justPressed = false;
|
||||
private _element: HTMLElement;
|
||||
|
||||
private _onKeyDown: (e: KeyboardEvent) => void;
|
||||
private _onKeyUp: (e: KeyboardEvent) => void;
|
||||
private _onMouseDown: (e: MouseEvent) => void;
|
||||
private _onMouseUp: (e: MouseEvent) => void;
|
||||
private _onMouseMove: (e: MouseEvent) => void;
|
||||
private _onWheel: (e: WheelEvent) => void;
|
||||
private _onContextMenu: (e: Event) => void;
|
||||
|
||||
get isMouseDown() {
|
||||
return this._mouseDown;
|
||||
}
|
||||
get mouseButton() {
|
||||
return this._mouseButton;
|
||||
}
|
||||
get mouseX() {
|
||||
return this._mouseX;
|
||||
}
|
||||
get mouseY() {
|
||||
return this._mouseY;
|
||||
}
|
||||
/** True only on the first frame the mouse is pressed */
|
||||
get justPressed() {
|
||||
const val = this._justPressed;
|
||||
this._justPressed = false;
|
||||
return val;
|
||||
}
|
||||
|
||||
constructor(element: HTMLElement) {
|
||||
this._element = element;
|
||||
|
||||
this._onKeyDown = (e) => {
|
||||
this._keys.add(e.code);
|
||||
};
|
||||
this._onKeyUp = (e) => {
|
||||
this._keys.delete(e.code);
|
||||
};
|
||||
this._onMouseDown = (e) => {
|
||||
this._mouseDown = true;
|
||||
this._mouseButton = e.button;
|
||||
this._justPressed = true;
|
||||
};
|
||||
this._onMouseUp = () => {
|
||||
this._mouseDown = false;
|
||||
};
|
||||
this._onMouseMove = (e) => {
|
||||
this._mouseX = e.clientX;
|
||||
this._mouseY = e.clientY;
|
||||
};
|
||||
this._onWheel = (e) => {
|
||||
e.preventDefault();
|
||||
this._scrollAccumulator += e.deltaY;
|
||||
};
|
||||
this._onContextMenu = (e) => {
|
||||
e.preventDefault(); // Disable right-click menu
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', this._onKeyDown);
|
||||
window.addEventListener('keyup', this._onKeyUp);
|
||||
element.addEventListener('mousedown', this._onMouseDown);
|
||||
window.addEventListener('mouseup', this._onMouseUp);
|
||||
window.addEventListener('mousemove', this._onMouseMove);
|
||||
element.addEventListener('wheel', this._onWheel, { passive: false });
|
||||
element.addEventListener('contextmenu', this._onContextMenu);
|
||||
}
|
||||
|
||||
isKeyDown(code: string): boolean {
|
||||
return this._keys.has(code);
|
||||
}
|
||||
|
||||
consumeScroll(): number {
|
||||
const val = this._scrollAccumulator;
|
||||
this._scrollAccumulator = 0;
|
||||
return val;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
window.removeEventListener('keydown', this._onKeyDown);
|
||||
window.removeEventListener('keyup', this._onKeyUp);
|
||||
this._element.removeEventListener('mousedown', this._onMouseDown);
|
||||
window.removeEventListener('mouseup', this._onMouseUp);
|
||||
window.removeEventListener('mousemove', this._onMouseMove);
|
||||
this._element.removeEventListener('wheel', this._onWheel);
|
||||
this._element.removeEventListener('contextmenu', this._onContextMenu);
|
||||
}
|
||||
}
|
||||
164
apps/manavoxel/apps/web/src/lib/engine/player.ts
Normal file
164
apps/manavoxel/apps/web/src/lib/engine/player.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { Container, Graphics } from 'pixi.js';
|
||||
import type { TilemapRenderer } from './tilemap';
|
||||
|
||||
const PLAYER_WIDTH = 6; // pixels (60cm at 10cm/pixel)
|
||||
const PLAYER_HEIGHT = 8; // pixels (80cm)
|
||||
const PLAYER_SPEED = 1.5; // pixels per frame
|
||||
const PLAYER_COLOR = '#4FC3F7';
|
||||
|
||||
export class Player {
|
||||
x: number;
|
||||
y: number;
|
||||
direction = 2; // 0=up, 1=right, 2=down, 3=left
|
||||
hp = 100;
|
||||
maxHp = 100;
|
||||
|
||||
private _sprite: Container;
|
||||
private _body: Graphics;
|
||||
private _dirIndicator: Graphics;
|
||||
private _tilemap: TilemapRenderer;
|
||||
|
||||
get worldX() {
|
||||
return this.x * this._tilemap.tileSize;
|
||||
}
|
||||
get worldY() {
|
||||
return this.y * this._tilemap.tileSize;
|
||||
}
|
||||
|
||||
constructor(worldContainer: Container, tilemap: TilemapRenderer, startX: number, startY: number) {
|
||||
this._tilemap = tilemap;
|
||||
this.x = startX;
|
||||
this.y = startY;
|
||||
|
||||
this._sprite = new Container();
|
||||
worldContainer.addChild(this._sprite);
|
||||
|
||||
// Body rectangle
|
||||
this._body = new Graphics();
|
||||
this._body.roundRect(
|
||||
0,
|
||||
0,
|
||||
PLAYER_WIDTH * tilemap.tileSize,
|
||||
PLAYER_HEIGHT * tilemap.tileSize,
|
||||
2
|
||||
);
|
||||
this._body.fill(PLAYER_COLOR);
|
||||
this._sprite.addChild(this._body);
|
||||
|
||||
// Direction indicator (small triangle)
|
||||
this._dirIndicator = new Graphics();
|
||||
this._sprite.addChild(this._dirIndicator);
|
||||
|
||||
this._updateSpritePosition();
|
||||
this._updateDirectionIndicator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Move the player by input direction with collision detection.
|
||||
* Returns true if the player actually moved.
|
||||
*/
|
||||
move(dx: number, dy: number): boolean {
|
||||
if (dx === 0 && dy === 0) return false;
|
||||
|
||||
// Normalize diagonal movement
|
||||
if (dx !== 0 && dy !== 0) {
|
||||
const len = Math.sqrt(dx * dx + dy * dy);
|
||||
dx = (dx / len) * PLAYER_SPEED;
|
||||
dy = (dy / len) * PLAYER_SPEED;
|
||||
} else {
|
||||
dx *= PLAYER_SPEED;
|
||||
dy *= PLAYER_SPEED;
|
||||
}
|
||||
|
||||
// Update direction
|
||||
if (Math.abs(dx) > Math.abs(dy)) {
|
||||
this.direction = dx > 0 ? 1 : 3;
|
||||
} else if (dy !== 0) {
|
||||
this.direction = dy > 0 ? 2 : 0;
|
||||
}
|
||||
|
||||
// Try X movement
|
||||
const newX = this.x + dx;
|
||||
if (!this._collides(newX, this.y)) {
|
||||
this.x = newX;
|
||||
}
|
||||
|
||||
// Try Y movement
|
||||
const newY = this.y + dy;
|
||||
if (!this._collides(this.x, newY)) {
|
||||
this.y = newY;
|
||||
}
|
||||
|
||||
this._updateSpritePosition();
|
||||
this._updateDirectionIndicator();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Check if the player hitbox collides with solid tiles at position (px, py) */
|
||||
private _collides(px: number, py: number): boolean {
|
||||
// Check corners and midpoints of the player hitbox
|
||||
const margin = 0.1; // Small margin to avoid getting stuck
|
||||
const left = px + margin;
|
||||
const right = px + PLAYER_WIDTH - margin;
|
||||
const top = py + margin;
|
||||
const bottom = py + PLAYER_HEIGHT - margin;
|
||||
const midX = px + PLAYER_WIDTH / 2;
|
||||
const midY = py + PLAYER_HEIGHT / 2;
|
||||
|
||||
// Check 8 points around the hitbox
|
||||
return (
|
||||
this._tilemap.isSolid(Math.floor(left), Math.floor(top)) ||
|
||||
this._tilemap.isSolid(Math.floor(right), Math.floor(top)) ||
|
||||
this._tilemap.isSolid(Math.floor(left), Math.floor(bottom)) ||
|
||||
this._tilemap.isSolid(Math.floor(right), Math.floor(bottom)) ||
|
||||
this._tilemap.isSolid(Math.floor(midX), Math.floor(top)) ||
|
||||
this._tilemap.isSolid(Math.floor(midX), Math.floor(bottom)) ||
|
||||
this._tilemap.isSolid(Math.floor(left), Math.floor(midY)) ||
|
||||
this._tilemap.isSolid(Math.floor(right), Math.floor(midY))
|
||||
);
|
||||
}
|
||||
|
||||
private _updateSpritePosition() {
|
||||
this._sprite.x = this.x * this._tilemap.tileSize;
|
||||
this._sprite.y = this.y * this._tilemap.tileSize;
|
||||
}
|
||||
|
||||
private _updateDirectionIndicator() {
|
||||
const g = this._dirIndicator;
|
||||
const ts = this._tilemap.tileSize;
|
||||
const w = PLAYER_WIDTH * ts;
|
||||
const h = PLAYER_HEIGHT * ts;
|
||||
const s = 4; // triangle size
|
||||
|
||||
g.clear();
|
||||
|
||||
switch (this.direction) {
|
||||
case 0: // up
|
||||
g.moveTo(w / 2, -s);
|
||||
g.lineTo(w / 2 - s, 0);
|
||||
g.lineTo(w / 2 + s, 0);
|
||||
break;
|
||||
case 1: // right
|
||||
g.moveTo(w + s, h / 2);
|
||||
g.lineTo(w, h / 2 - s);
|
||||
g.lineTo(w, h / 2 + s);
|
||||
break;
|
||||
case 2: // down
|
||||
g.moveTo(w / 2, h + s);
|
||||
g.lineTo(w / 2 - s, h);
|
||||
g.lineTo(w / 2 + s, h);
|
||||
break;
|
||||
case 3: // left
|
||||
g.moveTo(-s, h / 2);
|
||||
g.lineTo(0, h / 2 - s);
|
||||
g.lineTo(0, h / 2 + s);
|
||||
break;
|
||||
}
|
||||
g.closePath();
|
||||
g.fill('#ffffff');
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._sprite.destroy({ children: true });
|
||||
}
|
||||
}
|
||||
201
apps/manavoxel/apps/web/src/lib/engine/tilemap.ts
Normal file
201
apps/manavoxel/apps/web/src/lib/engine/tilemap.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import { Container, Graphics } from 'pixi.js';
|
||||
import { CHUNK_SIZE, MATERIAL_AIR, type Material } from '@manavoxel/shared';
|
||||
|
||||
/**
|
||||
* Chunk-based tilemap renderer.
|
||||
* Each chunk is a 32×32 grid of pixels rendered as a single Graphics object.
|
||||
* Only chunks in view are rendered. Chunks are re-drawn only when dirty.
|
||||
*/
|
||||
|
||||
interface Chunk {
|
||||
cx: number;
|
||||
cy: number;
|
||||
pixels: Uint16Array; // CHUNK_SIZE * CHUNK_SIZE
|
||||
graphics: Graphics;
|
||||
dirty: boolean;
|
||||
}
|
||||
|
||||
export class TilemapRenderer {
|
||||
readonly tileSize = 8; // Screen pixels per world pixel (at 1x zoom)
|
||||
private _container: Container;
|
||||
private _palette: Material[];
|
||||
private _chunks = new Map<string, Chunk>();
|
||||
private _worldWidth = 0;
|
||||
private _worldHeight = 0;
|
||||
|
||||
get worldWidth() {
|
||||
return this._worldWidth;
|
||||
}
|
||||
get worldHeight() {
|
||||
return this._worldHeight;
|
||||
}
|
||||
|
||||
constructor(worldContainer: Container, palette: Material[]) {
|
||||
this._container = new Container();
|
||||
worldContainer.addChild(this._container);
|
||||
this._palette = palette;
|
||||
}
|
||||
|
||||
/** Generate a flat world with grass floor and stone borders */
|
||||
generateFlatWorld(width: number, height: number) {
|
||||
this._worldWidth = width;
|
||||
this._worldHeight = height;
|
||||
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
let material = MATERIAL_AIR;
|
||||
|
||||
// Border walls
|
||||
if (x === 0 || x === width - 1 || y === 0 || y === height - 1) {
|
||||
material = 1; // Stone
|
||||
}
|
||||
// Grass floor (bottom third)
|
||||
else if (y > height * 0.7) {
|
||||
material = 3; // Grass
|
||||
}
|
||||
// Dirt under grass
|
||||
else if (y > height * 0.75) {
|
||||
material = 2; // Dirt
|
||||
}
|
||||
// Stone deep underground
|
||||
else if (y > height * 0.85) {
|
||||
material = 1; // Stone
|
||||
}
|
||||
// A few demo buildings
|
||||
else if (x >= 50 && x <= 70 && y >= 150 && y <= 180) {
|
||||
// Small stone house
|
||||
if (x === 50 || x === 70 || y === 150 || y === 180) {
|
||||
material = 8; // Brick
|
||||
} else if (y === 180 && x >= 58 && x <= 62) {
|
||||
material = MATERIAL_AIR; // Door opening
|
||||
} else if (y === 155 && (x === 55 || x === 65)) {
|
||||
material = 9; // Glass windows
|
||||
}
|
||||
}
|
||||
// A wooden platform
|
||||
else if (x >= 100 && x <= 130 && y === 170) {
|
||||
material = 5; // Plank
|
||||
}
|
||||
// Some trees (simple: trunk + leaves)
|
||||
else if (x === 200 && y >= 160 && y <= 170) {
|
||||
material = 4; // Wood trunk
|
||||
} else if (
|
||||
x >= 196 &&
|
||||
x <= 204 &&
|
||||
y >= 155 &&
|
||||
y <= 162 &&
|
||||
Math.abs(x - 200) + Math.abs(y - 158) <= 5
|
||||
) {
|
||||
material = 13; // Leaves
|
||||
}
|
||||
|
||||
if (material !== MATERIAL_AIR) {
|
||||
this._setPixelRaw(x, y, material);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mark all chunks dirty for initial render
|
||||
for (const chunk of this._chunks.values()) {
|
||||
chunk.dirty = true;
|
||||
}
|
||||
this._renderDirtyChunks();
|
||||
}
|
||||
|
||||
/** Set a pixel and mark chunk dirty */
|
||||
setPixel(x: number, y: number, material: number) {
|
||||
if (x < 0 || x >= this._worldWidth || y < 0 || y >= this._worldHeight) return;
|
||||
this._setPixelRaw(x, y, material);
|
||||
|
||||
const key = this._chunkKey(Math.floor(x / CHUNK_SIZE), Math.floor(y / CHUNK_SIZE));
|
||||
const chunk = this._chunks.get(key);
|
||||
if (chunk) {
|
||||
chunk.dirty = true;
|
||||
this._renderChunk(chunk);
|
||||
}
|
||||
}
|
||||
|
||||
/** Get pixel material at world position */
|
||||
getPixel(x: number, y: number): number {
|
||||
const cx = Math.floor(x / CHUNK_SIZE);
|
||||
const cy = Math.floor(y / CHUNK_SIZE);
|
||||
const chunk = this._chunks.get(this._chunkKey(cx, cy));
|
||||
if (!chunk) return MATERIAL_AIR;
|
||||
|
||||
const lx = x - cx * CHUNK_SIZE;
|
||||
const ly = y - cy * CHUNK_SIZE;
|
||||
return chunk.pixels[ly * CHUNK_SIZE + lx];
|
||||
}
|
||||
|
||||
/** Check if a world pixel is solid (for collision) */
|
||||
isSolid(x: number, y: number): boolean {
|
||||
const mat = this.getPixel(x, y);
|
||||
return this._palette[mat]?.solid ?? false;
|
||||
}
|
||||
|
||||
private _setPixelRaw(x: number, y: number, material: number) {
|
||||
const cx = Math.floor(x / CHUNK_SIZE);
|
||||
const cy = Math.floor(y / CHUNK_SIZE);
|
||||
const key = this._chunkKey(cx, cy);
|
||||
|
||||
let chunk = this._chunks.get(key);
|
||||
if (!chunk) {
|
||||
chunk = this._createChunk(cx, cy);
|
||||
this._chunks.set(key, chunk);
|
||||
}
|
||||
|
||||
const lx = x - cx * CHUNK_SIZE;
|
||||
const ly = y - cy * CHUNK_SIZE;
|
||||
chunk.pixels[ly * CHUNK_SIZE + lx] = material;
|
||||
chunk.dirty = true;
|
||||
}
|
||||
|
||||
private _createChunk(cx: number, cy: number): Chunk {
|
||||
const graphics = new Graphics();
|
||||
graphics.x = cx * CHUNK_SIZE * this.tileSize;
|
||||
graphics.y = cy * CHUNK_SIZE * this.tileSize;
|
||||
this._container.addChild(graphics);
|
||||
|
||||
return {
|
||||
cx,
|
||||
cy,
|
||||
pixels: new Uint16Array(CHUNK_SIZE * CHUNK_SIZE),
|
||||
graphics,
|
||||
dirty: true,
|
||||
};
|
||||
}
|
||||
|
||||
private _renderDirtyChunks() {
|
||||
for (const chunk of this._chunks.values()) {
|
||||
if (chunk.dirty) {
|
||||
this._renderChunk(chunk);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _renderChunk(chunk: Chunk) {
|
||||
const g = chunk.graphics;
|
||||
g.clear();
|
||||
|
||||
const ts = this.tileSize;
|
||||
|
||||
for (let ly = 0; ly < CHUNK_SIZE; ly++) {
|
||||
for (let lx = 0; lx < CHUNK_SIZE; lx++) {
|
||||
const mat = chunk.pixels[ly * CHUNK_SIZE + lx];
|
||||
if (mat === MATERIAL_AIR) continue;
|
||||
|
||||
const material = this._palette[mat];
|
||||
if (!material) continue;
|
||||
|
||||
g.rect(lx * ts, ly * ts, ts, ts);
|
||||
g.fill(material.color);
|
||||
}
|
||||
}
|
||||
|
||||
chunk.dirty = false;
|
||||
}
|
||||
|
||||
private _chunkKey(cx: number, cy: number): string {
|
||||
return `${cx},${cy}`;
|
||||
}
|
||||
}
|
||||
7
apps/manavoxel/apps/web/src/routes/+layout.svelte
Normal file
7
apps/manavoxel/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
|
||||
let { children } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
2
apps/manavoxel/apps/web/src/routes/+layout.ts
Normal file
2
apps/manavoxel/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Disable SSR — all data is local-first (IndexedDB via Dexie.js)
|
||||
export const ssr = false;
|
||||
205
apps/manavoxel/apps/web/src/routes/+page.svelte
Normal file
205
apps/manavoxel/apps/web/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { GameEngine } from '$lib/engine/game';
|
||||
import { DEFAULT_MATERIALS, MATERIAL_AIR } from '@manavoxel/shared';
|
||||
import type { ToolType } from '$lib/editor/tools';
|
||||
|
||||
let canvasContainer: HTMLDivElement;
|
||||
let engine: GameEngine | null = $state(null);
|
||||
let isEditing = $state(false);
|
||||
let selectedMaterial = $state(1);
|
||||
let activeTool = $state<ToolType>('brush');
|
||||
let brushSize = $state(1);
|
||||
|
||||
const tools: { id: ToolType; label: string; key: string }[] = [
|
||||
{ id: 'brush', label: 'Brush', key: 'B' },
|
||||
{ id: 'eraser', label: 'Eraser', key: 'E' },
|
||||
{ id: 'fill', label: 'Fill', key: 'G' },
|
||||
{ id: 'pipette', label: 'Pick', key: 'I' },
|
||||
];
|
||||
|
||||
const materials = DEFAULT_MATERIALS.filter((m) => m.id !== MATERIAL_AIR);
|
||||
|
||||
onMount(() => {
|
||||
const e = new GameEngine(canvasContainer);
|
||||
engine = e;
|
||||
|
||||
e.onStateChange = () => {
|
||||
isEditing = e.isEditing;
|
||||
selectedMaterial = e.selectedMaterial;
|
||||
activeTool = e.activeTool;
|
||||
brushSize = e.brushSize;
|
||||
};
|
||||
|
||||
// Keyboard shortcuts
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if (ev.target instanceof HTMLInputElement) return;
|
||||
switch (ev.key.toLowerCase()) {
|
||||
case 'tab':
|
||||
ev.preventDefault();
|
||||
e.toggleEditor();
|
||||
break;
|
||||
case 'b':
|
||||
e.setTool('brush');
|
||||
break;
|
||||
case 'e':
|
||||
e.setTool('eraser');
|
||||
break;
|
||||
case 'g':
|
||||
e.setTool('fill');
|
||||
break;
|
||||
case 'i':
|
||||
e.setTool('pipette');
|
||||
break;
|
||||
case '[':
|
||||
e.setBrushSize(e.brushSize - 2);
|
||||
break;
|
||||
case ']':
|
||||
e.setBrushSize(e.brushSize + 2);
|
||||
break;
|
||||
}
|
||||
// Number keys 1-9 select materials
|
||||
const num = parseInt(ev.key);
|
||||
if (num >= 1 && num <= 9 && num <= materials.length) {
|
||||
e.setMaterial(materials[num - 1].id);
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', onKey);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('keydown', onKey);
|
||||
e.destroy();
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="relative h-screen w-screen overflow-hidden bg-gray-900">
|
||||
<!-- PixiJS Canvas -->
|
||||
<div bind:this={canvasContainer} class="game-canvas h-full w-full"></div>
|
||||
|
||||
<!-- HUD Overlay -->
|
||||
<div class="pointer-events-none absolute inset-0">
|
||||
<!-- Top bar -->
|
||||
<div class="pointer-events-auto flex items-center justify-between p-3">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-sm font-medium text-white backdrop-blur"
|
||||
>
|
||||
ManaVoxel
|
||||
</div>
|
||||
{#if !isEditing && engine?.player}
|
||||
<div class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-gray-300 backdrop-blur">
|
||||
HP: {engine.player.hp}/{engine.player.maxHp}
|
||||
</div>
|
||||
{/if}
|
||||
{#if isEditing}
|
||||
<div class="rounded-lg bg-emerald-600/80 px-2 py-1 text-xs text-white backdrop-blur">
|
||||
EDITOR
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-lg px-3 py-1.5 text-sm text-white backdrop-blur transition {isEditing
|
||||
? 'bg-emerald-600/80 hover:bg-emerald-500/80'
|
||||
: 'bg-gray-800/80 hover:bg-gray-700/80'}"
|
||||
onclick={() => engine?.toggleEditor()}
|
||||
>
|
||||
{isEditing ? 'Play' : 'Edit'}
|
||||
<span class="ml-1 text-xs text-gray-400">Tab</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Tools (left side) -->
|
||||
{#if isEditing}
|
||||
<div class="pointer-events-auto absolute left-3 top-16 flex flex-col gap-1">
|
||||
{#each tools as tool}
|
||||
<button
|
||||
class="flex items-center gap-2 rounded-lg px-3 py-1.5 text-xs text-white backdrop-blur transition {activeTool ===
|
||||
tool.id
|
||||
? 'bg-emerald-600/80'
|
||||
: 'bg-gray-800/80 hover:bg-gray-700/80'}"
|
||||
onclick={() => engine?.setTool(tool.id)}
|
||||
>
|
||||
{tool.label}
|
||||
<span class="text-gray-500">{tool.key}</span>
|
||||
</button>
|
||||
{/each}
|
||||
|
||||
<!-- Brush size -->
|
||||
<div class="mt-2 rounded-lg bg-gray-800/80 px-3 py-2 text-xs text-white backdrop-blur">
|
||||
<div class="mb-1 text-gray-400">Size: {brushSize}px</div>
|
||||
<div class="flex gap-1">
|
||||
{#each [1, 3, 5, 7] as size}
|
||||
<button
|
||||
class="rounded px-2 py-0.5 transition {brushSize === size
|
||||
? 'bg-emerald-600'
|
||||
: 'bg-gray-700 hover:bg-gray-600'}"
|
||||
onclick={() => engine?.setBrushSize(size)}
|
||||
>
|
||||
{size}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Undo/Redo -->
|
||||
<div class="mt-2 flex gap-1">
|
||||
<button
|
||||
class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-white backdrop-blur hover:bg-gray-700/80 disabled:opacity-30"
|
||||
disabled={!engine?.undo.canUndo}
|
||||
onclick={() => engine?.undo.undo(engine.tilemap)}
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
<button
|
||||
class="rounded-lg bg-gray-800/80 px-3 py-1.5 text-xs text-white backdrop-blur hover:bg-gray-700/80 disabled:opacity-30"
|
||||
disabled={!engine?.undo.canRedo}
|
||||
onclick={() => engine?.undo.redo(engine.tilemap)}
|
||||
>
|
||||
Redo
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Material Palette (bottom) -->
|
||||
{#if isEditing}
|
||||
<div class="pointer-events-auto absolute bottom-4 left-1/2 -translate-x-1/2">
|
||||
<div class="flex gap-1 rounded-lg bg-gray-800/90 p-2 backdrop-blur">
|
||||
{#each materials as mat, i}
|
||||
<button
|
||||
class="group relative h-8 w-8 rounded border-2 transition-transform hover:scale-110 {selectedMaterial ===
|
||||
mat.id
|
||||
? 'border-white scale-110'
|
||||
: 'border-transparent'}"
|
||||
style="background-color: {mat.color}"
|
||||
onclick={() => engine?.setMaterial(mat.id)}
|
||||
title="{mat.name} ({i + 1})"
|
||||
>
|
||||
{#if i < 9}
|
||||
<span
|
||||
class="absolute -top-4 left-1/2 -translate-x-1/2 text-[10px] text-gray-500 opacity-0 group-hover:opacity-100"
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Controls hint (bottom left) -->
|
||||
<div class="pointer-events-auto absolute bottom-4 left-4">
|
||||
<div class="rounded-lg bg-gray-800/60 px-3 py-1.5 text-[10px] text-gray-500 backdrop-blur">
|
||||
{#if isEditing}
|
||||
WASD: Pan | Scroll: Zoom | LClick: Place | RClick: Erase | 1-9: Material
|
||||
{:else}
|
||||
WASD: Move | Scroll: Zoom | Tab: Editor
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
14
apps/manavoxel/apps/web/svelte.config.js
Normal file
14
apps/manavoxel/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
apps/manavoxel/apps/web/tsconfig.json
Normal file
14
apps/manavoxel/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
55
apps/manavoxel/apps/web/vite.config.ts
Normal file
55
apps/manavoxel/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
/// <reference types="vitest/config" />
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { createPWAConfig } from '@manacore/shared-pwa';
|
||||
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
tailwindcss(),
|
||||
sveltekit(),
|
||||
SvelteKitPWA(
|
||||
createPWAConfig({
|
||||
name: 'ManaVoxel - Pixel Worlds',
|
||||
shortName: 'ManaVoxel',
|
||||
description: 'Create and program detailed pixel worlds in your browser',
|
||||
themeColor: '#10b981',
|
||||
devEnabled: false,
|
||||
shortcuts: [
|
||||
{
|
||||
name: 'My Worlds',
|
||||
short_name: 'Worlds',
|
||||
description: 'Open your worlds',
|
||||
url: '/worlds',
|
||||
},
|
||||
{
|
||||
name: 'Discover',
|
||||
short_name: 'Discover',
|
||||
description: 'Discover community worlds',
|
||||
url: '/worlds?tab=discover',
|
||||
},
|
||||
],
|
||||
})
|
||||
),
|
||||
],
|
||||
server: {
|
||||
port: 5195,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...MANACORE_SHARED_PACKAGES, '@manavoxel/shared'],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES, '@manavoxel/shared'],
|
||||
},
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
include: ['src/**/*.test.ts'],
|
||||
globals: true,
|
||||
},
|
||||
define: {
|
||||
...getBuildDefines(),
|
||||
},
|
||||
});
|
||||
14
apps/manavoxel/package.json
Normal file
14
apps/manavoxel/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "manavoxel",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "ManaVoxel - 2D Pixel Platform for creating and programming miniature worlds",
|
||||
"scripts": {
|
||||
"dev": "pnpm run --filter=@manavoxel/* --parallel dev",
|
||||
"dev:web": "pnpm --filter @manavoxel/web dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
},
|
||||
"packageManager": "pnpm@9.15.0"
|
||||
}
|
||||
14
apps/manavoxel/packages/shared/package.json
Normal file
14
apps/manavoxel/packages/shared/package.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"name": "@manavoxel/shared",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"scripts": {
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3"
|
||||
}
|
||||
}
|
||||
1
apps/manavoxel/packages/shared/src/index.ts
Normal file
1
apps/manavoxel/packages/shared/src/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from './types';
|
||||
204
apps/manavoxel/packages/shared/src/types.ts
Normal file
204
apps/manavoxel/packages/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// ─── Materials ───────────────────────────────────────────────
|
||||
|
||||
export interface Material {
|
||||
id: number;
|
||||
name: string;
|
||||
color: string; // Hex
|
||||
solid: boolean;
|
||||
transparent: boolean;
|
||||
emissive: boolean;
|
||||
}
|
||||
|
||||
export const MATERIAL_AIR = 0;
|
||||
|
||||
export const DEFAULT_MATERIALS: Material[] = [
|
||||
{ id: 0, name: 'Air', color: '#000000', solid: false, transparent: true, emissive: false },
|
||||
{ id: 1, name: 'Stone', color: '#808080', solid: true, transparent: false, emissive: false },
|
||||
{ id: 2, name: 'Dirt', color: '#8B6914', solid: true, transparent: false, emissive: false },
|
||||
{ id: 3, name: 'Grass', color: '#4CAF50', solid: true, transparent: false, emissive: false },
|
||||
{ id: 4, name: 'Wood', color: '#A0522D', solid: true, transparent: false, emissive: false },
|
||||
{ id: 5, name: 'Plank', color: '#DEB887', solid: true, transparent: false, emissive: false },
|
||||
{ id: 6, name: 'Sand', color: '#F4E3B2', solid: true, transparent: false, emissive: false },
|
||||
{ id: 7, name: 'Water', color: '#4FC3F7', solid: false, transparent: true, emissive: false },
|
||||
{ id: 8, name: 'Brick', color: '#B71C1C', solid: true, transparent: false, emissive: false },
|
||||
{ id: 9, name: 'Glass', color: '#E0F7FA', solid: true, transparent: true, emissive: false },
|
||||
{ id: 10, name: 'Torch', color: '#FFD54F', solid: false, transparent: true, emissive: true },
|
||||
{ id: 11, name: 'Metal', color: '#B0BEC5', solid: true, transparent: false, emissive: false },
|
||||
{ id: 12, name: 'Cobble', color: '#9E9E9E', solid: true, transparent: false, emissive: false },
|
||||
{ id: 13, name: 'Leaf', color: '#2E7D32', solid: true, transparent: false, emissive: false },
|
||||
{ id: 14, name: 'Roof', color: '#5D4037', solid: true, transparent: false, emissive: false },
|
||||
{ id: 15, name: 'Snow', color: '#FAFAFA', solid: true, transparent: false, emissive: false },
|
||||
];
|
||||
|
||||
// ─── Pixel Grid (per chunk) ─────────────────────────────────
|
||||
|
||||
export const CHUNK_SIZE = 32;
|
||||
|
||||
// ─── Areas ──────────────────────────────────────────────────
|
||||
|
||||
export type AreaType = 'street' | 'interior';
|
||||
|
||||
export interface PortalDef {
|
||||
id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
floor: number;
|
||||
targetAreaId: string;
|
||||
targetX: number;
|
||||
targetY: number;
|
||||
targetFloor: number;
|
||||
requiresKey?: string; // Item ID
|
||||
}
|
||||
|
||||
export interface EntityDef {
|
||||
id: string;
|
||||
type: 'npc' | 'item' | 'light' | 'spawn';
|
||||
x: number;
|
||||
y: number;
|
||||
floor: number;
|
||||
spriteId?: string;
|
||||
properties?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface Area {
|
||||
id: string;
|
||||
worldId: string;
|
||||
name: string;
|
||||
type: AreaType;
|
||||
resolution: number; // 0.10 (street) or 0.05 (interior)
|
||||
width: number; // in pixels
|
||||
height: number;
|
||||
floors: number;
|
||||
pixelData: Uint8Array; // RLE compressed: floors × height × width
|
||||
palette: Material[];
|
||||
entities: EntityDef[];
|
||||
portals: PortalDef[];
|
||||
spawnPoint: { x: number; y: number; floor: number };
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
// ─── Worlds ─────────────────────────────────────────────────
|
||||
|
||||
export interface World {
|
||||
id: string;
|
||||
creatorId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
isPublished: boolean;
|
||||
playCount: number;
|
||||
startAreaId: string;
|
||||
settings: Record<string, unknown>;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
// ─── Items ──────────────────────────────────────────────────
|
||||
|
||||
export type Rarity = 'common' | 'uncommon' | 'rare' | 'epic' | 'legendary';
|
||||
export type ElementType = 'neutral' | 'fire' | 'ice' | 'poison' | 'lightning';
|
||||
|
||||
export interface ItemProperties {
|
||||
damage: number;
|
||||
range: number;
|
||||
speed: number;
|
||||
durabilityMax: number;
|
||||
durabilityCurrent: number;
|
||||
element: ElementType;
|
||||
rarity: Rarity;
|
||||
sound: string;
|
||||
particle: string;
|
||||
}
|
||||
|
||||
export interface TriggerAction {
|
||||
trigger: { type: string; params: Record<string, unknown> };
|
||||
conditions?: { type: string; params: Record<string, unknown> }[];
|
||||
actions: { type: string; params: Record<string, unknown> }[];
|
||||
}
|
||||
|
||||
export interface Item {
|
||||
id: string;
|
||||
creatorId: string;
|
||||
name: string;
|
||||
description: string;
|
||||
spriteData: Uint8Array; // Raw pixel data (RGBA or indexed)
|
||||
spriteWidth: number;
|
||||
spriteHeight: number;
|
||||
animationFrames: number;
|
||||
resolution: number; // 0.01 for detail items
|
||||
properties: ItemProperties;
|
||||
behavior: TriggerAction[];
|
||||
script?: string;
|
||||
wasmBinary?: Uint8Array;
|
||||
rarity: Rarity;
|
||||
capabilities: string[];
|
||||
isPublished: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
// ─── Inventory ──────────────────────────────────────────────
|
||||
|
||||
export interface InventorySlot {
|
||||
id: string;
|
||||
playerId: string;
|
||||
itemId: string;
|
||||
slot: number;
|
||||
quantity: number;
|
||||
instanceData: Record<string, unknown>; // durability state etc.
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
deletedAt?: string;
|
||||
}
|
||||
|
||||
// ─── Network Protocol ───────────────────────────────────────
|
||||
|
||||
export type ClientMessage =
|
||||
| { type: 'join'; worldId: string; areaId: string }
|
||||
| { type: 'move'; x: number; y: number; direction: number }
|
||||
| { type: 'setPixel'; x: number; y: number; floor: number; material: number }
|
||||
| { type: 'useItem'; itemId: string; targetX: number; targetY: number }
|
||||
| { type: 'enterPortal'; portalId: string }
|
||||
| { type: 'chat'; message: string }
|
||||
| { type: 'ping' };
|
||||
|
||||
export type ServerMessage =
|
||||
| { type: 'welcome'; playerId: string; areaState: Area; players: PlayerState[] }
|
||||
| { type: 'playerJoin'; player: PlayerState }
|
||||
| { type: 'playerLeave'; playerId: string }
|
||||
| { type: 'playerMove'; playerId: string; x: number; y: number; direction: number }
|
||||
| {
|
||||
type: 'pixelChanged';
|
||||
x: number;
|
||||
y: number;
|
||||
floor: number;
|
||||
material: number;
|
||||
playerId: string;
|
||||
}
|
||||
| { type: 'itemUsed'; playerId: string; itemId: string; effects: Effect[] }
|
||||
| { type: 'areaTransition'; areaId: string; areaState: Area; players: PlayerState[] }
|
||||
| { type: 'chat'; playerId: string; name: string; message: string }
|
||||
| { type: 'error'; message: string }
|
||||
| { type: 'pong' };
|
||||
|
||||
export interface PlayerState {
|
||||
id: string;
|
||||
name: string;
|
||||
x: number;
|
||||
y: number;
|
||||
floor: number;
|
||||
direction: number; // 0=up, 1=right, 2=down, 3=left
|
||||
heldItemId?: string;
|
||||
hp: number;
|
||||
maxHp: number;
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
type: 'damage' | 'heal' | 'particle' | 'sound' | 'pixelDestroy';
|
||||
x: number;
|
||||
y: number;
|
||||
params: Record<string, unknown>;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue