mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:21:09 +02:00
feat: add new projects bauntown, presi, voxel-lava, whopixels
- apps/bauntown: Developer community website (Astro landing) - apps/presi: Presentation project - games/voxel-lava: Voxel lava game (SvelteKit) - games/whopixels: Whopixels game 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
d35ba768cf
commit
5b1e12e5d6
407 changed files with 46356 additions and 0 deletions
23
games/voxel-lava/.gitignore
vendored
Normal file
23
games/voxel-lava/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
node_modules
|
||||
|
||||
# Output
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
.wrangler
|
||||
/.svelte-kit
|
||||
/build
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Env
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.test
|
||||
|
||||
# Vite
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
13
games/voxel-lava/.mcp.json
Normal file
13
games/voxel-lava/.mcp.json
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"pocketbase-voxel": {
|
||||
"command": "node",
|
||||
"args": ["/Users/tillschneider/Documents/__00__Code/uload/mcp-servers/pocketbase-mcp/build/index.js"],
|
||||
"env": {
|
||||
"POCKETBASE_URL": "https://pb.voxelava.com",
|
||||
"POCKETBASE_ADMIN_EMAIL": "till.schneider@memoro.ai",
|
||||
"POCKETBASE_ADMIN_PASSWORD": "p0ck3t-RA1N"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
games/voxel-lava/.npmrc
Normal file
1
games/voxel-lava/.npmrc
Normal file
|
|
@ -0,0 +1 @@
|
|||
engine-strict=true
|
||||
6
games/voxel-lava/.prettierignore
Normal file
6
games/voxel-lava/.prettierignore
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
# Package Managers
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
bun.lock
|
||||
bun.lockb
|
||||
15
games/voxel-lava/.prettierrc
Normal file
15
games/voxel-lava/.prettierrc
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [
|
||||
{
|
||||
"files": "*.svelte",
|
||||
"options": {
|
||||
"parser": "svelte"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
292
games/voxel-lava/DATABASE.md
Normal file
292
games/voxel-lava/DATABASE.md
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
# Voxel-Lava Datenbank-Dokumentation
|
||||
|
||||
Diese Dokumentation beschreibt den Aufbau und die Funktionsweise der Supabase-Datenbank für das Voxel-Lava-Spiel. Die Datenbank ermöglicht das Speichern, Teilen und Bewerten von Levels.
|
||||
|
||||
## Datenbankschema
|
||||
|
||||
### Tabellen
|
||||
|
||||
#### 1. `levels`
|
||||
|
||||
Speichert alle Level-Daten inklusive der Voxel-Informationen.
|
||||
|
||||
| Spalte | Typ | Beschreibung |
|
||||
|--------|-----|-------------|
|
||||
| `id` | UUID | Primärschlüssel, automatisch generiert |
|
||||
| `name` | TEXT | Name des Levels |
|
||||
| `description` | TEXT | Optionale Beschreibung des Levels |
|
||||
| `user_id` | UUID | Fremdschlüssel zur `auth.users`-Tabelle, Ersteller des Levels |
|
||||
| `voxel_data` | JSONB | Speichert alle Blöcke mit Position und Typ als JSON |
|
||||
| `spawn_point` | JSONB | Position des Spawn-Punkts als JSON `{x, y, z}` |
|
||||
| `world_size` | JSONB | Größe der Welt als JSON `{width, height, depth}` |
|
||||
| `is_public` | BOOLEAN | Gibt an, ob das Level öffentlich ist (Standard: false) |
|
||||
| `created_at` | TIMESTAMP | Zeitpunkt der Erstellung |
|
||||
| `updated_at` | TIMESTAMP | Zeitpunkt der letzten Aktualisierung |
|
||||
| `play_count` | INTEGER | Anzahl der Aufrufe des Levels |
|
||||
| `likes_count` | INTEGER | Anzahl der Likes des Levels |
|
||||
| `difficulty` | TEXT | Optionaler Schwierigkeitsgrad des Levels |
|
||||
| `tags` | TEXT[] | Array von Tags zur Kategorisierung |
|
||||
| `thumbnail_url` | TEXT | Optionale URL zum Vorschaubild |
|
||||
|
||||
#### 2. `level_likes`
|
||||
|
||||
Speichert die Likes für Levels.
|
||||
|
||||
| Spalte | Typ | Beschreibung |
|
||||
|--------|-----|-------------|
|
||||
| `id` | UUID | Primärschlüssel, automatisch generiert |
|
||||
| `level_id` | UUID | Fremdschlüssel zur `levels`-Tabelle |
|
||||
| `user_id` | UUID | Fremdschlüssel zur `auth.users`-Tabelle |
|
||||
| `created_at` | TIMESTAMP | Zeitpunkt des Likes |
|
||||
|
||||
Constraints:
|
||||
- Unique-Constraint auf `(level_id, user_id)`, um doppelte Likes zu verhindern
|
||||
|
||||
#### 3. `level_plays`
|
||||
|
||||
Speichert Spielstatistiken für Levels.
|
||||
|
||||
| Spalte | Typ | Beschreibung |
|
||||
|--------|-----|-------------|
|
||||
| `id` | UUID | Primärschlüssel, automatisch generiert |
|
||||
| `level_id` | UUID | Fremdschlüssel zur `levels`-Tabelle |
|
||||
| `user_id` | UUID | Fremdschlüssel zur `auth.users`-Tabelle |
|
||||
| `completion_time` | FLOAT | Zeit in Sekunden (null, wenn nicht abgeschlossen) |
|
||||
| `attempts` | INTEGER | Anzahl der Versuche (Standard: 1) |
|
||||
| `completed` | BOOLEAN | Gibt an, ob das Level abgeschlossen wurde |
|
||||
| `created_at` | TIMESTAMP | Zeitpunkt des Spielversuchs |
|
||||
|
||||
### Indizes
|
||||
|
||||
Zur Optimierung der Abfrageleistung wurden folgende Indizes erstellt:
|
||||
|
||||
```sql
|
||||
CREATE INDEX idx_levels_user_id ON levels(user_id);
|
||||
CREATE INDEX idx_levels_is_public ON levels(is_public);
|
||||
CREATE INDEX idx_level_likes_level_id ON level_likes(level_id);
|
||||
CREATE INDEX idx_level_likes_user_id ON level_likes(user_id);
|
||||
CREATE INDEX idx_level_plays_level_id ON level_plays(level_id);
|
||||
CREATE INDEX idx_level_plays_user_id ON level_plays(user_id);
|
||||
```
|
||||
|
||||
### Funktionen und Trigger
|
||||
|
||||
#### 1. `update_updated_at_column()`
|
||||
|
||||
Aktualisiert automatisch den `updated_at`-Zeitstempel bei Änderungen an einem Level.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = now();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_levels_updated_at
|
||||
BEFORE UPDATE ON levels
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
```
|
||||
|
||||
#### 2. `update_likes_count()`
|
||||
|
||||
Aktualisiert automatisch den `likes_count` in der `levels`-Tabelle, wenn ein Like hinzugefügt oder entfernt wird.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION update_likes_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE levels SET likes_count = likes_count + 1 WHERE id = NEW.level_id;
|
||||
ELSIF TG_OP = 'DELETE' THEN
|
||||
UPDATE levels SET likes_count = likes_count - 1 WHERE id = OLD.level_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_level_likes_count
|
||||
AFTER INSERT OR DELETE ON level_likes
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_likes_count();
|
||||
```
|
||||
|
||||
#### 3. `update_play_count()`
|
||||
|
||||
Aktualisiert automatisch den `play_count` in der `levels`-Tabelle, wenn ein Spielversuch hinzugefügt wird.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION update_play_count()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
UPDATE levels SET play_count = play_count + 1 WHERE id = NEW.level_id;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER update_level_play_count
|
||||
AFTER INSERT ON level_plays
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_play_count();
|
||||
```
|
||||
|
||||
#### 4. `increment_play_count(level_id UUID)`
|
||||
|
||||
Erhöht den `play_count` für ein Level, auch wenn kein Benutzer angemeldet ist.
|
||||
|
||||
```sql
|
||||
CREATE OR REPLACE FUNCTION increment_play_count(level_id UUID)
|
||||
RETURNS VOID AS $$
|
||||
BEGIN
|
||||
UPDATE levels SET play_count = play_count + 1 WHERE id = level_id;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
```
|
||||
|
||||
## Sicherheitsrichtlinien (RLS)
|
||||
|
||||
Die Datenbank verwendet Row Level Security (RLS), um den Zugriff auf Daten zu kontrollieren:
|
||||
|
||||
### 1. Levels
|
||||
|
||||
```sql
|
||||
-- Levels sind für Ersteller und öffentlich sichtbar
|
||||
CREATE POLICY "Levels sind für Ersteller und öffentlich sichtbar"
|
||||
ON levels FOR SELECT
|
||||
USING (auth.uid() = user_id OR is_public = true);
|
||||
|
||||
-- Levels können nur vom Ersteller bearbeitet werden
|
||||
CREATE POLICY "Levels können nur vom Ersteller bearbeitet werden"
|
||||
ON levels FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Levels können nur vom Ersteller gelöscht werden
|
||||
CREATE POLICY "Levels können nur vom Ersteller gelöscht werden"
|
||||
ON levels FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Neue Levels können nur vom authentifizierten Benutzer erstellt werden
|
||||
CREATE POLICY "Neue Levels können nur vom authentifizierten Benutzer erstellt werden"
|
||||
ON levels FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### 2. Level Likes
|
||||
|
||||
```sql
|
||||
-- Likes können von jedem authentifizierten Benutzer gesehen werden
|
||||
CREATE POLICY "Likes können von jedem authentifizierten Benutzer gesehen werden"
|
||||
ON level_likes FOR SELECT
|
||||
USING (true);
|
||||
|
||||
-- Likes können nur vom Benutzer selbst erstellt werden
|
||||
CREATE POLICY "Likes können nur vom Benutzer selbst erstellt werden"
|
||||
ON level_likes FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Likes können nur vom Benutzer selbst entfernt werden
|
||||
CREATE POLICY "Likes können nur vom Benutzer selbst entfernt werden"
|
||||
ON level_likes FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
### 3. Level Plays
|
||||
|
||||
```sql
|
||||
-- Spielstatistiken sind für Level-Ersteller sichtbar
|
||||
CREATE POLICY "Spielstatistiken sind für Level-Ersteller sichtbar"
|
||||
ON level_plays FOR SELECT
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM levels
|
||||
WHERE levels.id = level_plays.level_id
|
||||
AND levels.user_id = auth.uid()
|
||||
) OR auth.uid() = user_id);
|
||||
|
||||
-- Spielstatistiken können von jedem authentifizierten Benutzer erstellt werden
|
||||
CREATE POLICY "Spielstatistiken können von jedem authentifizierten Benutzer erstellt werden"
|
||||
ON level_plays FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
-- Spielstatistiken können nur vom Benutzer selbst aktualisiert werden
|
||||
CREATE POLICY "Spielstatistiken können nur vom Benutzer selbst aktualisiert werden"
|
||||
ON level_plays FOR UPDATE
|
||||
USING (auth.uid() = user_id);
|
||||
```
|
||||
|
||||
## Datenformat
|
||||
|
||||
### Voxel-Daten (JSONB)
|
||||
|
||||
Die Voxel-Daten werden als JSONB in der `voxel_data`-Spalte gespeichert. Das Format ist:
|
||||
|
||||
```json
|
||||
{
|
||||
"x,y,z": {
|
||||
"type": "blockType",
|
||||
"isSpawnPoint": false,
|
||||
"isGoal": false
|
||||
},
|
||||
"1,1,1": {
|
||||
"type": "grass",
|
||||
"isSpawnPoint": true,
|
||||
"isGoal": false
|
||||
},
|
||||
"5,1,5": {
|
||||
"type": "goal",
|
||||
"isSpawnPoint": false,
|
||||
"isGoal": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Wobei:
|
||||
- Der Schlüssel ist eine Zeichenkette im Format "x,y,z", die die Position des Blocks angibt
|
||||
- `type` ist der Typ des Blocks (z.B. "grass", "stone", "lava")
|
||||
- `isSpawnPoint` gibt an, ob dieser Block ein Spawn-Punkt ist
|
||||
- `isGoal` gibt an, ob dieser Block ein Ziel ist
|
||||
|
||||
## Verwendung in der Anwendung
|
||||
|
||||
Die Datenbank wird über den Supabase-Client in der Anwendung angesprochen. Dafür wurden folgende Services implementiert:
|
||||
|
||||
1. `LevelService`: Zum Speichern, Laden und Verwalten von Levels
|
||||
2. `AuthService`: Für die Benutzerauthentifizierung
|
||||
|
||||
Beispiel für die Verwendung des `LevelService`:
|
||||
|
||||
```typescript
|
||||
import { LevelService } from '../services/LevelService';
|
||||
|
||||
// Level speichern
|
||||
const level = {
|
||||
name: 'Mein Level',
|
||||
blocks: [/* ... */],
|
||||
spawnPoint: { x: 1, y: 1, z: 1 },
|
||||
worldSize: { width: 20, height: 10, depth: 20 },
|
||||
isPublic: true
|
||||
};
|
||||
const levelId = await LevelService.saveLevel(level);
|
||||
|
||||
// Level laden
|
||||
const loadedLevel = await LevelService.loadLevel(levelId);
|
||||
|
||||
// Öffentliche Levels abrufen
|
||||
const publicLevels = await LevelService.getPublicLevels();
|
||||
|
||||
// Level liken
|
||||
await LevelService.likeLevel(levelId);
|
||||
```
|
||||
|
||||
## Erweiterungsmöglichkeiten
|
||||
|
||||
Die Datenbankstruktur kann in Zukunft um folgende Funktionen erweitert werden:
|
||||
|
||||
1. **Kommentare**: Eine neue Tabelle für Kommentare zu Levels
|
||||
2. **Leaderboards**: Detaillierte Bestenlisten für jedes Level
|
||||
3. **Benutzerprofile**: Erweiterte Benutzerinformationen und Statistiken
|
||||
4. **Freundschaften**: Beziehungen zwischen Benutzern
|
||||
5. **Sammlungen**: Gruppierung von Levels in Sammlungen oder Playlists
|
||||
38
games/voxel-lava/README.md
Normal file
38
games/voxel-lava/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# sv
|
||||
|
||||
Everything you need to build a Svelte project, powered by [`sv`](https://github.com/sveltejs/cli).
|
||||
|
||||
## Creating a project
|
||||
|
||||
If you're seeing this, you've probably already done this step. Congrats!
|
||||
|
||||
```bash
|
||||
# create a new project in the current directory
|
||||
npx sv create
|
||||
|
||||
# create a new project in my-app
|
||||
npx sv create my-app
|
||||
```
|
||||
|
||||
## Developing
|
||||
|
||||
Once you've created a project and installed dependencies with `npm install` (or `pnpm install` or `yarn`), start a development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
||||
# or start the server and open the app in a new browser tab
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
## Building
|
||||
|
||||
To create a production version of your app:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
You can preview the production build with `npm run preview`.
|
||||
|
||||
> To deploy your app, you may need to install an [adapter](https://svelte.dev/docs/kit/adapters) for your target environment.
|
||||
36
games/voxel-lava/eslint.config.js
Normal file
36
games/voxel-lava/eslint.config.js
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import prettier from 'eslint-config-prettier';
|
||||
import js from '@eslint/js';
|
||||
import { includeIgnoreFile } from '@eslint/compat';
|
||||
import svelte from 'eslint-plugin-svelte';
|
||||
import globals from 'globals';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript-eslint';
|
||||
import svelteConfig from './svelte.config.js';
|
||||
|
||||
const gitignorePath = fileURLToPath(new URL('./.gitignore', import.meta.url));
|
||||
|
||||
export default ts.config(
|
||||
includeIgnoreFile(gitignorePath),
|
||||
js.configs.recommended,
|
||||
...ts.configs.recommended,
|
||||
...svelte.configs.recommended,
|
||||
prettier,
|
||||
...svelte.configs.prettier,
|
||||
{
|
||||
languageOptions: {
|
||||
globals: { ...globals.browser, ...globals.node }
|
||||
},
|
||||
rules: { 'no-undef': 'off' }
|
||||
},
|
||||
{
|
||||
files: ['**/*.svelte', '**/*.svelte.ts', '**/*.svelte.js'],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
projectService: true,
|
||||
extraFileExtensions: ['.svelte'],
|
||||
parser: ts.parser,
|
||||
svelteConfig
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
40
games/voxel-lava/package.json
Normal file
40
games/voxel-lava/package.json
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
{
|
||||
"name": "voxel-lava",
|
||||
"private": true,
|
||||
"version": "0.0.1",
|
||||
"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",
|
||||
"format": "prettier --write .",
|
||||
"lint": "prettier --check . && eslint ."
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/compat": "^1.2.5",
|
||||
"@eslint/js": "^9.18.0",
|
||||
"@sveltejs/adapter-auto": "^6.0.0",
|
||||
"@sveltejs/kit": "^2.16.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@types/three": "^0.176.0",
|
||||
"eslint": "^9.18.0",
|
||||
"eslint-config-prettier": "^10.0.1",
|
||||
"eslint-plugin-svelte": "^3.0.0",
|
||||
"globals": "^16.0.0",
|
||||
"prettier": "^3.4.2",
|
||||
"prettier-plugin-svelte": "^3.3.3",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "^5.0.0",
|
||||
"typescript-eslint": "^8.20.0",
|
||||
"vite": "^6.2.6"
|
||||
},
|
||||
"dependencies": {
|
||||
"axios": "^1.11.0",
|
||||
"pocketbase": "^0.26.2",
|
||||
"three": "^0.176.0"
|
||||
}
|
||||
}
|
||||
13
games/voxel-lava/src/app.d.ts
vendored
Normal file
13
games/voxel-lava/src/app.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
12
games/voxel-lava/src/app.html
Normal file
12
games/voxel-lava/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
607
games/voxel-lava/src/lib/BlockTypes.ts
Normal file
607
games/voxel-lava/src/lib/BlockTypes.ts
Normal file
|
|
@ -0,0 +1,607 @@
|
|||
/**
|
||||
* BlockTypes.ts
|
||||
*
|
||||
* Diese Datei enthält Definitionen und Funktionalitäten für das Blocksystem des Voxel-Spiels.
|
||||
* Sie definiert die verschiedenen Blocktypen, Hilfsfunktionen zur Texturerstellung und
|
||||
* den BlockManager, der die zentrale Verwaltung aller Blöcke übernimmt.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
|
||||
/**
|
||||
* BlockType Interface
|
||||
* Definiert die Eigenschaften, die ein Blocktyp haben kann:
|
||||
* - name: Anzeigename des Blocks
|
||||
* - color: Hauptfarbe des Blocks (hexadezimal)
|
||||
* - emissive: Leuchtfarbe für Blöcke, die Licht emittieren sollen
|
||||
* - topColor/sideColor/bottomColor: Spezifische Farben für verschiedene Seiten (z.B. bei Gras)
|
||||
* - opacity: Transparenz des Blocks (1 = undurchsichtig, <1 = durchsichtig)
|
||||
* - solid: Ob der Block fest ist und Kollisionen verursacht
|
||||
* - isDeadly: Ob der Block tödlich ist (z.B. Lava)
|
||||
* - frictionFactor: Reibungsfaktor für die Spielerphysik
|
||||
* - isGoal: Ob der Block ein Zielblock ist
|
||||
*/
|
||||
export type BlockType = {
|
||||
name: string;
|
||||
color?: number;
|
||||
emissive?: number;
|
||||
topColor?: number;
|
||||
sideColor?: number;
|
||||
bottomColor?: number;
|
||||
opacity?: number;
|
||||
solid: boolean;
|
||||
isDeadly: boolean;
|
||||
frictionFactor: number;
|
||||
isGoal: boolean;
|
||||
isFragile?: boolean;
|
||||
breakTimer?: number;
|
||||
isTrampoline?: boolean;
|
||||
trampolineForce?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* BlockTypes Interface
|
||||
* Ein Dictionary/Map von Blocktypen, indiziert durch ihren String-Schlüssel
|
||||
*/
|
||||
export type BlockTypes = {
|
||||
[key: string]: BlockType;
|
||||
};
|
||||
|
||||
/**
|
||||
* BLOCK_TYPES Konstante
|
||||
* Definiert alle verfügbaren Blocktypen im Spiel mit ihren Eigenschaften:
|
||||
* - lava: Tödlicher Block mit rötlicher Farbe und Leuchteffekt
|
||||
* - grass: Block mit grüner Oberseite und braunen Seiten
|
||||
* - ice: Transparenter, rutschiger Block
|
||||
* - goal: Zielblock mit gelber Farbe und Leuchteffekt
|
||||
* - spawn: Spawn-Punkt mit grüner Farbe und Leuchteffekt
|
||||
* - fragile: Zerbrechlicher Block, der nach einer Sekunde zerbricht, wenn der Spieler darauf steht
|
||||
* - trampoline: Trampolinblock, der den Spieler in die Höhe schießt
|
||||
*/
|
||||
export const BLOCK_TYPES: BlockTypes = {
|
||||
lava: { name: "Lava", color: 0xCF1020, topColor: 0xCF1020, sideColor: 0xAA0000, bottomColor: 0x990000, emissive: 0xFF4500, solid: true, isDeadly: true, frictionFactor: 0.8, isGoal: false },
|
||||
grass: { name: "Gras", color: 0x8B4513, topColor: 0x559944, sideColor: 0x8B4513, bottomColor: 0x8B4513, solid: true, isDeadly: false, frictionFactor: 0.7, isGoal: false },
|
||||
ice: { name: "Eis", color: 0xADD8E6, topColor: 0xADD8E6, sideColor: 0x89CFF0, bottomColor: 0x77A7C5, opacity: 0.85, solid: true, isDeadly: false, frictionFactor: 0.98, isGoal: false },
|
||||
goal: { name: "Ziel", color: 0xFFFF00, topColor: 0xFFFF00, sideColor: 0xFFD700, bottomColor: 0xDAA520, emissive: 0xCCCC00, solid: true, isDeadly: false, frictionFactor: 0.8, isGoal: true },
|
||||
spawn: { name: "Spawn", color: 0x00FF00, topColor: 0x00FF00, sideColor: 0x00CC00, bottomColor: 0x009900, emissive: 0x00AA00, solid: true, isDeadly: false, frictionFactor: 0.7, isGoal: false },
|
||||
fragile: { name: "Zerbrechlich", color: 0xA0522D, topColor: 0xA0522D, sideColor: 0x8B4513, bottomColor: 0x654321, emissive: 0x8B4513, solid: true, isDeadly: false, frictionFactor: 0.7, isGoal: false, isFragile: true, breakTimer: 1.0 },
|
||||
trampoline: { name: "Trampolin", color: 0x0000FF, topColor: 0x0000FF, sideColor: 0x0000CC, bottomColor: 0x000099, emissive: 0x0000AA, solid: true, isDeadly: false, frictionFactor: 0.7, isGoal: false, isTrampoline: true, trampolineForce: 20.0 }
|
||||
};
|
||||
|
||||
/**
|
||||
* Textur-Erstellungsfunktionen
|
||||
* Diese Funktionen erzeugen dynamisch Texturen für die verschiedenen Blockseiten,
|
||||
* indem sie Canvas-Elemente erstellen und mit den entsprechenden Farben füllen.
|
||||
*/
|
||||
|
||||
/**
|
||||
* createSideTexture
|
||||
* Erstellt eine Textur für die Seitenflächen eines Blocks.
|
||||
*
|
||||
* @param color - Die Farbe der Textur als Hexadezimalwert
|
||||
* @returns Eine THREE.CanvasTexture mit der erstellten Textur
|
||||
*/
|
||||
export function createSideTexture(color: number): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width=16; canvas.height=16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Fülle den Hintergrund mit der angegebenen Farbe
|
||||
// Stelle sicher, dass die Farbe korrekt formatiert ist (6 Stellen Hex)
|
||||
const colorHex = color.toString(16).padStart(6,'0');
|
||||
ctx.fillStyle=`#${colorHex}`;
|
||||
ctx.fillRect(0,0,16,16);
|
||||
// Füge einen leicht transparenten schwarzen Rahmen hinzu
|
||||
ctx.strokeStyle='#00000033';
|
||||
ctx.strokeRect(0,0,16,16);
|
||||
console.log(`Seitentextur erstellt mit Farbe: #${colorHex}`);
|
||||
}
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* createTopTexture
|
||||
* Erstellt eine Textur für die Oberseite eines Blocks.
|
||||
* Diese Textur hat einen weißen Rahmen, um sie von den Seitenflächen zu unterscheiden.
|
||||
*
|
||||
* @param color - Die Farbe der Textur als Hexadezimalwert
|
||||
* @returns Eine THREE.CanvasTexture mit der erstellten Textur
|
||||
*/
|
||||
export function createTopTexture(color: number): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width=16; canvas.height=16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Fülle den Hintergrund mit der angegebenen Farbe
|
||||
// Stelle sicher, dass die Farbe korrekt formatiert ist (6 Stellen Hex)
|
||||
const colorHex = color.toString(16).padStart(6,'0');
|
||||
ctx.fillStyle=`#${colorHex}`;
|
||||
ctx.fillRect(0,0,16,16);
|
||||
// Füge einen leicht transparenten weißen Rahmen hinzu
|
||||
ctx.strokeStyle='#FFFFFF33';
|
||||
ctx.strokeRect(0,0,16,16);
|
||||
console.log(`Oberseitentextur erstellt mit Farbe: #${colorHex}`);
|
||||
}
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* createBottomTexture
|
||||
* Erstellt eine Textur für die Unterseite eines Blocks.
|
||||
* Diese Textur hat keinen Rahmen, da die Unterseite meist nicht sichtbar ist.
|
||||
*
|
||||
* @param color - Die Farbe der Textur als Hexadezimalwert
|
||||
* @returns Eine THREE.CanvasTexture mit der erstellten Textur
|
||||
*/
|
||||
export function createBottomTexture(color: number): THREE.CanvasTexture {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width=16; canvas.height=16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (ctx) {
|
||||
// Fülle den Hintergrund mit der angegebenen Farbe
|
||||
// Stelle sicher, dass die Farbe korrekt formatiert ist (6 Stellen Hex)
|
||||
const colorHex = color.toString(16).padStart(6,'0');
|
||||
ctx.fillStyle=`#${colorHex}`;
|
||||
ctx.fillRect(0,0,16,16);
|
||||
// Kein Rahmen für die Unterseite
|
||||
console.log(`Unterseitentextur erstellt mit Farbe: #${colorHex}`);
|
||||
}
|
||||
return new THREE.CanvasTexture(canvas);
|
||||
}
|
||||
|
||||
/**
|
||||
* getVoxelKey
|
||||
* Erzeugt einen eindeutigen Schlüssel für einen Voxel basierend auf seinen Koordinaten.
|
||||
* Dieser Schlüssel wird verwendet, um Voxel in einer Map zu speichern und abzurufen.
|
||||
*
|
||||
* @param x - X-Koordinate des Voxels
|
||||
* @param y - Y-Koordinate des Voxels
|
||||
* @param z - Z-Koordinate des Voxels
|
||||
* @returns Ein String im Format "x,y,z", der als eindeutiger Schlüssel dient
|
||||
*/
|
||||
export function getVoxelKey(x: number, y: number, z: number): string {
|
||||
return `${x},${y},${z}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* BlockManager Klasse
|
||||
*
|
||||
* Diese Klasse ist verantwortlich für die zentrale Verwaltung aller Blöcke im Spiel.
|
||||
* Sie kapselt die Logik zum Hinzufügen, Entfernen und Abfragen von Blöcken sowie
|
||||
* die Verwaltung von Zielblöcken und deren Erreichungsstatus.
|
||||
*/
|
||||
export class BlockManager {
|
||||
/** Die THREE.js-Szene, in der die Blöcke gerendert werden */
|
||||
private scene: THREE.Scene;
|
||||
/** Map, die alle Voxel-Meshes anhand ihrer Koordinaten speichert */
|
||||
private voxels: Map<string, THREE.Mesh>;
|
||||
/** Größe eines einzelnen Voxels */
|
||||
private voxelSize: number;
|
||||
/** Liste aller Zielblöcke im Spiel */
|
||||
private goalBlocks: { x: number, y: number, z: number }[] = [];
|
||||
/** Liste der bereits erreichten Zielblöcke */
|
||||
private reachedGoals: { x: number, y: number, z: number }[] = [];
|
||||
/** Callback-Funktion, um die Zielanzeige im UI zu aktualisieren */
|
||||
private updateGoalDisplayCallback: () => void;
|
||||
/** Aktuell ausgewählter Blocktyp für das Platzieren neuer Blöcke */
|
||||
private currentBlockType: string = 'grass';
|
||||
|
||||
/**
|
||||
* Konstruktor für den BlockManager
|
||||
*
|
||||
* @param scene - Die THREE.js-Szene, in der die Blöcke gerendert werden
|
||||
* @param voxels - Map, die alle Voxel-Meshes anhand ihrer Koordinaten speichert
|
||||
* @param voxelSize - Größe eines einzelnen Voxels
|
||||
* @param goalBlocks - Liste aller Zielblöcke im Spiel
|
||||
* @param reachedGoals - Liste der bereits erreichten Zielblöcke
|
||||
* @param updateGoalDisplayCallback - Callback-Funktion, um die Zielanzeige im UI zu aktualisieren
|
||||
*/
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
voxels: Map<string, THREE.Mesh>,
|
||||
voxelSize: number,
|
||||
goalBlocks: { x: number, y: number, z: number }[],
|
||||
reachedGoals: { x: number, y: number, z: number }[],
|
||||
updateGoalDisplayCallback: () => void
|
||||
) {
|
||||
this.scene = scene;
|
||||
this.voxels = voxels;
|
||||
this.voxelSize = voxelSize;
|
||||
this.goalBlocks = goalBlocks;
|
||||
this.reachedGoals = reachedGoals;
|
||||
this.updateGoalDisplayCallback = updateGoalDisplayCallback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt den aktuell ausgewählten Blocktyp für das Platzieren neuer Blöcke
|
||||
*
|
||||
* @param type - Der Typ des Blocks (z.B. 'grass', 'lava', 'ice')
|
||||
*/
|
||||
setCurrentBlockType(type: string): void {
|
||||
this.currentBlockType = type;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt den aktuell ausgewählten Blocktyp zurück
|
||||
*
|
||||
* @returns Der aktuelle Blocktyp als String
|
||||
*/
|
||||
getCurrentBlockType(): string {
|
||||
return this.currentBlockType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findet das Mesh eines Voxels anhand seiner Koordinaten
|
||||
*
|
||||
* @param x - X-Koordinate des Voxels
|
||||
* @param y - Y-Koordinate des Voxels
|
||||
* @param z - Z-Koordinate des Voxels
|
||||
* @returns Das THREE.Mesh des Voxels oder undefined, wenn kein Voxel an dieser Position existiert
|
||||
*/
|
||||
getVoxelMesh(x: number, y: number, z: number): THREE.Mesh | undefined {
|
||||
return this.voxels.get(getVoxelKey(x, y, z));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ermittelt den Blocktyp eines Voxels anhand seiner Koordinaten
|
||||
*
|
||||
* @param x - X-Koordinate des Voxels
|
||||
* @param y - Y-Koordinate des Voxels
|
||||
* @param z - Z-Koordinate des Voxels
|
||||
* @returns Das BlockType-Objekt des Voxels oder null, wenn kein Voxel an dieser Position existiert
|
||||
*/
|
||||
getVoxelBlockType(x: number, y: number, z: number): BlockType | null {
|
||||
const mesh = this.getVoxelMesh(x, y, z);
|
||||
return mesh && mesh.userData.type ? BLOCK_TYPES[mesh.userData.type as string] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen neuen Voxel-Block an der angegebenen Position hinzu
|
||||
*
|
||||
* @param x - X-Koordinate des Voxels
|
||||
* @param y - Y-Koordinate des Voxels
|
||||
* @param z - Z-Koordinate des Voxels
|
||||
* @param type - Typ des Blocks (Standard: der aktuell ausgewählte Blocktyp)
|
||||
*/
|
||||
addVoxel(x: number, y: number, z: number, type: string = this.currentBlockType): void {
|
||||
// Erstelle einen eindeutigen Schlüssel für den Voxel
|
||||
const key = getVoxelKey(x, y, z);
|
||||
// Wenn an dieser Position bereits ein Block existiert, breche ab
|
||||
if (this.voxels.has(key)) return;
|
||||
|
||||
// Hole die Block-Definition für den angegebenen Typ
|
||||
const blockDef = BLOCK_TYPES[type];
|
||||
if (!blockDef) {
|
||||
console.warn(`Unbekannter Blocktyp: ${type}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Erstelle die Geometrie für den Voxel
|
||||
const voxelGeometry = new THREE.BoxGeometry(this.voxelSize, this.voxelSize, this.voxelSize);
|
||||
let voxelMaterial;
|
||||
|
||||
// Verwende verschiedene Texturen für Ober-, Unter- und Seitenflächen für alle Blöcke
|
||||
if (blockDef.topColor !== undefined && blockDef.sideColor !== undefined && blockDef.bottomColor !== undefined) {
|
||||
// Erstelle separate Materialien für jede Seite des Blocks
|
||||
// Die Reihenfolge ist wichtig: [rechts, links, oben, unten, vorne, hinten]
|
||||
const rightMaterial = new THREE.MeshStandardMaterial({ map: createSideTexture(blockDef.sideColor) });
|
||||
const leftMaterial = new THREE.MeshStandardMaterial({ map: createSideTexture(blockDef.sideColor) });
|
||||
const topMaterial = new THREE.MeshStandardMaterial({ map: createTopTexture(blockDef.topColor) });
|
||||
const bottomMaterial = new THREE.MeshStandardMaterial({ map: createBottomTexture(blockDef.bottomColor) });
|
||||
const frontMaterial = new THREE.MeshStandardMaterial({ map: createSideTexture(blockDef.sideColor) });
|
||||
const backMaterial = new THREE.MeshStandardMaterial({ map: createSideTexture(blockDef.sideColor) });
|
||||
|
||||
// Setze die Materialien in der richtigen Reihenfolge
|
||||
voxelMaterial = [
|
||||
rightMaterial, // rechts (+x)
|
||||
leftMaterial, // links (-x)
|
||||
topMaterial, // oben (+y)
|
||||
bottomMaterial, // unten (-y)
|
||||
frontMaterial, // vorne (+z)
|
||||
backMaterial // hinten (-z)
|
||||
];
|
||||
|
||||
// Füge Leuchteffekte hinzu, falls definiert
|
||||
if (blockDef.emissive) {
|
||||
voxelMaterial.forEach(material => {
|
||||
material.emissive = new THREE.Color(blockDef.emissive);
|
||||
material.emissiveIntensity = 0.9;
|
||||
});
|
||||
}
|
||||
|
||||
// Füge Transparenz hinzu, falls definiert
|
||||
if (blockDef.opacity !== undefined && blockDef.opacity < 1) {
|
||||
voxelMaterial.forEach(material => {
|
||||
material.transparent = true;
|
||||
material.opacity = blockDef.opacity || 1;
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// Fallback für Blöcke ohne definierte Seitenfarben
|
||||
const materialProperties: THREE.MeshStandardMaterialParameters = {
|
||||
color: new THREE.Color(blockDef.color || 0xffffff),
|
||||
transparent: blockDef.opacity !== undefined && blockDef.opacity < 1,
|
||||
opacity: blockDef.opacity !== undefined ? blockDef.opacity : 1,
|
||||
};
|
||||
// Füge Leuchteffekte hinzu, falls definiert
|
||||
if (blockDef.emissive) {
|
||||
materialProperties.emissive = new THREE.Color(blockDef.emissive);
|
||||
materialProperties.emissiveIntensity = 0.9;
|
||||
}
|
||||
voxelMaterial = new THREE.MeshStandardMaterial(materialProperties);
|
||||
}
|
||||
|
||||
// Erstelle das Mesh und positioniere es
|
||||
const voxelMesh = new THREE.Mesh(voxelGeometry, voxelMaterial);
|
||||
voxelMesh.position.set(
|
||||
x * this.voxelSize + this.voxelSize / 2, // Zentriere den Block an der X-Position
|
||||
y * this.voxelSize + this.voxelSize / 2, // Zentriere den Block an der Y-Position
|
||||
z * this.voxelSize + this.voxelSize / 2 // Zentriere den Block an der Z-Position
|
||||
);
|
||||
voxelMesh.castShadow = true;
|
||||
voxelMesh.receiveShadow = true;
|
||||
// Speichere Metadaten im userData-Objekt
|
||||
voxelMesh.userData = { x, y, z, type, isSpawnPoint: false };
|
||||
|
||||
// Wenn es ein Zielblock ist, füge ihn zur Zielliste hinzu
|
||||
if (blockDef.isGoal) {
|
||||
console.log(`Zielblock hinzugefügt an Position (${x}, ${y}, ${z})`);
|
||||
this.goalBlocks.push({ x, y, z });
|
||||
// Aktualisiere die Zielanzeige
|
||||
this.updateGoalDisplayCallback();
|
||||
}
|
||||
|
||||
// Füge das Mesh zur Szene hinzu und speichere es in der Voxel-Map
|
||||
this.scene.add(voxelMesh);
|
||||
this.voxels.set(key, voxelMesh);
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt einen Voxel-Block an der angegebenen Position
|
||||
*
|
||||
* @param x - X-Koordinate des Voxels
|
||||
* @param y - Y-Koordinate des Voxels
|
||||
* @param z - Z-Koordinate des Voxels
|
||||
* @param forceRemove - Wenn true, werden auch Spawn-Blöcke entfernt, die normalerweise geschützt sind
|
||||
*/
|
||||
removeVoxel(x: number, y: number, z: number, forceRemove: boolean = false): void {
|
||||
// Erstelle einen eindeutigen Schlüssel für den Voxel
|
||||
const key = getVoxelKey(x, y, z);
|
||||
if (this.voxels.has(key)) {
|
||||
const voxelMesh = this.voxels.get(key);
|
||||
if (!voxelMesh) return;
|
||||
|
||||
// Prüfe, ob es sich um einen Spawn-Block handelt
|
||||
// Spawn-Blöcke sind normalerweise geschützt, um versehentliches Entfernen zu verhindern
|
||||
if (voxelMesh.userData.isSpawnPoint && !forceRemove) {
|
||||
// Wenn der Spawn-Block durch einen Rechtsklick entfernt werden soll, verhindern wir das
|
||||
if (voxelMesh.userData.type === 'spawn') {
|
||||
console.log("Spawn-Block kann nicht direkt entfernt werden. Setze einen neuen Spawn-Block, um diesen zu ersetzen.");
|
||||
return;
|
||||
} else {
|
||||
console.log("Spawn-Punkt kann nicht entfernt werden.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Wenn es ein Zielblock ist, entferne ihn aus der Zielliste
|
||||
const blockDef = BLOCK_TYPES[voxelMesh.userData.type];
|
||||
if (blockDef && blockDef.isGoal) {
|
||||
const pos = { x, y, z };
|
||||
// Entferne aus der goalBlocks-Liste
|
||||
this.goalBlocks = this.goalBlocks.filter(goal =>
|
||||
!(goal.x === pos.x && goal.y === pos.y && goal.z === pos.z)
|
||||
);
|
||||
// Entferne auch aus der reachedGoals-Liste, falls vorhanden
|
||||
this.reachedGoals = this.reachedGoals.filter(goal =>
|
||||
!(goal.x === pos.x && goal.y === pos.y && goal.z === pos.z)
|
||||
);
|
||||
console.log(`Zielblock entfernt an Position (${x}, ${y}, ${z})`);
|
||||
// Aktualisiere die Zielanzeige
|
||||
this.updateGoalDisplayCallback();
|
||||
}
|
||||
|
||||
// Entferne den Block aus der Szene und bereinige Ressourcen
|
||||
this.scene.remove(voxelMesh);
|
||||
|
||||
// Bereinige die Materialien, um Speicherlecks zu vermeiden
|
||||
if (Array.isArray(voxelMesh.material)) {
|
||||
// Bei mehreren Materialien (z.B. Gras-Block) jeden einzeln bereinigen
|
||||
voxelMesh.material.forEach((m: THREE.Material) => {
|
||||
if ((m as THREE.MeshStandardMaterial).map) (m as THREE.MeshStandardMaterial).map?.dispose();
|
||||
m.dispose();
|
||||
});
|
||||
} else {
|
||||
// Bei einem einzelnen Material
|
||||
const mat = voxelMesh.material as THREE.MeshStandardMaterial;
|
||||
if (mat.map) mat.map.dispose();
|
||||
mat.dispose();
|
||||
}
|
||||
|
||||
// Bereinige die Geometrie
|
||||
voxelMesh.geometry.dispose();
|
||||
|
||||
// Entferne den Voxel aus der Map
|
||||
this.voxels.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle Blöcke eines bestimmten Typs aus der Welt
|
||||
*
|
||||
* @param types - Array mit Blocktypen, die entfernt werden sollen (z.B. ['fragile', 'ice'])
|
||||
*/
|
||||
removeBlocksByType(types: string[]): void {
|
||||
// Sammle zuerst alle zu entfernenden Blöcke
|
||||
const blocksToRemove: {x: number, y: number, z: number}[] = [];
|
||||
|
||||
// Durchlaufe alle Voxel und prüfe, ob ihr Typ entfernt werden soll
|
||||
this.voxels.forEach((voxelMesh, key) => {
|
||||
if (types.includes(voxelMesh.userData.type)) {
|
||||
blocksToRemove.push({
|
||||
x: voxelMesh.userData.x,
|
||||
y: voxelMesh.userData.y,
|
||||
z: voxelMesh.userData.z
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Entferne alle identifizierten Blöcke
|
||||
console.log(`Entferne ${blocksToRemove.length} Blöcke vom Typ: ${types.join(', ')}`);
|
||||
blocksToRemove.forEach(block => {
|
||||
this.removeVoxel(block.x, block.y, block.z);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Getter und Setter für Zielblöcke und erreichte Ziele
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gibt die Liste aller Zielblöcke zurück
|
||||
*
|
||||
* @returns Array mit den Koordinaten aller Zielblöcke
|
||||
*/
|
||||
getGoalBlocks(): { x: number, y: number, z: number }[] {
|
||||
return this.goalBlocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die Liste aller erreichten Zielblöcke zurück
|
||||
*
|
||||
* @returns Array mit den Koordinaten aller erreichten Zielblöcke
|
||||
*/
|
||||
getReachedGoals(): { x: number, y: number, z: number }[] {
|
||||
return this.reachedGoals;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Liste der erreichten Zielblöcke und aktualisiert die Zielanzeige
|
||||
*
|
||||
* @param goals - Array mit den Koordinaten der erreichten Zielblöcke
|
||||
*/
|
||||
setReachedGoals(goals: { x: number, y: number, z: number }[]): void {
|
||||
this.reachedGoals = goals;
|
||||
this.updateGoalDisplayCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Fügt einen erreichten Zielblock zur Liste hinzu und aktualisiert die Zielanzeige
|
||||
*
|
||||
* @param goal - Koordinaten des erreichten Zielblocks
|
||||
*/
|
||||
addReachedGoal(goal: { x: number, y: number, z: number }): void {
|
||||
this.reachedGoals.push(goal);
|
||||
this.updateGoalDisplayCallback();
|
||||
}
|
||||
|
||||
/**
|
||||
* Überprüft, ob ein bestimmter Zielblock bereits erreicht wurde
|
||||
*
|
||||
* @param x - X-Koordinate des zu prüfenden Zielblocks
|
||||
* @param y - Y-Koordinate des zu prüfenden Zielblocks
|
||||
* @param z - Z-Koordinate des zu prüfenden Zielblocks
|
||||
* @returns true, wenn der Zielblock bereits erreicht wurde, sonst false
|
||||
*/
|
||||
isGoalReached(x: number, y: number, z: number): boolean {
|
||||
return this.reachedGoals.some(goal =>
|
||||
goal.x === x && goal.y === y && goal.z === z
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Überprüft, ob alle Zielblöcke im Spiel erreicht wurden
|
||||
*
|
||||
* @returns true, wenn alle Zielblöcke erreicht wurden, sonst false.
|
||||
* Gibt false zurück, wenn keine Zielblöcke vorhanden sind.
|
||||
*/
|
||||
areAllGoalsReached(): boolean {
|
||||
// Wenn keine Zielblöcke vorhanden sind, gib false zurück
|
||||
if (this.goalBlocks.length === 0) return false;
|
||||
|
||||
// Überprüfe, ob die Anzahl der erreichten Ziele gleich der Anzahl aller Ziele ist
|
||||
return this.reachedGoals.length === this.goalBlocks.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt alle Blöcke in einem serialisierbaren Format zurück
|
||||
*
|
||||
* @returns Array mit allen Blöcken in einem serialisierbaren Format
|
||||
*/
|
||||
getSerializableBlocks(): { x: number, y: number, z: number, type: string, isSpawnPoint: boolean, isGoal: boolean }[] {
|
||||
const blocks: { x: number, y: number, z: number, type: string, isSpawnPoint: boolean, isGoal: boolean }[] = [];
|
||||
|
||||
// Gib die Größe der voxels-Map aus
|
||||
console.log('Anzahl der Blöcke in voxels Map:', this.voxels.size);
|
||||
|
||||
// Iteriere über alle Blöcke in der voxels-Map
|
||||
this.voxels.forEach((mesh, key) => {
|
||||
if (mesh && mesh.userData) {
|
||||
const block = {
|
||||
x: mesh.userData.x,
|
||||
y: mesh.userData.y,
|
||||
z: mesh.userData.z,
|
||||
type: mesh.userData.type,
|
||||
isSpawnPoint: mesh.userData.isSpawnPoint || false,
|
||||
isGoal: mesh.userData.type === 'goal'
|
||||
};
|
||||
|
||||
// Überprüfe, ob alle erforderlichen Felder vorhanden sind
|
||||
if (typeof block.x === 'number' &&
|
||||
typeof block.y === 'number' &&
|
||||
typeof block.z === 'number' &&
|
||||
block.type) {
|
||||
blocks.push(block);
|
||||
} else {
|
||||
console.warn('Ungültiger Block gefunden:', mesh.userData);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
console.log('Anzahl der serialisierbaren Blöcke:', blocks.length);
|
||||
if (blocks.length > 0) {
|
||||
console.log('Erster serialisierbarer Block:', blocks[0]);
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Entfernt alle Blöcke aus der Welt, mit Ausnahme von geschützten Blöcken
|
||||
*
|
||||
* @param preserveSpawnPoints - Wenn true, werden Spawn-Punkte nicht entfernt (Standard: true)
|
||||
*/
|
||||
removeAllBlocks(preserveSpawnPoints: boolean = true): void {
|
||||
console.log(`Entferne alle Blöcke (Spawn-Punkte ${preserveSpawnPoints ? 'werden beibehalten' : 'werden auch entfernt'})`);
|
||||
|
||||
// Sammle zuerst alle zu entfernenden Blöcke
|
||||
const blocksToRemove: {x: number, y: number, z: number}[] = [];
|
||||
|
||||
// Durchlaufe alle Voxel
|
||||
this.voxels.forEach((voxelMesh, key) => {
|
||||
// Wenn preserveSpawnPoints true ist, überspringe Spawn-Blöcke
|
||||
if (preserveSpawnPoints && voxelMesh.userData.isSpawnPoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
blocksToRemove.push({
|
||||
x: voxelMesh.userData.x,
|
||||
y: voxelMesh.userData.y,
|
||||
z: voxelMesh.userData.z
|
||||
});
|
||||
});
|
||||
|
||||
// Entferne alle identifizierten Blöcke
|
||||
console.log(`Entferne ${blocksToRemove.length} Blöcke`);
|
||||
blocksToRemove.forEach(block => {
|
||||
this.removeVoxel(block.x, block.y, block.z, !preserveSpawnPoints); // Wenn preserveSpawnPoints false ist, setze forceRemove auf true
|
||||
});
|
||||
|
||||
// Setze die Ziellisten zurück, wenn alle Blöcke entfernt werden
|
||||
if (!preserveSpawnPoints) {
|
||||
this.goalBlocks = [];
|
||||
this.reachedGoals = [];
|
||||
this.updateGoalDisplayCallback();
|
||||
}
|
||||
}
|
||||
}
|
||||
589
games/voxel-lava/src/lib/PlayerController.ts
Normal file
589
games/voxel-lava/src/lib/PlayerController.ts
Normal file
|
|
@ -0,0 +1,589 @@
|
|||
/**
|
||||
* PlayerController.ts
|
||||
*
|
||||
* Diese Klasse ist verantwortlich für die Verwaltung der Spielerphysik, Bewegung und Kollisionserkennung.
|
||||
* Sie kapselt die gesamte Spielerlogik und macht den GameCanvas-Code übersichtlicher.
|
||||
*/
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { BlockManager } from './BlockTypes';
|
||||
import type { BlockType } from './BlockTypes';
|
||||
|
||||
export interface PlayerConfig {
|
||||
height: number;
|
||||
radius: number;
|
||||
speed: number;
|
||||
jumpVelocity: number;
|
||||
gravity: number;
|
||||
}
|
||||
|
||||
export interface PlayerPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
export class PlayerController {
|
||||
// Spieler-Eigenschaften
|
||||
private height: number;
|
||||
private radius: number;
|
||||
private speed: number;
|
||||
private jumpVelocity: number;
|
||||
private gravity: number;
|
||||
|
||||
// Spieler-Zustand
|
||||
private cameraObject: THREE.PerspectiveCamera;
|
||||
private blockManager: BlockManager;
|
||||
private voxelSize: number;
|
||||
private keyboardState: { [key: string]: boolean } = {};
|
||||
private input: THREE.Vector3 = new THREE.Vector3();
|
||||
private velocity: THREE.Vector3 = new THREE.Vector3();
|
||||
private onGround: boolean = false;
|
||||
private simulatedJump: boolean = false;
|
||||
private isDying: boolean = false;
|
||||
private deathTimer: number = 0;
|
||||
private hasMovedSinceSpawn: boolean = false; // Flag, um zu verfolgen, ob der Spieler sich seit dem Spawn bewegt hat
|
||||
private fragileTouchedBlocks: Map<string, { x: number, y: number, z: number, timer: number }> = new Map();
|
||||
|
||||
// Callback-Funktionen
|
||||
private onRespawn: (isInitialSpawn: boolean) => void;
|
||||
private onGoalReached: (position: PlayerPosition) => void;
|
||||
private onFirstMovement?: () => void; // Neue Callback-Funktion für die erste Bewegung (optional)
|
||||
|
||||
/**
|
||||
* Konstruktor für den PlayerController
|
||||
*
|
||||
* @param config - Konfiguration für den Spieler (Höhe, Radius, Geschwindigkeit, etc.)
|
||||
* @param camera - Die Kamera, die als Spielerobjekt dient
|
||||
* @param blockManager - Der BlockManager für Kollisionsabfragen
|
||||
* @param voxelSize - Die Größe eines Voxels in der Welt
|
||||
* @param keyboardState - Der aktuelle Zustand der Tastatur
|
||||
* @param onRespawn - Callback-Funktion, die beim Respawn aufgerufen wird
|
||||
* @param onGoalReached - Callback-Funktion, die beim Erreichen eines Ziels aufgerufen wird
|
||||
*/
|
||||
constructor(
|
||||
config: PlayerConfig,
|
||||
camera: THREE.PerspectiveCamera,
|
||||
blockManager: BlockManager,
|
||||
voxelSize: number,
|
||||
keyboardState: { [key: string]: boolean },
|
||||
onRespawn: (isInitialSpawn?: boolean) => void,
|
||||
onGoalReached: (position: PlayerPosition) => void,
|
||||
onFirstMovement?: () => void // Neuer optionaler Parameter für die erste Bewegung
|
||||
) {
|
||||
this.height = config.height;
|
||||
this.radius = config.radius;
|
||||
this.speed = config.speed;
|
||||
this.jumpVelocity = config.jumpVelocity;
|
||||
this.gravity = config.gravity;
|
||||
|
||||
this.velocity = new THREE.Vector3();
|
||||
this.input = new THREE.Vector3();
|
||||
this.onGround = false;
|
||||
this.cameraObject = camera;
|
||||
|
||||
this.blockManager = blockManager;
|
||||
this.voxelSize = voxelSize;
|
||||
this.keyboardState = keyboardState;
|
||||
|
||||
this.onRespawn = onRespawn;
|
||||
this.onGoalReached = onGoalReached;
|
||||
this.onFirstMovement = onFirstMovement; // Speichere den neuen Callback
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert den Spieler für den aktuellen Frame
|
||||
*
|
||||
* @param deltaTime - Die Zeit seit dem letzten Frame in Sekunden
|
||||
* @param isLocked - Ob die Maussteuerung aktiv ist
|
||||
* @param gameWon - Ob das Spiel gewonnen wurde
|
||||
*/
|
||||
public update(deltaTime: number, isLocked: boolean, gameWon: boolean): void {
|
||||
if (gameWon) return;
|
||||
|
||||
// Wenn der Spieler stirbt (in Lava versinkt)
|
||||
if (this.isDying) {
|
||||
this.deathTimer += deltaTime;
|
||||
|
||||
// Schnell in die Lava sinken, direkt auf den Boden
|
||||
// Beschleunigung des Sinkens mit der Zeit
|
||||
const sinkSpeed = 2.0 * (1 + this.deathTimer * 2.0); // Schnellere Beschleunigung
|
||||
|
||||
// Berechne die aktuelle Y-Position des Blocks, in dem der Spieler sich befindet
|
||||
const playerBlockY = Math.floor(this.cameraObject.position.y / this.voxelSize);
|
||||
|
||||
// Berechne die Position des Bodens des Lava-Blocks
|
||||
const floorY = playerBlockY * this.voxelSize;
|
||||
|
||||
// Sinke zum Boden des Blocks
|
||||
const newY = this.cameraObject.position.y - sinkSpeed * deltaTime;
|
||||
this.cameraObject.position.y = Math.max(newY, floorY);
|
||||
|
||||
// Nach einer bestimmten Zeit respawnen (Respawn-Funktion kümmert sich um den Rest)
|
||||
if (this.deathTimer >= 0.4) { // Sehr kurze Zeit zum Respawnen (400ms)
|
||||
this.isDying = false;
|
||||
this.deathTimer = 0;
|
||||
this.onRespawn(false); // Rufe den Respawn-Callback auf
|
||||
return;
|
||||
}
|
||||
|
||||
// Während des Sterbens keine weitere Bewegung
|
||||
return;
|
||||
}
|
||||
|
||||
this.handleKeyboardInput();
|
||||
|
||||
// Simulierten Sprung verarbeiten
|
||||
if (this.simulatedJump) {
|
||||
this.simulateJump();
|
||||
this.simulatedJump = false;
|
||||
}
|
||||
|
||||
// Schwerkraft anwenden
|
||||
this.velocity.y -= this.gravity * deltaTime;
|
||||
|
||||
// Bewegung anwenden
|
||||
this.applyMovement(deltaTime);
|
||||
|
||||
// Zerbrechliche Blöcke aktualisieren
|
||||
this.updateFragileBlocks(deltaTime);
|
||||
|
||||
const playerWorldPos = this.cameraObject.position;
|
||||
let newPos = playerWorldPos.clone().add(this.velocity.clone().multiplyScalar(deltaTime));
|
||||
|
||||
// Verfolge, welche zerbrechlichen Blöcke wir in diesem Frame berühren
|
||||
const touchedBlocksInThisFrame = new Set<string>();
|
||||
|
||||
this.handleVerticalCollisions(newPos, playerWorldPos, touchedBlocksInThisFrame);
|
||||
this.handleHorizontalCollisions(newPos, playerWorldPos);
|
||||
|
||||
// Aktualisiere die Liste der berührten Blöcke
|
||||
this.updateTouchedBlocks(touchedBlocksInThisFrame);
|
||||
|
||||
// Aktualisiere die zerbrechlichen Blöcke
|
||||
this.updateFragileBlocks(deltaTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Verarbeitet die Tastatureingaben und setzt den Eingabevektor
|
||||
*/
|
||||
private handleKeyboardInput(): void {
|
||||
this.input.set(0, 0, 0);
|
||||
if (this.keyboardState['KeyW'] || this.keyboardState['ArrowUp']) this.input.z = -1; // Vorwärts
|
||||
if (this.keyboardState['KeyS'] || this.keyboardState['ArrowDown']) this.input.z = 1; // Rückwärts
|
||||
if (this.keyboardState['KeyA'] || this.keyboardState['ArrowLeft']) this.input.x = -1; // Links
|
||||
if (this.keyboardState['KeyD'] || this.keyboardState['ArrowRight']) this.input.x = 1; // Rechts
|
||||
|
||||
// Prüfe, ob der Spieler sich zum ersten Mal bewegt
|
||||
if (!this.hasMovedSinceSpawn && (this.input.x !== 0 || this.input.z !== 0)) {
|
||||
this.hasMovedSinceSpawn = true;
|
||||
|
||||
// Rufe den onFirstMovement-Callback auf, wenn er definiert ist
|
||||
if (this.onFirstMovement) {
|
||||
console.log("Erste Spielerbewegung erkannt, starte Timer...");
|
||||
this.onFirstMovement();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wendet die Bewegung basierend auf dem Eingabevektor an
|
||||
*
|
||||
* @param deltaTime - Die Zeit seit dem letzten Frame in Sekunden
|
||||
*/
|
||||
private applyMovement(deltaTime: number): void {
|
||||
const forward = new THREE.Vector3();
|
||||
this.cameraObject.getWorldDirection(forward);
|
||||
forward.y = 0;
|
||||
forward.normalize();
|
||||
|
||||
const right = new THREE.Vector3();
|
||||
right.crossVectors(this.cameraObject.up, forward).normalize();
|
||||
|
||||
// Ermittle den Block unter dem Spieler für die Reibung
|
||||
const blockBelow = this.blockManager.getVoxelBlockType(
|
||||
Math.floor(this.cameraObject.position.x / this.voxelSize),
|
||||
Math.floor((this.cameraObject.position.y - this.height / 2 - 0.01) / this.voxelSize),
|
||||
Math.floor(this.cameraObject.position.z / this.voxelSize)
|
||||
);
|
||||
|
||||
// Standardreibung für Gras verwenden, wenn kein Block gefunden wurde
|
||||
const friction = blockBelow ? blockBelow.frictionFactor : 0.7;
|
||||
const isIceBlock = blockBelow && blockBelow.frictionFactor > 0.9; // Erkennen von Eis-Blöcken
|
||||
|
||||
// Wenn der Spieler auf Eis steht, behalte die Geschwindigkeit bei und wende nur leichte Reibung an
|
||||
if (this.onGround && isIceBlock) {
|
||||
// Wenn der Spieler aktiv Eingaben macht, setze die Zielgeschwindigkeit
|
||||
if (this.input.z !== 0 || this.input.x !== 0) {
|
||||
let targetVelocityX = 0;
|
||||
let targetVelocityZ = 0;
|
||||
|
||||
// Vorwärts/Rückwärts Bewegung
|
||||
if (this.input.z !== 0) {
|
||||
targetVelocityX -= forward.x * this.input.z * this.speed;
|
||||
targetVelocityZ -= forward.z * this.input.z * this.speed;
|
||||
}
|
||||
|
||||
// Seitwärts Bewegung (Strafe)
|
||||
if (this.input.x !== 0) {
|
||||
targetVelocityX -= right.x * this.input.x * this.speed;
|
||||
targetVelocityZ -= right.z * this.input.x * this.speed;
|
||||
}
|
||||
|
||||
// Mische aktuelle Geschwindigkeit mit Zielgeschwindigkeit für Rutscheffekt
|
||||
this.velocity.x = this.velocity.x * 0.8 + targetVelocityX * 0.2;
|
||||
this.velocity.z = this.velocity.z * 0.8 + targetVelocityZ * 0.2;
|
||||
} else {
|
||||
// Wenn keine Eingabe, wende nur sehr geringe Reibung an (Gleiten)
|
||||
this.velocity.x *= 0.99;
|
||||
this.velocity.z *= 0.99;
|
||||
}
|
||||
} else {
|
||||
// Normales Verhalten für andere Blöcke
|
||||
let targetVelocityX = 0;
|
||||
let targetVelocityZ = 0;
|
||||
|
||||
// Vorwärts/Rückwärts Bewegung
|
||||
if (this.input.z !== 0) {
|
||||
targetVelocityX -= forward.x * this.input.z * this.speed;
|
||||
targetVelocityZ -= forward.z * this.input.z * this.speed;
|
||||
}
|
||||
|
||||
// Seitwärts Bewegung (Strafe)
|
||||
if (this.input.x !== 0) {
|
||||
targetVelocityX -= right.x * this.input.x * this.speed;
|
||||
targetVelocityZ -= right.z * this.input.x * this.speed;
|
||||
}
|
||||
|
||||
this.velocity.x = targetVelocityX;
|
||||
this.velocity.z = targetVelocityZ;
|
||||
|
||||
// Reibung anwenden, wenn der Spieler auf dem Boden steht und sich nicht bewegt
|
||||
if (this.onGround && this.input.x === 0 && this.input.z === 0) {
|
||||
this.velocity.x *= (1 - (1 - friction) * 15 * deltaTime);
|
||||
this.velocity.z *= (1 - (1 - friction) * 15 * deltaTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Kleine Werte auf 0 setzen, um Zittern zu vermeiden
|
||||
if (Math.abs(this.velocity.x) < 0.01) this.velocity.x = 0;
|
||||
if (Math.abs(this.velocity.z) < 0.01) this.velocity.z = 0;
|
||||
|
||||
// Schwerkraft anwenden
|
||||
this.velocity.y -= this.gravity * deltaTime;
|
||||
|
||||
// Springen
|
||||
if ((this.keyboardState['Space'] || this.simulatedJump) && this.onGround) {
|
||||
this.velocity.y = this.jumpVelocity;
|
||||
this.onGround = false;
|
||||
if (this.simulatedJump) this.simulatedJump = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft auf Kollisionen mit einem Voxel
|
||||
*
|
||||
* @param playerBox - Die Bounding Box des Spielers
|
||||
* @param voxelX - X-Koordinate des Voxels
|
||||
* @param voxelY - Y-Koordinate des Voxels
|
||||
* @param voxelZ - Z-Koordinate des Voxels
|
||||
* @returns Der Blocktyp, wenn eine Kollision vorliegt, sonst null
|
||||
*/
|
||||
private checkCollisionWithVoxel(playerBox: THREE.Box3, voxelX: number, voxelY: number, voxelZ: number): BlockType | null {
|
||||
const blockType = this.blockManager.getVoxelBlockType(voxelX, voxelY, voxelZ);
|
||||
if (blockType && blockType.solid) {
|
||||
const voxelBox = new THREE.Box3(
|
||||
new THREE.Vector3(voxelX * this.voxelSize, voxelY * this.voxelSize, voxelZ * this.voxelSize),
|
||||
new THREE.Vector3((voxelX + 1) * this.voxelSize, (voxelY + 1) * this.voxelSize, (voxelZ + 1) * this.voxelSize)
|
||||
);
|
||||
if (playerBox.intersectsBox(voxelBox)) {
|
||||
return blockType;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt vertikale Kollisionen (Boden und Decke)
|
||||
*
|
||||
* @param newPos - Die neue Position des Spielers
|
||||
* @param playerWorldPos - Die aktuelle Position des Spielers
|
||||
* @param touchedBlocksInThisFrame - Set mit Schlüsseln der Blöcke, die in diesem Frame berührt wurden
|
||||
* @returns true, wenn eine Kollision stattgefunden hat
|
||||
*/
|
||||
private handleVerticalCollisions(newPos: THREE.Vector3, playerWorldPos: THREE.Vector3, touchedBlocksInThisFrame: Set<string>): boolean {
|
||||
this.onGround = false;
|
||||
const playerFeetY = newPos.y - this.height / 2;
|
||||
const playerHeadY = newPos.y + this.height / 2;
|
||||
|
||||
const checkMinX = Math.floor((newPos.x - this.radius) / this.voxelSize);
|
||||
const checkMaxX = Math.floor((newPos.x + this.radius) / this.voxelSize);
|
||||
const checkMinZ = Math.floor((newPos.z - this.radius) / this.voxelSize);
|
||||
const checkMaxZ = Math.floor((newPos.z + this.radius) / this.voxelSize);
|
||||
|
||||
// Kollision mit dem Boden prüfen
|
||||
if (this.velocity.y <= 0) {
|
||||
const groundCheckY = Math.floor(playerFeetY / this.voxelSize);
|
||||
for (let x = checkMinX; x <= checkMaxX; x++) {
|
||||
for (let z = checkMinZ; z <= checkMaxZ; z++) {
|
||||
const blockAtFeet = this.blockManager.getVoxelBlockType(x, groundCheckY, z);
|
||||
if (blockAtFeet) {
|
||||
if (blockAtFeet.isDeadly) {
|
||||
// Starte den Sterbevorgang (in Lava versinken)
|
||||
if (!this.isDying) {
|
||||
this.isDying = true;
|
||||
this.deathTimer = 0;
|
||||
this.velocity.set(0, 0, 0); // Stoppe alle Bewegungen
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (blockAtFeet.isGoal) {
|
||||
// Rufe den Callback auf, aber erlaube dem Spieler, auf dem Ziel-Block zu stehen
|
||||
this.onGoalReached({ x, y: groundCheckY, z });
|
||||
|
||||
// Setze die Spielerposition auf die Oberkante des Blocks
|
||||
playerWorldPos.y = (groundCheckY + 1) * this.voxelSize + this.height / 2;
|
||||
this.velocity.y = 0;
|
||||
this.onGround = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (blockAtFeet.solid) {
|
||||
// Prüfe, ob wir auf einem zerbrechlichen Block stehen
|
||||
if (blockAtFeet.isFragile) {
|
||||
// Erzeuge einen eindeutigen Schlüssel für diesen Block
|
||||
const blockKey = `${x}_${groundCheckY}_${z}`;
|
||||
touchedBlocksInThisFrame.add(blockKey);
|
||||
|
||||
// Wenn wir diesen Block noch nicht verfolgen, füge ihn hinzu
|
||||
if (!this.fragileTouchedBlocks.has(blockKey)) {
|
||||
this.fragileTouchedBlocks.set(blockKey, {
|
||||
x,
|
||||
y: groundCheckY,
|
||||
z,
|
||||
timer: blockAtFeet.breakTimer || 1.0
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfe, ob wir auf einem Trampolinblock stehen
|
||||
if (blockAtFeet.isTrampoline) {
|
||||
// Setze die Spielerposition auf die Oberkante des Blocks
|
||||
playerWorldPos.y = (groundCheckY + 1) * this.voxelSize + this.height / 2;
|
||||
// Gib dem Spieler einen kräftigen Impuls nach oben
|
||||
const trampolineForce = blockAtFeet.trampolineForce || 20.0;
|
||||
this.velocity.y = trampolineForce;
|
||||
this.onGround = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
playerWorldPos.y = (groundCheckY + 1) * this.voxelSize + this.height / 2;
|
||||
this.velocity.y = 0;
|
||||
this.onGround = true;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kollision mit der Decke prüfen
|
||||
if (this.velocity.y > 0) {
|
||||
const headCheckY = Math.floor(playerHeadY / this.voxelSize);
|
||||
for (let x = checkMinX; x <= checkMaxX; x++) {
|
||||
for (let z = checkMinZ; z <= checkMaxZ; z++) {
|
||||
const blockAtHead = this.blockManager.getVoxelBlockType(x, headCheckY, z);
|
||||
if (blockAtHead && blockAtHead.solid) {
|
||||
playerWorldPos.y = headCheckY * this.voxelSize - this.height / 2 - 0.01;
|
||||
this.velocity.y = 0;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
playerWorldPos.y = newPos.y;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Behandelt horizontale Kollisionen (Wände)
|
||||
*
|
||||
* @param newPos - Die neue Position des Spielers
|
||||
* @param playerWorldPos - Die aktuelle Position des Spielers
|
||||
*/
|
||||
private handleHorizontalCollisions(newPos: THREE.Vector3, playerWorldPos: THREE.Vector3): void {
|
||||
const R = this.radius;
|
||||
const H_half = this.height / 2;
|
||||
|
||||
// X-Achsen-Kollision
|
||||
let proposedXPos = newPos.x;
|
||||
const playerBoxX = new THREE.Box3(
|
||||
new THREE.Vector3(proposedXPos - R, playerWorldPos.y - H_half, playerWorldPos.z - R),
|
||||
new THREE.Vector3(proposedXPos + R, playerWorldPos.y + H_half, playerWorldPos.z + R)
|
||||
);
|
||||
|
||||
const minX_col = Math.floor(playerBoxX.min.x / this.voxelSize);
|
||||
const maxX_col = Math.floor(playerBoxX.max.x / this.voxelSize);
|
||||
const minY_col = Math.floor(playerBoxX.min.y / this.voxelSize);
|
||||
const maxY_col = Math.floor(playerBoxX.max.y / this.voxelSize);
|
||||
const minZ_col = Math.floor(playerBoxX.min.z / this.voxelSize);
|
||||
const maxZ_col = Math.floor(playerBoxX.max.z / this.voxelSize);
|
||||
|
||||
let collisionX = false;
|
||||
for (let ix = minX_col; ix <= maxX_col; ix++) {
|
||||
for (let iy = minY_col; iy <= maxY_col; iy++) {
|
||||
for (let iz = minZ_col; iz <= maxZ_col; iz++) {
|
||||
const blockType = this.checkCollisionWithVoxel(playerBoxX, ix, iy, iz);
|
||||
if (blockType) {
|
||||
if (blockType.isDeadly) {
|
||||
this.onRespawn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockType.isGoal) {
|
||||
this.onGoalReached({ x: ix, y: iy, z: iz });
|
||||
return;
|
||||
}
|
||||
|
||||
collisionX = true;
|
||||
this.velocity.x = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collisionX) break;
|
||||
}
|
||||
if (collisionX) break;
|
||||
}
|
||||
|
||||
if (!collisionX) {
|
||||
playerWorldPos.x = proposedXPos;
|
||||
}
|
||||
|
||||
// Z-Achsen-Kollision
|
||||
let proposedZPos = newPos.z;
|
||||
const playerBoxZ = new THREE.Box3(
|
||||
new THREE.Vector3(playerWorldPos.x - R, playerWorldPos.y - H_half, proposedZPos - R),
|
||||
new THREE.Vector3(playerWorldPos.x + R, playerWorldPos.y + H_half, proposedZPos + R)
|
||||
);
|
||||
|
||||
const minX_colZ = Math.floor(playerBoxZ.min.x / this.voxelSize);
|
||||
const maxX_colZ = Math.floor(playerBoxZ.max.x / this.voxelSize);
|
||||
const minY_colZ = Math.floor(playerBoxZ.min.y / this.voxelSize);
|
||||
const maxY_colZ = Math.floor(playerBoxZ.max.y / this.voxelSize);
|
||||
const minZ_colZ = Math.floor(playerBoxZ.min.z / this.voxelSize);
|
||||
const maxZ_colZ = Math.floor(playerBoxZ.max.z / this.voxelSize);
|
||||
|
||||
let collisionZ = false;
|
||||
for (let ix = minX_colZ; ix <= maxX_colZ; ix++) {
|
||||
for (let iy = minY_colZ; iy <= maxY_colZ; iy++) {
|
||||
for (let iz = minZ_colZ; iz <= maxZ_colZ; iz++) {
|
||||
const blockType = this.checkCollisionWithVoxel(playerBoxZ, ix, iy, iz);
|
||||
if (blockType) {
|
||||
if (blockType.isDeadly) {
|
||||
this.onRespawn(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (blockType.isGoal) {
|
||||
this.onGoalReached({ x: ix, y: iy, z: iz });
|
||||
return;
|
||||
}
|
||||
|
||||
collisionZ = true;
|
||||
this.velocity.z = 0;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (collisionZ) break;
|
||||
}
|
||||
if (collisionZ) break;
|
||||
}
|
||||
|
||||
if (!collisionZ) {
|
||||
playerWorldPos.z = proposedZPos;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Timer für zerbrechliche Blöcke und zerstört sie, wenn der Timer abläuft
|
||||
* oder wenn der Spieler den Block verlassen hat
|
||||
*
|
||||
* @param deltaTime - Die Zeit seit dem letzten Frame in Sekunden
|
||||
*/
|
||||
private updateFragileBlocks(deltaTime: number): void {
|
||||
// Iteriere über alle verfolgten zerbrechlichen Blöcke
|
||||
for (const [blockKey, blockInfo] of this.fragileTouchedBlocks.entries()) {
|
||||
// Reduziere den Timer
|
||||
blockInfo.timer -= deltaTime;
|
||||
|
||||
// Wenn der Timer abgelaufen ist, zerstöre den Block
|
||||
if (blockInfo.timer <= 0) {
|
||||
// Entferne den Block aus der Welt
|
||||
this.blockManager.removeVoxel(blockInfo.x, blockInfo.y, blockInfo.z, true);
|
||||
|
||||
// Entferne den Block aus der Map
|
||||
this.fragileTouchedBlocks.delete(blockKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert die Liste der berührten zerbrechlichen Blöcke
|
||||
* Blöcke, die nicht mehr berührt werden, werden markiert, um später zerstört zu werden
|
||||
*
|
||||
* @param touchedBlocksInThisFrame - Set mit Schlüsseln der Blöcke, die in diesem Frame berührt wurden
|
||||
*/
|
||||
private updateTouchedBlocks(touchedBlocksInThisFrame: Set<string>): void {
|
||||
// Finde Blöcke, die nicht mehr berührt werden
|
||||
for (const [blockKey, blockInfo] of this.fragileTouchedBlocks.entries()) {
|
||||
if (!touchedBlocksInThisFrame.has(blockKey)) {
|
||||
// Setze den Timer auf 0, damit der Block im nächsten Frame zerstört wird
|
||||
blockInfo.timer = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die aktuelle Geschwindigkeit des Spielers zurück
|
||||
*/
|
||||
public getVelocity(): THREE.Vector3 {
|
||||
return this.velocity.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die aktuelle Position des Spielers zurück
|
||||
*/
|
||||
public getPosition(): THREE.Vector3 {
|
||||
return this.cameraObject.position.clone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Position des Spielers
|
||||
*
|
||||
* @param position - Die neue Position des Spielers
|
||||
*/
|
||||
public setPosition(position: PlayerPosition): void {
|
||||
this.cameraObject.position.set(position.x, position.y, position.z);
|
||||
|
||||
// Setze das Flag zurück, damit der Timer erst wieder startet, wenn der Spieler sich bewegt
|
||||
this.hasMovedSinceSpawn = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Geschwindigkeit des Spielers
|
||||
*
|
||||
* @param velocity - Die neue Geschwindigkeit des Spielers
|
||||
*/
|
||||
public setVelocity(velocity: THREE.Vector3): void {
|
||||
this.velocity.copy(velocity);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simuliert einen Sprung des Spielers
|
||||
*/
|
||||
public simulateJump(): void {
|
||||
this.simulatedJump = true;
|
||||
}
|
||||
}
|
||||
104
games/voxel-lava/src/lib/components/BlockButton.svelte
Normal file
104
games/voxel-lava/src/lib/components/BlockButton.svelte
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
// Props
|
||||
export let blockType = '';
|
||||
export let blockName = '';
|
||||
export let color: number = 0xffffff;
|
||||
export let emissive: number | undefined = undefined;
|
||||
export let opacity: number = 1;
|
||||
export let selected = false;
|
||||
export let topColor: number | undefined = undefined;
|
||||
export let sideColor: number | undefined = undefined;
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function handleClick() {
|
||||
dispatch('select', { blockType });
|
||||
}
|
||||
</script>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="block-button"
|
||||
class:selected={selected}
|
||||
title={blockName}
|
||||
on:click={handleClick}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleClick()}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
{#if topColor !== undefined && sideColor !== undefined}
|
||||
<div class="grass-style">
|
||||
<div class="grass-preview-top" style="background-color: #{topColor ? topColor.toString(16).padStart(6, '0') : '559944'};"></div>
|
||||
<div class="grass-preview-bottom" style="background-color: #{sideColor ? sideColor.toString(16).padStart(6, '0') : '8B4513'};"></div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
class="color-style"
|
||||
style="
|
||||
background-color: #{color.toString(16).padStart(6, '0')};
|
||||
opacity: {opacity};
|
||||
box-shadow: {emissive ? `inset 0 0 10px #${emissive.toString(16).padStart(6, '0')}` : 'none'};
|
||||
"
|
||||
></div>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.block-button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 2px solid #718096;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease-in-out;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.block-button.selected {
|
||||
border-color: #4FD1C5;
|
||||
box-shadow: 0 0 12px #4FD1C5;
|
||||
}
|
||||
|
||||
.block-button:hover {
|
||||
transform: scale(1.1);
|
||||
border-color: #A0AEC0;
|
||||
}
|
||||
|
||||
.grass-style {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.grass-preview-top {
|
||||
width: 100%;
|
||||
height: 40%;
|
||||
}
|
||||
|
||||
.grass-preview-bottom {
|
||||
width: 100%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.color-style {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
</style>
|
||||
1666
games/voxel-lava/src/lib/components/GameCanvas.svelte
Normal file
1666
games/voxel-lava/src/lib/components/GameCanvas.svelte
Normal file
File diff suppressed because it is too large
Load diff
102
games/voxel-lava/src/lib/components/auth/AuthButton.svelte
Normal file
102
games/voxel-lava/src/lib/components/auth/AuthButton.svelte
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import AuthModal from './AuthModal.svelte';
|
||||
|
||||
// Eigenschaften
|
||||
export let buttonClass = '';
|
||||
export let showUserEmail = true;
|
||||
|
||||
// Zustände
|
||||
let isModalOpen = false;
|
||||
let isLoggedIn = false;
|
||||
let currentUser: any = null;
|
||||
|
||||
// Beim Mounten prüfen, ob der Benutzer angemeldet ist
|
||||
onMount(async () => {
|
||||
await checkAuthStatus();
|
||||
});
|
||||
|
||||
// Authentifizierungsstatus prüfen
|
||||
async function checkAuthStatus() {
|
||||
const user = await AuthService.getCurrentUser();
|
||||
isLoggedIn = !!user;
|
||||
currentUser = user;
|
||||
}
|
||||
|
||||
// Modal öffnen
|
||||
function openModal() {
|
||||
isModalOpen = true;
|
||||
}
|
||||
|
||||
// Nach erfolgreicher Anmeldung
|
||||
async function handleLogin() {
|
||||
await checkAuthStatus();
|
||||
}
|
||||
|
||||
// Nach Abmeldung
|
||||
async function handleLogout() {
|
||||
await checkAuthStatus();
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-button-container">
|
||||
<button
|
||||
type="button"
|
||||
class="auth-button {buttonClass}"
|
||||
on:click={openModal}
|
||||
>
|
||||
{#if isLoggedIn}
|
||||
<span class="user-icon">👤</span>
|
||||
{#if showUserEmail && currentUser?.email}
|
||||
<span class="user-email">{currentUser.email.split('@')[0]}</span>
|
||||
{:else}
|
||||
<span>Profil</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="login-icon">🔑</span>
|
||||
<span>Anmelden</span>
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
<AuthModal
|
||||
bind:isOpen={isModalOpen}
|
||||
on:login={handleLogin}
|
||||
on:logout={handleLogout}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-button-container {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
background-color: rgba(42, 50, 66, 0.9);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 8px 16px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background-color: rgba(60, 70, 90, 0.9);
|
||||
}
|
||||
|
||||
.user-icon, .login-icon {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
max-width: 120px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
226
games/voxel-lava/src/lib/components/auth/AuthModal.svelte
Normal file
226
games/voxel-lava/src/lib/components/auth/AuthModal.svelte
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import Login from './Login.svelte';
|
||||
import Register from './Register.svelte';
|
||||
import PasswordReset from './PasswordReset.svelte';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
export let isOpen = false;
|
||||
|
||||
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Aktuelle Ansicht (login, register, passwordReset)
|
||||
let currentView = 'login';
|
||||
|
||||
// Benutzer-Status
|
||||
let isLoggedIn = false;
|
||||
let currentUser: any = null;
|
||||
|
||||
// Beim Mounten prüfen, ob der Benutzer angemeldet ist
|
||||
onMount(async () => {
|
||||
await checkAuthStatus();
|
||||
});
|
||||
|
||||
// Authentifizierungsstatus prüfen
|
||||
async function checkAuthStatus() {
|
||||
const user = await AuthService.getCurrentUser();
|
||||
isLoggedIn = !!user;
|
||||
currentUser = user;
|
||||
}
|
||||
|
||||
// Ansicht wechseln (login, register, passwordReset)
|
||||
function handleSwitchView(event: CustomEvent<string>) {
|
||||
currentView = event.detail;
|
||||
}
|
||||
|
||||
// Nach erfolgreicher Anmeldung
|
||||
async function handleLogin() {
|
||||
await checkAuthStatus();
|
||||
if (isLoggedIn) {
|
||||
dispatch('login', currentUser);
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Abmelden
|
||||
async function handleLogout() {
|
||||
const success = await AuthService.logout();
|
||||
if (success) {
|
||||
await checkAuthStatus();
|
||||
dispatch('logout');
|
||||
}
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
// Klick außerhalb des Modals abfangen
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Tastendruck abfangen (Escape zum Schließen)
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div class="modal-backdrop" on:click={handleBackdropClick} on:keydown={handleKeydown} role="dialog" aria-modal="true" tabindex="-1">
|
||||
<div class="modal-content">
|
||||
<button class="close-button" on:click={closeModal}>×</button>
|
||||
|
||||
{#if isLoggedIn}
|
||||
<div class="user-profile">
|
||||
<h2>Willkommen!</h2>
|
||||
<p class="user-email">{currentUser?.email}</p>
|
||||
|
||||
<div class="profile-actions">
|
||||
<button class="auth-button" on:click={handleLogout}>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
{#if currentView === 'login'}
|
||||
<Login
|
||||
on:switchView={handleSwitchView}
|
||||
on:login={handleLogin}
|
||||
/>
|
||||
{:else if currentView === 'register'}
|
||||
<Register
|
||||
on:switchView={handleSwitchView}
|
||||
/>
|
||||
{:else if currentView === 'passwordReset'}
|
||||
<PasswordReset
|
||||
on:switchView={handleSwitchView}
|
||||
/>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
min-width: 280px;
|
||||
max-height: 90%;
|
||||
overflow-y: auto;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
box-sizing: border-box;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.user-profile {
|
||||
background-color: rgba(42, 50, 66, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
color: white;
|
||||
text-align: center;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.user-profile h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 24px;
|
||||
padding: 8px;
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.profile-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #3182CE;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background-color: #2B6CB0;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
260
games/voxel-lava/src/lib/components/auth/Login.svelte
Normal file
260
games/voxel-lava/src/lib/components/auth/Login.svelte
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
<script lang="ts">
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Formular-Zustände
|
||||
let email = '';
|
||||
let password = '';
|
||||
let isLoading = false;
|
||||
let errorMessage = '';
|
||||
let successMessage = '';
|
||||
|
||||
// Formular absenden
|
||||
async function handleSubmit() {
|
||||
if (!email || !password) {
|
||||
errorMessage = 'Bitte fülle alle Felder aus.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
const success = await AuthService.login(email, password);
|
||||
|
||||
if (success) {
|
||||
successMessage = 'Anmeldung erfolgreich!';
|
||||
dispatch('login');
|
||||
// Formular zurücksetzen
|
||||
email = '';
|
||||
password = '';
|
||||
} else {
|
||||
errorMessage = 'Anmeldung fehlgeschlagen. Bitte überprüfe deine Eingaben.';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
|
||||
console.error('Login error:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Zur Registrierung wechseln
|
||||
function switchToRegister() {
|
||||
dispatch('switchView', 'register');
|
||||
}
|
||||
|
||||
// Zum Passwort-Reset wechseln
|
||||
function switchToPasswordReset() {
|
||||
dispatch('switchView', 'passwordReset');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-form-container">
|
||||
<h2>Anmelden</h2>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
placeholder="deine@email.de"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
placeholder="Dein Passwort"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="auth-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Wird angemeldet...' : 'Anmelden'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<button
|
||||
type="button"
|
||||
class="text-button"
|
||||
on:click={switchToPasswordReset}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Passwort vergessen?
|
||||
</button>
|
||||
|
||||
<div class="register-link">
|
||||
Noch kein Konto?
|
||||
<button
|
||||
type="button"
|
||||
class="text-button"
|
||||
on:click={switchToRegister}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Registrieren
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-form-container {
|
||||
background-color: rgba(42, 50, 66, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: white;
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #3182CE;
|
||||
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #3182CE;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background-color: #2B6CB0;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
background-color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
color: #FCA5A5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6EE7B7;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #63B3ED;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.text-button:hover {
|
||||
color: #90CDF4;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.register-link {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
218
games/voxel-lava/src/lib/components/auth/PasswordReset.svelte
Normal file
218
games/voxel-lava/src/lib/components/auth/PasswordReset.svelte
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
<script lang="ts">
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Formular-Zustände
|
||||
let email = '';
|
||||
let isLoading = false;
|
||||
let errorMessage = '';
|
||||
let successMessage = '';
|
||||
|
||||
// Formular absenden
|
||||
async function handleSubmit() {
|
||||
if (!email) {
|
||||
errorMessage = 'Bitte gib deine E-Mail-Adresse ein.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
const success = await AuthService.resetPassword(email);
|
||||
|
||||
if (success) {
|
||||
successMessage = 'Eine E-Mail zum Zurücksetzen deines Passworts wurde gesendet. Bitte überprüfe deinen Posteingang.';
|
||||
// Formular zurücksetzen
|
||||
email = '';
|
||||
} else {
|
||||
errorMessage = 'Fehler beim Senden der E-Mail. Bitte versuche es später erneut.';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
|
||||
console.error('Password reset error:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Zum Login wechseln
|
||||
function switchToLogin() {
|
||||
dispatch('switchView', 'login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-form-container">
|
||||
<h2>Passwort zurücksetzen</h2>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
placeholder="deine@email.de"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="auth-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Wird gesendet...' : 'Passwort zurücksetzen'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<button
|
||||
type="button"
|
||||
class="text-button"
|
||||
on:click={switchToLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Zurück zum Login
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-form-container {
|
||||
background-color: rgba(42, 50, 66, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: white;
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #3182CE;
|
||||
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #3182CE;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background-color: #2B6CB0;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
background-color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
color: #FCA5A5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6EE7B7;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #63B3ED;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.text-button:hover {
|
||||
color: #90CDF4;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
273
games/voxel-lava/src/lib/components/auth/Register.svelte
Normal file
273
games/voxel-lava/src/lib/components/auth/Register.svelte
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
<script lang="ts">
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
|
||||
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Formular-Zustände
|
||||
let email = '';
|
||||
let password = '';
|
||||
let confirmPassword = '';
|
||||
let isLoading = false;
|
||||
let errorMessage = '';
|
||||
let successMessage = '';
|
||||
|
||||
// Formular absenden
|
||||
async function handleSubmit() {
|
||||
// Validierung
|
||||
if (!email || !password || !confirmPassword) {
|
||||
errorMessage = 'Bitte fülle alle Felder aus.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password !== confirmPassword) {
|
||||
errorMessage = 'Die Passwörter stimmen nicht überein.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
errorMessage = 'Das Passwort muss mindestens 6 Zeichen lang sein.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
const success = await AuthService.register(email, password);
|
||||
|
||||
if (success) {
|
||||
successMessage = 'Registrierung erfolgreich! Bitte überprüfe deine E-Mails, um dein Konto zu bestätigen.';
|
||||
// Formular zurücksetzen
|
||||
email = '';
|
||||
password = '';
|
||||
confirmPassword = '';
|
||||
|
||||
// Nach kurzer Verzögerung zum Login wechseln
|
||||
setTimeout(() => {
|
||||
dispatch('switchView', 'login');
|
||||
}, 3000);
|
||||
} else {
|
||||
errorMessage = 'Registrierung fehlgeschlagen. Bitte versuche es mit einer anderen E-Mail-Adresse.';
|
||||
}
|
||||
} catch (error) {
|
||||
errorMessage = 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
|
||||
console.error('Registration error:', error);
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Zum Login wechseln
|
||||
function switchToLogin() {
|
||||
dispatch('switchView', 'login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="auth-form-container">
|
||||
<h2>Registrieren</h2>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="email">E-Mail</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
bind:value={email}
|
||||
placeholder="deine@email.de"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
bind:value={password}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmPassword">Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
bind:value={confirmPassword}
|
||||
placeholder="Passwort wiederholen"
|
||||
disabled={isLoading}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="auth-button"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Wird registriert...' : 'Registrieren'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-links">
|
||||
<div class="login-link">
|
||||
Bereits ein Konto?
|
||||
<button
|
||||
type="button"
|
||||
class="text-button"
|
||||
on:click={switchToLogin}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Anmelden
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.auth-form-container {
|
||||
background-color: rgba(42, 50, 66, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
min-width: 280px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: white;
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
color: white;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #3182CE;
|
||||
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.auth-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #3182CE;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
box-sizing: border-box;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.auth-button:hover {
|
||||
background-color: #2B6CB0;
|
||||
}
|
||||
|
||||
.auth-button:disabled {
|
||||
background-color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
color: #FCA5A5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6EE7B7;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.auth-links {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.text-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #63B3ED;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.text-button:hover {
|
||||
color: #90CDF4;
|
||||
}
|
||||
|
||||
.text-button:disabled {
|
||||
color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.login-link {
|
||||
color: white;
|
||||
font-size: 0.9rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
</style>
|
||||
516
games/voxel-lava/src/lib/components/auth/UserProfile.svelte
Normal file
516
games/voxel-lava/src/lib/components/auth/UserProfile.svelte
Normal file
|
|
@ -0,0 +1,516 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
import { LevelService } from '../../services/LevelService';
|
||||
import type { LevelMetadata } from '../../types/level.types';
|
||||
|
||||
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Benutzer-Daten
|
||||
let user: any = null;
|
||||
let userLevels: LevelMetadata[] = [];
|
||||
let isLoading = true;
|
||||
let errorMessage = '';
|
||||
|
||||
// Passwort-Änderung
|
||||
let showPasswordChange = false;
|
||||
let newPassword = '';
|
||||
let confirmNewPassword = '';
|
||||
let passwordChangeError = '';
|
||||
let passwordChangeSuccess = '';
|
||||
let isPasswordChanging = false;
|
||||
|
||||
// Beim Mounten Benutzerdaten laden
|
||||
onMount(async () => {
|
||||
await loadUserData();
|
||||
});
|
||||
|
||||
// Benutzerdaten laden
|
||||
async function loadUserData() {
|
||||
try {
|
||||
isLoading = true;
|
||||
errorMessage = '';
|
||||
|
||||
// Aktuellen Benutzer abrufen
|
||||
user = await AuthService.getCurrentUser();
|
||||
|
||||
if (user) {
|
||||
// Levels des Benutzers laden
|
||||
userLevels = await LevelService.getUserLevels();
|
||||
} else {
|
||||
errorMessage = 'Du bist nicht angemeldet.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading user data:', error);
|
||||
errorMessage = 'Fehler beim Laden der Benutzerdaten.';
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Passwort ändern
|
||||
async function handlePasswordChange() {
|
||||
// Validierung
|
||||
if (!newPassword || !confirmNewPassword) {
|
||||
passwordChangeError = 'Bitte fülle alle Felder aus.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword !== confirmNewPassword) {
|
||||
passwordChangeError = 'Die Passwörter stimmen nicht überein.';
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
passwordChangeError = 'Das Passwort muss mindestens 6 Zeichen lang sein.';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isPasswordChanging = true;
|
||||
passwordChangeError = '';
|
||||
|
||||
const success = await AuthService.updatePassword(newPassword);
|
||||
|
||||
if (success) {
|
||||
passwordChangeSuccess = 'Passwort erfolgreich geändert.';
|
||||
newPassword = '';
|
||||
confirmNewPassword = '';
|
||||
// Nach kurzer Verzögerung Passwort-Änderung ausblenden
|
||||
setTimeout(() => {
|
||||
showPasswordChange = false;
|
||||
passwordChangeSuccess = '';
|
||||
}, 3000);
|
||||
} else {
|
||||
passwordChangeError = 'Fehler beim Ändern des Passworts.';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Password change error:', error);
|
||||
passwordChangeError = 'Ein Fehler ist aufgetreten. Bitte versuche es später erneut.';
|
||||
} finally {
|
||||
isPasswordChanging = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Abmelden
|
||||
async function handleLogout() {
|
||||
const success = await AuthService.logout();
|
||||
if (success) {
|
||||
dispatch('logout');
|
||||
}
|
||||
}
|
||||
|
||||
// Level bearbeiten
|
||||
function editLevel(levelId: string) {
|
||||
dispatch('editLevel', levelId);
|
||||
}
|
||||
|
||||
// Level löschen
|
||||
async function deleteLevel(levelId: string) {
|
||||
if (confirm('Möchtest du dieses Level wirklich löschen?')) {
|
||||
const success = await LevelService.deleteLevel(levelId);
|
||||
if (success) {
|
||||
// Levels neu laden
|
||||
userLevels = await LevelService.getUserLevels();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Level öffentlich/privat umschalten
|
||||
function toggleLevelVisibility(level: LevelMetadata) {
|
||||
// Hier würde die Logik zum Umschalten der Sichtbarkeit implementiert werden
|
||||
// Da wir keinen direkten Zugriff auf die Funktion haben, müsste diese im LevelService ergänzt werden
|
||||
console.log('Toggle visibility for level:', level.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="profile-container">
|
||||
<h2>Dein Profil</h2>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if isLoading}
|
||||
<div class="loading">Daten werden geladen...</div>
|
||||
{:else if user}
|
||||
<div class="user-info">
|
||||
<div class="user-email">
|
||||
<span class="label">E-Mail:</span>
|
||||
<span class="value">{user.email}</span>
|
||||
</div>
|
||||
|
||||
<div class="account-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="action-button secondary"
|
||||
on:click={() => showPasswordChange = !showPasswordChange}
|
||||
>
|
||||
{showPasswordChange ? 'Abbrechen' : 'Passwort ändern'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
on:click={handleLogout}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showPasswordChange}
|
||||
<div class="password-change-form">
|
||||
<h3>Passwort ändern</h3>
|
||||
|
||||
{#if passwordChangeError}
|
||||
<div class="error-message">
|
||||
{passwordChangeError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if passwordChangeSuccess}
|
||||
<div class="success-message">
|
||||
{passwordChangeSuccess}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="newPassword">Neues Passwort</label>
|
||||
<input
|
||||
type="password"
|
||||
id="newPassword"
|
||||
bind:value={newPassword}
|
||||
placeholder="Mindestens 6 Zeichen"
|
||||
disabled={isPasswordChanging}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="confirmNewPassword">Passwort bestätigen</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmNewPassword"
|
||||
bind:value={confirmNewPassword}
|
||||
placeholder="Passwort wiederholen"
|
||||
disabled={isPasswordChanging}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="action-button"
|
||||
on:click={handlePasswordChange}
|
||||
disabled={isPasswordChanging}
|
||||
>
|
||||
{isPasswordChanging ? 'Wird geändert...' : 'Passwort ändern'}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="user-levels">
|
||||
<h3>Deine Levels</h3>
|
||||
|
||||
{#if userLevels.length === 0}
|
||||
<div class="no-levels">
|
||||
Du hast noch keine Levels erstellt.
|
||||
</div>
|
||||
{:else}
|
||||
<div class="levels-list">
|
||||
{#each userLevels as level}
|
||||
<div class="level-card">
|
||||
<div class="level-info">
|
||||
<h4 class="level-name">{level.name}</h4>
|
||||
<p class="level-description">{level.description || 'Keine Beschreibung'}</p>
|
||||
<div class="level-stats">
|
||||
<span class="stat">
|
||||
<i class="icon">👁️</i> {level.playCount}
|
||||
</span>
|
||||
<span class="stat">
|
||||
<i class="icon">❤️</i> {level.likesCount}
|
||||
</span>
|
||||
<span class="stat">
|
||||
<i class="icon">🏷️</i> {level.difficulty || 'Normal'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="level-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
on:click={() => editLevel(level.id)}
|
||||
title="Level bearbeiten"
|
||||
>
|
||||
✏️
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button"
|
||||
on:click={() => toggleLevelVisibility(level)}
|
||||
title={level.isPublic ? 'Auf privat setzen' : 'Öffentlich machen'}
|
||||
>
|
||||
{level.isPublic ? '🔒' : '🌐'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="icon-button delete"
|
||||
on:click={() => deleteLevel(level.id)}
|
||||
title="Level löschen"
|
||||
>
|
||||
🗑️
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="not-logged-in">
|
||||
Du bist nicht angemeldet. Bitte melde dich an, um dein Profil zu sehen.
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.profile-container {
|
||||
background-color: rgba(42, 50, 66, 0.9);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 800px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
color: white;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 24px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 1.2rem;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
.user-info {
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
padding: 16px;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
|
||||
.user-email {
|
||||
margin-bottom: 16px;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: bold;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.value {
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.account-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
padding: 10px 16px;
|
||||
background-color: #3182CE;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.action-button:hover {
|
||||
background-color: #2B6CB0;
|
||||
}
|
||||
|
||||
.action-button:disabled {
|
||||
background-color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
background-color: #4A5568;
|
||||
}
|
||||
|
||||
.action-button.secondary:hover {
|
||||
background-color: #2D3748;
|
||||
}
|
||||
|
||||
.password-change-form {
|
||||
margin-top: 24px;
|
||||
padding: 16px;
|
||||
background-color: rgba(49, 130, 206, 0.1);
|
||||
border-radius: 6px;
|
||||
border-left: 3px solid #3182CE;
|
||||
}
|
||||
|
||||
.password-change-form h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 16px;
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
border-color: #3182CE;
|
||||
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
color: #FCA5A5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6EE7B7;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.loading, .no-levels, .not-logged-in {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
border-radius: 6px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.levels-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.level-card {
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
border-radius: 6px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.level-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.level-name {
|
||||
margin-top: 0;
|
||||
margin-bottom: 8px;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.level-description {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.9rem;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.level-stats {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
font-size: 0.9rem;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
font-style: normal;
|
||||
}
|
||||
|
||||
.level-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.icon-button.delete:hover {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
}
|
||||
</style>
|
||||
100
games/voxel-lava/src/lib/components/game-ui/CircleButton.svelte
Normal file
100
games/voxel-lava/src/lib/components/game-ui/CircleButton.svelte
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
<script lang="ts">
|
||||
// Props für den Button
|
||||
export let icon: string; // Icon-Name (z.B. 'play', 'info')
|
||||
export let onClick: () => void; // Callback-Funktion für Klick-Event
|
||||
export let color: string = '#4A5568'; // Standardfarbe
|
||||
export let size: number = 50; // Größe in Pixeln
|
||||
export let tooltip: string = ''; // Tooltip-Text (optional)
|
||||
export let visible: boolean = true; // Sichtbarkeit des Buttons
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.circle-button {
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
border-radius: 50%;
|
||||
background-color: var(--color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.2s, background-color 0.2s;
|
||||
position: relative;
|
||||
border: none;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.circle-button:hover {
|
||||
transform: translateY(-2px);
|
||||
filter: brightness(1.1);
|
||||
}
|
||||
|
||||
.circle-button:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 50%;
|
||||
height: 50%;
|
||||
color: white;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.tooltip {
|
||||
position: absolute;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: white;
|
||||
padding: 5px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.circle-button:hover .tooltip {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* Tooltip-Positionen je nach Icon */
|
||||
.tooltip.play {
|
||||
bottom: calc(100% + 10px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.tooltip.info {
|
||||
top: calc(100% + 10px);
|
||||
right: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
{#if visible}
|
||||
<button
|
||||
class="circle-button"
|
||||
on:click={onClick}
|
||||
style="--size: {size}px; --color: {color};"
|
||||
aria-label={tooltip}
|
||||
>
|
||||
{#if icon === 'play'}
|
||||
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 5v14l11-7z" />
|
||||
</svg>
|
||||
{:else if icon === 'stop'}
|
||||
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M6 6h12v12H6z" />
|
||||
</svg>
|
||||
{:else if icon === 'info'}
|
||||
<svg class="icon" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z" />
|
||||
</svg>
|
||||
{/if}
|
||||
|
||||
{#if tooltip}
|
||||
<span class="tooltip {icon}">{tooltip}</span>
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
452
games/voxel-lava/src/lib/components/level/SaveLevelModal.svelte
Normal file
452
games/voxel-lava/src/lib/components/level/SaveLevelModal.svelte
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
<script lang="ts">
|
||||
import { createEventDispatcher, onMount } from 'svelte';
|
||||
import { LevelService } from '../../services/LevelService';
|
||||
import { AuthService } from '../../services/AuthService';
|
||||
|
||||
// Event-Dispatcher für Kommunikation mit übergeordneten Komponenten
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
// Props
|
||||
export let isOpen = false;
|
||||
export let blocks: { x: number, y: number, z: number, type: string, isSpawnPoint: boolean, isGoal: boolean }[] = [];
|
||||
export let spawnPoint: { x: number, y: number, z: number } | null = null;
|
||||
export let worldSize: { width: number, height: number, depth: number } = { width: 20, height: 10, depth: 20 };
|
||||
export let currentLevelId: string | null = null;
|
||||
export let currentLevelName = "Neues Level";
|
||||
|
||||
// Formular-Zustände
|
||||
let levelName = currentLevelName;
|
||||
let levelDescription = "";
|
||||
let isPublic = false;
|
||||
let difficulty = "normal";
|
||||
let tags = "";
|
||||
let isLoading = false;
|
||||
let errorMessage = "";
|
||||
let successMessage = "";
|
||||
let isLoggedIn = false;
|
||||
|
||||
// Beim Öffnen des Modals die Formularfelder aktualisieren
|
||||
$: if (isOpen) {
|
||||
levelName = currentLevelName;
|
||||
checkAuthStatus();
|
||||
}
|
||||
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
async function checkAuthStatus() {
|
||||
const user = await AuthService.getCurrentUser();
|
||||
isLoggedIn = !!user;
|
||||
|
||||
if (!isLoggedIn) {
|
||||
errorMessage = "Du musst angemeldet sein, um ein Level zu speichern.";
|
||||
} else {
|
||||
errorMessage = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Formular absenden
|
||||
async function handleSubmit() {
|
||||
if (!levelName.trim()) {
|
||||
errorMessage = "Bitte gib einen Namen für das Level ein.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isLoggedIn) {
|
||||
errorMessage = "Du musst angemeldet sein, um ein Level zu speichern.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (!spawnPoint) {
|
||||
errorMessage = "Das Level muss einen Spawn-Punkt haben.";
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoading = true;
|
||||
errorMessage = "";
|
||||
|
||||
// Die Blöcke sind bereits im richtigen Format, da sie vom BlockManager.getSerializableBlocks() kommen
|
||||
console.log('Blöcke zum Speichern:', blocks);
|
||||
console.log('Anzahl der Blöcke:', blocks.length);
|
||||
|
||||
// Detaillierte Debugging-Ausgaben
|
||||
if (blocks.length > 0) {
|
||||
console.log('Erster Block:', blocks[0]);
|
||||
} else {
|
||||
console.warn('Keine Blöcke zum Speichern gefunden!');
|
||||
}
|
||||
|
||||
if (blocks.length === 0) {
|
||||
errorMessage = "Keine Blöcke gefunden. Das Level kann nicht gespeichert werden.";
|
||||
isLoading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Level-Objekt erstellen
|
||||
const level = {
|
||||
id: currentLevelId,
|
||||
name: levelName.trim(),
|
||||
description: levelDescription.trim(),
|
||||
blocks: blocks,
|
||||
spawnPoint: spawnPoint,
|
||||
worldSize: worldSize,
|
||||
isPublic: isPublic,
|
||||
difficulty: difficulty,
|
||||
tags: tags.split(',').map(tag => tag.trim()).filter(tag => tag)
|
||||
};
|
||||
|
||||
// Level speichern
|
||||
// @ts-ignore - Typprobleme ignorieren, da wir die Struktur kennen
|
||||
const levelId = await LevelService.saveLevel(level);
|
||||
|
||||
if (levelId) {
|
||||
successMessage = `Level "${levelName}" erfolgreich gespeichert!`;
|
||||
currentLevelName = levelName;
|
||||
currentLevelId = levelId;
|
||||
|
||||
// Event auslösen
|
||||
dispatch('save', { levelId, levelName });
|
||||
|
||||
// Modal nach kurzer Verzögerung schließen
|
||||
setTimeout(() => {
|
||||
closeModal();
|
||||
}, 1500);
|
||||
} else {
|
||||
errorMessage = "Fehler beim Speichern des Levels.";
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error saving level:', error);
|
||||
errorMessage = "Ein Fehler ist aufgetreten. Bitte versuche es später erneut.";
|
||||
} finally {
|
||||
isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Modal schließen
|
||||
function closeModal() {
|
||||
isOpen = false;
|
||||
errorMessage = "";
|
||||
successMessage = "";
|
||||
dispatch('close');
|
||||
}
|
||||
|
||||
// Klick außerhalb des Modals abfangen
|
||||
function handleBackdropClick(event: MouseEvent) {
|
||||
if (event.target === event.currentTarget) {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
|
||||
// Tastendruck abfangen (Escape zum Schließen)
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if (event.key === 'Escape') {
|
||||
closeModal();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window on:keydown={handleKeydown} />
|
||||
|
||||
{#if isOpen}
|
||||
<div
|
||||
class="modal-backdrop"
|
||||
on:click={handleBackdropClick}
|
||||
on:keydown={(e) => e.key === 'Enter' && handleSubmit()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="save-level-title"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div class="modal-content">
|
||||
<button
|
||||
class="close-button"
|
||||
on:click={closeModal}
|
||||
aria-label="Schließen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
|
||||
<h2 id="save-level-title">Level speichern</h2>
|
||||
|
||||
{#if errorMessage}
|
||||
<div class="error-message">
|
||||
{errorMessage}
|
||||
|
||||
{#if !isLoggedIn}
|
||||
<div class="login-prompt">
|
||||
<p>Bitte melde dich an, um dein Level zu speichern.</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if successMessage}
|
||||
<div class="success-message">
|
||||
{successMessage}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<form on:submit|preventDefault={handleSubmit}>
|
||||
<div class="form-group">
|
||||
<label for="levelName">Level-Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
id="levelName"
|
||||
bind:value={levelName}
|
||||
placeholder="Gib deinem Level einen Namen"
|
||||
disabled={isLoading || !isLoggedIn}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="levelDescription">Beschreibung</label>
|
||||
<textarea
|
||||
id="levelDescription"
|
||||
bind:value={levelDescription}
|
||||
placeholder="Beschreibe dein Level (optional)"
|
||||
rows="3"
|
||||
disabled={isLoading || !isLoggedIn}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="difficulty">Schwierigkeit</label>
|
||||
<select
|
||||
id="difficulty"
|
||||
bind:value={difficulty}
|
||||
disabled={isLoading || !isLoggedIn}
|
||||
>
|
||||
<option value="easy">Leicht</option>
|
||||
<option value="normal">Normal</option>
|
||||
<option value="hard">Schwer</option>
|
||||
<option value="expert">Experte</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group checkbox-group">
|
||||
<label class="checkbox-label">
|
||||
<input
|
||||
type="checkbox"
|
||||
bind:checked={isPublic}
|
||||
disabled={isLoading || !isLoggedIn}
|
||||
/>
|
||||
<span>Öffentlich teilen</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="tags">Tags</label>
|
||||
<input
|
||||
type="text"
|
||||
id="tags"
|
||||
bind:value={tags}
|
||||
placeholder="z.B. parkour, puzzle, speedrun (durch Kommas getrennt)"
|
||||
disabled={isLoading || !isLoggedIn}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
class="save-button"
|
||||
disabled={isLoading || !isLoggedIn}
|
||||
>
|
||||
{isLoading ? 'Wird gespeichert...' : 'Level speichern'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background-color: rgba(42, 50, 66, 0.95);
|
||||
border-radius: 8px;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
min-width: 280px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
color: white;
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
z-index: 10;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 50%;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 24px;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 16px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
.form-row .form-group {
|
||||
flex: 1;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
input, textarea, select {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background-color: rgba(30, 36, 48, 0.8);
|
||||
color: white;
|
||||
font-size: 1rem;
|
||||
outline: none;
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
input:focus, textarea:focus, select:focus {
|
||||
border-color: #3182CE;
|
||||
box-shadow: 0 0 0 2px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
input::placeholder, textarea::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
textarea {
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.checkbox-label input {
|
||||
width: auto;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
width: 100%;
|
||||
padding: 12px;
|
||||
background-color: #3182CE;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.save-button:hover {
|
||||
background-color: #2B6CB0;
|
||||
}
|
||||
|
||||
.save-button:disabled {
|
||||
background-color: #64748B;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: rgba(220, 38, 38, 0.2);
|
||||
color: #FCA5A5;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
background-color: rgba(16, 185, 129, 0.2);
|
||||
color: #6EE7B7;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 16px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.login-prompt {
|
||||
margin-top: 8px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
9
games/voxel-lava/src/lib/game/levels.js
Normal file
9
games/voxel-lava/src/lib/game/levels.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
// Beispielhafte Leveldaten (können auch in JSON ausgelagert werden)
|
||||
|
||||
export const LEVELS = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tutorial',
|
||||
data: [/* ... */]
|
||||
}
|
||||
];
|
||||
12
games/voxel-lava/src/lib/game/physics.js
Normal file
12
games/voxel-lava/src/lib/game/physics.js
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
// Physik- und Kollisionslogik für das Voxel-Spiel
|
||||
|
||||
/**
|
||||
* Überprüft Kollisionen zwischen dem Spieler und Voxeln
|
||||
* @param {Object} player - Das Spielerobjekt mit Position und Größe
|
||||
* @param {Object} voxels - Die Voxel-Daten der Spielwelt
|
||||
* @returns {boolean} - True wenn eine Kollision erkannt wurde
|
||||
*/
|
||||
export function checkCollision(player, voxels) {
|
||||
// ...
|
||||
return false; // Standardmäßig keine Kollision zurückgeben
|
||||
}
|
||||
13
games/voxel-lava/src/lib/game/playerControls.js
Normal file
13
games/voxel-lava/src/lib/game/playerControls.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// Spielersteuerung (Keyboard, Maus, Touch)
|
||||
// Hier werden später die Steuerungsfunktionen implementiert.
|
||||
|
||||
/**
|
||||
* Richtet die Steuerung für den Spieler ein
|
||||
* @param {HTMLCanvasElement} canvas - Das Canvas-Element für die Spielanzeige
|
||||
* @param {Object} player - Das Spielerobjekt, das gesteuert werden soll
|
||||
* @returns {Object} - Steuerungsobjekt mit Methoden zum Aktualisieren und Entfernen der Steuerung
|
||||
*/
|
||||
export function setupPlayerControls(canvas, player) {
|
||||
// ...
|
||||
return {}; // Steuerungsobjekt zurückgeben
|
||||
}
|
||||
15
games/voxel-lava/src/lib/game/voxelUtils.js
Normal file
15
games/voxel-lava/src/lib/game/voxelUtils.js
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Hilfsfunktionen für Voxel-Logik
|
||||
// Hier kannst du z.B. Methoden für Blockplatzierung, Nachbarschaftsprüfung etc. einfügen.
|
||||
|
||||
/**
|
||||
* Platziert einen Voxel an der angegebenen Position
|
||||
* @param {number} x - X-Koordinate des Voxels
|
||||
* @param {number} y - Y-Koordinate des Voxels
|
||||
* @param {number} z - Z-Koordinate des Voxels
|
||||
* @param {string} type - Typ des Voxels (z.B. 'grass', 'stone', etc.)
|
||||
* @returns {Object} - Das platzierte Voxel-Objekt
|
||||
*/
|
||||
export function placeVoxel(x, y, z, type) {
|
||||
// ...
|
||||
return { x, y, z, type }; // Voxel-Objekt zurückgeben
|
||||
}
|
||||
1
games/voxel-lava/src/lib/index.ts
Normal file
1
games/voxel-lava/src/lib/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// place files you want to import through the `$lib` alias in this folder.
|
||||
14
games/voxel-lava/src/lib/pocketbase.ts
Normal file
14
games/voxel-lava/src/lib/pocketbase.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import PocketBase from 'pocketbase';
|
||||
|
||||
// PocketBase Instanz mit deiner Domain
|
||||
export const pb = new PocketBase('https://pb.voxelava.com');
|
||||
|
||||
// Auto-refresh für Auth Token
|
||||
pb.authStore.onChange(() => {
|
||||
// Token wird automatisch erneuert
|
||||
});
|
||||
|
||||
// Optional: SSR Support für SvelteKit
|
||||
export function createPocketBase() {
|
||||
return new PocketBase('https://pb.voxelava.com');
|
||||
}
|
||||
166
games/voxel-lava/src/lib/services/AuthService.ts
Normal file
166
games/voxel-lava/src/lib/services/AuthService.ts
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
import { pb } from '../pocketbase';
|
||||
|
||||
/**
|
||||
* Service zur Verwaltung der Benutzerauthentifizierung mit PocketBase
|
||||
*/
|
||||
export class AuthService {
|
||||
/**
|
||||
* Registriert einen neuen Benutzer
|
||||
* @param email E-Mail-Adresse des Benutzers
|
||||
* @param password Passwort des Benutzers
|
||||
* @param name Name des Benutzers (optional)
|
||||
* @returns true, wenn die Registrierung erfolgreich war, sonst false
|
||||
*/
|
||||
static async register(email: string, password: string, name?: string): Promise<boolean> {
|
||||
try {
|
||||
const data = {
|
||||
email,
|
||||
password,
|
||||
passwordConfirm: password,
|
||||
name: name || email.split('@')[0],
|
||||
emailVisibility: true
|
||||
};
|
||||
|
||||
const record = await pb.collection('users').create(data);
|
||||
|
||||
// Automatisch anmelden nach erfolgreicher Registrierung
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Registrierung:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet einen Benutzer an
|
||||
* @param email E-Mail-Adresse des Benutzers
|
||||
* @param password Passwort des Benutzers
|
||||
* @returns true, wenn die Anmeldung erfolgreich war, sonst false
|
||||
*/
|
||||
static async login(email: string, password: string): Promise<boolean> {
|
||||
try {
|
||||
const authData = await pb.collection('users').authWithPassword(email, password);
|
||||
return !!authData.token;
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Anmeldung:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Meldet den aktuellen Benutzer ab
|
||||
* @returns true, wenn die Abmeldung erfolgreich war, sonst false
|
||||
*/
|
||||
static async logout(): Promise<boolean> {
|
||||
try {
|
||||
pb.authStore.clear();
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Abmeldung:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob ein Benutzer angemeldet ist
|
||||
* @returns Der angemeldete Benutzer oder null, wenn kein Benutzer angemeldet ist
|
||||
*/
|
||||
static getCurrentUser() {
|
||||
try {
|
||||
if (pb.authStore.isValid) {
|
||||
return pb.authStore.model;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Abrufen des aktuellen Benutzers:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sendet eine E-Mail zum Zurücksetzen des Passworts
|
||||
* @param email E-Mail-Adresse des Benutzers
|
||||
* @returns true, wenn die E-Mail erfolgreich gesendet wurde, sonst false
|
||||
*/
|
||||
static async resetPassword(email: string): Promise<boolean> {
|
||||
try {
|
||||
await pb.collection('users').requestPasswordReset(email);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Zurücksetzen des Passworts:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert das Passwort des aktuellen Benutzers
|
||||
* @param newPassword Das neue Passwort
|
||||
* @returns true, wenn das Passwort erfolgreich aktualisiert wurde, sonst false
|
||||
*/
|
||||
static async updatePassword(newPassword: string): Promise<boolean> {
|
||||
try {
|
||||
const user = pb.authStore.model;
|
||||
if (!user) {
|
||||
throw new Error('Kein Benutzer angemeldet');
|
||||
}
|
||||
|
||||
await pb.collection('users').update(user.id, {
|
||||
password: newPassword,
|
||||
passwordConfirm: newPassword
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Passworts:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktualisiert automatisch das Auth-Token
|
||||
* @returns true, wenn das Token erfolgreich aktualisiert wurde, sonst false
|
||||
*/
|
||||
static async refreshAuth(): Promise<boolean> {
|
||||
try {
|
||||
if (pb.authStore.isValid) {
|
||||
await pb.collection('users').authRefresh();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aktualisieren des Auth-Tokens:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Überprüft, ob die aktuelle Sitzung gültig ist
|
||||
* @returns true, wenn die Sitzung gültig ist, sonst false
|
||||
*/
|
||||
static isAuthenticated(): boolean {
|
||||
return pb.authStore.isValid;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die User-ID des aktuell angemeldeten Benutzers zurück
|
||||
* @returns Die User-ID oder null
|
||||
*/
|
||||
static getUserId(): string | null {
|
||||
const user = pb.authStore.model;
|
||||
return user?.id || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gibt die E-Mail des aktuell angemeldeten Benutzers zurück
|
||||
* @returns Die E-Mail oder null
|
||||
*/
|
||||
static getUserEmail(): string | null {
|
||||
const user = pb.authStore.model;
|
||||
return user?.email || null;
|
||||
}
|
||||
}
|
||||
|
||||
// Default export für Kompatibilität
|
||||
export default AuthService;
|
||||
498
games/voxel-lava/src/lib/services/LevelService.ts
Normal file
498
games/voxel-lava/src/lib/services/LevelService.ts
Normal file
|
|
@ -0,0 +1,498 @@
|
|||
import { pb } from '../pocketbase';
|
||||
|
||||
// Typdefinitionen
|
||||
interface Block {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
type: string;
|
||||
isSpawnPoint?: boolean;
|
||||
isGoal?: boolean;
|
||||
}
|
||||
|
||||
interface WorldSize {
|
||||
width: number;
|
||||
height: number;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
interface SpawnPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
interface LevelMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
user_id: string | null;
|
||||
created: string | null;
|
||||
updated: string | null;
|
||||
is_public?: boolean | null;
|
||||
play_count: number;
|
||||
likes_count: number;
|
||||
difficulty?: string | undefined;
|
||||
tags?: string[];
|
||||
thumbnail_url?: string | undefined;
|
||||
}
|
||||
|
||||
interface Level extends Partial<LevelMetadata> {
|
||||
id?: string;
|
||||
name: string;
|
||||
blocks: Block[];
|
||||
spawnPoint: SpawnPoint | null;
|
||||
worldSize: WorldSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service zur Verwaltung von Levels in PocketBase
|
||||
*/
|
||||
export class LevelService {
|
||||
/**
|
||||
* Speichert ein Level in der Datenbank
|
||||
* @param level Das zu speichernde Level
|
||||
* @returns Die ID des gespeicherten Levels
|
||||
*/
|
||||
static async saveLevel(level: Level): Promise<string | null> {
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const user = pb.authStore.model;
|
||||
if (!user) {
|
||||
throw new Error('Du musst angemeldet sein, um ein Level zu speichern');
|
||||
}
|
||||
|
||||
// Level-Daten für die Datenbank vorbereiten
|
||||
const levelData = {
|
||||
name: level.name,
|
||||
description: level.description || '',
|
||||
user_id: user.id,
|
||||
voxel_data: this.convertBlocksToVoxelData(level.blocks),
|
||||
spawn_point: level.spawnPoint,
|
||||
world_size: level.worldSize,
|
||||
is_public: level.is_public || false,
|
||||
difficulty: level.difficulty || null,
|
||||
tags: level.tags || [],
|
||||
play_count: level.play_count || 0,
|
||||
likes_count: level.likes_count || 0,
|
||||
thumbnail_url: level.thumbnail_url || null
|
||||
};
|
||||
|
||||
// Prüfen, ob das Level bereits existiert
|
||||
if (level.id) {
|
||||
// Level aktualisieren
|
||||
const record = await pb.collection('levels').update(level.id, levelData);
|
||||
return record.id;
|
||||
} else {
|
||||
// Neues Level erstellen
|
||||
const record = await pb.collection('levels').create(levelData);
|
||||
return record.id;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Speichern des Levels:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt ein Level aus der Datenbank
|
||||
* @param levelId Die ID des zu ladenden Levels
|
||||
* @returns Das geladene Level oder null, wenn es nicht gefunden wurde
|
||||
*/
|
||||
static async loadLevel(levelId: string): Promise<Level | null> {
|
||||
try {
|
||||
const record = await pb.collection('levels').getOne(levelId);
|
||||
|
||||
if (!record) return null;
|
||||
|
||||
// Level-Daten konvertieren
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description || '',
|
||||
blocks: this.convertVoxelDataToBlocks(record.voxel_data),
|
||||
spawnPoint: record.spawn_point,
|
||||
worldSize: record.world_size,
|
||||
is_public: record.is_public || false,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
user_id: record.user_id,
|
||||
play_count: record.play_count || 0,
|
||||
likes_count: record.likes_count || 0,
|
||||
difficulty: record.difficulty || undefined,
|
||||
tags: record.tags || [],
|
||||
thumbnail_url: record.thumbnail_url || undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden des Levels:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle öffentlichen Levels
|
||||
* @param page Seitennummer (startet bei 1)
|
||||
* @param perPage Anzahl der Einträge pro Seite
|
||||
* @returns Liste der Level-Metadaten
|
||||
*/
|
||||
static async getPublicLevels(page = 1, perPage = 20): Promise<LevelMetadata[]> {
|
||||
try {
|
||||
const records = await pb.collection('levels').getList(page, perPage, {
|
||||
filter: 'is_public = true',
|
||||
sort: '-created',
|
||||
});
|
||||
|
||||
return records.items.map(record => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description || '',
|
||||
user_id: record.user_id,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
play_count: record.play_count || 0,
|
||||
likes_count: record.likes_count || 0,
|
||||
difficulty: record.difficulty || undefined,
|
||||
tags: record.tags || [],
|
||||
thumbnail_url: record.thumbnail_url || undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der öffentlichen Levels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt alle Levels des aktuellen Benutzers
|
||||
* @returns Liste der Level-Metadaten
|
||||
*/
|
||||
static async getUserLevels(): Promise<LevelMetadata[]> {
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const user = pb.authStore.model;
|
||||
if (!user) {
|
||||
throw new Error('Du musst angemeldet sein, um deine Levels zu sehen');
|
||||
}
|
||||
|
||||
const records = await pb.collection('levels').getFullList({
|
||||
filter: `user_id = "${user.id}"`,
|
||||
sort: '-updated',
|
||||
});
|
||||
|
||||
return records.map(record => ({
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description || '',
|
||||
user_id: user.id,
|
||||
created: record.created,
|
||||
updated: record.updated,
|
||||
is_public: record.is_public,
|
||||
play_count: record.play_count || 0,
|
||||
likes_count: record.likes_count || 0,
|
||||
difficulty: record.difficulty || undefined,
|
||||
tags: record.tags || [],
|
||||
thumbnail_url: record.thumbnail_url || undefined,
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Benutzer-Levels:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Löscht ein Level aus der Datenbank
|
||||
* @param levelId Die ID des zu löschenden Levels
|
||||
* @returns true, wenn das Level erfolgreich gelöscht wurde, sonst false
|
||||
*/
|
||||
static async deleteLevel(levelId: string): Promise<boolean> {
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const user = pb.authStore.model;
|
||||
if (!user) {
|
||||
throw new Error('Du musst angemeldet sein, um ein Level zu löschen');
|
||||
}
|
||||
|
||||
// Erst prüfen, ob das Level dem User gehört
|
||||
const level = await pb.collection('levels').getOne(levelId);
|
||||
if (level.user_id !== user.id) {
|
||||
throw new Error('Du kannst nur deine eigenen Levels löschen');
|
||||
}
|
||||
|
||||
await pb.collection('levels').delete(levelId);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Löschen des Levels:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt einen "Like" für ein Level
|
||||
* @param levelId Die ID des Levels
|
||||
* @returns true, wenn der Like erfolgreich gesetzt wurde, sonst false
|
||||
*/
|
||||
static async likeLevel(levelId: string): Promise<boolean> {
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const user = pb.authStore.model;
|
||||
if (!user) {
|
||||
throw new Error('Du musst angemeldet sein, um ein Level zu liken');
|
||||
}
|
||||
|
||||
// Prüfen, ob der Benutzer das Level bereits geliked hat
|
||||
const existingLikes = await pb.collection('level_likes').getList(1, 1, {
|
||||
filter: `level_id = "${levelId}" && user_id = "${user.id}"`
|
||||
});
|
||||
|
||||
if (existingLikes.items.length > 0) {
|
||||
// Like entfernen
|
||||
await pb.collection('level_likes').delete(existingLikes.items[0].id);
|
||||
|
||||
// Likes-Count im Level aktualisieren
|
||||
const level = await pb.collection('levels').getOne(levelId);
|
||||
await pb.collection('levels').update(levelId, {
|
||||
likes_count: Math.max(0, (level.likes_count || 0) - 1)
|
||||
});
|
||||
|
||||
return false; // Like wurde entfernt
|
||||
} else {
|
||||
// Like hinzufügen
|
||||
await pb.collection('level_likes').create({
|
||||
level_id: levelId,
|
||||
user_id: user.id
|
||||
});
|
||||
|
||||
// Likes-Count im Level aktualisieren
|
||||
const level = await pb.collection('levels').getOne(levelId);
|
||||
await pb.collection('levels').update(levelId, {
|
||||
likes_count: (level.likes_count || 0) + 1
|
||||
});
|
||||
|
||||
return true; // Like wurde hinzugefügt
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Liken des Levels:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Prüft, ob der aktuelle Benutzer ein Level geliked hat
|
||||
* @param levelId Die ID des Levels
|
||||
* @returns true, wenn der Benutzer das Level geliked hat, sonst false
|
||||
*/
|
||||
static async hasLiked(levelId: string): Promise<boolean> {
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const user = pb.authStore.model;
|
||||
if (!user) return false;
|
||||
|
||||
const likes = await pb.collection('level_likes').getList(1, 1, {
|
||||
filter: `level_id = "${levelId}" && user_id = "${user.id}"`
|
||||
});
|
||||
|
||||
return likes.items.length > 0;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Prüfen des Likes:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Zeichnet einen Spielversuch auf
|
||||
* @param levelId Die ID des Levels
|
||||
* @param completed Ob das Level abgeschlossen wurde
|
||||
* @param completionTime Die Zeit in Sekunden (optional, nur wenn completed = true)
|
||||
* @returns true, wenn der Versuch erfolgreich aufgezeichnet wurde, sonst false
|
||||
*/
|
||||
static async recordPlay(levelId: string, completed: boolean, completionTime?: number): Promise<boolean> {
|
||||
try {
|
||||
// Prüfen, ob der Benutzer angemeldet ist
|
||||
const user = pb.authStore.model;
|
||||
|
||||
if (!user) {
|
||||
// Für nicht angemeldete Benutzer nur den Play-Count erhöhen
|
||||
const level = await pb.collection('levels').getOne(levelId);
|
||||
await pb.collection('levels').update(levelId, {
|
||||
play_count: (level.play_count || 0) + 1
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
// Prüfen, ob bereits ein Spielversuch existiert
|
||||
const existingPlays = await pb.collection('level_plays').getList(1, 1, {
|
||||
filter: `level_id = "${levelId}" && user_id = "${user.id}"`,
|
||||
sort: '-created'
|
||||
});
|
||||
|
||||
if (existingPlays.items.length > 0) {
|
||||
// Vorhandenen Spielversuch aktualisieren
|
||||
const play = existingPlays.items[0];
|
||||
await pb.collection('level_plays').update(play.id, {
|
||||
attempts: (play.attempts || 1) + 1,
|
||||
completed: completed || play.completed,
|
||||
completion_time: completed && completionTime ? completionTime : play.completion_time
|
||||
});
|
||||
} else {
|
||||
// Neuen Spielversuch erstellen
|
||||
await pb.collection('level_plays').create({
|
||||
level_id: levelId,
|
||||
user_id: user.id,
|
||||
completed,
|
||||
completion_time: completed ? completionTime : null,
|
||||
attempts: 1
|
||||
});
|
||||
}
|
||||
|
||||
// Play-Count im Level erhöhen
|
||||
const level = await pb.collection('levels').getOne(levelId);
|
||||
await pb.collection('levels').update(levelId, {
|
||||
play_count: (level.play_count || 0) + 1
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aufzeichnen des Spielversuchs:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt die Bestenliste für ein Level
|
||||
* @param levelId Die ID des Levels
|
||||
* @param limit Maximale Anzahl der Einträge
|
||||
* @returns Liste der besten Completion-Times
|
||||
*/
|
||||
static async getLeaderboard(levelId: string, limit = 10): Promise<any[]> {
|
||||
try {
|
||||
const records = await pb.collection('level_plays').getList(1, limit, {
|
||||
filter: `level_id = "${levelId}" && completed = true && completion_time > 0`,
|
||||
sort: 'completion_time',
|
||||
expand: 'user_id'
|
||||
});
|
||||
|
||||
return records.items.map(record => ({
|
||||
user_id: record.user_id,
|
||||
user_name: record.expand?.user_id?.name || 'Unbekannt',
|
||||
completion_time: record.completion_time,
|
||||
attempts: record.attempts || 1
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Laden der Bestenliste:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert die Blöcke in ein optimiertes JSON-Format für die Datenbank
|
||||
* @param blocks Die zu konvertierenden Blöcke
|
||||
* @returns Die konvertierten Blöcke im optimierten JSON-Format
|
||||
*/
|
||||
private static convertBlocksToVoxelData(blocks: Block[]): any {
|
||||
// Filtere ungültige Blöcke heraus
|
||||
const validBlocks = blocks.filter(block =>
|
||||
block &&
|
||||
block.x !== undefined &&
|
||||
block.y !== undefined &&
|
||||
block.z !== undefined &&
|
||||
block.type
|
||||
);
|
||||
|
||||
if (validBlocks.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Einfaches Format: Position als Key, Block-Daten als Value
|
||||
const voxelData: any = {};
|
||||
|
||||
validBlocks.forEach(block => {
|
||||
const key = `${block.x},${block.y},${block.z}`;
|
||||
voxelData[key] = {
|
||||
type: block.type,
|
||||
isSpawnPoint: block.isSpawnPoint || false,
|
||||
isGoal: block.isGoal || false
|
||||
};
|
||||
});
|
||||
|
||||
return voxelData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertiert das JSON-Format aus der Datenbank in Blöcke
|
||||
* @param voxelData Die zu konvertierenden Daten im JSON-Format
|
||||
* @returns Die konvertierten Blöcke
|
||||
*/
|
||||
private static convertVoxelDataToBlocks(voxelData: any): Block[] {
|
||||
const blocks: Block[] = [];
|
||||
|
||||
if (!voxelData || typeof voxelData !== 'object') {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
// Prüfen, ob es das neue optimierte Format ist
|
||||
if (voxelData.format === 'v2' && voxelData.types) {
|
||||
// Neues Format: Konvertiere zurück zu Blöcken
|
||||
Object.entries(voxelData.types).forEach(([type, positions]: [string, any]) => {
|
||||
if (Array.isArray(positions)) {
|
||||
positions.forEach((pos: number[]) => {
|
||||
if (pos.length >= 3) {
|
||||
blocks.push({
|
||||
x: pos[0],
|
||||
y: pos[1],
|
||||
z: pos[2],
|
||||
type,
|
||||
isSpawnPoint: false,
|
||||
isGoal: false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Spezielle Blöcke hinzufügen
|
||||
if (voxelData.special) {
|
||||
if (voxelData.special.spawn) {
|
||||
const spawn = voxelData.special.spawn;
|
||||
const spawnBlock = blocks.find(b => b.x === spawn.x && b.y === spawn.y && b.z === spawn.z);
|
||||
if (spawnBlock) {
|
||||
spawnBlock.isSpawnPoint = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (voxelData.special.goals && Array.isArray(voxelData.special.goals)) {
|
||||
voxelData.special.goals.forEach((goal: any) => {
|
||||
const goalBlock = blocks.find(b => b.x === goal.x && b.y === goal.y && b.z === goal.z);
|
||||
if (goalBlock) {
|
||||
goalBlock.isGoal = true;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Altes Format: Position als Key
|
||||
Object.entries(voxelData).forEach(([key, value]: [string, any]) => {
|
||||
// Überspringe Metadaten-Keys
|
||||
if (key === 'format' || key === 'types' || key === 'special') {
|
||||
return;
|
||||
}
|
||||
|
||||
const [x, y, z] = key.split(',').map(Number);
|
||||
|
||||
if (!isNaN(x) && !isNaN(y) && !isNaN(z) && value && value.type) {
|
||||
blocks.push({
|
||||
x,
|
||||
y,
|
||||
z,
|
||||
type: value.type,
|
||||
isSpawnPoint: value.isSpawnPoint || false,
|
||||
isGoal: value.isGoal || false
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
}
|
||||
|
||||
// Default export für Kompatibilität
|
||||
export default LevelService;
|
||||
7
games/voxel-lava/src/lib/stores.js
Normal file
7
games/voxel-lava/src/lib/stores.js
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// Zentrale Svelte Stores für Spielstatus, Leveldaten etc.
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export const gameMode = writable('play'); // 'play' oder 'editor'
|
||||
export const currentLevel = writable(null);
|
||||
export const playerStats = writable({ attempts: 0 });
|
||||
export const levels = writable([]); // initial aus levels.js/json
|
||||
83
games/voxel-lava/src/lib/types/level.types.ts
Normal file
83
games/voxel-lava/src/lib/types/level.types.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
/**
|
||||
* Repräsentiert einen einzelnen Block im Voxel-Spiel
|
||||
*/
|
||||
export interface Block {
|
||||
/** X-Koordinate des Blocks */
|
||||
x: number;
|
||||
/** Y-Koordinate des Blocks */
|
||||
y: number;
|
||||
/** Z-Koordinate des Blocks */
|
||||
z: number;
|
||||
/** Typ des Blocks (z.B. 'grass', 'stone', 'lava') */
|
||||
type: string;
|
||||
/** Gibt an, ob dieser Block ein Spawn-Punkt ist */
|
||||
isSpawnPoint?: boolean;
|
||||
/** Gibt an, ob dieser Block ein Ziel ist */
|
||||
isGoal?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repräsentiert die Größe der Spielwelt
|
||||
*/
|
||||
export interface WorldSize {
|
||||
/** Breite der Welt in Blöcken */
|
||||
width: number;
|
||||
/** Höhe der Welt in Blöcken */
|
||||
height: number;
|
||||
/** Tiefe der Welt in Blöcken */
|
||||
depth: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repräsentiert die Position des Spawn-Punkts
|
||||
*/
|
||||
export interface SpawnPoint {
|
||||
/** X-Koordinate des Spawn-Punkts */
|
||||
x: number;
|
||||
/** Y-Koordinate des Spawn-Punkts */
|
||||
y: number;
|
||||
/** Z-Koordinate des Spawn-Punkts */
|
||||
z: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repräsentiert die Metadaten eines Levels (ohne Blockdaten)
|
||||
*/
|
||||
export interface LevelMetadata {
|
||||
/** Eindeutige ID des Levels */
|
||||
id: string;
|
||||
/** Name des Levels */
|
||||
name: string;
|
||||
/** Beschreibung des Levels */
|
||||
description: string;
|
||||
/** ID des Benutzers, der das Level erstellt hat */
|
||||
userId: string;
|
||||
/** Zeitpunkt der Erstellung des Levels */
|
||||
createdAt: string;
|
||||
/** Zeitpunkt der letzten Aktualisierung des Levels */
|
||||
updatedAt: string;
|
||||
/** Gibt an, ob das Level öffentlich ist */
|
||||
isPublic?: boolean;
|
||||
/** Anzahl der Aufrufe des Levels */
|
||||
playCount: number;
|
||||
/** Anzahl der Likes des Levels */
|
||||
likesCount: number;
|
||||
/** Schwierigkeitsgrad des Levels */
|
||||
difficulty?: string;
|
||||
/** Tags zur Kategorisierung des Levels */
|
||||
tags?: string[];
|
||||
/** URL zum Vorschaubild des Levels */
|
||||
thumbnailUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Repräsentiert ein vollständiges Level mit allen Daten
|
||||
*/
|
||||
export interface Level extends LevelMetadata {
|
||||
/** Liste aller Blöcke im Level */
|
||||
blocks: Block[];
|
||||
/** Position des Spawn-Punkts */
|
||||
spawnPoint: SpawnPoint;
|
||||
/** Größe der Spielwelt */
|
||||
worldSize: WorldSize;
|
||||
}
|
||||
17
games/voxel-lava/src/routes/+page.svelte
Normal file
17
games/voxel-lava/src/routes/+page.svelte
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
<script>
|
||||
import GameCanvas from '$lib/components/GameCanvas.svelte';
|
||||
</script>
|
||||
|
||||
<main class="w-full h-screen">
|
||||
<GameCanvas />
|
||||
</main>
|
||||
|
||||
<style>
|
||||
main {
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
BIN
games/voxel-lava/static/favicon.png
Normal file
BIN
games/voxel-lava/static/favicon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
18
games/voxel-lava/svelte.config.js
Normal file
18
games/voxel-lava/svelte.config.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import adapter from '@sveltejs/adapter-auto';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
// Consult https://svelte.dev/docs/kit/integrations
|
||||
// for more information about preprocessors
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
// adapter-auto only supports some environments, see https://svelte.dev/docs/kit/adapter-auto for a list.
|
||||
// If your environment is not supported, or you settled on a specific environment, switch out the adapter.
|
||||
// See https://svelte.dev/docs/kit/adapters for more information about adapters.
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
19
games/voxel-lava/tsconfig.json
Normal file
19
games/voxel-lava/tsconfig.json
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
6
games/voxel-lava/vite.config.ts
Normal file
6
games/voxel-lava/vite.config.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [sveltekit()]
|
||||
});
|
||||
27
games/whopixels/.gitignore
vendored
Normal file
27
games/whopixels/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Umgebungsvariablen
|
||||
.env
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
package-lock.json
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Betriebssystem-Dateien
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE und Editor Dateien
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build-Verzeichnisse
|
||||
dist/
|
||||
build/
|
||||
74
games/whopixels/README.md
Normal file
74
games/whopixels/README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# WhoPixels
|
||||
|
||||
Ein webbasiertes Pixel-Spiel, entwickelt mit Phaser.js.
|
||||
|
||||
Projekt Starten:
|
||||
node server.js
|
||||
|
||||
## Über das Projekt
|
||||
|
||||
WhoPixels ist ein einfaches Pixel-Art-Editor-Spiel, in dem du deine eigenen Pixel-Kunstwerke erstellen kannst. Das Projekt verwendet Phaser.js, eine leistungsstarke HTML5-Spieleentwicklungsbibliothek.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Interaktives Pixel-Art-Editor-Interface
|
||||
- Farbpalette mit 8 Grundfarben
|
||||
- Einfache und intuitive Benutzeroberfläche
|
||||
- Responsive Design
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
Um das Spiel lokal zu starten, benötigst du einen lokalen Webserver. Du kannst einen einfachen Server mit Python oder Node.js starten.
|
||||
|
||||
### Mit Python:
|
||||
|
||||
```bash
|
||||
# Python 3
|
||||
python -m http.server
|
||||
|
||||
# Python 2
|
||||
python -m SimpleHTTPServer
|
||||
```
|
||||
|
||||
### Mit Node.js:
|
||||
|
||||
Installiere zuerst das `http-server`-Paket:
|
||||
|
||||
```bash
|
||||
npm install -g http-server
|
||||
```
|
||||
|
||||
Dann starte den Server:
|
||||
|
||||
```bash
|
||||
http-server
|
||||
```
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
whopixels/
|
||||
├── assets/ # Spielressourcen (Bilder, Sounds, etc.)
|
||||
├── css/ # CSS-Stylesheets
|
||||
├── js/ # JavaScript-Dateien
|
||||
│ ├── scenes/ # Phaser-Szenen
|
||||
│ │ ├── BootScene.js
|
||||
│ │ ├── MainMenuScene.js
|
||||
│ │ └── GameScene.js
|
||||
│ └── main.js # Hauptspieldatei
|
||||
└── index.html # Haupt-HTML-Datei
|
||||
```
|
||||
|
||||
## Weiterentwicklung
|
||||
|
||||
Hier sind einige Ideen für zukünftige Erweiterungen:
|
||||
|
||||
- Speichern und Laden von Pixel-Art
|
||||
- Mehr Werkzeuge (Pinsel, Radierer, Füllen, etc.)
|
||||
- Animation-Editor
|
||||
- Teilen von Kunstwerken
|
||||
- Mehrere Ebenen für komplexere Designs
|
||||
|
||||
## Lizenz
|
||||
|
||||
Dieses Projekt ist Open Source und steht unter der MIT-Lizenz.
|
||||
35
games/whopixels/assets/background.html
Normal file
35
games/whopixels/assets/background.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Background Image</title>
|
||||
<style>
|
||||
body { margin: 0; background: #222233; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Fill background
|
||||
ctx.fillStyle = '#222233';
|
||||
ctx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern
|
||||
ctx.fillStyle = '#1a1a2a';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Instructions
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillText('Right-click and save this image as background.png', 200, 300);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
games/whopixels/assets/background.png
Normal file
1
games/whopixels/assets/background.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
68
games/whopixels/assets/create_placeholder_images.html
Normal file
68
games/whopixels/assets/create_placeholder_images.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Create Placeholder Images</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Creating placeholder images...</h1>
|
||||
<canvas id="backgroundCanvas" width="800" height="600" style="display: none;"></canvas>
|
||||
<canvas id="playerCanvas" width="32" height="32" style="display: none;"></canvas>
|
||||
<canvas id="tileCanvas" width="32" height="32" style="display: none;"></canvas>
|
||||
|
||||
<div id="downloadLinks"></div>
|
||||
|
||||
<script>
|
||||
// Create background image
|
||||
const bgCanvas = document.getElementById('backgroundCanvas');
|
||||
const bgCtx = bgCanvas.getContext('2d');
|
||||
bgCtx.fillStyle = '#222233';
|
||||
bgCtx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern to background
|
||||
bgCtx.fillStyle = '#1a1a2a';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgCtx.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Create player image
|
||||
const playerCanvas = document.getElementById('playerCanvas');
|
||||
const playerCtx = playerCanvas.getContext('2d');
|
||||
playerCtx.fillStyle = '#ff0000';
|
||||
playerCtx.fillRect(0, 0, 32, 32);
|
||||
playerCtx.fillStyle = '#ff5555';
|
||||
playerCtx.fillRect(8, 8, 16, 16);
|
||||
|
||||
// Create tile image
|
||||
const tileCanvas = document.getElementById('tileCanvas');
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
tileCtx.fillStyle = '#ffffff';
|
||||
tileCtx.fillRect(0, 0, 32, 32);
|
||||
tileCtx.strokeStyle = '#cccccc';
|
||||
tileCtx.lineWidth = 1;
|
||||
tileCtx.strokeRect(0.5, 0.5, 31, 31);
|
||||
|
||||
// Create download links
|
||||
const downloadDiv = document.getElementById('downloadLinks');
|
||||
|
||||
function createDownloadLink(canvas, filename) {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.textContent = `Download ${filename}`;
|
||||
link.style.display = 'block';
|
||||
link.style.margin = '10px';
|
||||
downloadDiv.appendChild(link);
|
||||
|
||||
// Auto-click to download
|
||||
setTimeout(() => link.click(), 500);
|
||||
}
|
||||
|
||||
createDownloadLink(bgCanvas, 'background.png');
|
||||
createDownloadLink(playerCanvas, 'player.png');
|
||||
createDownloadLink(tileCanvas, 'tile.png');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
games/whopixels/assets/player.html
Normal file
26
games/whopixels/assets/player.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Player Image</title>
|
||||
<style>
|
||||
body { margin: 0; background: #333; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { display: block; border: 1px solid #fff; background: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="32" height="32"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw player
|
||||
ctx.fillStyle = '#ff0000';
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
ctx.fillStyle = '#ff5555';
|
||||
ctx.fillRect(8, 8, 16, 16);
|
||||
|
||||
// Instructions (shown in console)
|
||||
console.log('Right-click and save this image as player.png');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
games/whopixels/assets/player.png
Normal file
1
games/whopixels/assets/player.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
54
games/whopixels/assets/simple_images.js
Normal file
54
games/whopixels/assets/simple_images.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Simple script to create basic placeholder images
|
||||
// Just open this in a browser and it will create data URLs you can copy
|
||||
|
||||
document.body.innerHTML = `
|
||||
<h1>Placeholder Images for WhoPixels</h1>
|
||||
<div>
|
||||
<h2>Background (800x600)</h2>
|
||||
<canvas id="bg" width="800" height="600" style="border:1px solid #000; max-width: 100%;"></canvas>
|
||||
<p id="bgData"></p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Player (32x32)</h2>
|
||||
<canvas id="player" width="32" height="32" style="border:1px solid #000;"></canvas>
|
||||
<p id="playerData"></p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Tile (32x32)</h2>
|
||||
<canvas id="tile" width="32" height="32" style="border:1px solid #000;"></canvas>
|
||||
<p id="tileData"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Draw background
|
||||
const bgCanvas = document.getElementById('bg');
|
||||
const bgCtx = bgCanvas.getContext('2d');
|
||||
bgCtx.fillStyle = '#222233';
|
||||
bgCtx.fillRect(0, 0, 800, 600);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
bgCtx.fillStyle = '#1a1a2a';
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgCtx.fillRect(x, y, size, size);
|
||||
}
|
||||
document.getElementById('bgData').textContent = 'Save this image as background.png';
|
||||
|
||||
// Draw player
|
||||
const playerCanvas = document.getElementById('player');
|
||||
const playerCtx = playerCanvas.getContext('2d');
|
||||
playerCtx.fillStyle = '#ff0000';
|
||||
playerCtx.fillRect(0, 0, 32, 32);
|
||||
playerCtx.fillStyle = '#ff5555';
|
||||
playerCtx.fillRect(8, 8, 16, 16);
|
||||
document.getElementById('playerData').textContent = 'Save this image as player.png';
|
||||
|
||||
// Draw tile
|
||||
const tileCanvas = document.getElementById('tile');
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
tileCtx.fillStyle = '#ffffff';
|
||||
tileCtx.fillRect(0, 0, 32, 32);
|
||||
tileCtx.strokeStyle = '#cccccc';
|
||||
tileCtx.lineWidth = 1;
|
||||
tileCtx.strokeRect(0.5, 0.5, 31, 31);
|
||||
document.getElementById('tileData').textContent = 'Save this image as tile.png';
|
||||
27
games/whopixels/assets/tile.html
Normal file
27
games/whopixels/assets/tile.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tile Image</title>
|
||||
<style>
|
||||
body { margin: 0; background: #333; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { display: block; border: 1px solid #fff; background: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="32" height="32"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw tile
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
ctx.strokeStyle = '#cccccc';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, 31, 31);
|
||||
|
||||
// Instructions (shown in console)
|
||||
console.log('Right-click and save this image as tile.png');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
games/whopixels/assets/tile.png
Normal file
1
games/whopixels/assets/tile.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
14
games/whopixels/css/style.css
Normal file
14
games/whopixels/css/style.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-family: 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
#game-container {
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
73
games/whopixels/data/npc_characters.js
Normal file
73
games/whopixels/data/npc_characters.js
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
// Liste der NPC-Charaktere mit Namen und Persönlichkeiten
|
||||
// NPC-Charaktere: Berühmte Erfinder durch die Historie
|
||||
const npcCharacters = [
|
||||
{
|
||||
id: 1,
|
||||
name: "Leonardo da Vinci",
|
||||
personality: "Ein vielseitiger Universalgelehrter der Renaissance, bekannt für seine Kunst und Erfindungen. Er spricht nachdenklich und philosophisch, oft mit Metaphern über Natur und Kunst. Er ist neugierig und beobachtet alles genau.",
|
||||
hint: "Meine Skizzenbücher enthalten Flugmaschinen und anatomische Studien, die ihrer Zeit weit voraus waren."
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: "Nikola Tesla",
|
||||
personality: "Ein exzentrischer Elektroingenieur mit visionären Ideen. Er spricht leidenschaftlich über Elektrizität und drahtlose Energieübertragung. Er ist brillant, aber auch etwas eigenartig und distanziert.",
|
||||
hint: "Meine Arbeiten mit Wechselstrom revolutionierten die Art, wie wir Energie nutzen."
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: "Marie Curie",
|
||||
personality: "Eine entschlossene und präzise Wissenschaftlerin, die für ihre Entdeckungen im Bereich der Radioaktivität bekannt ist. Sie spricht klar und methodisch, mit einem starken Fokus auf wissenschaftliche Genauigkeit.",
|
||||
hint: "Meine Forschung zu radioaktiven Elementen brachte mir zwei Nobelpreise ein, obwohl sie letztendlich meine Gesundheit beeinträchtigte."
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: "Thomas Edison",
|
||||
personality: "Ein pragmatischer und geschäftstüchtiger Erfinder mit über 1.000 Patenten. Er spricht direkt und selbstbewusst, oft mit praktischen Beispielen. Er betont harte Arbeit und Ausdauer über Inspiration.",
|
||||
hint: "Meine Erfindung brachte Licht in die Dunkelheit und veränderte die Art, wie Menschen nach Sonnenuntergang leben."
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: "Ada Lovelace",
|
||||
personality: "Eine visionäre Mathematikerin des 19. Jahrhunderts mit einer einzigartigen Verbindung von Logik und Kreativität. Sie spricht eloquent und präzise, mit einer Mischung aus poetischer und mathematischer Sprache.",
|
||||
hint: "Ich schrieb den ersten Algorithmus für eine Maschine, lange bevor Computer existierten."
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: "Archimedes",
|
||||
personality: "Ein genialer antiker Mathematiker und Erfinder aus Syrakus. Er ist von mathematischen Problemen fasziniert und kann sich darin verlieren. Er spricht mit Begeisterung über Geometrie und physikalische Prinzipien.",
|
||||
hint: "Mein berühmtester Ausruf war 'Heureka!' als ich das Prinzip des Auftriebs in der Badewanne entdeckte."
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: "Johannes Gutenberg",
|
||||
personality: "Ein geduldiger und präziser Handwerker, der die Druckkunst revolutionierte. Er spricht bescheiden über seine Erfindung, aber mit Stolz über deren Auswirkungen auf die Verbreitung von Wissen.",
|
||||
hint: "Meine Erfindung machte Bücher für die Massen zugänglich und veränderte die Verbreitung von Wissen für immer."
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: "Grace Hopper",
|
||||
personality: "Eine pragmatische und humorvolle Computerpionierin und Marineoffizierin. Sie erklärt komplexe Konzepte mit einfachen Analogien und hat einen trockenen Humor. Sie ist direkt und lösungsorientiert.",
|
||||
hint: "Ich entwickelte den ersten Compiler und fand einmal einen echten 'Bug' im Computer - eine Motte, die einen Fehler verursachte."
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: "Alexander Graham Bell",
|
||||
personality: "Ein einfallsreicher und geduldiger Erfinder, der sich für Kommunikation und Gehörlose engagierte. Er spricht deutlich und artikuliert, mit einem schottischen Akzent. Er ist enthusiastisch, wenn er über seine Erfindungen spricht.",
|
||||
hint: "Meine Erfindung ermöglichte es Menschen, über große Entfernungen miteinander zu sprechen."
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: "Hedy Lamarr",
|
||||
personality: "Eine glamouröse Hollywoodschauspielerin mit einem brillanten technischen Verstand. Sie spricht charmant und selbstbewusst, mit einer Mischung aus Eleganz und technischem Scharfsinn. Sie ist kreativ und unkonventionell.",
|
||||
hint: "Meine Erfindung der Frequenzsprungverfahren bildet die Grundlage für moderne WLAN- und Bluetooth-Technologien, obwohl viele mich nur als Filmstar kennen."
|
||||
}
|
||||
];
|
||||
|
||||
// Mache die Charaktere sowohl im Browser als auch in Node.js verfügbar
|
||||
if (typeof window !== 'undefined') {
|
||||
// Browser-Umgebung
|
||||
window.npcCharacters = npcCharacters;
|
||||
} else if (typeof module !== 'undefined') {
|
||||
// Node.js-Umgebung
|
||||
module.exports = npcCharacters;
|
||||
}
|
||||
47
games/whopixels/generate_assets.js
Normal file
47
games/whopixels/generate_assets.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// This script uses Node.js to generate placeholder images for our game
|
||||
const fs = require('fs');
|
||||
const { createCanvas } = require('canvas');
|
||||
|
||||
// Create background image (800x600)
|
||||
const bgCanvas = createCanvas(800, 600);
|
||||
const bgCtx = bgCanvas.getContext('2d');
|
||||
bgCtx.fillStyle = '#222233';
|
||||
bgCtx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern to background
|
||||
bgCtx.fillStyle = '#1a1a2a';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgCtx.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Create player image (32x32)
|
||||
const playerCanvas = createCanvas(32, 32);
|
||||
const playerCtx = playerCanvas.getContext('2d');
|
||||
playerCtx.fillStyle = '#ff0000';
|
||||
playerCtx.fillRect(0, 0, 32, 32);
|
||||
playerCtx.fillStyle = '#ff5555';
|
||||
playerCtx.fillRect(8, 8, 16, 16);
|
||||
|
||||
// Create tile image (32x32)
|
||||
const tileCanvas = createCanvas(32, 32);
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
tileCtx.fillStyle = '#ffffff';
|
||||
tileCtx.fillRect(0, 0, 32, 32);
|
||||
tileCtx.strokeStyle = '#cccccc';
|
||||
tileCtx.lineWidth = 1;
|
||||
tileCtx.strokeRect(0.5, 0.5, 31, 31);
|
||||
|
||||
// Save images
|
||||
const bgBuffer = bgCanvas.toBuffer('image/png');
|
||||
fs.writeFileSync('./assets/background.png', bgBuffer);
|
||||
|
||||
const playerBuffer = playerCanvas.toBuffer('image/png');
|
||||
fs.writeFileSync('./assets/player.png', playerBuffer);
|
||||
|
||||
const tileBuffer = tileCanvas.toBuffer('image/png');
|
||||
fs.writeFileSync('./assets/tile.png', tileBuffer);
|
||||
|
||||
console.log('All placeholder images have been generated!');
|
||||
25
games/whopixels/index.html
Normal file
25
games/whopixels/index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WhoPixels - Pixel Game</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container"></div>
|
||||
|
||||
<!-- Phaser Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.55.2/dist/phaser.min.js"></script>
|
||||
|
||||
<!-- Game Data -->
|
||||
<script src="data/npc_characters.js"></script>
|
||||
|
||||
<!-- Game Scripts -->
|
||||
<script src="js/scenes/BootScene.js"></script>
|
||||
<script src="js/scenes/MainMenuScene.js"></script>
|
||||
<script src="js/scenes/GameScene.js"></script>
|
||||
<script src="js/scenes/RPGScene.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
games/whopixels/js/main.js
Normal file
18
games/whopixels/js/main.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Game configuration
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
width: 800,
|
||||
height: 600,
|
||||
pixelArt: true,
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
gravity: { y: 0 },
|
||||
debug: false
|
||||
}
|
||||
},
|
||||
scene: [BootScene, MainMenuScene, GameScene, RPGScene]
|
||||
};
|
||||
|
||||
// Create and start the game
|
||||
const game = new Phaser.Game(config);
|
||||
445
games/whopixels/js/scenes/BootScene.js
Normal file
445
games/whopixels/js/scenes/BootScene.js
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
// Loading screen
|
||||
this.graphics = this.add.graphics();
|
||||
this.newGraphics = this.add.graphics();
|
||||
const progressBar = new Phaser.Geom.Rectangle(200, 300, 400, 50);
|
||||
const progressBarFill = new Phaser.Geom.Rectangle(205, 305, 290, 40);
|
||||
|
||||
this.graphics.fillStyle(0xffffff, 1);
|
||||
this.graphics.fillRectShape(progressBar);
|
||||
|
||||
this.newGraphics.fillStyle(0x3587e2, 1);
|
||||
this.newGraphics.fillRectShape(progressBarFill);
|
||||
|
||||
const loadingText = this.add.text(250, 260, "Loading: ", { fontSize: '32px', fill: '#FFF' });
|
||||
|
||||
// Update as load progresses
|
||||
this.load.on('progress', (percent) => {
|
||||
loadingText.setText("Loading: " + parseInt(percent * 100) + "%");
|
||||
progressBarFill.width = 390 * percent;
|
||||
this.newGraphics.clear();
|
||||
this.newGraphics.fillStyle(0x3587e2, 1);
|
||||
this.newGraphics.fillRectShape(progressBarFill);
|
||||
});
|
||||
|
||||
this.load.on('complete', () => {
|
||||
loadingText.destroy();
|
||||
this.graphics.destroy();
|
||||
this.newGraphics.destroy();
|
||||
});
|
||||
|
||||
// We'll create graphics objects instead of loading images
|
||||
}
|
||||
|
||||
create() {
|
||||
// Create a texture for background
|
||||
const bgGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
bgGraphics.fillStyle(0x222233);
|
||||
bgGraphics.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern to background
|
||||
bgGraphics.fillStyle(0x1a1a2a);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgGraphics.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Erstelle ein Partikel-Sprite für Spezialeffekte
|
||||
const particleGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
particleGraphics.fillStyle(0xffffff);
|
||||
particleGraphics.fillCircle(4, 4, 4);
|
||||
particleGraphics.generateTexture('particle', 8, 8);
|
||||
|
||||
bgGraphics.generateTexture('background', 800, 600);
|
||||
|
||||
// Create a texture for player (pixel editor)
|
||||
const playerGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
playerGraphics.fillStyle(0xff0000);
|
||||
playerGraphics.fillRect(0, 0, 32, 32);
|
||||
playerGraphics.fillStyle(0xff5555);
|
||||
playerGraphics.fillRect(8, 8, 16, 16);
|
||||
playerGraphics.generateTexture('player', 32, 32);
|
||||
|
||||
// Erstelle Texturen für verschiedene Tile-Typen (8x8 Pixel Tiles, skaliert auf 32x32)
|
||||
this.createTileTextures();
|
||||
|
||||
// Erstelle NPC-Texturen
|
||||
this.createNPCTextures();
|
||||
|
||||
// Create a texture for basic tile
|
||||
const tileGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
tileGraphics.fillStyle(0xffffff);
|
||||
tileGraphics.fillRect(0, 0, 32, 32);
|
||||
tileGraphics.lineStyle(1, 0xcccccc);
|
||||
tileGraphics.strokeRect(0, 0, 32, 32);
|
||||
tileGraphics.generateTexture('tile', 32, 32);
|
||||
|
||||
// Create player walk animation frames (4 directions)
|
||||
this.createPlayerWalkAnimations();
|
||||
|
||||
this.scene.start('MainMenuScene');
|
||||
}
|
||||
|
||||
createPlayerWalkAnimations() {
|
||||
// Erstelle eine Spritesheet-Textur für den Spieler im RPG
|
||||
const frameWidth = 32;
|
||||
const frameHeight = 32;
|
||||
|
||||
// Farbpalette für den Spieler
|
||||
const colors = {
|
||||
body: 0x3366cc, // Blauer Körper
|
||||
face: 0xffcc99, // Hautfarbe für Gesicht
|
||||
hair: 0x663300, // Braune Haare
|
||||
shirt: 0x339933, // Grünes Hemd
|
||||
pants: 0x333366, // Dunkelblaue Hose
|
||||
shoes: 0x663300, // Braune Schuhe
|
||||
outline: 0x000000 // Schwarze Umrisse
|
||||
};
|
||||
|
||||
// Gemeinsame Funktion zum Zeichnen der Grundform des Spielers
|
||||
const drawPlayerBase = (graphics) => {
|
||||
// Umriss
|
||||
graphics.lineStyle(1, colors.outline);
|
||||
|
||||
// Körper (Torso)
|
||||
graphics.fillStyle(colors.shirt);
|
||||
graphics.fillRect(10, 12, 12, 10);
|
||||
graphics.strokeRect(10, 12, 12, 10);
|
||||
|
||||
// Kopf
|
||||
graphics.fillStyle(colors.face);
|
||||
graphics.fillRect(10, 4, 12, 8);
|
||||
graphics.strokeRect(10, 4, 12, 8);
|
||||
|
||||
// Haare
|
||||
graphics.fillStyle(colors.hair);
|
||||
graphics.fillRect(10, 4, 12, 3);
|
||||
graphics.strokeRect(10, 4, 12, 3);
|
||||
|
||||
// Hose
|
||||
graphics.fillStyle(colors.pants);
|
||||
graphics.fillRect(10, 22, 12, 6);
|
||||
graphics.strokeRect(10, 22, 12, 6);
|
||||
};
|
||||
|
||||
// Nach unten (0)
|
||||
const downGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(downGraphics);
|
||||
|
||||
// Gesicht nach unten
|
||||
downGraphics.fillStyle(colors.outline);
|
||||
downGraphics.fillRect(14, 8, 1, 1); // Linkes Auge
|
||||
downGraphics.fillRect(17, 8, 1, 1); // Rechtes Auge
|
||||
downGraphics.fillRect(15, 10, 2, 1); // Mund
|
||||
|
||||
// Beine und Schuhe nach unten
|
||||
downGraphics.fillStyle(colors.pants);
|
||||
downGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
downGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
downGraphics.fillStyle(colors.shoes);
|
||||
downGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
downGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
downGraphics.lineStyle(1, colors.outline);
|
||||
downGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
downGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
downGraphics.generateTexture('player_down', frameWidth, frameHeight);
|
||||
|
||||
// Nach oben (1)
|
||||
const upGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(upGraphics);
|
||||
|
||||
// Rücken der Haare
|
||||
upGraphics.fillStyle(colors.hair);
|
||||
upGraphics.fillRect(10, 2, 12, 2);
|
||||
upGraphics.lineStyle(1, colors.outline);
|
||||
upGraphics.strokeRect(10, 2, 12, 2);
|
||||
|
||||
// Beine und Schuhe nach oben
|
||||
upGraphics.fillStyle(colors.pants);
|
||||
upGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
upGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
upGraphics.fillStyle(colors.shoes);
|
||||
upGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
upGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
upGraphics.lineStyle(1, colors.outline);
|
||||
upGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
upGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
upGraphics.generateTexture('player_up', frameWidth, frameHeight);
|
||||
|
||||
// Nach links (2)
|
||||
const leftGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(leftGraphics);
|
||||
|
||||
// Gesicht nach links
|
||||
leftGraphics.fillStyle(colors.outline);
|
||||
leftGraphics.fillRect(12, 8, 1, 1); // Auge
|
||||
leftGraphics.fillRect(11, 10, 2, 1); // Mund
|
||||
|
||||
// Arm nach links
|
||||
leftGraphics.fillStyle(colors.shirt);
|
||||
leftGraphics.fillRect(6, 14, 4, 3);
|
||||
leftGraphics.lineStyle(1, colors.outline);
|
||||
leftGraphics.strokeRect(6, 14, 4, 3);
|
||||
|
||||
// Beine und Schuhe nach links
|
||||
leftGraphics.fillStyle(colors.pants);
|
||||
leftGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
leftGraphics.fillRect(15, 28, 3, 2); // Rechtes Bein
|
||||
leftGraphics.fillStyle(colors.shoes);
|
||||
leftGraphics.fillRect(9, 30, 6, 2); // Schuhe
|
||||
leftGraphics.lineStyle(1, colors.outline);
|
||||
leftGraphics.strokeRect(9, 28, 9, 4); // Beine Umriss
|
||||
|
||||
leftGraphics.generateTexture('player_left', frameWidth, frameHeight);
|
||||
|
||||
// Nach rechts (3)
|
||||
const rightGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(rightGraphics);
|
||||
|
||||
// Gesicht nach rechts
|
||||
rightGraphics.fillStyle(colors.outline);
|
||||
rightGraphics.fillRect(19, 8, 1, 1); // Auge
|
||||
rightGraphics.fillRect(19, 10, 2, 1); // Mund
|
||||
|
||||
// Arm nach rechts
|
||||
rightGraphics.fillStyle(colors.shirt);
|
||||
rightGraphics.fillRect(22, 14, 4, 3);
|
||||
rightGraphics.lineStyle(1, colors.outline);
|
||||
rightGraphics.strokeRect(22, 14, 4, 3);
|
||||
|
||||
// Beine und Schuhe nach rechts
|
||||
rightGraphics.fillStyle(colors.pants);
|
||||
rightGraphics.fillRect(14, 28, 3, 2); // Linkes Bein
|
||||
rightGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
rightGraphics.fillStyle(colors.shoes);
|
||||
rightGraphics.fillRect(17, 30, 6, 2); // Schuhe
|
||||
rightGraphics.lineStyle(1, colors.outline);
|
||||
rightGraphics.strokeRect(14, 28, 9, 4); // Beine Umriss
|
||||
|
||||
rightGraphics.generateTexture('player_right', frameWidth, frameHeight);
|
||||
}
|
||||
|
||||
createTileTextures() {
|
||||
const tileSize = 32; // Größe jedes Tiles
|
||||
|
||||
// 1. Gras
|
||||
const grassGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
grassGraphics.fillStyle(0x88aa44); // Grün für Gras
|
||||
grassGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Gras
|
||||
grassGraphics.fillStyle(0x779933);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
grassGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
grassGraphics.generateTexture('tile_grass', tileSize, tileSize);
|
||||
|
||||
// 2. Gras mit Blumen
|
||||
const grassFlowerGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
grassFlowerGraphics.fillStyle(0x88aa44); // Grün für Gras
|
||||
grassFlowerGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Gras
|
||||
grassFlowerGraphics.fillStyle(0x779933);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
grassFlowerGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
// Blumen hinzufügen
|
||||
grassFlowerGraphics.fillStyle(0xffff00); // Gelb für Blumen
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = 5 + Math.random() * (tileSize - 10);
|
||||
const y = 5 + Math.random() * (tileSize - 10);
|
||||
grassFlowerGraphics.fillRect(x, y, 3, 3);
|
||||
}
|
||||
grassFlowerGraphics.fillStyle(0xff5555); // Rot für Blumen
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const x = 5 + Math.random() * (tileSize - 10);
|
||||
const y = 5 + Math.random() * (tileSize - 10);
|
||||
grassFlowerGraphics.fillRect(x, y, 3, 3);
|
||||
}
|
||||
grassFlowerGraphics.generateTexture('tile_grass_flower', tileSize, tileSize);
|
||||
|
||||
// 3. Erde
|
||||
const dirtGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
dirtGraphics.fillStyle(0x8B4513); // Braun für Erde
|
||||
dirtGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Erde
|
||||
dirtGraphics.fillStyle(0x6B3304);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
dirtGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
dirtGraphics.generateTexture('tile_dirt', tileSize, tileSize);
|
||||
|
||||
// 4. Erde mit Steinen
|
||||
const dirtStoneGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
dirtStoneGraphics.fillStyle(0x8B4513); // Braun für Erde
|
||||
dirtStoneGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Erde
|
||||
dirtStoneGraphics.fillStyle(0x6B3304);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
dirtStoneGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
// Steine hinzufügen
|
||||
dirtStoneGraphics.fillStyle(0x888888); // Grau für Steine
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const size = 3 + Math.random() * 4;
|
||||
const x = Math.random() * (tileSize - size);
|
||||
const y = Math.random() * (tileSize - size);
|
||||
dirtStoneGraphics.fillRect(x, y, size, size);
|
||||
}
|
||||
dirtStoneGraphics.generateTexture('tile_dirt_stone', tileSize, tileSize);
|
||||
|
||||
// 5. Steinwand
|
||||
const stoneWallGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
stoneWallGraphics.fillStyle(0x777777); // Grau für Steinwand
|
||||
stoneWallGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
|
||||
// Steinmuster für die Wand
|
||||
stoneWallGraphics.fillStyle(0x555555);
|
||||
const brickHeight = 8;
|
||||
const brickWidth = 16;
|
||||
|
||||
for (let y = 0; y < tileSize; y += brickHeight) {
|
||||
let offset = (y % (brickHeight * 2) === 0) ? 0 : brickWidth / 2;
|
||||
for (let x = offset; x < tileSize; x += brickWidth) {
|
||||
stoneWallGraphics.fillRect(x, y, brickWidth - 1, brickHeight - 1);
|
||||
}
|
||||
}
|
||||
stoneWallGraphics.generateTexture('tile_stone_wall', tileSize, tileSize);
|
||||
|
||||
// 6. Steinwand mit Blumen/Moos
|
||||
const stoneWallFlowerGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
stoneWallFlowerGraphics.fillStyle(0x777777); // Grau für Steinwand
|
||||
stoneWallFlowerGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
|
||||
// Steinmuster für die Wand
|
||||
stoneWallFlowerGraphics.fillStyle(0x555555);
|
||||
for (let y = 0; y < tileSize; y += brickHeight) {
|
||||
let offset = (y % (brickHeight * 2) === 0) ? 0 : brickWidth / 2;
|
||||
for (let x = offset; x < tileSize; x += brickWidth) {
|
||||
stoneWallFlowerGraphics.fillRect(x, y, brickWidth - 1, brickHeight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Moos/Blumen an der Wand
|
||||
stoneWallFlowerGraphics.fillStyle(0x55AA55); // Grün für Moos
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
const size = 2 + Math.random() * 3;
|
||||
stoneWallFlowerGraphics.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
stoneWallFlowerGraphics.fillStyle(0xffff00); // Gelb für Blumen
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const x = 5 + Math.random() * (tileSize - 10);
|
||||
const y = 5 + Math.random() * (tileSize - 10);
|
||||
stoneWallFlowerGraphics.fillRect(x, y, 3, 3);
|
||||
}
|
||||
|
||||
stoneWallFlowerGraphics.generateTexture('tile_stone_wall_flower', tileSize, tileSize);
|
||||
}
|
||||
|
||||
createNPCTextures() {
|
||||
const frameWidth = 32;
|
||||
const frameHeight = 32;
|
||||
|
||||
// Farbpalette für den NPC
|
||||
const colors = {
|
||||
body: 0xcc6633, // Bräunlicher Körper
|
||||
face: 0xffcc99, // Hautfarbe für Gesicht
|
||||
hair: 0x996600, // Blonde Haare
|
||||
shirt: 0xcc3333, // Rotes Hemd
|
||||
pants: 0x333333, // Schwarze Hose
|
||||
shoes: 0x663300, // Braune Schuhe
|
||||
outline: 0x000000 // Schwarze Umrisse
|
||||
};
|
||||
|
||||
// Gemeinsame Funktion zum Zeichnen der Grundform des NPCs
|
||||
const drawNPCBase = (graphics) => {
|
||||
// Umriss
|
||||
graphics.lineStyle(1, colors.outline);
|
||||
|
||||
// Körper (Torso)
|
||||
graphics.fillStyle(colors.shirt);
|
||||
graphics.fillRect(10, 12, 12, 10);
|
||||
graphics.strokeRect(10, 12, 12, 10);
|
||||
|
||||
// Kopf
|
||||
graphics.fillStyle(colors.face);
|
||||
graphics.fillRect(10, 4, 12, 8);
|
||||
graphics.strokeRect(10, 4, 12, 8);
|
||||
|
||||
// Haare
|
||||
graphics.fillStyle(colors.hair);
|
||||
graphics.fillRect(10, 4, 12, 3);
|
||||
graphics.strokeRect(10, 4, 12, 3);
|
||||
|
||||
// Hose
|
||||
graphics.fillStyle(colors.pants);
|
||||
graphics.fillRect(10, 22, 12, 6);
|
||||
graphics.strokeRect(10, 22, 12, 6);
|
||||
};
|
||||
|
||||
// NPC nach unten schauend
|
||||
const npcDownGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawNPCBase(npcDownGraphics);
|
||||
|
||||
// Gesicht nach unten
|
||||
npcDownGraphics.fillStyle(colors.outline);
|
||||
npcDownGraphics.fillRect(14, 8, 1, 1); // Linkes Auge
|
||||
npcDownGraphics.fillRect(17, 8, 1, 1); // Rechtes Auge
|
||||
npcDownGraphics.fillRect(15, 10, 2, 1); // Mund
|
||||
|
||||
// Beine und Schuhe nach unten
|
||||
npcDownGraphics.fillStyle(colors.pants);
|
||||
npcDownGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
npcDownGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
npcDownGraphics.fillStyle(colors.shoes);
|
||||
npcDownGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
npcDownGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
npcDownGraphics.lineStyle(1, colors.outline);
|
||||
npcDownGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
npcDownGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
npcDownGraphics.generateTexture('npc_down', frameWidth, frameHeight);
|
||||
|
||||
// NPC nach oben schauend
|
||||
const npcUpGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawNPCBase(npcUpGraphics);
|
||||
|
||||
// Rücken der Haare
|
||||
npcUpGraphics.fillStyle(colors.hair);
|
||||
npcUpGraphics.fillRect(10, 2, 12, 2);
|
||||
npcUpGraphics.lineStyle(1, colors.outline);
|
||||
npcUpGraphics.strokeRect(10, 2, 12, 2);
|
||||
|
||||
// Beine und Schuhe nach oben
|
||||
npcUpGraphics.fillStyle(colors.pants);
|
||||
npcUpGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
npcUpGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
npcUpGraphics.fillStyle(colors.shoes);
|
||||
npcUpGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
npcUpGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
npcUpGraphics.lineStyle(1, colors.outline);
|
||||
npcUpGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
npcUpGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
npcUpGraphics.generateTexture('npc_up', frameWidth, frameHeight);
|
||||
}
|
||||
}
|
||||
108
games/whopixels/js/scenes/GameScene.js
Normal file
108
games/whopixels/js/scenes/GameScene.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Add background
|
||||
this.add.image(400, 300, 'background');
|
||||
|
||||
// Create grid for pixel art (16x16 grid of 32x32 pixel tiles)
|
||||
this.grid = [];
|
||||
this.tileSize = 32;
|
||||
this.gridWidth = 16;
|
||||
this.gridHeight = 16;
|
||||
this.gridStartX = (800 - (this.gridWidth * this.tileSize)) / 2;
|
||||
this.gridStartY = (600 - (this.gridHeight * this.tileSize)) / 2;
|
||||
|
||||
// Create grid of tiles
|
||||
for (let y = 0; y < this.gridHeight; y++) {
|
||||
this.grid[y] = [];
|
||||
for (let x = 0; x < this.gridWidth; x++) {
|
||||
const tile = this.add.image(
|
||||
this.gridStartX + x * this.tileSize + this.tileSize / 2,
|
||||
this.gridStartY + y * this.tileSize + this.tileSize / 2,
|
||||
'tile'
|
||||
);
|
||||
tile.setTint(0xffffff); // Default white color
|
||||
tile.setInteractive();
|
||||
tile.on('pointerdown', () => {
|
||||
this.paintTile(x, y);
|
||||
});
|
||||
this.grid[y][x] = tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Current selected color (default: black)
|
||||
this.currentColor = 0x000000;
|
||||
|
||||
// Create color palette
|
||||
this.createColorPalette();
|
||||
|
||||
// Add UI text
|
||||
this.add.text(400, 50, 'Pixel Editor', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Add back button
|
||||
const backButton = this.add.text(100, 50, 'Zurück', {
|
||||
fontSize: '24px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 10, y: 5 }
|
||||
}).setOrigin(0.5).setInteractive();
|
||||
|
||||
backButton.on('pointerover', () => {
|
||||
backButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
backButton.on('pointerout', () => {
|
||||
backButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
backButton.on('pointerdown', () => {
|
||||
this.scene.start('MainMenuScene');
|
||||
});
|
||||
}
|
||||
|
||||
createColorPalette() {
|
||||
const colors = [
|
||||
0x000000, // Black
|
||||
0xffffff, // White
|
||||
0xff0000, // Red
|
||||
0x00ff00, // Green
|
||||
0x0000ff, // Blue
|
||||
0xffff00, // Yellow
|
||||
0xff00ff, // Magenta
|
||||
0x00ffff // Cyan
|
||||
];
|
||||
|
||||
const paletteX = 700;
|
||||
const paletteY = 150;
|
||||
const paletteSize = 30;
|
||||
const paletteGap = 10;
|
||||
|
||||
colors.forEach((color, index) => {
|
||||
const colorButton = this.add.rectangle(
|
||||
paletteX,
|
||||
paletteY + index * (paletteSize + paletteGap),
|
||||
paletteSize,
|
||||
paletteSize,
|
||||
color
|
||||
);
|
||||
|
||||
colorButton.setInteractive();
|
||||
colorButton.on('pointerdown', () => {
|
||||
this.currentColor = color;
|
||||
});
|
||||
|
||||
// Add stroke around the button
|
||||
colorButton.setStrokeStyle(2, 0xffffff);
|
||||
});
|
||||
}
|
||||
|
||||
paintTile(x, y) {
|
||||
this.grid[y][x].setTint(this.currentColor);
|
||||
}
|
||||
}
|
||||
85
games/whopixels/js/scenes/MainMenuScene.js
Normal file
85
games/whopixels/js/scenes/MainMenuScene.js
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
class MainMenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MainMenuScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Add background
|
||||
this.add.image(400, 300, 'background');
|
||||
|
||||
// Add title
|
||||
this.add.text(400, 120, 'WhoPixels', {
|
||||
fontSize: '64px',
|
||||
fill: '#fff',
|
||||
fontStyle: 'bold'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Add subtitle
|
||||
this.add.text(400, 180, 'Ein Pixel-Abenteuer', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff'
|
||||
}).setOrigin(0.5);
|
||||
|
||||
// Create buttons
|
||||
const startButton = this.add.text(400, 280, 'RPG Spiel starten', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 20, y: 10 }
|
||||
}).setOrigin(0.5).setInteractive();
|
||||
|
||||
const editorButton = this.add.text(400, 350, 'Pixel Editor', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 20, y: 10 }
|
||||
}).setOrigin(0.5).setInteractive();
|
||||
|
||||
const optionsButton = this.add.text(400, 420, 'Optionen', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 20, y: 10 }
|
||||
}).setOrigin(0.5).setInteractive();
|
||||
|
||||
// Button interactions - RPG Game
|
||||
startButton.on('pointerover', () => {
|
||||
startButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
startButton.on('pointerout', () => {
|
||||
startButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
startButton.on('pointerdown', () => {
|
||||
this.scene.start('RPGScene');
|
||||
});
|
||||
|
||||
// Button interactions - Pixel Editor
|
||||
editorButton.on('pointerover', () => {
|
||||
editorButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
editorButton.on('pointerout', () => {
|
||||
editorButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
editorButton.on('pointerdown', () => {
|
||||
this.scene.start('GameScene');
|
||||
});
|
||||
|
||||
// Button interactions - Options
|
||||
optionsButton.on('pointerover', () => {
|
||||
optionsButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
optionsButton.on('pointerout', () => {
|
||||
optionsButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
optionsButton.on('pointerdown', () => {
|
||||
// Options functionality would go here
|
||||
console.log('Options button clicked');
|
||||
});
|
||||
}
|
||||
}
|
||||
1225
games/whopixels/js/scenes/RPGScene.js
Normal file
1225
games/whopixels/js/scenes/RPGScene.js
Normal file
File diff suppressed because it is too large
Load diff
6
games/whopixels/package.json
Normal file
6
games/whopixels/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
261
games/whopixels/server.js
Normal file
261
games/whopixels/server.js
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
// Lade Umgebungsvariablen aus .env-Datei
|
||||
require('dotenv').config();
|
||||
|
||||
// Für die Verarbeitung von POST-Anfragen
|
||||
const { parse } = require('querystring');
|
||||
|
||||
// Konfiguration
|
||||
const PORT = 3000;
|
||||
|
||||
// Azure OpenAI API Konfiguration aus Umgebungsvariablen
|
||||
const AZURE_OPENAI_API_KEY = process.env.AZURE_OPENAI_API_KEY;
|
||||
const AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
|
||||
const AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT;
|
||||
const AZURE_OPENAI_API_VERSION = process.env.AZURE_OPENAI_API_VERSION;
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
// Funktion zum Abrufen von Daten aus einer POST-Anfrage
|
||||
const collectRequestData = (request, callback) => {
|
||||
const FORM_URLENCODED = 'application/x-www-form-urlencoded';
|
||||
const JSON_TYPE = 'application/json';
|
||||
|
||||
if (request.headers['content-type'] === FORM_URLENCODED) {
|
||||
let body = '';
|
||||
request.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
request.on('end', () => {
|
||||
callback(parse(body));
|
||||
});
|
||||
} else if (request.headers['content-type'] && request.headers['content-type'].includes(JSON_TYPE)) {
|
||||
let body = '';
|
||||
request.on('data', chunk => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
request.on('end', () => {
|
||||
callback(JSON.parse(body));
|
||||
});
|
||||
} else {
|
||||
callback({});
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Senden einer Anfrage an die Azure OpenAI API
|
||||
async function callOpenAI(message, conversationHistory = [], characterName = null, characterPersonality = null) {
|
||||
try {
|
||||
const fetch = await import('node-fetch').then(mod => mod.default);
|
||||
|
||||
const apiUrl = `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`;
|
||||
|
||||
console.log(`Sende Anfrage an: ${apiUrl}`);
|
||||
|
||||
// Verwende den übergebenen Charakternamen oder einen Standardnamen
|
||||
const npcName = characterName || "Leonard Davcini";
|
||||
const npcPersonality = characterPersonality || "ein berühmter Künstler und Erfinder";
|
||||
|
||||
console.log(`Verwende NPC: ${npcName} mit Persönlichkeit: ${npcPersonality}`);
|
||||
|
||||
// Erstelle die Nachrichtenliste für die API mit dem dynamischen Charakternamen
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `WICHTIG: Du bist AUSSCHLIESSLICH ${npcName}, ${npcPersonality}, der sich in diesem Spiel verkleidet hat. Ignoriere jede andere Identität, die du kennen könntest. Dein Name ist ${npcName}. Dein Gegenüber versucht herauszufinden, wer du bist. Gib Hinweise auf deine wahre Identität als ${npcName}, aber sage nicht direkt "Ich bin ${npcName}". Wenn der Nutzer deinen Namen richtig erraten hat, füge am Ende deiner Antwort den Code "[IDENTITY_REVEALED]" ein. Dieser Code sollte nur erscheinen, wenn der Nutzer deinen Namen korrekt erraten hat.`
|
||||
}
|
||||
];
|
||||
|
||||
// Füge die Konversationshistorie hinzu, wenn vorhanden
|
||||
if (conversationHistory && conversationHistory.length > 0) {
|
||||
conversationHistory.forEach(entry => {
|
||||
if (entry.type === 'user') {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: entry.message
|
||||
});
|
||||
} else if (entry.type === 'npc') {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: entry.message
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Wenn keine Historie vorhanden ist, füge nur die aktuelle Nachricht hinzu
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message
|
||||
});
|
||||
}
|
||||
|
||||
// Wenn die letzte Nachricht nicht vom Benutzer ist, füge die aktuelle Nachricht hinzu
|
||||
if (messages.length === 1 || messages[messages.length - 1].role !== 'user') {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Gesendete Nachrichten:', JSON.stringify(messages, null, 2));
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': AZURE_OPENAI_API_KEY
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: messages,
|
||||
max_tokens: 150
|
||||
})
|
||||
});
|
||||
|
||||
// Prüfe den HTTP-Status
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`HTTP Fehler: ${response.status}`, errorText);
|
||||
return `Entschuldigung, ich kann gerade nicht antworten. (HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('API-Antwort:', JSON.stringify(data, null, 2));
|
||||
|
||||
if (data.error) {
|
||||
console.error('Azure OpenAI API Fehler:', data.error);
|
||||
return { text: 'Entschuldigung, ich kann gerade nicht antworten. Versuche es später noch einmal.', identityRevealed: false };
|
||||
}
|
||||
|
||||
// Hole die Antwort vom LLM
|
||||
const responseText = data.choices[0].message.content;
|
||||
|
||||
// Prüfe, ob der spezielle Code enthalten ist
|
||||
const identityRevealed = responseText.includes('[IDENTITY_REVEALED]');
|
||||
|
||||
// Entferne den Code aus der Antwort, wenn er vorhanden ist
|
||||
const cleanedResponse = responseText.replace('[IDENTITY_REVEALED]', '').trim();
|
||||
|
||||
console.log('Identität aufgedeckt:', identityRevealed);
|
||||
|
||||
// Gib die Antwort und das Flag zurück
|
||||
return {
|
||||
text: cleanedResponse,
|
||||
identityRevealed: identityRevealed
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aufrufen der Azure OpenAI API:', error);
|
||||
return {
|
||||
text: 'Entschuldigung, ich kann gerade nicht antworten. Versuche es später noch einmal.',
|
||||
identityRevealed: false
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
|
||||
// CORS-Header hinzufügen für Cross-Origin-Anfragen
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
// OPTIONS-Anfragen für CORS-Preflight behandeln
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// API-Endpunkt für OpenAI-Anfragen
|
||||
if (req.method === 'POST' && req.url === '/api/chat') {
|
||||
collectRequestData(req, async (data) => {
|
||||
try {
|
||||
if (!data.message) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Nachricht fehlt' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verwende die Konversationshistorie, wenn vorhanden
|
||||
const conversationHistory = data.conversationHistory || [];
|
||||
console.log('Erhaltene Konversationshistorie:', JSON.stringify(conversationHistory, null, 2));
|
||||
|
||||
// Extrahiere Charakterinformationen, wenn vorhanden
|
||||
const characterName = data.characterName;
|
||||
const characterPersonality = data.characterPersonality;
|
||||
|
||||
if (characterName) {
|
||||
console.log(`NPC-Charakter in der Anfrage: ${characterName}`);
|
||||
}
|
||||
|
||||
const response = await callOpenAI(data.message, conversationHistory, characterName, characterPersonality);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({
|
||||
response: response.text,
|
||||
identityRevealed: response.identityRevealed
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Verarbeitung der Chat-Anfrage:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Interner Serverfehler' }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Statische Dateien behandeln
|
||||
let filePath = '.' + req.url;
|
||||
if (filePath === './') {
|
||||
filePath = './index.html';
|
||||
}
|
||||
|
||||
// Get the file extension
|
||||
const extname = path.extname(filePath);
|
||||
let contentType = MIME_TYPES[extname] || 'application/octet-stream';
|
||||
|
||||
// Read the file
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Page not found
|
||||
fs.readFile('./index.html', (err, content) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end('Error loading index.html');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(content, 'utf-8');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Server error
|
||||
res.writeHead(500);
|
||||
res.end(`Server Error: ${error.code}`);
|
||||
}
|
||||
} else {
|
||||
// Success
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content, 'utf-8');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running at http://localhost:${PORT}/`);
|
||||
console.log('Press Ctrl+C to stop the server');
|
||||
console.log('Azure OpenAI API ist konfiguriert und bereit!');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue