rename(mana-games): rebrand to Arcade
Rename games/mana-games/ to games/arcade/, update all package names (@mana-games/* → @arcade/*), appIds, display names, docker-compose service, root scripts, and documentation. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
159
games/arcade/CLAUDE.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Arcade - CLAUDE.md
|
||||
|
||||
AI-powered browser games platform mit 22+ Spielen und KI-Spielgenerierung.
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
games/arcade/
|
||||
├── apps/
|
||||
│ ├── web/ # SvelteKit Web-App (@arcade/web)
|
||||
│ │ ├── src/
|
||||
│ │ │ ├── routes/ # SvelteKit-Routen
|
||||
│ │ │ │ ├── (app)/ # App-Routen mit PillNavigation
|
||||
│ │ │ │ │ ├── play/[slug] # Spiel im iframe
|
||||
│ │ │ │ │ ├── create/ # AI Game Generator
|
||||
│ │ │ │ │ ├── community/ # Community-Spiele
|
||||
│ │ │ │ │ ├── stats/ # Spieler-Statistiken
|
||||
│ │ │ │ │ └── play-generated/ # Generierte Spiele
|
||||
│ │ │ │ └── (auth)/ # Login/Register
|
||||
│ │ │ └── lib/
|
||||
│ │ │ ├── components/ # Svelte 5 Komponenten
|
||||
│ │ │ ├── data/ # Local-first Store, Game-Katalog
|
||||
│ │ │ ├── stores/ # Theme, Auth, Navigation
|
||||
│ │ │ ├── services/ # Game-Kommunikation (postMessage)
|
||||
│ │ │ └── i18n/ # DE + EN Übersetzungen
|
||||
│ │ └── static/
|
||||
│ │ ├── games/ # 22 HTML-Spiele
|
||||
│ │ └── screenshots/ # Game-Thumbnails
|
||||
│ ├── web-astro/ # Alte Astro-App (Referenz, zum Löschen)
|
||||
│ └── backend/ # NestJS API (@arcade/backend)
|
||||
│ └── src/
|
||||
│ ├── game-generator/ # AI-Spielgenerierung (Gemini, Claude, GPT-4)
|
||||
│ ├── game-submission/ # Community-Einreichungen (GitHub API)
|
||||
│ └── health/
|
||||
└── package.json # Root (arcade)
|
||||
```
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Aspekt | Technologie |
|
||||
|--------|-------------|
|
||||
| Frontend | SvelteKit 2 + Svelte 5 (Runes) |
|
||||
| Styling | Tailwind CSS 4 + @manacore/shared-tailwind |
|
||||
| Auth | @manacore/shared-auth (SSO) |
|
||||
| PWA | @vite-pwa/sveltekit + @manacore/shared-pwa |
|
||||
| State | @manacore/local-store (Dexie.js + sync) |
|
||||
| i18n | svelte-i18n (DE + EN) |
|
||||
| UI | @manacore/shared-ui (PillNav, AuthGate, etc.) |
|
||||
| Theming | @manacore/shared-theme (multi-theme) |
|
||||
| Backend | NestJS (AI-Generierung, Community) |
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
# Alles starten (Web + Backend)
|
||||
pnpm arcade:dev
|
||||
|
||||
# Nur Web (SvelteKit)
|
||||
pnpm dev:arcade:web
|
||||
|
||||
# Nur Backend (NestJS)
|
||||
pnpm dev:arcade:backend
|
||||
|
||||
# Web + Backend zusammen
|
||||
pnpm dev:arcade:app
|
||||
```
|
||||
|
||||
**Ports:**
|
||||
- Web: http://localhost:5210
|
||||
- Backend: http://localhost:3011
|
||||
|
||||
## Local-First Daten
|
||||
|
||||
Stats und generierte Spiele werden in IndexedDB gespeichert (Dexie.js) mit optionalem Sync:
|
||||
|
||||
**Collections:**
|
||||
- `gameStats` — Highscores, Spielzeit, Spiele pro Game
|
||||
- `generatedGames` — Mit KI erstellte Spiele (HTML, Prompt, Modell)
|
||||
- `favorites` — Favorisierte Spiele
|
||||
|
||||
**Dateien:**
|
||||
- `src/lib/data/local-store.ts` — Dexie-Store Definition
|
||||
- `src/lib/data/queries.ts` — Reactive Queries (useLiveQuery)
|
||||
- `src/lib/data/games.ts` — Statischer Spielekatalog (21 Spiele)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
| Endpoint | Method | Beschreibung |
|
||||
|----------|--------|--------------|
|
||||
| `/api/health` | GET | Health Check |
|
||||
| `/api/games/generate` | POST | AI-Spielgenerierung |
|
||||
| `/api/games/submit` | POST | Community-Einreichung |
|
||||
|
||||
### POST /api/games/generate
|
||||
|
||||
```json
|
||||
{
|
||||
"description": "Ein Snake-Spiel im Neon-Stil",
|
||||
"mode": "create",
|
||||
"model": "gemini-2.0-flash",
|
||||
"originalPrompt": "...",
|
||||
"currentCode": "..."
|
||||
}
|
||||
```
|
||||
|
||||
**Unterstützte Modelle:**
|
||||
|
||||
| Modell | Provider | Beschreibung |
|
||||
|--------|----------|--------------|
|
||||
| `gemini-2.0-flash` | Google | Schnell & günstig (Standard) |
|
||||
| `gemini-2.5-flash` | Google | Schnell & gut |
|
||||
| `gemini-2.5-pro` | Google | Höchste Qualität |
|
||||
| `claude-3.5-haiku` | Anthropic | Schnell & präzise |
|
||||
| `claude-3.5-sonnet` | Anthropic | Beste Code-Qualität |
|
||||
| `gpt-4o-mini` | Azure OpenAI | Ausgewogen |
|
||||
| `gpt-4o` | Azure OpenAI | Sehr gut |
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
MANA_GAMES_BACKEND_PORT=3011
|
||||
MANA_GAMES_GOOGLE_GENAI_API_KEY=your_key
|
||||
MANA_GAMES_ANTHROPIC_API_KEY=your_key
|
||||
MANA_GAMES_AZURE_OPENAI_ENDPOINT=https://your-endpoint.openai.azure.com
|
||||
MANA_GAMES_AZURE_OPENAI_API_KEY=your_key
|
||||
MANA_GAMES_AZURE_OPENAI_DEPLOYMENT=gpt-4o
|
||||
MANA_GAMES_GITHUB_TOKEN=your_token
|
||||
MANA_GAMES_GITHUB_OWNER=tillschneider
|
||||
MANA_GAMES_GITHUB_REPO=arcade
|
||||
```
|
||||
|
||||
## Spiel hinzufügen
|
||||
|
||||
1. HTML-Datei in `apps/web/static/games/spiel_name.html`
|
||||
2. Screenshot in `apps/web/static/screenshots/spiel-name.jpg`
|
||||
3. Registrieren in `apps/web/src/lib/data/games.ts`
|
||||
|
||||
## Spiel-postMessage Integration
|
||||
|
||||
```javascript
|
||||
// Beim Laden
|
||||
window.parent.postMessage({ type: 'GAME_LOADED', gameId: 'spiel-slug' }, '*');
|
||||
|
||||
// Bei Score-Update
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT', gameId: 'spiel-slug',
|
||||
event: 'SCORE_UPDATE', data: { score: 123 }
|
||||
}, '*');
|
||||
|
||||
// Bei Game Over
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT', gameId: 'spiel-slug',
|
||||
event: 'GAME_OVER', data: { score: 123 }
|
||||
}, '*');
|
||||
```
|
||||
|
||||
## Spielekatalog
|
||||
|
||||
**21 Spiele** in folgenden Genres: Arcade, Puzzle, Tower Defense, Idle/Incremental, Jump 'n' Run, Action, Strategie
|
||||
17
games/arcade/apps/backend/eslint.config.mjs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// @ts-check
|
||||
import {
|
||||
baseConfig,
|
||||
typescriptConfig,
|
||||
nestjsConfig,
|
||||
prettierConfig,
|
||||
} from '@manacore/eslint-config';
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ['dist/**', 'node_modules/**'],
|
||||
},
|
||||
...baseConfig,
|
||||
...typescriptConfig,
|
||||
...nestjsConfig,
|
||||
...prettierConfig,
|
||||
];
|
||||
8
games/arcade/apps/backend/nest-cli.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
34
games/arcade/apps/backend/package.json
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
{
|
||||
"name": "@arcade/backend",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "nest start --watch",
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:prod": "node dist/main",
|
||||
"lint": "eslint ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.65.0",
|
||||
"openai": "^4.76.0",
|
||||
"@google/genai": "^1.14.0",
|
||||
"@nestjs/common": "^10.4.15",
|
||||
"@nestjs/config": "^3.3.0",
|
||||
"@nestjs/core": "^10.4.15",
|
||||
"@nestjs/platform-express": "^10.4.15",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.4.9",
|
||||
"@nestjs/schematics": "^10.2.3",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.10.2",
|
||||
"ts-node": "^10.9.2",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.7.2"
|
||||
}
|
||||
}
|
||||
17
games/arcade/apps/backend/src/app.module.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { GameGeneratorModule } from './game-generator/game-generator.module';
|
||||
import { GameSubmissionModule } from './game-submission/game-submission.module';
|
||||
import { HealthModule } from './health/health.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
HealthModule,
|
||||
GameGeneratorModule,
|
||||
GameSubmissionModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import { IsString, IsOptional, IsIn, MinLength, IsNumber } from 'class-validator';
|
||||
|
||||
export class GenerateGameDto {
|
||||
@IsString()
|
||||
@MinLength(10, { message: 'Bitte gib eine Spielbeschreibung mit mindestens 10 Zeichen ein' })
|
||||
description: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['create', 'iterate'])
|
||||
mode?: 'create' | 'iterate' = 'create';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
originalPrompt?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
currentCode?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
iterationCount?: number = 0;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
model?: string = 'gemini-2.0-flash';
|
||||
}
|
||||
|
||||
export class GenerateGameResponseDto {
|
||||
success: boolean;
|
||||
html: string;
|
||||
metadata: {
|
||||
description: string;
|
||||
generatedAt: string;
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { GameGeneratorService } from './game-generator.service';
|
||||
import { GenerateGameDto, GenerateGameResponseDto } from './dto/generate-game.dto';
|
||||
|
||||
@Controller('games')
|
||||
export class GameGeneratorController {
|
||||
constructor(private readonly gameGeneratorService: GameGeneratorService) {}
|
||||
|
||||
@Post('generate')
|
||||
async generateGame(@Body() dto: GenerateGameDto): Promise<GenerateGameResponseDto> {
|
||||
return this.gameGeneratorService.generateGame(dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GameGeneratorController } from './game-generator.controller';
|
||||
import { GameGeneratorService } from './game-generator.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GameGeneratorController],
|
||||
providers: [GameGeneratorService],
|
||||
})
|
||||
export class GameGeneratorModule {}
|
||||
|
|
@ -0,0 +1,362 @@
|
|||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { GenerateGameDto, GenerateGameResponseDto } from './dto/generate-game.dto';
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
import { AzureOpenAI } from 'openai';
|
||||
|
||||
type AIProvider = 'google' | 'anthropic' | 'azure';
|
||||
|
||||
interface ModelConfig {
|
||||
provider: AIProvider;
|
||||
modelId: string;
|
||||
displayName: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class GameGeneratorService {
|
||||
private readonly logger = new Logger(GameGeneratorService.name);
|
||||
|
||||
// Model configurations
|
||||
private readonly modelConfigs: Record<string, ModelConfig> = {
|
||||
// Google Gemini Models
|
||||
'gemini-2.0-flash': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.0-flash',
|
||||
displayName: 'Gemini 2.0 Flash',
|
||||
},
|
||||
'gemini-2.5-flash': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.5-flash-preview-05-20',
|
||||
displayName: 'Gemini 2.5 Flash',
|
||||
},
|
||||
'gemini-2.5-pro': {
|
||||
provider: 'google',
|
||||
modelId: 'gemini-2.5-pro-preview-05-06',
|
||||
displayName: 'Gemini 2.5 Pro',
|
||||
},
|
||||
// Anthropic Claude Models
|
||||
'claude-3.5-haiku': {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-3-5-haiku-20241022',
|
||||
displayName: 'Claude 3.5 Haiku',
|
||||
},
|
||||
'claude-3.5-sonnet': {
|
||||
provider: 'anthropic',
|
||||
modelId: 'claude-sonnet-4-20250514',
|
||||
displayName: 'Claude Sonnet 4',
|
||||
},
|
||||
// Azure OpenAI Models
|
||||
'gpt-4o': { provider: 'azure', modelId: 'gpt-4o', displayName: 'GPT-4o' },
|
||||
'gpt-4o-mini': { provider: 'azure', modelId: 'gpt-4o-mini', displayName: 'GPT-4o Mini' },
|
||||
};
|
||||
|
||||
// AI Clients
|
||||
private googleClient: GoogleGenAI | null = null;
|
||||
private anthropicClient: Anthropic | null = null;
|
||||
private azureClient: AzureOpenAI | null = null;
|
||||
|
||||
constructor(private readonly configService: ConfigService) {
|
||||
this.initializeClients();
|
||||
}
|
||||
|
||||
private initializeClients(): void {
|
||||
// Initialize Google Gemini
|
||||
const googleApiKey = this.configService.get<string>('GOOGLE_GENAI_API_KEY');
|
||||
if (googleApiKey && googleApiKey !== 'your_google_genai_key_here') {
|
||||
this.googleClient = new GoogleGenAI({ apiKey: googleApiKey });
|
||||
this.logger.log('Google Gemini client initialized');
|
||||
}
|
||||
|
||||
// Initialize Anthropic Claude
|
||||
const anthropicApiKey = this.configService.get<string>('ANTHROPIC_API_KEY');
|
||||
if (anthropicApiKey && anthropicApiKey !== 'your_anthropic_key_here') {
|
||||
this.anthropicClient = new Anthropic({ apiKey: anthropicApiKey });
|
||||
this.logger.log('Anthropic Claude client initialized');
|
||||
}
|
||||
|
||||
// Initialize Azure OpenAI
|
||||
const azureEndpoint = this.configService.get<string>('AZURE_OPENAI_ENDPOINT');
|
||||
const azureApiKey = this.configService.get<string>('AZURE_OPENAI_API_KEY');
|
||||
if (azureEndpoint && azureApiKey && azureApiKey !== 'your_azure_openai_key_here') {
|
||||
this.azureClient = new AzureOpenAI({
|
||||
endpoint: azureEndpoint,
|
||||
apiKey: azureApiKey,
|
||||
apiVersion: '2024-08-01-preview',
|
||||
});
|
||||
this.logger.log('Azure OpenAI client initialized');
|
||||
}
|
||||
}
|
||||
|
||||
async generateGame(dto: GenerateGameDto): Promise<GenerateGameResponseDto> {
|
||||
const model = dto.model || 'gemini-2.0-flash';
|
||||
const config = this.modelConfigs[model];
|
||||
|
||||
if (!config) {
|
||||
this.logger.warn(`Unknown model: ${model}, falling back to gemini-2.0-flash`);
|
||||
return this.generateGame({ ...dto, model: 'gemini-2.0-flash' });
|
||||
}
|
||||
|
||||
// Check if the provider is available
|
||||
const providerAvailable = this.isProviderAvailable(config.provider);
|
||||
if (!providerAvailable) {
|
||||
this.logger.error(`Provider ${config.provider} is not configured`);
|
||||
throw new InternalServerErrorException(
|
||||
`AI provider ${config.provider} is not configured. Please add the API key.`
|
||||
);
|
||||
}
|
||||
|
||||
// Build prompt
|
||||
const prompt = this.createGamePrompt(
|
||||
dto.description.trim(),
|
||||
dto.mode || 'create',
|
||||
dto.originalPrompt,
|
||||
dto.currentCode
|
||||
);
|
||||
|
||||
this.logger.log(
|
||||
`${dto.mode === 'iterate' ? 'Iterating' : 'Generating'} game with model: ${config.displayName} (${config.provider})`
|
||||
);
|
||||
|
||||
try {
|
||||
let generatedContent: string;
|
||||
|
||||
switch (config.provider) {
|
||||
case 'google':
|
||||
generatedContent = await this.generateWithGoogle(config.modelId, prompt);
|
||||
break;
|
||||
case 'anthropic':
|
||||
generatedContent = await this.generateWithAnthropic(config.modelId, prompt);
|
||||
break;
|
||||
case 'azure':
|
||||
generatedContent = await this.generateWithAzure(config.modelId, prompt);
|
||||
break;
|
||||
default:
|
||||
throw new InternalServerErrorException(`Unknown provider: ${config.provider}`);
|
||||
}
|
||||
|
||||
// Extract HTML from response
|
||||
let html = generatedContent;
|
||||
const htmlMatch = generatedContent.match(/```html\n([\s\S]*?)\n```/);
|
||||
if (htmlMatch) {
|
||||
html = htmlMatch[1];
|
||||
}
|
||||
|
||||
// Validate and sanitize
|
||||
const safeHtml = this.validateAndSanitizeGame(html);
|
||||
|
||||
this.logger.log(`Game generated successfully with ${config.displayName}`);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
html: safeHtml,
|
||||
metadata: {
|
||||
description: dto.description.trim(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
} catch (error: any) {
|
||||
if (error instanceof BadRequestException || error instanceof InternalServerErrorException) {
|
||||
throw error;
|
||||
}
|
||||
this.logger.error(`Generation error with ${config.displayName}:`, error);
|
||||
throw new InternalServerErrorException(
|
||||
`Failed to generate game: ${error.message || 'Unknown error'}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private isProviderAvailable(provider: AIProvider): boolean {
|
||||
switch (provider) {
|
||||
case 'google':
|
||||
return this.googleClient !== null;
|
||||
case 'anthropic':
|
||||
return this.anthropicClient !== null;
|
||||
case 'azure':
|
||||
return this.azureClient !== null;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private async generateWithGoogle(modelId: string, prompt: string): Promise<string> {
|
||||
if (!this.googleClient) {
|
||||
throw new InternalServerErrorException('Google Gemini client not initialized');
|
||||
}
|
||||
|
||||
const response = await this.googleClient.models.generateContent({
|
||||
model: modelId,
|
||||
contents: prompt,
|
||||
config: {
|
||||
temperature: 0.7,
|
||||
maxOutputTokens: 8192,
|
||||
},
|
||||
});
|
||||
|
||||
const content = response.text;
|
||||
if (!content) {
|
||||
throw new InternalServerErrorException('No content generated by Google Gemini');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
private async generateWithAnthropic(modelId: string, prompt: string): Promise<string> {
|
||||
if (!this.anthropicClient) {
|
||||
throw new InternalServerErrorException('Anthropic Claude client not initialized');
|
||||
}
|
||||
|
||||
const response = await this.anthropicClient.messages.create({
|
||||
model: modelId,
|
||||
max_tokens: 8192,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
});
|
||||
|
||||
const content = response.content[0];
|
||||
if (!content || content.type !== 'text') {
|
||||
throw new InternalServerErrorException('No content generated by Anthropic Claude');
|
||||
}
|
||||
|
||||
return content.text;
|
||||
}
|
||||
|
||||
private async generateWithAzure(modelId: string, prompt: string): Promise<string> {
|
||||
if (!this.azureClient) {
|
||||
throw new InternalServerErrorException('Azure OpenAI client not initialized');
|
||||
}
|
||||
|
||||
const deployment = this.configService.get<string>('AZURE_OPENAI_DEPLOYMENT') || modelId;
|
||||
|
||||
const response = await this.azureClient.chat.completions.create({
|
||||
model: deployment,
|
||||
messages: [{ role: 'user', content: prompt }],
|
||||
temperature: 0.7,
|
||||
max_tokens: 8192,
|
||||
});
|
||||
|
||||
const content = response.choices?.[0]?.message?.content;
|
||||
if (!content) {
|
||||
throw new InternalServerErrorException('No content generated by Azure OpenAI');
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
getAvailableModels(): { id: string; name: string; provider: string; available: boolean }[] {
|
||||
return Object.entries(this.modelConfigs).map(([id, config]) => ({
|
||||
id,
|
||||
name: config.displayName,
|
||||
provider: config.provider,
|
||||
available: this.isProviderAvailable(config.provider),
|
||||
}));
|
||||
}
|
||||
|
||||
private createGamePrompt(
|
||||
description: string,
|
||||
mode: 'create' | 'iterate',
|
||||
originalPrompt?: string,
|
||||
currentCode?: string
|
||||
): string {
|
||||
if (mode === 'iterate' && originalPrompt && currentCode) {
|
||||
return `Du bist ein begabter Coder und Gamedesigner.
|
||||
|
||||
Der Nutzer hat ursprünglich folgendes Spiel gewünscht: "${originalPrompt}"
|
||||
|
||||
Jetzt möchte der Nutzer folgende Änderung: "${description}"
|
||||
|
||||
ERSTELLE DAS SPIEL KOMPLETT NEU mit den gewünschten Änderungen. Orientiere dich am ursprünglichen Konzept, aber implementiere die Änderungen vollständig.
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Erstelle ein VOLLSTÄNDIGES neues HTML-Dokument
|
||||
- Maximal 400 Zeilen Code insgesamt
|
||||
- Nutze Canvas für die Grafik
|
||||
- Das Spiel muss sofort spielbar sein
|
||||
- Implementiere die gewünschten Änderungen vollständig
|
||||
- PostMessage Integration: window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
|
||||
STRUKTUR:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spielname</title>
|
||||
<style>
|
||||
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { border: 1px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielcode hier mit den gewünschten Änderungen
|
||||
window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Schreibe nur den Code, keine weiteren Kommentare. Nutze keine externen Bibliotheken, Bilder oder Sounds.`;
|
||||
}
|
||||
|
||||
return `Du bist ein begabter Coder und Gamedesigner. Erstelle ein HTML5-Spiel basierend auf dieser Beschreibung: ${description}
|
||||
|
||||
WICHTIGE REGELN:
|
||||
- Maximal 400 Zeilen Code insgesamt
|
||||
- Nutze Canvas für die Grafik
|
||||
- Verwende einfache Formen (Rechtecke, Kreise, etc.)
|
||||
- Das Spiel muss sofort spielbar sein
|
||||
- Füge Steuerungshinweise im Spiel ein
|
||||
- PostMessage Integration: window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
|
||||
STRUKTUR:
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Spielname</title>
|
||||
<style>
|
||||
body { margin: 0; background: #000; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { border: 1px solid #333; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="game" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('game');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielcode hier
|
||||
// PostMessage beim Start senden:
|
||||
window.parent.postMessage({type: 'GAME_LOADED', gameId: 'generated'}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Schreibe nur den Code, keine weiteren Kommentare. Nutze keine externen Bibliotheken, Bilder oder Sounds.`;
|
||||
}
|
||||
|
||||
private validateAndSanitizeGame(html: string): string {
|
||||
if (!html || typeof html !== 'string') {
|
||||
throw new BadRequestException('Invalid HTML content');
|
||||
}
|
||||
|
||||
if (!html.includes('<!DOCTYPE html>')) {
|
||||
throw new BadRequestException('Invalid game HTML structure');
|
||||
}
|
||||
|
||||
// Security sanitization
|
||||
const sanitized = html
|
||||
.replace(/<script[^>]*src=[^>]*>/gi, '')
|
||||
.replace(/<link[^>]*href=[^>]*>/gi, '')
|
||||
.replace(/fetch\s*\(/gi, '// fetch disabled: fetch(')
|
||||
.replace(/XMLHttpRequest/gi, '// XMLHttpRequest disabled')
|
||||
.replace(/eval\s*\(/gi, '// eval disabled: eval(');
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import { IsString, IsArray, IsOptional, ValidateNested, IsIn } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
class AuthorDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
email?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
github?: string;
|
||||
}
|
||||
|
||||
class FileDto {
|
||||
@IsString()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
content: string;
|
||||
}
|
||||
|
||||
class FilesDto {
|
||||
@ValidateNested()
|
||||
@Type(() => FileDto)
|
||||
html: FileDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FileDto)
|
||||
screenshot: FileDto;
|
||||
}
|
||||
|
||||
export class SubmitGameDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsString()
|
||||
description: string;
|
||||
|
||||
@IsString()
|
||||
controls: string;
|
||||
|
||||
@IsIn(['Einfach', 'Mittel', 'Schwer'])
|
||||
difficulty: string;
|
||||
|
||||
@IsIn(['Minimal', 'Einfach', 'Mittel', 'Komplex'])
|
||||
complexity: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
tags: string[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => AuthorDto)
|
||||
author: AuthorDto;
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => FilesDto)
|
||||
files: FilesDto;
|
||||
|
||||
@IsString()
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
export class SubmitGameResponseDto {
|
||||
success: boolean;
|
||||
message: string;
|
||||
prUrl: string;
|
||||
prNumber: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { GameSubmissionService } from './game-submission.service';
|
||||
import { SubmitGameDto, SubmitGameResponseDto } from './dto/submit-game.dto';
|
||||
|
||||
@Controller('games')
|
||||
export class GameSubmissionController {
|
||||
constructor(private readonly gameSubmissionService: GameSubmissionService) {}
|
||||
|
||||
@Post('submit')
|
||||
async submitGame(@Body() dto: SubmitGameDto): Promise<SubmitGameResponseDto> {
|
||||
return this.gameSubmissionService.submitGame(dto);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { GameSubmissionController } from './game-submission.controller';
|
||||
import { GameSubmissionService } from './game-submission.service';
|
||||
|
||||
@Module({
|
||||
controllers: [GameSubmissionController],
|
||||
providers: [GameSubmissionService],
|
||||
})
|
||||
export class GameSubmissionModule {}
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import {
|
||||
Injectable,
|
||||
BadRequestException,
|
||||
InternalServerErrorException,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { SubmitGameDto, SubmitGameResponseDto } from './dto/submit-game.dto';
|
||||
|
||||
@Injectable()
|
||||
export class GameSubmissionService {
|
||||
private readonly logger = new Logger(GameSubmissionService.name);
|
||||
|
||||
constructor(private readonly configService: ConfigService) {}
|
||||
|
||||
async submitGame(dto: SubmitGameDto): Promise<SubmitGameResponseDto> {
|
||||
const githubToken = this.configService.get<string>('GITHUB_TOKEN');
|
||||
const githubOwner = this.configService.get<string>('GITHUB_OWNER') || 'tillschneider';
|
||||
const githubRepo = this.configService.get<string>('GITHUB_REPO') || 'mana-games';
|
||||
|
||||
if (!githubToken) {
|
||||
this.logger.error('GitHub token not configured');
|
||||
throw new InternalServerErrorException('Server configuration error - GitHub token missing');
|
||||
}
|
||||
|
||||
// Generate safe file names
|
||||
const gameSlug = dto.title
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const timestamp = Date.now();
|
||||
const branchName = `community-game-${gameSlug}-${timestamp}`;
|
||||
|
||||
const headers = {
|
||||
Authorization: `Bearer ${githubToken}`,
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
|
||||
try {
|
||||
// 1. Get the default branch
|
||||
this.logger.log(`Fetching repo: ${githubOwner}/${githubRepo}`);
|
||||
const repoResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!repoResponse.ok) {
|
||||
const errorBody = await repoResponse.text();
|
||||
this.logger.error('GitHub API Error:', { status: repoResponse.status, body: errorBody });
|
||||
throw new InternalServerErrorException(
|
||||
`Failed to fetch repository info: ${repoResponse.status}`
|
||||
);
|
||||
}
|
||||
|
||||
const repoData = await repoResponse.json();
|
||||
const defaultBranch = repoData.default_branch;
|
||||
|
||||
// 2. Get the latest commit SHA from the default branch
|
||||
const refResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/git/refs/heads/${defaultBranch}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!refResponse.ok) {
|
||||
throw new InternalServerErrorException('Failed to fetch branch info');
|
||||
}
|
||||
|
||||
const refData = await refResponse.json();
|
||||
const baseSha = refData.object.sha;
|
||||
|
||||
// 3. Create a new branch
|
||||
const createBranchResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/git/refs`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
ref: `refs/heads/${branchName}`,
|
||||
sha: baseSha,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!createBranchResponse.ok) {
|
||||
throw new InternalServerErrorException('Failed to create branch');
|
||||
}
|
||||
|
||||
// 4. Prepare game data
|
||||
const nextId = String(Date.now());
|
||||
const gameData = {
|
||||
id: nextId,
|
||||
title: dto.title,
|
||||
description: dto.description,
|
||||
slug: gameSlug,
|
||||
htmlFile: `/games/${gameSlug}.html`,
|
||||
thumbnail: `/screenshots/${gameSlug}.jpg`,
|
||||
tags: dto.tags,
|
||||
difficulty: dto.difficulty,
|
||||
complexity: dto.complexity,
|
||||
controls: dto.controls,
|
||||
community: true,
|
||||
author: dto.author.name,
|
||||
submittedAt: dto.submittedAt,
|
||||
};
|
||||
|
||||
// 5. Create files
|
||||
const filesToCreate = [
|
||||
{
|
||||
path: `public/games/${gameSlug}.html`,
|
||||
content: dto.files.html.content,
|
||||
encoding: 'utf-8' as const,
|
||||
},
|
||||
{
|
||||
path: `public/screenshots/${gameSlug}.jpg`,
|
||||
content: dto.files.screenshot.content.split(',')[1], // Remove data:image/jpeg;base64,
|
||||
encoding: 'base64' as const,
|
||||
},
|
||||
];
|
||||
|
||||
// Fetch existing community games
|
||||
const communityGamesPath = 'src/data/community-games.json';
|
||||
let communityGames: any[] = [];
|
||||
|
||||
try {
|
||||
const existingFileResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/contents/${communityGamesPath}?ref=${defaultBranch}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (existingFileResponse.ok) {
|
||||
const existingFile = await existingFileResponse.json();
|
||||
const content = Buffer.from(existingFile.content, 'base64').toString('utf-8');
|
||||
communityGames = JSON.parse(content);
|
||||
}
|
||||
} catch {
|
||||
// File doesn't exist yet
|
||||
}
|
||||
|
||||
communityGames.push(gameData);
|
||||
|
||||
filesToCreate.push({
|
||||
path: communityGamesPath,
|
||||
content: JSON.stringify(communityGames, null, 2),
|
||||
encoding: 'utf-8',
|
||||
});
|
||||
|
||||
// Create all files
|
||||
for (const file of filesToCreate) {
|
||||
const fileContent =
|
||||
file.encoding === 'base64' ? file.content : Buffer.from(file.content).toString('base64');
|
||||
|
||||
const createFileResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/contents/${file.path}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
message: `Add community game: ${dto.title}`,
|
||||
content: fileContent,
|
||||
branch: branchName,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!createFileResponse.ok) {
|
||||
const error = await createFileResponse.text();
|
||||
this.logger.error(`Failed to create file ${file.path}:`, error);
|
||||
throw new InternalServerErrorException(`Failed to create file ${file.path}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 6. Create pull request
|
||||
const prBody = `## Neues Community-Spiel: ${dto.title}
|
||||
|
||||
### Spiel-Details
|
||||
- **Autor:** ${dto.author.name}${dto.author.github ? ` (@${dto.author.github})` : ''}
|
||||
- **Beschreibung:** ${dto.description}
|
||||
- **Schwierigkeit:** ${dto.difficulty}
|
||||
- **Komplexität:** ${dto.complexity}
|
||||
- **Steuerung:** ${dto.controls}
|
||||
- **Tags:** ${dto.tags.join(', ')}
|
||||
|
||||
### Dateien
|
||||
- HTML: \`public/games/${gameSlug}.html\`
|
||||
- Screenshot: \`public/screenshots/${gameSlug}.jpg\`
|
||||
|
||||
### Checkliste für Review
|
||||
- [ ] Spiel funktioniert einwandfrei
|
||||
- [ ] Keine externen Abhängigkeiten oder Sicherheitsprobleme
|
||||
- [ ] Familienfreundlicher Inhalt
|
||||
- [ ] Screenshot zeigt das Spiel korrekt
|
||||
- [ ] postMessage Integration vorhanden (optional)
|
||||
|
||||
---
|
||||
*Eingereicht am: ${new Date(dto.submittedAt).toLocaleString('de-DE')}*
|
||||
${dto.author.email ? `*Kontakt: ${dto.author.email}*` : ''}`;
|
||||
|
||||
const prResponse = await fetch(
|
||||
`https://api.github.com/repos/${githubOwner}/${githubRepo}/pulls`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
title: `Community: ${dto.title}`,
|
||||
body: prBody,
|
||||
head: branchName,
|
||||
base: defaultBranch,
|
||||
}),
|
||||
}
|
||||
);
|
||||
|
||||
if (!prResponse.ok) {
|
||||
const error = await prResponse.text();
|
||||
this.logger.error('Failed to create PR:', error);
|
||||
throw new InternalServerErrorException('Failed to create pull request');
|
||||
}
|
||||
|
||||
const prData = await prResponse.json();
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Game submitted successfully',
|
||||
prUrl: prData.html_url,
|
||||
prNumber: prData.number,
|
||||
};
|
||||
} catch (error: any) {
|
||||
this.logger.error('Submission error:', error);
|
||||
if (error instanceof BadRequestException || error instanceof InternalServerErrorException) {
|
||||
throw error;
|
||||
}
|
||||
throw new InternalServerErrorException(
|
||||
'Failed to submit game: ' + (error.message || 'Unknown error')
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
13
games/arcade/apps/backend/src/health/health.controller.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
|
||||
@Controller('health')
|
||||
export class HealthController {
|
||||
@Get()
|
||||
check() {
|
||||
return {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
service: 'mana-games-backend',
|
||||
};
|
||||
}
|
||||
}
|
||||
7
games/arcade/apps/backend/src/health/health.module.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { HealthController } from './health.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [HealthController],
|
||||
})
|
||||
export class HealthModule {}
|
||||
37
games/arcade/apps/backend/src/main.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
// CORS configuration
|
||||
app.enableCors({
|
||||
origin: [
|
||||
'http://localhost:5210', // SvelteKit dev
|
||||
'http://localhost:4321', // Legacy Astro dev
|
||||
'http://localhost:3000', // Alternative dev
|
||||
],
|
||||
methods: ['GET', 'POST', 'OPTIONS'],
|
||||
credentials: false,
|
||||
});
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
transform: true,
|
||||
forbidNonWhitelisted: true,
|
||||
})
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3010;
|
||||
|
||||
// Increase timeout for long-running AI requests (2 minutes)
|
||||
const server = await app.listen(port);
|
||||
server.setTimeout(120000);
|
||||
|
||||
console.log(`Arcade backend running on http://localhost:${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
22
games/arcade/apps/backend/tsconfig.json
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
}
|
||||
}
|
||||
29
games/arcade/apps/web/Dockerfile
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
FROM sveltekit-base:local AS builder
|
||||
|
||||
ARG PUBLIC_MANA_CORE_AUTH_URL=http://mana-auth:3001
|
||||
ENV PUBLIC_MANA_CORE_AUTH_URL=$PUBLIC_MANA_CORE_AUTH_URL
|
||||
|
||||
COPY games/arcade/apps/web ./games/arcade/apps/web
|
||||
|
||||
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
|
||||
pnpm install --no-frozen-lockfile --ignore-scripts
|
||||
|
||||
WORKDIR /app/games/arcade/apps/web
|
||||
RUN pnpm exec svelte-kit sync
|
||||
RUN NODE_OPTIONS="--max-old-space-size=4096" pnpm build
|
||||
|
||||
FROM node:20-alpine AS production
|
||||
WORKDIR /app/games/arcade/apps/web
|
||||
COPY --from=builder /app/node_modules/.pnpm /app/node_modules/.pnpm
|
||||
COPY --from=builder /app/games/arcade/apps/web/node_modules ./node_modules
|
||||
COPY --from=builder /app/games/arcade/apps/web/build ./build
|
||||
COPY --from=builder /app/games/arcade/apps/web/package.json ./
|
||||
|
||||
EXPOSE 5210
|
||||
ENV NODE_ENV=production PORT=5210 HOST=0.0.0.0
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
||||
CMD wget --no-verbose --tries=1 --spider http://localhost:5210/health || exit 1
|
||||
|
||||
CMD ["node", "build"]
|
||||
58
games/arcade/apps/web/package.json
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
{
|
||||
"name": "@arcade/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prepare": "svelte-kit sync || echo ''",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write .",
|
||||
"type-check": "svelte-kit sync && svelte-check --threshold error"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@manacore/shared-pwa": "workspace:*",
|
||||
"@manacore/shared-vite-config": "workspace:*",
|
||||
"@sveltejs/adapter-node": "^5.0.0",
|
||||
"@sveltejs/kit": "^2.47.1",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.1.7",
|
||||
"@types/node": "^20.0.0",
|
||||
"@vite-pwa/sveltekit": "^1.1.0",
|
||||
"prettier": "^3.1.1",
|
||||
"prettier-plugin-svelte": "^3.1.2",
|
||||
"svelte": "^5.41.0",
|
||||
"svelte-check": "^4.3.3",
|
||||
"tailwindcss": "^4.1.7",
|
||||
"tslib": "^2.4.1",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^6.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@manacore/local-store": "workspace:*",
|
||||
"@manacore/shared-app-onboarding": "workspace:*",
|
||||
"@manacore/shared-auth": "workspace:*",
|
||||
"@manacore/shared-auth-stores": "workspace:*",
|
||||
"@manacore/shared-auth-ui": "workspace:*",
|
||||
"@manacore/shared-branding": "workspace:*",
|
||||
"@manacore/shared-error-tracking": "workspace:*",
|
||||
"@manacore/feedback": "workspace:*",
|
||||
"@manacore/shared-i18n": "workspace:*",
|
||||
"@manacore/help": "workspace:*",
|
||||
"@manacore/shared-icons": "workspace:*",
|
||||
"@manacore/shared-profile-ui": "workspace:*",
|
||||
"@manacore/shared-stores": "workspace:*",
|
||||
"@manacore/shared-tags": "workspace:*",
|
||||
"@manacore/subscriptions": "workspace:*",
|
||||
"@manacore/shared-tailwind": "workspace:*",
|
||||
"@manacore/shared-theme": "workspace:*",
|
||||
"@manacore/shared-theme-ui": "workspace:*",
|
||||
"@manacore/shared-ui": "workspace:*",
|
||||
"@manacore/shared-utils": "workspace:*",
|
||||
"svelte-i18n": "^4.0.1"
|
||||
},
|
||||
"type": "module"
|
||||
}
|
||||
10
games/arcade/apps/web/src/app.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
@import "tailwindcss";
|
||||
@import "@manacore/shared-tailwind/themes.css";
|
||||
|
||||
/* Scan shared packages for Tailwind classes */
|
||||
@source "../../../../../../packages/shared-ui/src";
|
||||
@source "../../../../../../packages/shared-auth-ui/src";
|
||||
@source "../../../../../../packages/shared-branding/src";
|
||||
@source "../../../../../../packages/shared-theme-ui/src";
|
||||
@source "../../../../../../packages/shared-theme-ui/src/components";
|
||||
@source "../../../../../../packages/shared-theme-ui/src/pages";
|
||||
12
games/arcade/apps/web/src/app.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
12
games/arcade/apps/web/src/hooks.client.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { initErrorTracking, handleSvelteError } from '@manacore/shared-error-tracking/browser';
|
||||
import type { HandleClientError } from '@sveltejs/kit';
|
||||
|
||||
initErrorTracking({
|
||||
serviceName: 'arcade-web',
|
||||
dsn: (window as any).__PUBLIC_GLITCHTIP_DSN__,
|
||||
environment: import.meta.env.MODE,
|
||||
});
|
||||
|
||||
export const handleError: HandleClientError = ({ error }) => {
|
||||
handleSvelteError(error);
|
||||
};
|
||||
28
games/arcade/apps/web/src/hooks.server.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import type { Handle } from '@sveltejs/kit';
|
||||
import { injectUmamiAnalytics } from '@manacore/shared-utils/analytics-server';
|
||||
import { setSecurityHeaders } from '@manacore/shared-utils/security-headers';
|
||||
|
||||
const PUBLIC_MANA_CORE_AUTH_URL_CLIENT =
|
||||
process.env.PUBLIC_MANA_CORE_AUTH_URL_CLIENT || process.env.PUBLIC_MANA_CORE_AUTH_URL || '';
|
||||
const PUBLIC_BACKEND_URL_CLIENT =
|
||||
process.env.PUBLIC_BACKEND_URL_CLIENT || process.env.PUBLIC_BACKEND_URL || '';
|
||||
const PUBLIC_GLITCHTIP_DSN = process.env.PUBLIC_GLITCHTIP_DSN || '';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
const response = await resolve(event, {
|
||||
transformPageChunk: ({ html }) => {
|
||||
const envScript = `<script>
|
||||
window.__PUBLIC_MANA_CORE_AUTH_URL__ = ${JSON.stringify(PUBLIC_MANA_CORE_AUTH_URL_CLIENT)};
|
||||
window.__PUBLIC_BACKEND_URL__ = ${JSON.stringify(PUBLIC_BACKEND_URL_CLIENT)};
|
||||
window.__PUBLIC_GLITCHTIP_DSN__ = ${JSON.stringify(PUBLIC_GLITCHTIP_DSN)};
|
||||
</script>`;
|
||||
return injectUmamiAnalytics(html.replace('<head>', `<head>${envScript}`));
|
||||
},
|
||||
});
|
||||
|
||||
setSecurityHeaders(response, {
|
||||
connectSrc: [PUBLIC_MANA_CORE_AUTH_URL_CLIENT, PUBLIC_BACKEND_URL_CLIENT],
|
||||
});
|
||||
|
||||
return response;
|
||||
};
|
||||
54
games/arcade/apps/web/src/lib/components/GameCard.svelte
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script lang="ts">
|
||||
import type { Game } from '$lib/data/games';
|
||||
|
||||
let { game, href }: { game: Game; href: string } = $props();
|
||||
|
||||
const difficultyColors: Record<string, string> = {
|
||||
Einfach: 'bg-green-500/20 text-green-400',
|
||||
Mittel: 'bg-yellow-500/20 text-yellow-400',
|
||||
Schwer: 'bg-red-500/20 text-red-400',
|
||||
};
|
||||
</script>
|
||||
|
||||
<a
|
||||
{href}
|
||||
class="group block rounded-xl border border-border bg-card p-0 overflow-hidden transition-all hover:border-primary/50 hover:shadow-lg hover:shadow-primary/5 hover:-translate-y-0.5"
|
||||
>
|
||||
{#if game.thumbnail}
|
||||
<div class="aspect-video w-full overflow-hidden bg-muted">
|
||||
<img
|
||||
src={game.thumbnail}
|
||||
alt={game.title}
|
||||
class="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="aspect-video w-full bg-muted flex items-center justify-center">
|
||||
<span class="text-4xl opacity-40">🎮</span>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="p-4">
|
||||
<div class="flex items-start justify-between gap-2 mb-2">
|
||||
<h3 class="font-semibold text-foreground group-hover:text-primary transition-colors">
|
||||
{game.title}
|
||||
</h3>
|
||||
<span class="shrink-0 text-xs px-2 py-0.5 rounded-full {difficultyColors[game.difficulty]}">
|
||||
{game.difficulty}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p class="text-sm text-muted-foreground line-clamp-2 mb-3">
|
||||
{game.description}
|
||||
</p>
|
||||
|
||||
<div class="flex flex-wrap gap-1">
|
||||
{#each game.tags.slice(0, 3) as tag}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
<script lang="ts">
|
||||
import { SkeletonBox } from '@manacore/shared-ui';
|
||||
</script>
|
||||
|
||||
<div class="app-loading-skeleton" role="status" aria-label="App wird geladen...">
|
||||
<div class="header-skeleton">
|
||||
<SkeletonBox width="120px" height="32px" borderRadius="8px" />
|
||||
<div class="header-nav">
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
<SkeletonBox width="80px" height="32px" borderRadius="16px" />
|
||||
</div>
|
||||
<SkeletonBox width="36px" height="36px" borderRadius="50%" />
|
||||
</div>
|
||||
|
||||
<div class="content-skeleton">
|
||||
<div class="games-placeholder">
|
||||
{#each Array(6) as _}
|
||||
<SkeletonBox width="100%" height="200px" borderRadius="12px" />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.app-loading-skeleton {
|
||||
min-height: 100vh;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem 2rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.header-nav {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
max-width: 80rem;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.games-placeholder {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.header-nav {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.header-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.content-skeleton {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.games-placeholder {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default as AppLoadingSkeleton } from './AppLoadingSkeleton.svelte';
|
||||
325
games/arcade/apps/web/src/lib/data/games.ts
Normal file
|
|
@ -0,0 +1,325 @@
|
|||
export interface Game {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string;
|
||||
slug: string;
|
||||
htmlFile: string;
|
||||
thumbnail?: string;
|
||||
tags: string[];
|
||||
difficulty: 'Einfach' | 'Mittel' | 'Schwer';
|
||||
complexity: 'Minimal' | 'Einfach' | 'Mittel' | 'Komplex';
|
||||
controls: string;
|
||||
codeStats?: {
|
||||
total: number;
|
||||
code: number;
|
||||
comments: number;
|
||||
};
|
||||
community?: boolean;
|
||||
author?: string;
|
||||
submittedAt?: string;
|
||||
}
|
||||
|
||||
export const games: Game[] = [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Snake',
|
||||
description:
|
||||
'Der Klassiker! Steuere die Schlange und sammle Nahrung, aber vermeide die roten Felder!',
|
||||
slug: 'snake',
|
||||
htmlFile: '/games/snake_game.html',
|
||||
thumbnail: '/screenshots/snake.jpg',
|
||||
tags: ['Arcade', 'Klassiker', 'Retro'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Komplex',
|
||||
controls: 'Pfeiltasten oder WASD zum Steuern',
|
||||
codeStats: { total: 604, code: 338, comments: 192 },
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
title: 'Space Defender',
|
||||
description:
|
||||
'Verteidige dein Raumschiff gegen Wellen von Aliens. Die Schwierigkeit steigt mit der Zeit!',
|
||||
slug: 'space-defender',
|
||||
htmlFile: '/games/space_defender_game.html',
|
||||
thumbnail: '/screenshots/space-defenders.jpg',
|
||||
tags: ['Shooter', 'Arcade', 'Action'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Mittel',
|
||||
controls: 'A/D oder Pfeiltasten zum Bewegen, Leertaste zum Schießen',
|
||||
codeStats: { total: 436, code: 348, comments: 32 },
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
title: 'Gravity Painter',
|
||||
description:
|
||||
'Ein kreatives Physik-Puzzle! Setze Gravitationspunkte und lenke Partikel zu den Zielen.',
|
||||
slug: 'gravity-painter',
|
||||
htmlFile: '/games/gravity_painter.html',
|
||||
thumbnail: '/screenshots/gravity-painter.jpg',
|
||||
tags: ['Puzzle', 'Physik', 'Kreativ'],
|
||||
difficulty: 'Schwer',
|
||||
complexity: 'Mittel',
|
||||
controls: 'Klicke für Gravitationspunkte, Leertaste für Partikel',
|
||||
codeStats: { total: 426, code: 348, comments: 21 },
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
title: 'Bounce & Catch Tutorial',
|
||||
description:
|
||||
'Ein einfaches Lernspiel, das die Grundlagen der Spieleentwicklung zeigt. Perfekt für Anfänger!',
|
||||
slug: 'bounce-catch-tutorial',
|
||||
htmlFile: '/games/bounce_catch_tutorial.html',
|
||||
thumbnail: '/screenshots/bounce-catch.jpg',
|
||||
tags: ['Tutorial', 'Lernspiel', 'Arcade'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Einfach',
|
||||
controls: 'Mausbewegung zum Steuern des Paddles',
|
||||
codeStats: { total: 437, code: 289, comments: 87 },
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
title: 'Neon Maze Runner',
|
||||
description:
|
||||
'Navigiere durch prozedural generierte Labyrinthe! Sammle Diamanten, nutze Power-ups und finde den Ausgang.',
|
||||
slug: 'neon-maze-runner',
|
||||
htmlFile: '/games/neon_maze_runner.html',
|
||||
thumbnail: '/screenshots/neon-maze-runner.jpg',
|
||||
tags: ['Puzzle', 'Labyrinth', 'Arcade'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Komplex',
|
||||
controls: 'WASD oder Pfeiltasten zum Bewegen',
|
||||
codeStats: { total: 832, code: 644, comments: 69 },
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
title: 'Rhythm Defender',
|
||||
description:
|
||||
'Verteidige dich im Takt der Musik! Drücke die richtigen Tasten im perfekten Timing für maximale Combos.',
|
||||
slug: 'rhythm-defender',
|
||||
htmlFile: '/games/rhythm_defender.html',
|
||||
thumbnail: '/screenshots/rhythm-defender.jpg',
|
||||
tags: ['Rhythmus', 'Musik', 'Arcade'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Komplex',
|
||||
controls: 'A, S, D, F Tasten im Rhythmus drücken',
|
||||
codeStats: { total: 741, code: 584, comments: 56 },
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
title: 'Click Race',
|
||||
description: 'Das schnellste Spiel! Klicke 30 mal so schnell du kannst. Wie schnell bist du?',
|
||||
slug: 'click-race',
|
||||
htmlFile: '/games/click_race.html',
|
||||
thumbnail: '/screenshots/click-race.jpg',
|
||||
tags: ['Geschwindigkeit', 'Minimal', 'Arcade'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Minimal',
|
||||
controls: 'Klicke auf das rote Quadrat',
|
||||
codeStats: { total: 111, code: 88, comments: 23 },
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
title: 'Color Memory',
|
||||
description:
|
||||
'Merke dir die Farbreihenfolge! Ein klassisches Gedächtnisspiel das immer schwerer wird.',
|
||||
slug: 'color-memory',
|
||||
htmlFile: '/games/color_memory.html',
|
||||
thumbnail: '/screenshots/color-memory.jpg',
|
||||
tags: ['Gedächtnis', 'Minimal', 'Puzzle'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Minimal',
|
||||
controls: 'Klicke die Farben in der richtigen Reihenfolge',
|
||||
codeStats: { total: 86, code: 86, comments: 0 },
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
title: 'Reaction Test',
|
||||
description:
|
||||
'Wie schnell sind deine Reflexe? Klicke so schnell wie möglich wenn der Bildschirm grün wird!',
|
||||
slug: 'reaction-test',
|
||||
htmlFile: '/games/reaction_test.html',
|
||||
thumbnail: '/screenshots/reaction-test.jpg',
|
||||
tags: ['Reaktion', 'Minimal', 'Test'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Minimal',
|
||||
controls: 'Klicke wenn der Bildschirm grün wird',
|
||||
codeStats: { total: 78, code: 78, comments: 0 },
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
title: 'Asteroid Dash',
|
||||
description:
|
||||
'Fliege durch gefährliche Asteroidenfelder! Sammle Energie-Kristalle, nutze Power-ups und weiche den rotierenden Asteroiden aus.',
|
||||
slug: 'asteroid-dash',
|
||||
htmlFile: '/games/asteroid_dash.html',
|
||||
thumbnail: '/screenshots/asteroid-dash.jpg',
|
||||
tags: ['Action', 'Arcade', 'Weltraum'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Mittel',
|
||||
controls: 'WASD oder Pfeiltasten zum Fliegen, Leertaste für Boost',
|
||||
codeStats: { total: 485, code: 428, comments: 57 },
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
title: 'Fish Catcher',
|
||||
description:
|
||||
'Fange Fische mit deinem Boot! Verschiedene Fischarten bringen unterschiedliche Punkte.',
|
||||
slug: 'fish-catcher',
|
||||
htmlFile: '/games/fish_catcher.html',
|
||||
thumbnail: '/screenshots/fish-catcher.jpg',
|
||||
tags: ['Arcade', 'Familie', 'Entspannend'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Einfach',
|
||||
controls: 'A/D oder Pfeiltasten zum Bewegen',
|
||||
codeStats: { total: 362, code: 321, comments: 41 },
|
||||
},
|
||||
{
|
||||
id: '12',
|
||||
title: 'Balloon Pop',
|
||||
description:
|
||||
'Platze bunte Ballons bevor sie entkommen! Verschiedene Ballonarten, Power-ups und Combo-System.',
|
||||
slug: 'balloon-pop',
|
||||
htmlFile: '/games/balloon_pop.html',
|
||||
thumbnail: '/screenshots/balloon-pop.jpg',
|
||||
tags: ['Geschicklichkeit', 'Familie', 'Bunt'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Einfach',
|
||||
controls: 'Maus zum Klicken auf Ballons',
|
||||
codeStats: { total: 398, code: 351, comments: 47 },
|
||||
},
|
||||
{
|
||||
id: '13',
|
||||
title: 'Word Scramble',
|
||||
description:
|
||||
'Entschlüssele durcheinandergewürfelte Wörter! Mit 5 Kategorien, Combo-System und steigender Schwierigkeit.',
|
||||
slug: 'word-scramble',
|
||||
htmlFile: '/games/word_scramble.html',
|
||||
tags: ['Puzzle', 'Wortspiel', 'Bildung'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Mittel',
|
||||
controls: 'Tastatur zum Eingeben, Maus zum Klicken auf Buchstaben',
|
||||
codeStats: { total: 850, code: 720, comments: 130 },
|
||||
},
|
||||
{
|
||||
id: '14',
|
||||
title: 'Memory Card Match',
|
||||
description:
|
||||
'Das klassische Memory-Spiel! Finde alle Kartenpaare mit Emojis. Drei Schwierigkeitsstufen.',
|
||||
slug: 'memory-card-match',
|
||||
htmlFile: '/games/memory_card_match.html',
|
||||
tags: ['Gedächtnis', 'Kartenspiel', 'Familie'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Einfach',
|
||||
controls: 'Maus zum Aufdecken der Karten',
|
||||
codeStats: { total: 415, code: 350, comments: 0 },
|
||||
},
|
||||
{
|
||||
id: '15',
|
||||
title: 'Turbo Racer',
|
||||
description:
|
||||
'Drift durch die Kurven und stelle Bestzeiten auf! Mit realistischer Drift-Physik und Nitro-Boost.',
|
||||
slug: 'turbo-racer',
|
||||
htmlFile: '/games/turbo_racer.html',
|
||||
tags: ['Rennen', 'Action', 'Arcade'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Mittel',
|
||||
controls: 'WASD oder Pfeiltasten zum Fahren, Leertaste für Boost',
|
||||
codeStats: { total: 680, code: 620, comments: 60 },
|
||||
},
|
||||
{
|
||||
id: '16',
|
||||
title: 'Card Stack Rush',
|
||||
description:
|
||||
'Sortiere Karten blitzschnell auf die richtigen Stapel! Mit wechselnden Regeln und Combo-System.',
|
||||
slug: 'card-stack-rush',
|
||||
htmlFile: '/games/card_stack_rush.html',
|
||||
tags: ['Kartenspiel', 'Geschwindigkeit', 'Arcade'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Einfach',
|
||||
controls: 'Drag & Drop oder Klicken zum Platzieren',
|
||||
codeStats: { total: 520, code: 480, comments: 0 },
|
||||
},
|
||||
{
|
||||
id: '17',
|
||||
title: 'Flappy Mana',
|
||||
description:
|
||||
'Fliege durch Röhren und sammle Punkte! Ein Flappy Bird Klon mit Partikeleffekten.',
|
||||
slug: 'flappy-mana',
|
||||
htmlFile: '/games/flappy_mana.html',
|
||||
tags: ['Arcade', 'Geschicklichkeit', 'Endless'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Einfach',
|
||||
controls: 'Klick oder Leertaste zum Fliegen',
|
||||
codeStats: { total: 450, code: 430, comments: 20 },
|
||||
},
|
||||
{
|
||||
id: '18',
|
||||
title: 'Mana Runner',
|
||||
description:
|
||||
'Laufe und springe durch magische Welten! Sammle Mana-Kristalle und weiche Hindernissen aus.',
|
||||
slug: 'mana-runner',
|
||||
htmlFile: '/games/mana_runner.html',
|
||||
tags: ['Jump n Run', 'Arcade', 'Endless'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Mittel',
|
||||
controls: 'Leertaste zum Springen, Doppelsprung nach 10 Kristallen',
|
||||
codeStats: { total: 600, code: 580, comments: 20 },
|
||||
},
|
||||
{
|
||||
id: '19',
|
||||
title: 'Mana Defense',
|
||||
description:
|
||||
'Verteidige deinen Mana-Kristall! Baue Türme, plane deine Strategie und überlebe 20 Wellen.',
|
||||
slug: 'mana-defense',
|
||||
htmlFile: '/games/mana_defense.html',
|
||||
tags: ['Tower Defense', 'Strategie', 'Aufbau'],
|
||||
difficulty: 'Schwer',
|
||||
complexity: 'Komplex',
|
||||
controls: 'Maus zum Platzieren, 1-3 für Turmauswahl, S zum Verkaufen',
|
||||
codeStats: { total: 900, code: 850, comments: 50 },
|
||||
},
|
||||
{
|
||||
id: '20',
|
||||
title: 'Mana Factory',
|
||||
description:
|
||||
'Baue die größte Mana-Produktionsanlage! Ein Idle-Game mit Upgrades und Prestige-System.',
|
||||
slug: 'mana-factory',
|
||||
htmlFile: '/games/mana_factory.html',
|
||||
tags: ['Idle', 'Incremental', 'Aufbau'],
|
||||
difficulty: 'Einfach',
|
||||
complexity: 'Mittel',
|
||||
controls: 'Maus zum Klicken und Kaufen',
|
||||
codeStats: { total: 800, code: 750, comments: 50 },
|
||||
},
|
||||
{
|
||||
id: '21',
|
||||
title: 'Puzzle Blocks',
|
||||
description:
|
||||
'Klassisches Tetris-Gameplay! Stapele fallende Blöcke, vervollständige Reihen und erreiche den höchsten Score.',
|
||||
slug: 'puzzle-blocks',
|
||||
htmlFile: '/games/puzzle_blocks.html',
|
||||
tags: ['Puzzle', 'Klassiker', 'Arcade'],
|
||||
difficulty: 'Mittel',
|
||||
complexity: 'Einfach',
|
||||
controls: '← → zum Bewegen, ↑ zum Drehen, ↓ schneller fallen, Space für Harddrop',
|
||||
codeStats: { total: 450, code: 420, comments: 30 },
|
||||
},
|
||||
];
|
||||
|
||||
export function getGameBySlug(slug: string): Game | undefined {
|
||||
return games.find((g) => g.slug === slug);
|
||||
}
|
||||
|
||||
export function getGamesByTag(tag: string): Game[] {
|
||||
return games.filter((g) => g.tags.includes(tag));
|
||||
}
|
||||
|
||||
export function getAllTags(): string[] {
|
||||
const tagSet = new Set<string>();
|
||||
for (const game of games) {
|
||||
for (const tag of game.tags) {
|
||||
tagSet.add(tag);
|
||||
}
|
||||
}
|
||||
return [...tagSet].sort();
|
||||
}
|
||||
1
games/arcade/apps/web/src/lib/data/guest-seed.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
// No guest seed data needed — games are static HTML files, stats build up from play
|
||||
55
games/arcade/apps/web/src/lib/data/local-store.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { createLocalStore, type BaseRecord } from '@manacore/local-store';
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────
|
||||
|
||||
export interface LocalGameStats extends BaseRecord {
|
||||
gameId: string;
|
||||
highScore: number;
|
||||
lastScore: number;
|
||||
gamesPlayed: number;
|
||||
totalPlayTime: number;
|
||||
lastPlayed: string;
|
||||
}
|
||||
|
||||
export interface LocalGeneratedGame extends BaseRecord {
|
||||
title: string;
|
||||
description: string;
|
||||
htmlCode: string;
|
||||
prompt: string;
|
||||
model: string;
|
||||
iterationCount: number;
|
||||
}
|
||||
|
||||
export interface LocalFavorite extends BaseRecord {
|
||||
gameId: string;
|
||||
}
|
||||
|
||||
// ─── Store ──────────────────────────────────────────────────
|
||||
|
||||
const SYNC_SERVER_URL = import.meta.env.PUBLIC_SYNC_SERVER_URL || 'http://localhost:3050';
|
||||
|
||||
export const gamesStore = createLocalStore({
|
||||
appId: 'arcade',
|
||||
collections: [
|
||||
{
|
||||
name: 'gameStats',
|
||||
indexes: ['gameId', 'highScore'],
|
||||
},
|
||||
{
|
||||
name: 'generatedGames',
|
||||
indexes: ['title'],
|
||||
},
|
||||
{
|
||||
name: 'favorites',
|
||||
indexes: ['gameId'],
|
||||
},
|
||||
],
|
||||
sync: {
|
||||
serverUrl: SYNC_SERVER_URL,
|
||||
},
|
||||
});
|
||||
|
||||
// Typed collection accessors
|
||||
export const gameStatsCollection = gamesStore.collection<LocalGameStats>('gameStats');
|
||||
export const generatedGameCollection = gamesStore.collection<LocalGeneratedGame>('generatedGames');
|
||||
export const favoriteCollection = gamesStore.collection<LocalFavorite>('favorites');
|
||||
24
games/arcade/apps/web/src/lib/data/queries.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
|
||||
import {
|
||||
gameStatsCollection,
|
||||
generatedGameCollection,
|
||||
favoriteCollection,
|
||||
type LocalGameStats,
|
||||
type LocalGeneratedGame,
|
||||
type LocalFavorite,
|
||||
} from './local-store';
|
||||
|
||||
export function useAllGameStats() {
|
||||
return useLiveQueryWithDefault(async () => gameStatsCollection.getAll(), [] as LocalGameStats[]);
|
||||
}
|
||||
|
||||
export function useAllGeneratedGames() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const games = await generatedGameCollection.getAll();
|
||||
return games.reverse();
|
||||
}, [] as LocalGeneratedGame[]);
|
||||
}
|
||||
|
||||
export function useAllFavorites() {
|
||||
return useLiveQueryWithDefault(async () => favoriteCollection.getAll(), [] as LocalFavorite[]);
|
||||
}
|
||||
38
games/arcade/apps/web/src/lib/i18n/index.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { init, register, locale, waitLocale } from 'svelte-i18n';
|
||||
|
||||
export const supportedLocales = ['de', 'en'] as const;
|
||||
export type SupportedLocale = (typeof supportedLocales)[number];
|
||||
|
||||
const defaultLocale = 'de';
|
||||
|
||||
register('de', () => import('./locales/de.json'));
|
||||
register('en', () => import('./locales/en.json'));
|
||||
|
||||
function getInitialLocale(): SupportedLocale {
|
||||
if (browser) {
|
||||
const stored = localStorage.getItem('arcade_locale');
|
||||
if (stored && supportedLocales.includes(stored as SupportedLocale)) {
|
||||
return stored as SupportedLocale;
|
||||
}
|
||||
const browserLang = navigator.language.split('-')[0];
|
||||
if (supportedLocales.includes(browserLang as SupportedLocale)) {
|
||||
return browserLang as SupportedLocale;
|
||||
}
|
||||
}
|
||||
return defaultLocale;
|
||||
}
|
||||
|
||||
init({
|
||||
fallbackLocale: defaultLocale,
|
||||
initialLocale: getInitialLocale(),
|
||||
});
|
||||
|
||||
export function setLocale(newLocale: SupportedLocale) {
|
||||
locale.set(newLocale);
|
||||
if (browser) {
|
||||
localStorage.setItem('arcade_locale', newLocale);
|
||||
}
|
||||
}
|
||||
|
||||
export { waitLocale };
|
||||
66
games/arcade/apps/web/src/lib/i18n/locales/de.json
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Arcade",
|
||||
"loading": "Wird geladen..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Spiele",
|
||||
"create": "Erstellen",
|
||||
"community": "Community",
|
||||
"stats": "Statistiken",
|
||||
"settings": "Einstellungen"
|
||||
},
|
||||
"home": {
|
||||
"title": "Browser-Spiele",
|
||||
"subtitle": "22+ Spiele direkt im Browser spielen",
|
||||
"search": "Spiel suchen...",
|
||||
"noResults": "Keine Spiele gefunden",
|
||||
"allGames": "Alle Spiele",
|
||||
"favorites": "Favoriten",
|
||||
"recentlyPlayed": "Zuletzt gespielt"
|
||||
},
|
||||
"game": {
|
||||
"play": "Spielen",
|
||||
"difficulty": "Schwierigkeit",
|
||||
"controls": "Steuerung",
|
||||
"tags": "Tags",
|
||||
"stats": "Statistiken",
|
||||
"highScore": "Highscore",
|
||||
"gamesPlayed": "Spiele gespielt",
|
||||
"totalPlayTime": "Gesamtspielzeit",
|
||||
"lastPlayed": "Zuletzt gespielt",
|
||||
"back": "Zurück",
|
||||
"fullscreen": "Vollbild",
|
||||
"editor": "Code ansehen"
|
||||
},
|
||||
"create": {
|
||||
"title": "Spiel erstellen",
|
||||
"subtitle": "Beschreibe dein Spiel und lass es von KI generieren",
|
||||
"prompt": "Was für ein Spiel soll erstellt werden?",
|
||||
"promptPlaceholder": "Ein Neon-Snake-Spiel mit Partikeleffekten...",
|
||||
"generate": "Generieren",
|
||||
"generating": "Generiere Spiel...",
|
||||
"model": "KI-Modell",
|
||||
"iterate": "Verbessern",
|
||||
"save": "Speichern",
|
||||
"preview": "Vorschau"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Deine Statistiken",
|
||||
"totalGames": "Gespielte Spiele",
|
||||
"totalTime": "Gesamtspielzeit",
|
||||
"favoriteGame": "Lieblingsspiel",
|
||||
"noStats": "Noch keine Statistiken. Spiele ein paar Spiele!"
|
||||
},
|
||||
"difficulty": {
|
||||
"Einfach": "Einfach",
|
||||
"Mittel": "Mittel",
|
||||
"Schwer": "Schwer"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "Gerade eben",
|
||||
"minutesAgo": "Vor {minutes} Minuten",
|
||||
"hoursAgo": "Vor {hours} Stunden",
|
||||
"daysAgo": "Vor {days} Tagen"
|
||||
}
|
||||
}
|
||||
66
games/arcade/apps/web/src/lib/i18n/locales/en.json
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Arcade",
|
||||
"loading": "Loading..."
|
||||
},
|
||||
"nav": {
|
||||
"home": "Games",
|
||||
"create": "Create",
|
||||
"community": "Community",
|
||||
"stats": "Statistics",
|
||||
"settings": "Settings"
|
||||
},
|
||||
"home": {
|
||||
"title": "Browser Games",
|
||||
"subtitle": "22+ games to play right in your browser",
|
||||
"search": "Search games...",
|
||||
"noResults": "No games found",
|
||||
"allGames": "All Games",
|
||||
"favorites": "Favorites",
|
||||
"recentlyPlayed": "Recently Played"
|
||||
},
|
||||
"game": {
|
||||
"play": "Play",
|
||||
"difficulty": "Difficulty",
|
||||
"controls": "Controls",
|
||||
"tags": "Tags",
|
||||
"stats": "Statistics",
|
||||
"highScore": "High Score",
|
||||
"gamesPlayed": "Games Played",
|
||||
"totalPlayTime": "Total Play Time",
|
||||
"lastPlayed": "Last Played",
|
||||
"back": "Back",
|
||||
"fullscreen": "Fullscreen",
|
||||
"editor": "View Code"
|
||||
},
|
||||
"create": {
|
||||
"title": "Create Game",
|
||||
"subtitle": "Describe your game and let AI generate it",
|
||||
"prompt": "What kind of game should be created?",
|
||||
"promptPlaceholder": "A neon snake game with particle effects...",
|
||||
"generate": "Generate",
|
||||
"generating": "Generating game...",
|
||||
"model": "AI Model",
|
||||
"iterate": "Improve",
|
||||
"save": "Save",
|
||||
"preview": "Preview"
|
||||
},
|
||||
"stats": {
|
||||
"title": "Your Statistics",
|
||||
"totalGames": "Games Played",
|
||||
"totalTime": "Total Play Time",
|
||||
"favoriteGame": "Favorite Game",
|
||||
"noStats": "No statistics yet. Play some games!"
|
||||
},
|
||||
"difficulty": {
|
||||
"Einfach": "Easy",
|
||||
"Mittel": "Medium",
|
||||
"Schwer": "Hard"
|
||||
},
|
||||
"time": {
|
||||
"justNow": "Just now",
|
||||
"minutesAgo": "{minutes} minutes ago",
|
||||
"hoursAgo": "{hours} hours ago",
|
||||
"daysAgo": "{days} days ago"
|
||||
}
|
||||
}
|
||||
8
games/arcade/apps/web/src/lib/services/feedback.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { createFeedbackService } from '@manacore/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
export const feedbackService = createFeedbackService({
|
||||
apiUrl: import.meta.env.DEV ? 'http://localhost:3001' : 'https://auth.mana.how',
|
||||
appId: 'arcade',
|
||||
getAuthToken: async () => authStore.getAccessToken(),
|
||||
});
|
||||
114
games/arcade/apps/web/src/lib/services/game-communication.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
import { gameStatsCollection, type LocalGameStats } from '$lib/data/local-store';
|
||||
|
||||
export interface GameMessage {
|
||||
type: 'GAME_EVENT' | 'GAME_LOADED' | 'GAME_ENDED';
|
||||
gameId: string;
|
||||
event?: string;
|
||||
data?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function initGameCommunication(gameSlug: string) {
|
||||
let gameStartTime: number | null = null;
|
||||
|
||||
async function getOrCreateStats(gameId: string): Promise<LocalGameStats | null> {
|
||||
const all = await gameStatsCollection.getAll();
|
||||
return all.find((s) => s.gameId === gameId) || null;
|
||||
}
|
||||
|
||||
async function updateGameStats(gameId: string, update: Partial<LocalGameStats>) {
|
||||
const existing = await getOrCreateStats(gameId);
|
||||
|
||||
if (existing) {
|
||||
await gameStatsCollection.update(existing.id, {
|
||||
...update,
|
||||
lastPlayed: new Date().toISOString(),
|
||||
});
|
||||
} else {
|
||||
await gameStatsCollection.insert({
|
||||
gameId,
|
||||
highScore: 0,
|
||||
lastScore: 0,
|
||||
gamesPlayed: 0,
|
||||
totalPlayTime: 0,
|
||||
lastPlayed: new Date().toISOString(),
|
||||
...update,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleMessage(event: MessageEvent) {
|
||||
if (event.origin !== window.location.origin) return;
|
||||
|
||||
const message = event.data as GameMessage;
|
||||
if (!message.type || message.gameId !== gameSlug) return;
|
||||
|
||||
switch (message.type) {
|
||||
case 'GAME_LOADED':
|
||||
gameStartTime = Date.now();
|
||||
getOrCreateStats(gameSlug).then((stats) => {
|
||||
updateGameStats(gameSlug, {
|
||||
gamesPlayed: (stats?.gamesPlayed || 0) + 1,
|
||||
});
|
||||
});
|
||||
break;
|
||||
|
||||
case 'GAME_EVENT':
|
||||
handleGameEvent(gameSlug, message.event!, message.data);
|
||||
break;
|
||||
|
||||
case 'GAME_ENDED':
|
||||
if (gameStartTime) {
|
||||
const playTime = Math.floor((Date.now() - gameStartTime) / 1000);
|
||||
getOrCreateStats(gameSlug).then((stats) => {
|
||||
updateGameStats(gameSlug, {
|
||||
totalPlayTime: (stats?.totalPlayTime || 0) + playTime,
|
||||
});
|
||||
});
|
||||
gameStartTime = null;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
function handleBeforeUnload() {
|
||||
if (gameStartTime) {
|
||||
const playTime = Math.floor((Date.now() - gameStartTime) / 1000);
|
||||
getOrCreateStats(gameSlug).then((stats) => {
|
||||
updateGameStats(gameSlug, {
|
||||
totalPlayTime: (stats?.totalPlayTime || 0) + playTime,
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
window.addEventListener('beforeunload', handleBeforeUnload);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
window.removeEventListener('beforeunload', handleBeforeUnload);
|
||||
};
|
||||
|
||||
async function handleGameEvent(
|
||||
gameId: string,
|
||||
event: string,
|
||||
data: Record<string, unknown> | undefined
|
||||
) {
|
||||
if (!data) return;
|
||||
|
||||
switch (event) {
|
||||
case 'SCORE_UPDATE':
|
||||
case 'GAME_OVER': {
|
||||
const score = data.score as number;
|
||||
if (score) {
|
||||
const stats = await getOrCreateStats(gameId);
|
||||
await updateGameStats(gameId, {
|
||||
lastScore: score,
|
||||
highScore: Math.max(score, stats?.highScore || 0),
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
import { createAppOnboardingStore, type AppOnboardingStep } from '@manacore/shared-app-onboarding';
|
||||
import { userSettings } from './user-settings.svelte';
|
||||
|
||||
const onboardingSteps: AppOnboardingStep[] = [
|
||||
{
|
||||
id: 'features',
|
||||
type: 'info',
|
||||
question: 'Willkommen bei Arcade!',
|
||||
description: 'Das erwartet dich:',
|
||||
emoji: '🎮',
|
||||
gradient: { from: 'green-500', to: 'green-700' },
|
||||
bullets: [
|
||||
'22+ Browser-Spiele direkt spielbar',
|
||||
'KI-Spielgenerator: Erstelle eigene Games',
|
||||
'Statistiken: Highscores & Spielzeit',
|
||||
'Community: Reiche eigene Spiele ein',
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'welcome',
|
||||
type: 'info',
|
||||
question: "Los geht's!",
|
||||
description: 'Tipps:',
|
||||
emoji: '🕹️',
|
||||
gradient: { from: 'primary', to: 'primary/70' },
|
||||
bullets: [
|
||||
'Cmd/Ctrl+K für Schnellsuche',
|
||||
'Spiele laufen komplett im Browser',
|
||||
'Stats werden lokal gespeichert',
|
||||
'Anmelden synchronisiert deine Daten',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export const gamesOnboarding = createAppOnboardingStore({
|
||||
appId: 'arcade',
|
||||
steps: onboardingSteps,
|
||||
userSettings,
|
||||
onComplete: async () => {},
|
||||
onSkip: async () => {},
|
||||
});
|
||||
5
games/arcade/apps/web/src/lib/stores/auth.svelte.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createManaAuthStore } from '@manacore/shared-auth-stores';
|
||||
|
||||
export const authStore = createManaAuthStore({
|
||||
devBackendPort: 3011,
|
||||
});
|
||||
5
games/arcade/apps/web/src/lib/stores/navigation.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { createSimpleNavigationStores } from '@manacore/shared-stores';
|
||||
|
||||
export const { isNavCollapsed } = createSimpleNavigationStores({
|
||||
storageKey: 'arcade',
|
||||
});
|
||||
6
games/arcade/apps/web/src/lib/stores/theme.svelte.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createThemeStore } from '@manacore/shared-theme';
|
||||
|
||||
export const theme = createThemeStore({
|
||||
appId: 'arcade',
|
||||
defaultVariant: 'lume',
|
||||
});
|
||||
18
games/arcade/apps/web/src/lib/stores/user-settings.svelte.ts
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
import { browser } from '$app/environment';
|
||||
import { createUserSettingsStore } from '@manacore/shared-theme';
|
||||
import { authStore } from './auth.svelte';
|
||||
|
||||
function getAuthUrl(): string {
|
||||
if (browser && typeof window !== 'undefined') {
|
||||
const injectedUrl = (window as unknown as { __PUBLIC_MANA_CORE_AUTH_URL__?: string })
|
||||
.__PUBLIC_MANA_CORE_AUTH_URL__;
|
||||
if (injectedUrl) return injectedUrl;
|
||||
}
|
||||
return import.meta.env.DEV ? 'http://localhost:3001' : '';
|
||||
}
|
||||
|
||||
export const userSettings = createUserSettingsStore({
|
||||
appId: 'arcade',
|
||||
authUrl: getAuthUrl,
|
||||
getAccessToken: () => authStore.getAccessToken(),
|
||||
});
|
||||
298
games/arcade/apps/web/src/routes/(app)/+layout.svelte
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/stores';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { PillNavigation, CommandBar, SyncIndicator } from '@manacore/shared-ui';
|
||||
import type {
|
||||
PillNavItem,
|
||||
PillDropdownItem,
|
||||
CommandBarItem,
|
||||
QuickAction,
|
||||
} from '@manacore/shared-ui';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { userSettings } from '$lib/stores/user-settings.svelte';
|
||||
import { games, getGameBySlug } from '$lib/data/games';
|
||||
import {
|
||||
THEME_DEFINITIONS,
|
||||
DEFAULT_THEME_VARIANTS,
|
||||
EXTENDED_THEME_VARIANTS,
|
||||
} from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
import { filterHiddenNavItems } from '@manacore/shared-theme';
|
||||
import { isNavCollapsed as collapsedStore } from '$lib/stores/navigation';
|
||||
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
|
||||
import { getPillAppItems } from '@manacore/shared-branding';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
|
||||
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
|
||||
import { gamesOnboarding } from '$lib/stores/app-onboarding.svelte';
|
||||
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
|
||||
import { gamesStore } from '$lib/data/local-store';
|
||||
import {
|
||||
tagLocalStore,
|
||||
tagMutations,
|
||||
useAllTags as useAllSharedTags,
|
||||
} from '@manacore/shared-stores';
|
||||
|
||||
const allTags = useAllSharedTags();
|
||||
|
||||
let showGuestWelcome = $state(false);
|
||||
|
||||
function initGuestWelcome() {
|
||||
if (!authStore.isAuthenticated && shouldShowGuestWelcome('arcade')) {
|
||||
showGuestWelcome = true;
|
||||
}
|
||||
}
|
||||
|
||||
const appItems = getPillAppItems('arcade');
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let commandBarOpen = $state(false);
|
||||
|
||||
const commandBarQuickActions: QuickAction[] = [
|
||||
{ id: 'home', label: 'Alle Spiele', icon: 'gamepad-2', href: '/', shortcut: '1' },
|
||||
{ id: 'create', label: 'Spiel erstellen', icon: 'sparkles', href: '/create', shortcut: '2' },
|
||||
{ id: 'community', label: 'Community', icon: 'users', href: '/community', shortcut: '3' },
|
||||
{ id: 'stats', label: 'Statistiken', icon: 'bar-chart-3', href: '/stats', shortcut: '4' },
|
||||
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
|
||||
];
|
||||
|
||||
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
|
||||
if (!query.trim()) return [];
|
||||
const queryLower = query.toLowerCase();
|
||||
|
||||
return games
|
||||
.filter(
|
||||
(g) =>
|
||||
g.title.toLowerCase().includes(queryLower) ||
|
||||
g.tags.some((t) => t.toLowerCase().includes(queryLower))
|
||||
)
|
||||
.slice(0, 10)
|
||||
.map((g) => ({
|
||||
id: `game-${g.slug}`,
|
||||
title: g.title,
|
||||
subtitle: g.tags.join(', '),
|
||||
}));
|
||||
}
|
||||
|
||||
function handleCommandBarSelect(item: CommandBarItem) {
|
||||
const slug = item.id.replace('game-', '');
|
||||
goto(`/play/${slug}`);
|
||||
}
|
||||
|
||||
let isCollapsed = $state(false);
|
||||
let isDark = $derived(theme.isDark);
|
||||
|
||||
let pinnedThemes = $derived<ThemeVariant[]>(
|
||||
(userSettings.theme?.pinnedThemes || []).filter((t): t is ThemeVariant =>
|
||||
EXTENDED_THEME_VARIANTS.includes(t as ThemeVariant)
|
||||
)
|
||||
);
|
||||
|
||||
let visibleThemes = $derived<ThemeVariant[]>([...DEFAULT_THEME_VARIANTS, ...pinnedThemes]);
|
||||
|
||||
let themeVariantItems = $derived<PillDropdownItem[]>([
|
||||
...visibleThemes.map((variant) => ({
|
||||
id: variant,
|
||||
label: THEME_DEFINITIONS[variant]?.label || variant,
|
||||
icon: THEME_DEFINITIONS[variant]?.icon || '🎨',
|
||||
onClick: () => theme.setVariant(variant),
|
||||
active: (theme.variant || 'lume') === variant,
|
||||
})),
|
||||
{
|
||||
id: 'all-themes',
|
||||
label: 'Alle Themes',
|
||||
icon: 'palette',
|
||||
onClick: () => goto('/themes'),
|
||||
active: false,
|
||||
},
|
||||
]);
|
||||
|
||||
let currentThemeVariantLabel = $derived(
|
||||
THEME_DEFINITIONS[theme.variant]?.label || THEME_DEFINITIONS.lume?.label || 'Lume'
|
||||
);
|
||||
|
||||
let currentLocale = $derived($locale || 'de');
|
||||
function handleLocaleChange(newLocale: string) {
|
||||
setLocale(newLocale as any);
|
||||
}
|
||||
let languageItems = $derived(
|
||||
getLanguageDropdownItems(supportedLocales, currentLocale, handleLocaleChange)
|
||||
);
|
||||
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
|
||||
|
||||
let userEmail = $derived(authStore.isAuthenticated ? authStore.user?.email || 'Menü' : '');
|
||||
|
||||
const baseNavItems: PillNavItem[] = [
|
||||
{ href: '/', label: 'Spiele', icon: 'gamepad-2' },
|
||||
{ href: '/create', label: 'Erstellen', icon: 'sparkles' },
|
||||
{ href: '/community', label: 'Community', icon: 'users' },
|
||||
{ href: '/stats', label: 'Statistiken', icon: 'bar-chart-3' },
|
||||
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
|
||||
];
|
||||
|
||||
const navItems = $derived(
|
||||
filterHiddenNavItems('arcade', baseNavItems, userSettings.nav?.hiddenNavItems || {})
|
||||
);
|
||||
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
|
||||
event.preventDefault();
|
||||
commandBarOpen = true;
|
||||
}
|
||||
}
|
||||
|
||||
function handleCollapsedChange(collapsed: boolean) {
|
||||
isCollapsed = collapsed;
|
||||
collapsedStore.set(collapsed);
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('arcade-nav-collapsed', String(collapsed));
|
||||
}
|
||||
}
|
||||
|
||||
function handleToggleTheme() {
|
||||
theme.toggleMode();
|
||||
}
|
||||
|
||||
function handleThemeModeChange(mode: 'light' | 'dark' | 'system') {
|
||||
theme.setMode(mode);
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
|
||||
async function handleAuthReady() {
|
||||
await Promise.all([gamesStore.initialize(), tagLocalStore.initialize()]);
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
const getToken = () => authStore.getValidToken();
|
||||
gamesStore.startSync(getToken);
|
||||
tagMutations.startSync(getToken);
|
||||
}
|
||||
|
||||
const savedCollapsed = localStorage.getItem('arcade-nav-collapsed');
|
||||
if (savedCollapsed === 'true') {
|
||||
isCollapsed = true;
|
||||
collapsedStore.set(true);
|
||||
}
|
||||
|
||||
initGuestWelcome();
|
||||
|
||||
if (authStore.isAuthenticated) {
|
||||
await userSettings.load();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:window onkeydown={handleKeydown} />
|
||||
|
||||
<AuthGate {authStore} {goto} allowGuest={true} onReady={handleAuthReady}>
|
||||
<div class="layout-container">
|
||||
<PillNavigation
|
||||
items={navItems}
|
||||
currentPath={$page.url.pathname}
|
||||
appName="Arcade"
|
||||
homeRoute="/"
|
||||
onToggleTheme={handleToggleTheme}
|
||||
{isDark}
|
||||
{isCollapsed}
|
||||
onCollapsedChange={handleCollapsedChange}
|
||||
showThemeToggle={true}
|
||||
showThemeVariants={true}
|
||||
{themeVariantItems}
|
||||
{currentThemeVariantLabel}
|
||||
themeMode={theme.mode}
|
||||
onThemeModeChange={handleThemeModeChange}
|
||||
showLanguageSwitcher={true}
|
||||
{languageItems}
|
||||
{currentLanguageLabel}
|
||||
showLogout={authStore.isAuthenticated}
|
||||
onLogout={handleLogout}
|
||||
loginHref="/login"
|
||||
primaryColor="#00ff88"
|
||||
showAppSwitcher={true}
|
||||
{appItems}
|
||||
{userEmail}
|
||||
settingsHref="/settings"
|
||||
manaHref="/mana"
|
||||
profileHref="/profile"
|
||||
themesHref="/themes"
|
||||
helpHref="/help"
|
||||
allAppsHref="/apps"
|
||||
/>
|
||||
|
||||
<main class="main-content bg-background">
|
||||
<div class="content-wrapper">
|
||||
{@render children()}
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<CommandBar
|
||||
bind:open={commandBarOpen}
|
||||
onClose={() => (commandBarOpen = false)}
|
||||
onSearch={handleCommandBarSearch}
|
||||
onSelect={handleCommandBarSelect}
|
||||
quickActions={commandBarQuickActions}
|
||||
placeholder="Spiel suchen..."
|
||||
emptyText="Keine Ergebnisse"
|
||||
searchingText="Suche..."
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if gamesOnboarding.shouldShow}
|
||||
<MiniOnboardingModal store={gamesOnboarding} appName="Arcade" appEmoji="🎮" />
|
||||
{/if}
|
||||
|
||||
<GuestWelcomeModal
|
||||
appId="arcade"
|
||||
visible={showGuestWelcome}
|
||||
onClose={() => (showGuestWelcome = false)}
|
||||
onLogin={() => goto('/login')}
|
||||
onRegister={() => goto('/register')}
|
||||
locale={($locale || 'de') === 'de' ? 'de' : 'en'}
|
||||
/>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<SessionExpiredBanner locale={$locale || 'de'} loginHref="/login" />
|
||||
{/if}
|
||||
<SyncIndicator />
|
||||
</AuthGate>
|
||||
|
||||
<style>
|
||||
.layout-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
padding-bottom: 100px;
|
||||
}
|
||||
|
||||
.content-wrapper {
|
||||
max-width: 100%;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
padding: 1rem;
|
||||
position: relative;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
.content-wrapper {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.content-wrapper {
|
||||
padding: 2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
83
games/arcade/apps/web/src/routes/(app)/+page.svelte
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { games, getAllTags } from '$lib/data/games';
|
||||
import GameCard from '$lib/components/GameCard.svelte';
|
||||
|
||||
let searchQuery = $state('');
|
||||
let selectedTag = $state<string | null>(null);
|
||||
|
||||
const allTags = getAllTags();
|
||||
|
||||
let filteredGames = $derived(() => {
|
||||
let result = games;
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
result = result.filter(
|
||||
(g) =>
|
||||
g.title.toLowerCase().includes(q) ||
|
||||
g.description.toLowerCase().includes(q) ||
|
||||
g.tags.some((t) => t.toLowerCase().includes(q))
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedTag) {
|
||||
result = result.filter((g) => g.tags.includes(selectedTag!));
|
||||
}
|
||||
|
||||
return result;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('app.name')} - {$_('home.title')}</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('home.title')}</h1>
|
||||
<p class="text-muted-foreground mt-1">{$_('home.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={searchQuery}
|
||||
placeholder={$_('home.search')}
|
||||
class="flex-1 rounded-lg border border-border bg-background px-4 py-2 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-full transition-colors {selectedTag === null
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => (selectedTag = null)}
|
||||
>
|
||||
{$_('home.allGames')}
|
||||
</button>
|
||||
{#each allTags as tag}
|
||||
<button
|
||||
class="text-xs px-3 py-1.5 rounded-full transition-colors {selectedTag === tag
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => (selectedTag = selectedTag === tag ? null : tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if filteredGames().length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground">{$_('home.noResults')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
|
||||
{#each filteredGames() as game (game.id)}
|
||||
<GameCard {game} href="/play/{game.slug}" />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.community')} - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('nav.community')}</h1>
|
||||
<p class="text-muted-foreground mt-1">
|
||||
Von der Community erstellte Spiele. Reiche dein eigenes Spiel ein!
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="text-center py-12 rounded-xl border border-dashed border-border">
|
||||
<p class="text-4xl mb-4">🎮</p>
|
||||
<p class="text-muted-foreground">Noch keine Community-Spiele vorhanden.</p>
|
||||
<a
|
||||
href="/create"
|
||||
class="inline-block mt-4 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Erstelle das erste Spiel!
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
211
games/arcade/apps/web/src/routes/(app)/create/+page.svelte
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { generatedGameCollection } from '$lib/data/local-store';
|
||||
|
||||
const BACKEND_URL = import.meta.env.DEV
|
||||
? 'http://localhost:3011'
|
||||
: import.meta.env.PUBLIC_MANA_GAMES_BACKEND_URL || '';
|
||||
|
||||
const models = [
|
||||
{ id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', provider: 'Google', speed: 'Schnell' },
|
||||
{ id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', provider: 'Google', speed: 'Schnell' },
|
||||
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', provider: 'Google', speed: 'Langsam' },
|
||||
{ id: 'claude-3.5-haiku', label: 'Claude 3.5 Haiku', provider: 'Anthropic', speed: 'Schnell' },
|
||||
{ id: 'claude-3.5-sonnet', label: 'Claude Sonnet', provider: 'Anthropic', speed: 'Mittel' },
|
||||
{ id: 'gpt-4o-mini', label: 'GPT-4o Mini', provider: 'Azure', speed: 'Schnell' },
|
||||
{ id: 'gpt-4o', label: 'GPT-4o', provider: 'Azure', speed: 'Mittel' },
|
||||
];
|
||||
|
||||
let prompt = $state('');
|
||||
let selectedModel = $state('gemini-2.0-flash');
|
||||
let isGenerating = $state(false);
|
||||
let generatedHtml = $state('');
|
||||
let error = $state('');
|
||||
let iterationCount = $state(0);
|
||||
let originalPrompt = $state('');
|
||||
|
||||
async function generateGame() {
|
||||
if (!prompt.trim() || isGenerating) return;
|
||||
|
||||
isGenerating = true;
|
||||
error = '';
|
||||
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
description: prompt,
|
||||
model: selectedModel,
|
||||
mode: iterationCount > 0 ? 'iterate' : 'create',
|
||||
};
|
||||
|
||||
if (iterationCount > 0 && generatedHtml) {
|
||||
body.originalPrompt = originalPrompt;
|
||||
body.currentCode = generatedHtml;
|
||||
body.iterationCount = iterationCount;
|
||||
}
|
||||
|
||||
const response = await fetch(`${BACKEND_URL}/api/games/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Fehler: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success && data.html) {
|
||||
generatedHtml = data.html;
|
||||
if (iterationCount === 0) {
|
||||
originalPrompt = prompt;
|
||||
}
|
||||
iterationCount++;
|
||||
} else {
|
||||
error = data.error || 'Unbekannter Fehler bei der Generierung.';
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : 'Verbindungsfehler zum Backend.';
|
||||
} finally {
|
||||
isGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveGame() {
|
||||
if (!generatedHtml || !prompt) return;
|
||||
|
||||
await generatedGameCollection.insert({
|
||||
title: originalPrompt || prompt,
|
||||
description: prompt,
|
||||
htmlCode: generatedHtml,
|
||||
prompt: originalPrompt || prompt,
|
||||
model: selectedModel,
|
||||
iterationCount,
|
||||
});
|
||||
|
||||
// Reset
|
||||
prompt = '';
|
||||
generatedHtml = '';
|
||||
iterationCount = 0;
|
||||
originalPrompt = '';
|
||||
}
|
||||
|
||||
function resetGame() {
|
||||
generatedHtml = '';
|
||||
iterationCount = 0;
|
||||
originalPrompt = '';
|
||||
prompt = '';
|
||||
error = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('create.title')} - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('create.title')}</h1>
|
||||
<p class="text-muted-foreground mt-1">{$_('create.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Input Panel -->
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="prompt" class="block text-sm font-medium text-foreground mb-2">
|
||||
{$_('create.prompt')}
|
||||
</label>
|
||||
<textarea
|
||||
id="prompt"
|
||||
bind:value={prompt}
|
||||
placeholder={$_('create.promptPlaceholder')}
|
||||
rows="4"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-3 text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="model" class="block text-sm font-medium text-foreground mb-2">
|
||||
{$_('create.model')}
|
||||
</label>
|
||||
<select
|
||||
id="model"
|
||||
bind:value={selectedModel}
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2.5 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
{#each models as model}
|
||||
<option value={model.id}>
|
||||
{model.label} ({model.provider} - {model.speed})
|
||||
</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="rounded-lg border border-red-500/30 bg-red-500/10 p-3 text-sm text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={generateGame}
|
||||
disabled={!prompt.trim() || isGenerating}
|
||||
class="flex-1 px-4 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{#if isGenerating}
|
||||
<span class="inline-flex items-center gap-2">
|
||||
<span class="animate-spin">⚡</span>
|
||||
{$_('create.generating')}
|
||||
</span>
|
||||
{:else if iterationCount > 0}
|
||||
{$_('create.iterate')}
|
||||
{:else}
|
||||
{$_('create.generate')}
|
||||
{/if}
|
||||
</button>
|
||||
|
||||
{#if generatedHtml}
|
||||
<button
|
||||
onclick={saveGame}
|
||||
class="px-4 py-2.5 rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{$_('create.save')}
|
||||
</button>
|
||||
<button
|
||||
onclick={resetGame}
|
||||
class="px-4 py-2.5 rounded-lg border border-border bg-card text-muted-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
Neu
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if iterationCount > 0}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
Iteration {iterationCount} · Beschreibe Änderungen im Prompt-Feld
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Preview Panel -->
|
||||
<div class="rounded-xl border border-border bg-black overflow-hidden">
|
||||
{#if generatedHtml}
|
||||
<iframe
|
||||
srcdoc={generatedHtml}
|
||||
title="Generiertes Spiel"
|
||||
class="w-full aspect-[16/10] border-0"
|
||||
sandbox="allow-scripts"
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="w-full aspect-[16/10] flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<p class="text-4xl mb-3 opacity-40">🎮</p>
|
||||
<p class="text-muted-foreground text-sm">{$_('create.preview')}</p>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
11
games/arcade/apps/web/src/routes/(app)/feedback/+page.svelte
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { FeedbackPage } from '@manacore/feedback';
|
||||
import { feedbackService } from '$lib/services/feedback';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<FeedbackPage {feedbackService} appName="Arcade" currentUserId={authStore.user?.id} />
|
||||
47
games/arcade/apps/web/src/routes/(app)/help/+page.svelte
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
<svelte:head>
|
||||
<title>Hilfe - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">Hilfe</h1>
|
||||
|
||||
<section class="space-y-4">
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<h2 class="font-semibold text-foreground mb-2">Wie spiele ich?</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Wähle ein Spiel auf der Startseite aus und klicke darauf. Das Spiel läuft direkt im Browser.
|
||||
Die Steuerung wird auf der Spielseite angezeigt.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<h2 class="font-semibold text-foreground mb-2">KI-Spielgenerator</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Unter "Erstellen" kannst du eigene Spiele beschreiben und von verschiedenen KI-Modellen
|
||||
generieren lassen. Generierte Spiele werden lokal in deinem Browser gespeichert.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<h2 class="font-semibold text-foreground mb-2">Statistiken</h2>
|
||||
<p class="text-sm text-muted-foreground">
|
||||
Deine Highscores, Spielzeiten und Fortschritte werden automatisch gespeichert. Melde dich
|
||||
an, um sie geräteübergreifend zu synchronisieren.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card p-4">
|
||||
<h2 class="font-semibold text-foreground mb-2">Tastaturkürzel</h2>
|
||||
<div class="grid grid-cols-2 gap-2 mt-2">
|
||||
{#each [['Cmd/Ctrl+K', 'Schnellsuche'], ['Esc', 'Suche schließen']] as [key, desc]}
|
||||
<div class="flex items-center gap-2">
|
||||
<kbd class="px-2 py-0.5 rounded bg-muted text-xs font-mono text-muted-foreground"
|
||||
>{key}</kbd
|
||||
>
|
||||
<span class="text-sm text-foreground">{desc}</span>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
9
games/arcade/apps/web/src/routes/(app)/mana/+page.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svelte:head>
|
||||
<title>Mana - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto text-center py-12">
|
||||
<p class="text-4xl mb-4">💎</p>
|
||||
<h1 class="text-2xl font-bold text-foreground">Mana</h1>
|
||||
<p class="text-muted-foreground mt-2">Demnächst verfügbar.</p>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
<script lang="ts">
|
||||
import { useAllGeneratedGames } from '$lib/data/queries';
|
||||
import { generatedGameCollection } from '$lib/data/local-store';
|
||||
|
||||
const generatedGames = useAllGeneratedGames();
|
||||
|
||||
let selectedGameId = $state<string | null>(null);
|
||||
|
||||
let selectedGame = $derived(generatedGames.value.find((g) => g.id === selectedGameId));
|
||||
|
||||
async function deleteGame(id: string) {
|
||||
await generatedGameCollection.remove(id);
|
||||
if (selectedGameId === id) {
|
||||
selectedGameId = null;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Generierte Spiele - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Generierte Spiele</h1>
|
||||
<p class="text-muted-foreground mt-1">Deine mit KI erstellten Spiele</p>
|
||||
</div>
|
||||
|
||||
{#if generatedGames.value.length === 0}
|
||||
<div class="text-center py-12 rounded-xl border border-dashed border-border">
|
||||
<p class="text-4xl mb-4">✨</p>
|
||||
<p class="text-muted-foreground">Noch keine generierten Spiele.</p>
|
||||
<a
|
||||
href="/create"
|
||||
class="inline-block mt-4 px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Erstelle dein erstes Spiel!
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div class="space-y-2 lg:col-span-1">
|
||||
{#each generatedGames.value as game (game.id)}
|
||||
<button
|
||||
onclick={() => (selectedGameId = game.id)}
|
||||
class="w-full text-left rounded-lg border p-3 transition-colors {selectedGameId ===
|
||||
game.id
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-border bg-card hover:bg-muted/50'}"
|
||||
>
|
||||
<p class="font-medium text-foreground text-sm truncate">{game.title}</p>
|
||||
<p class="text-xs text-muted-foreground mt-1">
|
||||
{game.model} · {game.iterationCount} Iterationen
|
||||
</p>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<div class="lg:col-span-2 rounded-xl border border-border bg-black overflow-hidden">
|
||||
{#if selectedGame}
|
||||
<div class="flex items-center justify-between px-3 py-2 bg-card border-b border-border">
|
||||
<span class="text-sm text-foreground truncate">{selectedGame.title}</span>
|
||||
<button
|
||||
onclick={() => deleteGame(selectedGame!.id)}
|
||||
class="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
<iframe
|
||||
srcdoc={selectedGame.htmlCode}
|
||||
title={selectedGame.title}
|
||||
class="w-full aspect-[16/10] border-0"
|
||||
sandbox="allow-scripts"
|
||||
></iframe>
|
||||
{:else}
|
||||
<div class="w-full aspect-[16/10] flex items-center justify-center">
|
||||
<p class="text-muted-foreground text-sm">Wähle ein Spiel aus der Liste</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
132
games/arcade/apps/web/src/routes/(app)/play/[slug]/+page.svelte
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { getGameBySlug } from '$lib/data/games';
|
||||
import { initGameCommunication } from '$lib/services/game-communication';
|
||||
import { gameStatsCollection, type LocalGameStats } from '$lib/data/local-store';
|
||||
|
||||
const slug = $derived($page.params.slug);
|
||||
const game = $derived(getGameBySlug(slug));
|
||||
|
||||
let stats = $state<LocalGameStats | null>(null);
|
||||
let isFullscreen = $state(false);
|
||||
let iframeEl: HTMLIFrameElement;
|
||||
let cleanup: (() => void) | undefined;
|
||||
|
||||
onMount(async () => {
|
||||
if (!slug) return;
|
||||
cleanup = initGameCommunication(slug);
|
||||
|
||||
const all = await gameStatsCollection.getAll();
|
||||
stats = all.find((s) => s.gameId === slug) || null;
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
cleanup?.();
|
||||
});
|
||||
|
||||
function toggleFullscreen() {
|
||||
if (!iframeEl) return;
|
||||
if (!document.fullscreenElement) {
|
||||
iframeEl.requestFullscreen();
|
||||
isFullscreen = true;
|
||||
} else {
|
||||
document.exitFullscreen();
|
||||
isFullscreen = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatPlayTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{game?.title || 'Spiel'} - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if game}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-3">
|
||||
<a href="/" class="text-muted-foreground hover:text-foreground transition-colors">
|
||||
← {$_('game.back')}
|
||||
</a>
|
||||
<h1 class="text-xl font-bold text-foreground">{game.title}</h1>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
onclick={toggleFullscreen}
|
||||
class="px-3 py-1.5 text-sm rounded-lg border border-border bg-card text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
{$_('game.fullscreen')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl overflow-hidden border border-border bg-black">
|
||||
<iframe
|
||||
bind:this={iframeEl}
|
||||
src={game.htmlFile}
|
||||
title={game.title}
|
||||
class="w-full aspect-[16/10] border-0"
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
></iframe>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="rounded-xl border border-border bg-card p-4 space-y-3">
|
||||
<h2 class="font-semibold text-foreground">{game.title}</h2>
|
||||
<p class="text-sm text-muted-foreground">{game.description}</p>
|
||||
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$_('game.difficulty')}</span>
|
||||
<span class="text-foreground">{game.difficulty}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$_('game.controls')}</span>
|
||||
<span class="text-foreground text-right max-w-[60%]">{game.controls}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap gap-1 pt-2">
|
||||
{#each game.tags as tag}
|
||||
<span class="text-xs px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
|
||||
{tag}
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if stats}
|
||||
<div class="rounded-xl border border-border bg-card p-4 space-y-3">
|
||||
<h2 class="font-semibold text-foreground">{$_('game.stats')}</h2>
|
||||
<div class="space-y-2 text-sm">
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$_('game.highScore')}</span>
|
||||
<span class="text-foreground font-mono">{stats.highScore.toLocaleString()}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$_('game.gamesPlayed')}</span>
|
||||
<span class="text-foreground">{stats.gamesPlayed}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span class="text-muted-foreground">{$_('game.totalPlayTime')}</span>
|
||||
<span class="text-foreground">{formatPlayTime(stats.totalPlayTime)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-muted-foreground">Spiel nicht gefunden.</p>
|
||||
<a href="/" class="text-primary hover:underline mt-2 inline-block">Zurück zur Übersicht</a>
|
||||
</div>
|
||||
{/if}
|
||||
18
games/arcade/apps/web/src/routes/(app)/profile/+page.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { ProfilePage } from '@manacore/shared-profile-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Profil - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<ProfilePage {authStore} {goto} />
|
||||
{:else}
|
||||
<div class="max-w-2xl mx-auto text-center py-12">
|
||||
<p class="text-muted-foreground">Bitte melde dich an.</p>
|
||||
<a href="/login" class="text-primary hover:underline mt-2 inline-block">Anmelden</a>
|
||||
</div>
|
||||
{/if}
|
||||
165
games/arcade/apps/web/src/routes/(app)/settings/+page.svelte
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { locale } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { setLocale, supportedLocales } from '$lib/i18n';
|
||||
import { goto } from '$app/navigation';
|
||||
import { gameStatsCollection } from '$lib/data/local-store';
|
||||
|
||||
async function clearStats() {
|
||||
const all = await gameStatsCollection.getAll();
|
||||
for (const stat of all) {
|
||||
await gameStatsCollection.remove(stat.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleLogout() {
|
||||
await authStore.signOut();
|
||||
goto('/login');
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('nav.settings')} - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="settings-page">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('nav.settings')}</h1>
|
||||
<p class="text-muted-foreground text-sm mt-1">Passe Arcade an deine Bedürfnisse an</p>
|
||||
</header>
|
||||
|
||||
<!-- Theme -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Darstellung</h2>
|
||||
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Farbmodus</div>
|
||||
<div class="setting-desc">Hell, Dunkel oder System</div>
|
||||
</div>
|
||||
<div class="flex gap-1">
|
||||
{#each ['light', 'dark', 'system'] as mode}
|
||||
<button
|
||||
class="px-3 py-1.5 text-sm rounded-lg transition-colors {theme.mode === mode
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground hover:bg-muted/80'}"
|
||||
onclick={() => theme.setMode(mode as 'light' | 'dark' | 'system')}
|
||||
>
|
||||
{mode === 'light' ? 'Hell' : mode === 'dark' ? 'Dunkel' : 'System'}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Language -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Sprache</h2>
|
||||
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">App-Sprache</div>
|
||||
<div class="setting-desc">Sprache der Benutzeroberfläche</div>
|
||||
</div>
|
||||
<select
|
||||
value={$locale}
|
||||
onchange={(e) => setLocale((e.target as HTMLSelectElement).value as any)}
|
||||
class="h-9 px-3 rounded-lg bg-background border border-border text-foreground text-sm"
|
||||
>
|
||||
{#each supportedLocales as loc}
|
||||
<option value={loc}>{loc === 'de' ? 'Deutsch' : 'English'}</option>
|
||||
{/each}
|
||||
</select>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Account -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Konto</h2>
|
||||
|
||||
{#if authStore.isAuthenticated}
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Eingeloggt als</div>
|
||||
<div class="setting-desc">{authStore.user?.email}</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm"
|
||||
onclick={handleLogout}
|
||||
>
|
||||
Abmelden
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Gast-Modus</div>
|
||||
<div class="setting-desc">Melde dich an, um Stats zu synchronisieren</div>
|
||||
</div>
|
||||
<a
|
||||
href="/login"
|
||||
class="px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors text-sm"
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<!-- Data -->
|
||||
<section class="settings-section">
|
||||
<h2 class="text-lg font-bold text-foreground mb-4">Daten</h2>
|
||||
|
||||
<div class="setting-row">
|
||||
<div>
|
||||
<div class="setting-label">Spielstatistiken löschen</div>
|
||||
<div class="setting-desc">Alle Highscores und Spielzeiten zurücksetzen</div>
|
||||
</div>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg bg-red-500/10 text-red-400 hover:bg-red-500/20 transition-colors text-sm"
|
||||
onclick={clearStats}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.settings-page {
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.settings-section {
|
||||
margin-bottom: 2rem;
|
||||
padding-bottom: 2rem;
|
||||
border-bottom: 1px solid hsl(var(--border));
|
||||
}
|
||||
|
||||
.settings-section:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.setting-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding: 0.75rem 0;
|
||||
}
|
||||
|
||||
.setting-label {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.setting-desc {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 2px;
|
||||
}
|
||||
</style>
|
||||
95
games/arcade/apps/web/src/routes/(app)/stats/+page.svelte
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { useAllGameStats } from '$lib/data/queries';
|
||||
import { games } from '$lib/data/games';
|
||||
|
||||
const allStats = useAllGameStats();
|
||||
|
||||
let totalGamesPlayed = $derived(allStats.value.reduce((sum, s) => sum + s.gamesPlayed, 0));
|
||||
|
||||
let totalPlayTime = $derived(allStats.value.reduce((sum, s) => sum + s.totalPlayTime, 0));
|
||||
|
||||
let favoriteGame = $derived(() => {
|
||||
if (allStats.value.length === 0) return null;
|
||||
const top = allStats.value.reduce((fav, s) => (s.gamesPlayed > fav.gamesPlayed ? s : fav));
|
||||
return games.find((g) => g.slug === top.gameId || g.id === top.gameId);
|
||||
});
|
||||
|
||||
let sortedStats = $derived([...allStats.value].sort((a, b) => b.highScore - a.highScore));
|
||||
|
||||
function formatPlayTime(seconds: number): string {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) return `${hours}h ${minutes}m`;
|
||||
return `${minutes}m`;
|
||||
}
|
||||
|
||||
function getGameTitle(gameId: string): string {
|
||||
return games.find((g) => g.slug === gameId || g.id === gameId)?.title || gameId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{$_('stats.title')} - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="space-y-6">
|
||||
<h1 class="text-2xl font-bold text-foreground">{$_('stats.title')}</h1>
|
||||
|
||||
{#if allStats.value.length === 0}
|
||||
<div class="text-center py-12">
|
||||
<p class="text-4xl mb-4">📊</p>
|
||||
<p class="text-muted-foreground">{$_('stats.noStats')}</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p class="text-3xl font-bold text-primary">{totalGamesPlayed}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_('stats.totalGames')}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p class="text-3xl font-bold text-primary">{formatPlayTime(totalPlayTime)}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_('stats.totalTime')}</p>
|
||||
</div>
|
||||
<div class="rounded-xl border border-border bg-card p-4 text-center">
|
||||
<p class="text-3xl font-bold text-primary">{favoriteGame()?.title || '-'}</p>
|
||||
<p class="text-sm text-muted-foreground mt-1">{$_('stats.favoriteGame')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-xl border border-border bg-card overflow-hidden">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="border-b border-border text-left">
|
||||
<th class="px-4 py-3 text-muted-foreground font-medium">Spiel</th>
|
||||
<th class="px-4 py-3 text-muted-foreground font-medium text-right"
|
||||
>{$_('game.highScore')}</th
|
||||
>
|
||||
<th class="px-4 py-3 text-muted-foreground font-medium text-right hidden sm:table-cell"
|
||||
>{$_('game.gamesPlayed')}</th
|
||||
>
|
||||
<th class="px-4 py-3 text-muted-foreground font-medium text-right hidden md:table-cell"
|
||||
>{$_('game.totalPlayTime')}</th
|
||||
>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each sortedStats as stat (stat.id)}
|
||||
<tr class="border-b border-border/50 hover:bg-muted/30 transition-colors">
|
||||
<td class="px-4 py-3 text-foreground">{getGameTitle(stat.gameId)}</td>
|
||||
<td class="px-4 py-3 text-foreground font-mono text-right"
|
||||
>{stat.highScore.toLocaleString()}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-muted-foreground text-right hidden sm:table-cell"
|
||||
>{stat.gamesPlayed}</td
|
||||
>
|
||||
<td class="px-4 py-3 text-muted-foreground text-right hidden md:table-cell"
|
||||
>{formatPlayTime(stat.totalPlayTime)}</td
|
||||
>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
202
games/arcade/apps/web/src/routes/(app)/submit/+page.svelte
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
|
||||
const BACKEND_URL = import.meta.env.DEV
|
||||
? 'http://localhost:3011'
|
||||
: import.meta.env.PUBLIC_MANA_GAMES_BACKEND_URL || '';
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let controls = $state('');
|
||||
let difficulty = $state<'Einfach' | 'Mittel' | 'Schwer'>('Mittel');
|
||||
let tags = $state('');
|
||||
let htmlCode = $state('');
|
||||
let authorName = $state('');
|
||||
let isSubmitting = $state(false);
|
||||
let submitResult = $state<{ success: boolean; message: string } | null>(null);
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!title.trim() || !htmlCode.trim() || !authorName.trim()) return;
|
||||
|
||||
isSubmitting = true;
|
||||
submitResult = null;
|
||||
|
||||
try {
|
||||
const response = await fetch(`${BACKEND_URL}/api/games/submit`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description,
|
||||
controls,
|
||||
difficulty,
|
||||
complexity: 'Mittel',
|
||||
tags: tags
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean),
|
||||
author: { name: authorName },
|
||||
files: { html: htmlCode },
|
||||
submittedAt: new Date().toISOString(),
|
||||
}),
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
submitResult = {
|
||||
success: data.success,
|
||||
message: data.success
|
||||
? `Eingereicht! PR #${data.prNumber} erstellt.`
|
||||
: data.error || 'Fehler beim Einreichen.',
|
||||
};
|
||||
|
||||
if (data.success) {
|
||||
title = '';
|
||||
description = '';
|
||||
controls = '';
|
||||
tags = '';
|
||||
htmlCode = '';
|
||||
}
|
||||
} catch {
|
||||
submitResult = { success: false, message: 'Verbindungsfehler zum Backend.' };
|
||||
} finally {
|
||||
isSubmitting = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Spiel einreichen - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Spiel einreichen</h1>
|
||||
<p class="text-muted-foreground mt-1">Reiche dein eigenes HTML5-Spiel bei der Community ein.</p>
|
||||
</div>
|
||||
|
||||
{#if !authStore.isAuthenticated}
|
||||
<div class="rounded-xl border border-border bg-card p-6 text-center">
|
||||
<p class="text-muted-foreground mb-4">Bitte melde dich an, um ein Spiel einzureichen.</p>
|
||||
<a
|
||||
href="/login"
|
||||
class="inline-block px-4 py-2 rounded-lg bg-primary text-primary-foreground hover:bg-primary/90 transition-colors"
|
||||
>
|
||||
Anmelden
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<form
|
||||
onsubmit={(e) => {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}}
|
||||
class="space-y-4"
|
||||
>
|
||||
<div>
|
||||
<label for="title" class="block text-sm font-medium text-foreground mb-1">Titel *</label>
|
||||
<input
|
||||
id="title"
|
||||
type="text"
|
||||
bind:value={title}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="author" class="block text-sm font-medium text-foreground mb-1">Autor *</label>
|
||||
<input
|
||||
id="author"
|
||||
type="text"
|
||||
bind:value={authorName}
|
||||
required
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="desc" class="block text-sm font-medium text-foreground mb-1">Beschreibung</label
|
||||
>
|
||||
<textarea
|
||||
id="desc"
|
||||
bind:value={description}
|
||||
rows="3"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label for="controls" class="block text-sm font-medium text-foreground mb-1"
|
||||
>Steuerung</label
|
||||
>
|
||||
<input
|
||||
id="controls"
|
||||
type="text"
|
||||
bind:value={controls}
|
||||
placeholder="Pfeiltasten, Maus..."
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="difficulty" class="block text-sm font-medium text-foreground mb-1"
|
||||
>Schwierigkeit</label
|
||||
>
|
||||
<select
|
||||
id="difficulty"
|
||||
bind:value={difficulty}
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
>
|
||||
<option value="Einfach">Einfach</option>
|
||||
<option value="Mittel">Mittel</option>
|
||||
<option value="Schwer">Schwer</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tags" class="block text-sm font-medium text-foreground mb-1"
|
||||
>Tags (kommagetrennt)</label
|
||||
>
|
||||
<input
|
||||
id="tags"
|
||||
type="text"
|
||||
bind:value={tags}
|
||||
placeholder="Arcade, Action, Puzzle"
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="html" class="block text-sm font-medium text-foreground mb-1">HTML-Code *</label>
|
||||
<textarea
|
||||
id="html"
|
||||
bind:value={htmlCode}
|
||||
rows="12"
|
||||
required
|
||||
placeholder="<!DOCTYPE html>..."
|
||||
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground font-mono text-sm focus:outline-none focus:ring-2 focus:ring-primary/50 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
{#if submitResult}
|
||||
<div
|
||||
class="rounded-lg border p-3 text-sm {submitResult.success
|
||||
? 'border-green-500/30 bg-green-500/10 text-green-400'
|
||||
: 'border-red-500/30 bg-red-500/10 text-red-400'}"
|
||||
>
|
||||
{submitResult.message}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!title.trim() || !htmlCode.trim() || !authorName.trim() || isSubmitting}
|
||||
class="w-full px-4 py-2.5 rounded-lg bg-primary text-primary-foreground font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Wird eingereicht...' : 'Spiel einreichen'}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
9
games/arcade/apps/web/src/routes/(app)/tags/+page.svelte
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
<svelte:head>
|
||||
<title>Tags - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-2xl mx-auto text-center py-12">
|
||||
<p class="text-4xl mb-4">🏷️</p>
|
||||
<h1 class="text-2xl font-bold text-foreground">Tags</h1>
|
||||
<p class="text-muted-foreground mt-2">Demnächst verfügbar.</p>
|
||||
</div>
|
||||
36
games/arcade/apps/web/src/routes/(app)/themes/+page.svelte
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
<script lang="ts">
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { THEME_DEFINITIONS, EXTENDED_THEME_VARIANTS } from '@manacore/shared-theme';
|
||||
import type { ThemeVariant } from '@manacore/shared-theme';
|
||||
|
||||
const allThemes = EXTENDED_THEME_VARIANTS;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Themes - Arcade</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="max-w-4xl mx-auto space-y-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-foreground">Themes</h1>
|
||||
<p class="text-muted-foreground mt-1">Wähle ein Theme für Arcade</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-3">
|
||||
{#each allThemes as variant}
|
||||
{@const def = THEME_DEFINITIONS[variant]}
|
||||
{#if def}
|
||||
<button
|
||||
onclick={() => theme.setVariant(variant)}
|
||||
class="rounded-xl border p-4 text-left transition-all hover:-translate-y-0.5 {theme.variant ===
|
||||
variant
|
||||
? 'border-primary bg-primary/5 ring-2 ring-primary/30'
|
||||
: 'border-border bg-card hover:border-primary/30'}"
|
||||
>
|
||||
<div class="text-2xl mb-2">{def.icon || '🎨'}</div>
|
||||
<div class="font-medium text-foreground text-sm">{def.label}</div>
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { ForgotPasswordPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Arcade - Passwort vergessen</title>
|
||||
</svelte:head>
|
||||
|
||||
<ForgotPasswordPage {authStore} {goto} appName="Arcade" loginHref="/login" />
|
||||
18
games/arcade/apps/web/src/routes/(auth)/login/+page.svelte
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { LoginPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Arcade - Login</title>
|
||||
</svelte:head>
|
||||
|
||||
<LoginPage
|
||||
{authStore}
|
||||
{goto}
|
||||
appName="Arcade"
|
||||
registerHref="/register"
|
||||
forgotPasswordHref="/forgot-password"
|
||||
primaryColor="#00ff88"
|
||||
/>
|
||||
|
|
@ -0,0 +1,11 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { RegisterPage } from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Arcade - Registrieren</title>
|
||||
</svelte:head>
|
||||
|
||||
<RegisterPage {authStore} {goto} appName="Arcade" loginHref="/login" primaryColor="#00ff88" />
|
||||
|
|
@ -0,0 +1,15 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Arcade - Passwort zurücksetzen</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen flex items-center justify-center">
|
||||
<div class="max-w-md w-full p-6 text-center">
|
||||
<h1 class="text-xl font-bold text-foreground mb-4">Passwort zurücksetzen</h1>
|
||||
<p class="text-muted-foreground mb-6">Funktion wird eingerichtet.</p>
|
||||
<a href="/login" class="text-primary hover:underline">Zurück zum Login</a>
|
||||
</div>
|
||||
</div>
|
||||
39
games/arcade/apps/web/src/routes/+layout.svelte
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import { onMount } from 'svelte';
|
||||
import { isLoading as isLocaleLoading } from 'svelte-i18n';
|
||||
import { theme } from '$lib/stores/theme.svelte';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { waitLocale } from '$lib/i18n';
|
||||
import { ToastContainer, setupGlobalErrorHandler } from '@manacore/shared-ui';
|
||||
import { AppLoadingSkeleton } from '$lib/components/skeletons';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
let loading = $state(true);
|
||||
|
||||
onMount(() => {
|
||||
const cleanupErrorHandler = setupGlobalErrorHandler();
|
||||
|
||||
const init = async () => {
|
||||
await waitLocale();
|
||||
theme.initialize();
|
||||
await authStore.initialize();
|
||||
loading = false;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
return cleanupErrorHandler;
|
||||
});
|
||||
</script>
|
||||
|
||||
<ToastContainer />
|
||||
|
||||
{#if $isLocaleLoading || loading}
|
||||
<AppLoadingSkeleton />
|
||||
{:else}
|
||||
<div class="min-h-screen bg-background text-foreground">
|
||||
{@render children()}
|
||||
</div>
|
||||
{/if}
|
||||
2
games/arcade/apps/web/src/routes/+layout.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
// Disable SSR — all data is local-first (IndexedDB + mana-sync)
|
||||
export const ssr = false;
|
||||
14
games/arcade/apps/web/src/routes/health/+server.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { RequestHandler } from './$types';
|
||||
|
||||
export const GET: RequestHandler = async () => {
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
status: 'ok',
|
||||
service: 'arcade-web',
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
{
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
};
|
||||
5
games/arcade/apps/web/static/favicon.png
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect x="0" y="0" width="32" height="32" rx="6" fill="#0a0a0a"/>
|
||||
<path d="M8 12 L8 20 L10 20 L10 16 L12 20 L14 20 L16 16 L16 20 L18 20 L18 12 L15 12 L13 16 L11 12 Z" fill="#ffffff"/>
|
||||
<path d="M20 16 Q20 12 24 12 L24 14 Q22 14 22 16 Q22 18 24 18 Q24 16 26 16 L26 18 Q26 20 22 20 Q20 20 20 16 Z" fill="#00ff88"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 385 B |
666
games/arcade/apps/web/static/games/asteroid_dash.html
Normal file
|
|
@ -0,0 +1,666 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Asteroid Dash</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #000814;
|
||||
color: #fff;
|
||||
font-family: 'Courier New', monospace;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #001d3d;
|
||||
background: radial-gradient(circle at 30% 20%, #001d3d 0%, #000814 70%);
|
||||
box-shadow: 0 0 30px rgba(0, 123, 255, 0.3);
|
||||
}
|
||||
|
||||
.ui {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
font-size: 18px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #00f5ff;
|
||||
text-shadow: 0 0 10px rgba(0, 245, 255, 0.5);
|
||||
}
|
||||
|
||||
.lives {
|
||||
color: #ff0080;
|
||||
margin-top: 10px;
|
||||
text-shadow: 0 0 10px rgba(255, 0, 128, 0.5);
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 30px;
|
||||
border: 2px solid #00f5ff;
|
||||
border-radius: 10px;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #001d3d;
|
||||
color: #00f5ff;
|
||||
border: 2px solid #00f5ff;
|
||||
padding: 10px 20px;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
border-radius: 5px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #00f5ff;
|
||||
color: #000814;
|
||||
box-shadow: 0 0 15px rgba(0, 245, 255, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas" width="800" height="600"></canvas>
|
||||
|
||||
<div class="ui">
|
||||
<div class="score">Score: <span id="score">0</span></div>
|
||||
<div class="lives">Lives: <span id="lives">3</span></div>
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
WASD / Pfeiltasten: Bewegung | Leertaste: Boost
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>Game Over!</h2>
|
||||
<p>Endpunktzahl: <span id="finalScore">0</span></p>
|
||||
<button onclick="restartGame()">Nochmal spielen</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'asteroid-dash';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielvariablen
|
||||
let gameRunning = true;
|
||||
let score = 0;
|
||||
let lives = 3;
|
||||
let stars = [];
|
||||
|
||||
// Eingabe-System
|
||||
const keys = {};
|
||||
|
||||
// Spieler-Objekt
|
||||
const player = {
|
||||
x: canvas.width / 2,
|
||||
y: canvas.height / 2,
|
||||
width: 20,
|
||||
height: 15,
|
||||
vx: 0,
|
||||
vy: 0,
|
||||
speed: 0.3,
|
||||
maxSpeed: 8,
|
||||
friction: 0.95,
|
||||
boost: false,
|
||||
boostCooldown: 0,
|
||||
invulnerable: 0,
|
||||
trail: []
|
||||
};
|
||||
|
||||
// Asteroid-Array
|
||||
const asteroids = [];
|
||||
|
||||
// Kristall-Array
|
||||
const crystals = [];
|
||||
|
||||
// Power-up Array
|
||||
const powerups = [];
|
||||
|
||||
// Partikel-Array
|
||||
const particles = [];
|
||||
|
||||
// Sterne für Hintergrund erzeugen
|
||||
function createStars() {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
stars.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: Math.random() * 2,
|
||||
brightness: Math.random()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Asteroid erstellen
|
||||
function createAsteroid() {
|
||||
const side = Math.floor(Math.random() * 4);
|
||||
let x, y;
|
||||
|
||||
switch(side) {
|
||||
case 0: x = -30; y = Math.random() * canvas.height; break;
|
||||
case 1: x = canvas.width + 30; y = Math.random() * canvas.height; break;
|
||||
case 2: x = Math.random() * canvas.width; y = -30; break;
|
||||
case 3: x = Math.random() * canvas.width; y = canvas.height + 30; break;
|
||||
}
|
||||
|
||||
const asteroid = {
|
||||
x: x,
|
||||
y: y,
|
||||
size: 15 + Math.random() * 25,
|
||||
vx: (Math.random() - 0.5) * 4,
|
||||
vy: (Math.random() - 0.5) * 4,
|
||||
rotation: 0,
|
||||
rotationSpeed: (Math.random() - 0.5) * 0.1,
|
||||
color: `hsl(${20 + Math.random() * 40}, 70%, ${40 + Math.random() * 20}%)`
|
||||
};
|
||||
|
||||
asteroids.push(asteroid);
|
||||
}
|
||||
|
||||
// Kristall erstellen
|
||||
function createCrystal() {
|
||||
crystals.push({
|
||||
x: Math.random() * (canvas.width - 40) + 20,
|
||||
y: Math.random() * (canvas.height - 40) + 20,
|
||||
size: 8,
|
||||
rotation: 0,
|
||||
rotationSpeed: 0.05,
|
||||
pulse: 0,
|
||||
collected: false
|
||||
});
|
||||
}
|
||||
|
||||
// Power-up erstellen
|
||||
function createPowerup() {
|
||||
const types = ['shield', 'boost', 'magnet'];
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
|
||||
powerups.push({
|
||||
x: Math.random() * (canvas.width - 40) + 20,
|
||||
y: Math.random() * (canvas.height - 40) + 20,
|
||||
type: type,
|
||||
size: 12,
|
||||
rotation: 0,
|
||||
life: 300
|
||||
});
|
||||
}
|
||||
|
||||
// Partikel erstellen
|
||||
function createParticles(x, y, count, color) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: (Math.random() - 0.5) * 10,
|
||||
vy: (Math.random() - 0.5) * 10,
|
||||
size: Math.random() * 3 + 1,
|
||||
life: 30,
|
||||
maxLife: 30,
|
||||
color: color || '#00f5ff'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Kollisionserkennung
|
||||
function checkCollision(rect1, rect2) {
|
||||
return rect1.x < rect2.x + rect2.size &&
|
||||
rect1.x + rect1.width > rect2.x &&
|
||||
rect1.y < rect2.y + rect2.size &&
|
||||
rect1.y + rect1.height > rect2.y;
|
||||
}
|
||||
|
||||
// Distanz zwischen zwei Punkten
|
||||
function distance(x1, y1, x2, y2) {
|
||||
return Math.sqrt((x2 - x1) ** 2 + (y2 - y1) ** 2);
|
||||
}
|
||||
|
||||
// Eingabe-Event-Listener
|
||||
document.addEventListener('keydown', (e) => {
|
||||
keys[e.key.toLowerCase()] = true;
|
||||
if (e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (player.boostCooldown <= 0) {
|
||||
player.boost = true;
|
||||
player.boostCooldown = 60;
|
||||
createParticles(player.x + player.width/2, player.y + player.height, 5, '#ff6b00');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
keys[e.key.toLowerCase()] = false;
|
||||
if (e.key === ' ') {
|
||||
player.boost = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Spieler updaten
|
||||
function updatePlayer() {
|
||||
// Bewegung
|
||||
if (keys['w'] || keys['arrowup']) player.vy -= player.speed;
|
||||
if (keys['s'] || keys['arrowdown']) player.vy += player.speed;
|
||||
if (keys['a'] || keys['arrowleft']) player.vx -= player.speed;
|
||||
if (keys['d'] || keys['arrowright']) player.vx += player.speed;
|
||||
|
||||
// Boost
|
||||
let currentMaxSpeed = player.maxSpeed;
|
||||
if (player.boost) {
|
||||
currentMaxSpeed *= 2;
|
||||
}
|
||||
|
||||
// Geschwindigkeit begrenzen
|
||||
const speed = Math.sqrt(player.vx ** 2 + player.vy ** 2);
|
||||
if (speed > currentMaxSpeed) {
|
||||
player.vx = (player.vx / speed) * currentMaxSpeed;
|
||||
player.vy = (player.vy / speed) * currentMaxSpeed;
|
||||
}
|
||||
|
||||
// Reibung
|
||||
player.vx *= player.friction;
|
||||
player.vy *= player.friction;
|
||||
|
||||
// Position updaten
|
||||
player.x += player.vx;
|
||||
player.y += player.vy;
|
||||
|
||||
// Bildschirmgrenzen
|
||||
if (player.x < 0) player.x = 0;
|
||||
if (player.x + player.width > canvas.width) player.x = canvas.width - player.width;
|
||||
if (player.y < 0) player.y = 0;
|
||||
if (player.y + player.height > canvas.height) player.y = canvas.height - player.height;
|
||||
|
||||
// Cooldowns
|
||||
if (player.boostCooldown > 0) player.boostCooldown--;
|
||||
if (player.invulnerable > 0) player.invulnerable--;
|
||||
|
||||
// Trail für Boost-Effekt
|
||||
if (player.boost) {
|
||||
player.trail.push({
|
||||
x: player.x + player.width/2,
|
||||
y: player.y + player.height/2,
|
||||
life: 10
|
||||
});
|
||||
}
|
||||
|
||||
// Trail updaten
|
||||
player.trail = player.trail.filter(t => {
|
||||
t.life--;
|
||||
return t.life > 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Asteroiden updaten
|
||||
function updateAsteroids() {
|
||||
for (let i = asteroids.length - 1; i >= 0; i--) {
|
||||
const asteroid = asteroids[i];
|
||||
|
||||
asteroid.x += asteroid.vx;
|
||||
asteroid.y += asteroid.vy;
|
||||
asteroid.rotation += asteroid.rotationSpeed;
|
||||
|
||||
// Asteroiden entfernen die zu weit weg sind
|
||||
if (asteroid.x < -100 || asteroid.x > canvas.width + 100 ||
|
||||
asteroid.y < -100 || asteroid.y > canvas.height + 100) {
|
||||
asteroids.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kollision mit Spieler
|
||||
if (player.invulnerable <= 0 &&
|
||||
distance(player.x + player.width/2, player.y + player.height/2,
|
||||
asteroid.x, asteroid.y) < asteroid.size + 10) {
|
||||
player.invulnerable = 120;
|
||||
lives--;
|
||||
createParticles(player.x + player.width/2, player.y + player.height/2, 10, '#ff0080');
|
||||
|
||||
if (lives <= 0) {
|
||||
gameOver();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Kristalle updaten
|
||||
function updateCrystals() {
|
||||
for (let i = crystals.length - 1; i >= 0; i--) {
|
||||
const crystal = crystals[i];
|
||||
|
||||
crystal.rotation += crystal.rotationSpeed;
|
||||
crystal.pulse += 0.1;
|
||||
|
||||
// Kollision mit Spieler
|
||||
if (distance(player.x + player.width/2, player.y + player.height/2,
|
||||
crystal.x, crystal.y) < crystal.size + 10) {
|
||||
score += 100;
|
||||
createParticles(crystal.x, crystal.y, 8, '#00ff80');
|
||||
crystals.splice(i, 1);
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Power-ups updaten
|
||||
function updatePowerups() {
|
||||
for (let i = powerups.length - 1; i >= 0; i--) {
|
||||
const powerup = powerups[i];
|
||||
|
||||
powerup.rotation += 0.03;
|
||||
powerup.life--;
|
||||
|
||||
if (powerup.life <= 0) {
|
||||
powerups.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kollision mit Spieler
|
||||
if (distance(player.x + player.width/2, player.y + player.height/2,
|
||||
powerup.x, powerup.y) < powerup.size + 10) {
|
||||
|
||||
if (powerup.type === 'shield') {
|
||||
player.invulnerable = 180;
|
||||
} else if (powerup.type === 'boost') {
|
||||
player.boostCooldown = 0;
|
||||
}
|
||||
|
||||
createParticles(powerup.x, powerup.y, 6, '#ffff00');
|
||||
powerups.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partikel updaten
|
||||
function updateParticles() {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const particle = particles[i];
|
||||
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.vx *= 0.98;
|
||||
particle.vy *= 0.98;
|
||||
particle.life--;
|
||||
|
||||
if (particle.life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichnen
|
||||
function draw() {
|
||||
// Hintergrund löschen
|
||||
ctx.fillStyle = '#000814';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Sterne zeichnen
|
||||
ctx.fillStyle = '#ffffff';
|
||||
for (const star of stars) {
|
||||
ctx.globalAlpha = star.brightness;
|
||||
ctx.fillRect(star.x, star.y, star.size, star.size);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Spieler-Trail zeichnen
|
||||
for (const trail of player.trail) {
|
||||
ctx.globalAlpha = trail.life / 10;
|
||||
ctx.fillStyle = '#ff6b00';
|
||||
ctx.fillRect(trail.x - 2, trail.y - 2, 4, 4);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Spieler zeichnen
|
||||
ctx.save();
|
||||
ctx.translate(player.x + player.width/2, player.y + player.height/2);
|
||||
|
||||
if (player.invulnerable > 0 && Math.floor(player.invulnerable / 5) % 2) {
|
||||
ctx.globalAlpha = 0.5;
|
||||
}
|
||||
|
||||
ctx.fillStyle = player.boost ? '#ff6b00' : '#00f5ff';
|
||||
ctx.fillRect(-player.width/2, -player.height/2, player.width, player.height);
|
||||
|
||||
// Spieler-Spitze
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(-2, -player.height/2 - 3, 4, 3);
|
||||
|
||||
ctx.restore();
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Asteroiden zeichnen
|
||||
for (const asteroid of asteroids) {
|
||||
ctx.save();
|
||||
ctx.translate(asteroid.x, asteroid.y);
|
||||
ctx.rotate(asteroid.rotation);
|
||||
|
||||
ctx.fillStyle = asteroid.color;
|
||||
ctx.fillRect(-asteroid.size/2, -asteroid.size/2, asteroid.size, asteroid.size);
|
||||
|
||||
// Dunkle Kanten für 3D-Effekt
|
||||
ctx.fillStyle = '#2d1810';
|
||||
ctx.fillRect(-asteroid.size/2, -asteroid.size/2, asteroid.size, 3);
|
||||
ctx.fillRect(-asteroid.size/2, -asteroid.size/2, 3, asteroid.size);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Kristalle zeichnen
|
||||
for (const crystal of crystals) {
|
||||
ctx.save();
|
||||
ctx.translate(crystal.x, crystal.y);
|
||||
ctx.rotate(crystal.rotation);
|
||||
|
||||
const pulseSize = crystal.size + Math.sin(crystal.pulse) * 2;
|
||||
|
||||
ctx.fillStyle = '#00ff80';
|
||||
ctx.fillRect(-pulseSize/2, -pulseSize/2, pulseSize, pulseSize);
|
||||
|
||||
// Glitzer-Effekt
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(-2, -2, 4, 4);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Power-ups zeichnen
|
||||
for (const powerup of powerups) {
|
||||
ctx.save();
|
||||
ctx.translate(powerup.x, powerup.y);
|
||||
ctx.rotate(powerup.rotation);
|
||||
|
||||
let color = '#ffff00';
|
||||
if (powerup.type === 'shield') color = '#ff00ff';
|
||||
if (powerup.type === 'boost') color = '#ff6b00';
|
||||
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(-powerup.size/2, -powerup.size/2, powerup.size, powerup.size);
|
||||
|
||||
// Symbol
|
||||
ctx.fillStyle = '#000000';
|
||||
if (powerup.type === 'shield') {
|
||||
ctx.fillRect(-4, -6, 8, 12);
|
||||
} else if (powerup.type === 'boost') {
|
||||
ctx.fillRect(-2, -6, 4, 12);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Partikel zeichnen
|
||||
for (const particle of particles) {
|
||||
ctx.globalAlpha = particle.life / particle.maxLife;
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fillRect(particle.x - particle.size/2, particle.y - particle.size/2,
|
||||
particle.size, particle.size);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Spawn-System
|
||||
let asteroidSpawnTimer = 0;
|
||||
let crystalSpawnTimer = 0;
|
||||
let powerupSpawnTimer = 0;
|
||||
|
||||
function handleSpawning() {
|
||||
asteroidSpawnTimer++;
|
||||
crystalSpawnTimer++;
|
||||
powerupSpawnTimer++;
|
||||
|
||||
// Asteroiden spawnen (Schwierigkeit steigt)
|
||||
const asteroidDelay = Math.max(30, 120 - Math.floor(score / 500));
|
||||
if (asteroidSpawnTimer >= asteroidDelay) {
|
||||
createAsteroid();
|
||||
asteroidSpawnTimer = 0;
|
||||
}
|
||||
|
||||
// Kristalle spawnen
|
||||
if (crystalSpawnTimer >= 180 && crystals.length < 3) {
|
||||
createCrystal();
|
||||
crystalSpawnTimer = 0;
|
||||
}
|
||||
|
||||
// Power-ups spawnen
|
||||
if (powerupSpawnTimer >= 600 && powerups.length < 1) {
|
||||
createPowerup();
|
||||
powerupSpawnTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Spiel-Loop
|
||||
function gameLoop() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
updatePlayer();
|
||||
updateAsteroids();
|
||||
updateCrystals();
|
||||
updatePowerups();
|
||||
updateParticles();
|
||||
handleSpawning();
|
||||
|
||||
draw();
|
||||
|
||||
// UI updaten
|
||||
document.getElementById('score').textContent = score;
|
||||
document.getElementById('lives').textContent = lives;
|
||||
|
||||
score++;
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Game Over
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 5000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'asteroid_survivor',
|
||||
name: 'Asteroid Survivor',
|
||||
description: 'Score 5000 points in Asteroid Dash',
|
||||
icon: '🚀'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (score >= 10000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'space_ace',
|
||||
name: 'Space Ace',
|
||||
description: 'Score 10000 points in Asteroid Dash',
|
||||
icon: '⭐'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Spiel neustarten
|
||||
function restartGame() {
|
||||
gameRunning = true;
|
||||
score = 0;
|
||||
lives = 3;
|
||||
|
||||
// Arrays leeren
|
||||
asteroids.length = 0;
|
||||
crystals.length = 0;
|
||||
powerups.length = 0;
|
||||
particles.length = 0;
|
||||
|
||||
// Spieler zurücksetzen
|
||||
player.x = canvas.width / 2;
|
||||
player.y = canvas.height / 2;
|
||||
player.vx = 0;
|
||||
player.vy = 0;
|
||||
player.invulnerable = 60;
|
||||
player.boostCooldown = 0;
|
||||
player.trail.length = 0;
|
||||
|
||||
// Timer zurücksetzen
|
||||
asteroidSpawnTimer = 0;
|
||||
crystalSpawnTimer = 0;
|
||||
powerupSpawnTimer = 0;
|
||||
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Spiel initialisieren
|
||||
createStars();
|
||||
gameLoop();
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
677
games/arcade/apps/web/static/games/balloon_pop.html
Normal file
|
|
@ -0,0 +1,677 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Balloon Pop</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #87CEEB 0%, #98FB98 50%, #90EE90 100%);
|
||||
color: #fff;
|
||||
font-family: 'Comic Sans MS', cursive;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px solid #fff;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 25px rgba(0, 0, 0, 0.3);
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.ui {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
font-size: 22px;
|
||||
z-index: 10;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #ff6b35;
|
||||
font-weight: bold;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.level {
|
||||
color: #4CAF50;
|
||||
margin-top: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.combo {
|
||||
color: #ff1744;
|
||||
margin-top: 8px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
background: rgba(50, 150, 250, 0.95);
|
||||
padding: 30px;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 25px;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(145deg, #ff6b35, #ff8c42);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
border-radius: 25px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.powerup-ui {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
font-size: 16px;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.powerup-active {
|
||||
color: #ffff00;
|
||||
font-weight: bold;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas" width="800" height="600"></canvas>
|
||||
|
||||
<div class="ui">
|
||||
<div class="score">🎈 Punkte: <span id="score">0</span></div>
|
||||
<div class="level">Level: <span id="level">1</span></div>
|
||||
<div class="combo">Combo: <span id="combo">0</span>x</div>
|
||||
</div>
|
||||
|
||||
<div class="powerup-ui" id="powerupStatus">
|
||||
<!-- Power-up Status wird hier angezeigt -->
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
🖱️ Klicke auf die Ballons zum Platzen lassen!
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>🎉 Spiel beendet!</h2>
|
||||
<p>Endpunktzahl: <span id="finalScore">0</span></p>
|
||||
<p>Erreichte Level: <span id="finalLevel">1</span></p>
|
||||
<p id="achievement"></p>
|
||||
<button onclick="restartGame()">🔄 Nochmal spielen</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'balloon-pop';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spiel-Zustand
|
||||
let gameRunning = true;
|
||||
let score = 0;
|
||||
let level = 1;
|
||||
let combo = 0;
|
||||
let comboTimer = 0;
|
||||
let balloonsMissed = 0;
|
||||
let maxMissed = 10;
|
||||
|
||||
// Power-ups
|
||||
let multiShotActive = false;
|
||||
let multiShotTimer = 0;
|
||||
let slowTimeActive = false;
|
||||
let slowTimeTimer = 0;
|
||||
|
||||
// Arrays für Spielobjekte
|
||||
const balloons = [];
|
||||
const particles = [];
|
||||
const clouds = [];
|
||||
const powerups = [];
|
||||
|
||||
// Ballon-Typen
|
||||
const balloonTypes = {
|
||||
normal: { color: '#ff6b35', points: 10, speed: 1 },
|
||||
fast: { color: '#ff1744', points: 20, speed: 2 },
|
||||
big: { color: '#9c27b0', points: 30, speed: 0.7, size: 1.5 },
|
||||
bonus: { color: '#ffff00', points: 50, speed: 1.2 },
|
||||
bomb: { color: '#424242', points: -20, speed: 1.5 }
|
||||
};
|
||||
|
||||
// Wolken für Hintergrund erstellen
|
||||
function createClouds() {
|
||||
for (let i = 0; i < 6; i++) {
|
||||
clouds.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * 200,
|
||||
size: 40 + Math.random() * 60,
|
||||
speed: 0.2 + Math.random() * 0.3,
|
||||
opacity: 0.6 + Math.random() * 0.3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Ballon erstellen
|
||||
function createBalloon() {
|
||||
const typeNames = Object.keys(balloonTypes);
|
||||
let typeName;
|
||||
|
||||
// Wahrscheinlichkeiten basierend auf Level
|
||||
const rand = Math.random();
|
||||
if (rand < 0.5) typeName = 'normal';
|
||||
else if (rand < 0.7) typeName = 'fast';
|
||||
else if (rand < 0.85) typeName = 'big';
|
||||
else if (rand < 0.95) typeName = 'bonus';
|
||||
else typeName = 'bomb';
|
||||
|
||||
const type = balloonTypes[typeName];
|
||||
const baseSize = 25;
|
||||
const size = baseSize * (type.size || 1);
|
||||
|
||||
balloons.push({
|
||||
x: Math.random() * (canvas.width - size * 2) + size,
|
||||
y: canvas.height + size,
|
||||
size: size,
|
||||
type: typeName,
|
||||
color: type.color,
|
||||
points: type.points,
|
||||
speed: type.speed * (slowTimeActive ? 0.5 : 1),
|
||||
wiggle: Math.random() * Math.PI * 2,
|
||||
wiggleSpeed: 0.02 + Math.random() * 0.02,
|
||||
string: true
|
||||
});
|
||||
}
|
||||
|
||||
// Power-up erstellen
|
||||
function createPowerup() {
|
||||
const types = ['multishot', 'slowtime'];
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
|
||||
powerups.push({
|
||||
x: Math.random() * (canvas.width - 30) + 15,
|
||||
y: canvas.height + 15,
|
||||
size: 20,
|
||||
type: type,
|
||||
speed: 0.8,
|
||||
rotation: 0,
|
||||
pulse: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Partikel-Effekt erstellen
|
||||
function createParticles(x, y, color, count = 8) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: (Math.random() - 0.5) * 12,
|
||||
vy: (Math.random() - 0.5) * 12 - 5,
|
||||
size: Math.random() * 4 + 2,
|
||||
life: 30,
|
||||
maxLife: 30,
|
||||
color: color,
|
||||
gravity: 0.3
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Maus-Klick Event
|
||||
canvas.addEventListener('click', (e) => {
|
||||
if (!gameRunning) return;
|
||||
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const mouseX = e.clientX - rect.left;
|
||||
const mouseY = e.clientY - rect.top;
|
||||
|
||||
let hit = false;
|
||||
|
||||
// Prüfe Ballons
|
||||
for (let i = balloons.length - 1; i >= 0; i--) {
|
||||
const balloon = balloons[i];
|
||||
const distance = Math.sqrt(
|
||||
(mouseX - balloon.x) ** 2 + (mouseY - balloon.y) ** 2
|
||||
);
|
||||
|
||||
if (distance < balloon.size) {
|
||||
// Ballon getroffen
|
||||
hit = true;
|
||||
|
||||
if (balloon.type === 'bomb') {
|
||||
// Bombe - negative Punkte
|
||||
score += balloon.points;
|
||||
combo = 0;
|
||||
createParticles(balloon.x, balloon.y, '#ff0000', 12);
|
||||
} else {
|
||||
// Normaler Ballon
|
||||
combo++;
|
||||
comboTimer = 180; // 3 Sekunden
|
||||
const comboBonus = Math.floor(combo / 5);
|
||||
score += balloon.points + (comboBonus * 5);
|
||||
createParticles(balloon.x, balloon.y, balloon.color, 10);
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
}
|
||||
|
||||
balloons.splice(i, 1);
|
||||
|
||||
// Multi-Shot: nur ein Ballon pro Klick
|
||||
if (!multiShotActive) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Prüfe Power-ups
|
||||
for (let i = powerups.length - 1; i >= 0; i--) {
|
||||
const powerup = powerups[i];
|
||||
const distance = Math.sqrt(
|
||||
(mouseX - powerup.x) ** 2 + (mouseY - powerup.y) ** 2
|
||||
);
|
||||
|
||||
if (distance < powerup.size) {
|
||||
if (powerup.type === 'multishot') {
|
||||
multiShotActive = true;
|
||||
multiShotTimer = 300; // 5 Sekunden
|
||||
} else if (powerup.type === 'slowtime') {
|
||||
slowTimeActive = true;
|
||||
slowTimeTimer = 600; // 10 Sekunden
|
||||
}
|
||||
|
||||
createParticles(powerup.x, powerup.y, '#ffff00', 6);
|
||||
powerups.splice(i, 1);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Combo-Timer zurücksetzen wenn kein Treffer
|
||||
if (!hit) {
|
||||
combo = Math.max(0, combo - 1);
|
||||
}
|
||||
});
|
||||
|
||||
// Ballons updaten
|
||||
function updateBalloons() {
|
||||
for (let i = balloons.length - 1; i >= 0; i--) {
|
||||
const balloon = balloons[i];
|
||||
|
||||
// Bewegung
|
||||
const currentSpeed = balloon.speed * (slowTimeActive ? 0.5 : 1);
|
||||
balloon.y -= currentSpeed;
|
||||
balloon.wiggle += balloon.wiggleSpeed;
|
||||
balloon.x += Math.sin(balloon.wiggle) * 0.8;
|
||||
|
||||
// Ballon entkommen
|
||||
if (balloon.y + balloon.size < 0) {
|
||||
balloons.splice(i, 1);
|
||||
balloonsMissed++;
|
||||
combo = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Power-ups updaten
|
||||
function updatePowerups() {
|
||||
for (let i = powerups.length - 1; i >= 0; i--) {
|
||||
const powerup = powerups[i];
|
||||
|
||||
powerup.y -= powerup.speed;
|
||||
powerup.rotation += 0.1;
|
||||
powerup.pulse += 0.15;
|
||||
|
||||
if (powerup.y + powerup.size < 0) {
|
||||
powerups.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Power-up Timer
|
||||
if (multiShotTimer > 0) {
|
||||
multiShotTimer--;
|
||||
if (multiShotTimer <= 0) {
|
||||
multiShotActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
if (slowTimeTimer > 0) {
|
||||
slowTimeTimer--;
|
||||
if (slowTimeTimer <= 0) {
|
||||
slowTimeActive = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wolken updaten
|
||||
function updateClouds() {
|
||||
for (const cloud of clouds) {
|
||||
cloud.x += cloud.speed;
|
||||
if (cloud.x > canvas.width + cloud.size) {
|
||||
cloud.x = -cloud.size;
|
||||
cloud.y = Math.random() * 200;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partikel updaten
|
||||
function updateParticles() {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const particle = particles[i];
|
||||
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.vy += particle.gravity;
|
||||
particle.life--;
|
||||
|
||||
if (particle.life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichnen
|
||||
function draw() {
|
||||
// Himmel-Gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
gradient.addColorStop(0, '#87CEEB');
|
||||
gradient.addColorStop(0.5, '#98FB98');
|
||||
gradient.addColorStop(1, '#90EE90');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Wolken zeichnen
|
||||
for (const cloud of clouds) {
|
||||
ctx.globalAlpha = cloud.opacity;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
|
||||
// Einfache Wolken-Form
|
||||
ctx.beginPath();
|
||||
ctx.arc(cloud.x, cloud.y, cloud.size * 0.5, 0, Math.PI * 2);
|
||||
ctx.arc(cloud.x + cloud.size * 0.3, cloud.y, cloud.size * 0.4, 0, Math.PI * 2);
|
||||
ctx.arc(cloud.x - cloud.size * 0.3, cloud.y, cloud.size * 0.4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Ballons zeichnen
|
||||
for (const balloon of balloons) {
|
||||
// Ballon-String
|
||||
if (balloon.string) {
|
||||
ctx.strokeStyle = '#8B4513';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(balloon.x, balloon.y + balloon.size);
|
||||
ctx.lineTo(balloon.x, balloon.y + balloon.size + 30);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Ballon-Körper
|
||||
ctx.fillStyle = balloon.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(balloon.x, balloon.y, balloon.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Ballon-Highlight
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.beginPath();
|
||||
ctx.arc(balloon.x - balloon.size * 0.3, balloon.y - balloon.size * 0.3,
|
||||
balloon.size * 0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Spezielle Markierungen
|
||||
if (balloon.type === 'bomb') {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '16px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('💣', balloon.x, balloon.y + 5);
|
||||
} else if (balloon.type === 'bonus') {
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.fillText('★', balloon.x, balloon.y + 4);
|
||||
}
|
||||
}
|
||||
|
||||
// Power-ups zeichnen
|
||||
for (const powerup of powerups) {
|
||||
ctx.save();
|
||||
ctx.translate(powerup.x, powerup.y);
|
||||
ctx.rotate(powerup.rotation);
|
||||
|
||||
const pulseSize = powerup.size + Math.sin(powerup.pulse) * 3;
|
||||
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
ctx.fillRect(-pulseSize/2, -pulseSize/2, pulseSize, pulseSize);
|
||||
|
||||
// Symbol
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
if (powerup.type === 'multishot') {
|
||||
ctx.fillText('⚡', 0, 4);
|
||||
} else {
|
||||
ctx.fillText('⏰', 0, 4);
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Partikel zeichnen
|
||||
for (const particle of particles) {
|
||||
ctx.globalAlpha = particle.life / particle.maxLife;
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(particle.x, particle.y, particle.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Slow-Time Effekt
|
||||
if (slowTimeActive) {
|
||||
ctx.fillStyle = 'rgba(100, 200, 255, 0.1)';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
}
|
||||
|
||||
// Spawn-System
|
||||
let balloonSpawnTimer = 0;
|
||||
let powerupSpawnTimer = 0;
|
||||
|
||||
function handleSpawning() {
|
||||
balloonSpawnTimer++;
|
||||
powerupSpawnTimer++;
|
||||
|
||||
// Ballons spawnen (Häufigkeit steigt mit Level)
|
||||
const spawnRate = Math.max(30, 80 - level * 3);
|
||||
if (balloonSpawnTimer >= spawnRate) {
|
||||
createBalloon();
|
||||
balloonSpawnTimer = 0;
|
||||
}
|
||||
|
||||
// Power-ups spawnen
|
||||
if (powerupSpawnTimer >= 900 && powerups.length < 1) {
|
||||
createPowerup();
|
||||
powerupSpawnTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Spiel-Loop
|
||||
function gameLoop() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
updateBalloons();
|
||||
updatePowerups();
|
||||
updateClouds();
|
||||
updateParticles();
|
||||
handleSpawning();
|
||||
|
||||
// Combo-Timer
|
||||
if (comboTimer > 0) {
|
||||
comboTimer--;
|
||||
if (comboTimer <= 0) {
|
||||
combo = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Level-System
|
||||
const newLevel = Math.floor(score / 500) + 1;
|
||||
if (newLevel > level) {
|
||||
level = newLevel;
|
||||
// Bonus für Level-Up
|
||||
score += level * 50;
|
||||
createParticles(canvas.width/2, canvas.height/2, '#ffff00', 15);
|
||||
}
|
||||
|
||||
draw();
|
||||
|
||||
// UI updaten
|
||||
document.getElementById('score').textContent = score;
|
||||
document.getElementById('level').textContent = level;
|
||||
document.getElementById('combo').textContent = combo;
|
||||
|
||||
// Power-up Status
|
||||
const powerupStatus = document.getElementById('powerupStatus');
|
||||
let statusText = '';
|
||||
if (multiShotActive) {
|
||||
statusText += `⚡ Multi-Shot: ${Math.ceil(multiShotTimer / 60)}s<br>`;
|
||||
}
|
||||
if (slowTimeActive) {
|
||||
statusText += `⏰ Slow-Time: ${Math.ceil(slowTimeTimer / 60)}s`;
|
||||
}
|
||||
powerupStatus.innerHTML = statusText;
|
||||
|
||||
// Spiel beenden
|
||||
if (balloonsMissed >= maxMissed) {
|
||||
gameOver();
|
||||
}
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Game Over
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('finalLevel').textContent = level;
|
||||
|
||||
let achievement = '';
|
||||
if (score >= 2000) achievement = '🏆 Ballon-Meister!';
|
||||
else if (score >= 1000) achievement = '🥈 Profi-Platzer!';
|
||||
else if (score >= 500) achievement = '🥉 Guter Start!';
|
||||
else achievement = '🎈 Weiter üben!';
|
||||
|
||||
document.getElementById('achievement').textContent = achievement;
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 2000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'balloon_master',
|
||||
name: 'Balloon Master',
|
||||
description: 'Score 2000 points in Balloon Pop',
|
||||
icon: '🏆'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (combo >= 20) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'combo_popper',
|
||||
name: 'Combo Popper',
|
||||
description: 'Achieve a 20x combo in Balloon Pop',
|
||||
icon: '💥'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Neustart
|
||||
function restartGame() {
|
||||
gameRunning = true;
|
||||
score = 0;
|
||||
level = 1;
|
||||
combo = 0;
|
||||
comboTimer = 0;
|
||||
balloonsMissed = 0;
|
||||
|
||||
// Power-ups zurücksetzen
|
||||
multiShotActive = false;
|
||||
multiShotTimer = 0;
|
||||
slowTimeActive = false;
|
||||
slowTimeTimer = 0;
|
||||
|
||||
// Arrays leeren
|
||||
balloons.length = 0;
|
||||
particles.length = 0;
|
||||
powerups.length = 0;
|
||||
|
||||
// Timer zurücksetzen
|
||||
balloonSpawnTimer = 0;
|
||||
powerupSpawnTimer = 0;
|
||||
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Spiel starten
|
||||
createClouds();
|
||||
gameLoop();
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
491
games/arcade/apps/web/static/games/bounce_catch_tutorial.html
Normal file
|
|
@ -0,0 +1,491 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bounce & Catch - Tutorial Game</title>
|
||||
<style>
|
||||
/* ============================================
|
||||
GRUNDLEGENDE STYLES
|
||||
Definiert das Aussehen der Seite
|
||||
============================================ */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #1a1a1a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Arial', sans-serif;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Canvas ist die Zeichenfläche für unser Spiel */
|
||||
canvas {
|
||||
border: 2px solid #4CAF50;
|
||||
background: #0a0a0a;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
/* UI-Elemente für Spielinformationen */
|
||||
.game-info {
|
||||
margin: 10px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #4CAF50;
|
||||
}
|
||||
|
||||
.lives {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
/* Start-Bildschirm */
|
||||
.start-screen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.start-screen h1 {
|
||||
color: #4CAF50;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.start-screen p {
|
||||
margin: 10px 0;
|
||||
max-width: 400px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
background: #4CAF50;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.start-button:hover {
|
||||
background: #45a049;
|
||||
}
|
||||
|
||||
/* Game Over Bildschirm */
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
color: #ff6b6b;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.final-score {
|
||||
font-size: 24px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.restart-button {
|
||||
background: #ff6b6b;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 25px;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
border-radius: 5px;
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.restart-button:hover {
|
||||
background: #ff5252;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<!-- Spielinformationen -->
|
||||
<div class="game-info">
|
||||
<span class="score">Punkte: <span id="score">0</span></span> |
|
||||
<span class="lives">Leben: <span id="lives">3</span></span>
|
||||
</div>
|
||||
|
||||
<!-- Das Haupt-Canvas für das Spiel -->
|
||||
<canvas id="gameCanvas" width="600" height="400"></canvas>
|
||||
|
||||
<!-- Start-Bildschirm -->
|
||||
<div class="start-screen" id="startScreen">
|
||||
<h1>Bounce & Catch Tutorial</h1>
|
||||
<p>Ein einfaches Lernspiel, das die Grundlagen der Spieleentwicklung zeigt!</p>
|
||||
<p><strong>Steuerung:</strong> Bewege die Maus, um das Paddle zu steuern</p>
|
||||
<p><strong>Ziel:</strong> Fange den Ball mit dem Paddle auf, bevor er unten aus dem Bild fällt</p>
|
||||
<p>Je länger du spielst, desto schneller wird der Ball!</p>
|
||||
<button class="start-button" onclick="startGame()">Spiel starten</button>
|
||||
</div>
|
||||
|
||||
<!-- Game Over Bildschirm -->
|
||||
<div class="game-over" id="gameOverScreen">
|
||||
<h2>Game Over!</h2>
|
||||
<div class="final-score">Endpunktzahl: <span id="finalScore">0</span></div>
|
||||
<button class="restart-button" onclick="restartGame()">Nochmal spielen</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ============================================
|
||||
SPIEL-INITIALISIERUNG
|
||||
Hier werden alle wichtigen Variablen und
|
||||
Objekte für das Spiel definiert
|
||||
============================================ */
|
||||
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'bounce-catch-tutorial';
|
||||
|
||||
// Canvas und 2D-Kontext holen
|
||||
// Der Canvas ist unsere Zeichenfläche, ctx ist der "Pinsel"
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// UI-Elemente für die Anzeige
|
||||
const scoreElement = document.getElementById('score');
|
||||
const livesElement = document.getElementById('lives');
|
||||
const startScreen = document.getElementById('startScreen');
|
||||
const gameOverScreen = document.getElementById('gameOverScreen');
|
||||
const finalScoreElement = document.getElementById('finalScore');
|
||||
|
||||
/* ============================================
|
||||
SPIELOBJEKTE
|
||||
Definiert die Eigenschaften von Ball und Paddle
|
||||
============================================ */
|
||||
|
||||
// Ball-Objekt mit Position, Geschwindigkeit und Größe
|
||||
const ball = {
|
||||
x: canvas.width / 2, // Horizontale Position (Mitte)
|
||||
y: 50, // Vertikale Position (oben)
|
||||
radius: 10, // Radius des Balls
|
||||
dx: 3, // Horizontale Geschwindigkeit
|
||||
dy: 3, // Vertikale Geschwindigkeit
|
||||
color: '#4CAF50', // Farbe des Balls
|
||||
speedIncrease: 0.1 // Geschwindigkeitszunahme pro Treffer
|
||||
};
|
||||
|
||||
// Paddle-Objekt (das Brett zum Fangen)
|
||||
const paddle = {
|
||||
width: 100, // Breite des Paddles
|
||||
height: 15, // Höhe des Paddles
|
||||
x: canvas.width / 2 - 50, // Startposition (zentriert)
|
||||
y: canvas.height - 30, // Position am unteren Rand
|
||||
color: '#2196F3', // Farbe des Paddles
|
||||
speed: 8 // Bewegungsgeschwindigkeit
|
||||
};
|
||||
|
||||
/* ============================================
|
||||
SPIELZUSTAND
|
||||
Variablen die den aktuellen Zustand speichern
|
||||
============================================ */
|
||||
let score = 0; // Aktuelle Punktzahl
|
||||
let lives = 3; // Anzahl der Leben
|
||||
let gameRunning = false; // Ist das Spiel aktiv?
|
||||
let mouseX = canvas.width / 2; // Mausposition
|
||||
|
||||
/* ============================================
|
||||
EINGABE-VERWALTUNG
|
||||
Reagiert auf Mausbewegungen des Spielers
|
||||
============================================ */
|
||||
|
||||
// Event-Listener für Mausbewegung
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
// Berechne die Mausposition relativ zum Canvas
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
|
||||
// Aktualisiere Paddle-Position (zentriert auf Maus)
|
||||
if (gameRunning) {
|
||||
paddle.x = mouseX - paddle.width / 2;
|
||||
|
||||
// Verhindere, dass das Paddle aus dem Bildschirm geht
|
||||
if (paddle.x < 0) paddle.x = 0;
|
||||
if (paddle.x + paddle.width > canvas.width) {
|
||||
paddle.x = canvas.width - paddle.width;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/* ============================================
|
||||
SPIEL-FUNKTIONEN
|
||||
Hauptfunktionen für Spiellogik und Rendering
|
||||
============================================ */
|
||||
|
||||
// Funktion zum Starten des Spiels
|
||||
function startGame() {
|
||||
gameRunning = true;
|
||||
startScreen.style.display = 'none';
|
||||
gameLoop(); // Starte die Spielschleife
|
||||
}
|
||||
|
||||
// Funktion zum Neustarten des Spiels
|
||||
function restartGame() {
|
||||
// Setze alle Werte zurück
|
||||
score = 0;
|
||||
lives = 3;
|
||||
ball.x = canvas.width / 2;
|
||||
ball.y = 50;
|
||||
ball.dx = 3;
|
||||
ball.dy = 3;
|
||||
|
||||
// Aktualisiere UI
|
||||
scoreElement.textContent = score;
|
||||
livesElement.textContent = lives;
|
||||
gameOverScreen.style.display = 'none';
|
||||
|
||||
gameRunning = true;
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Ball-Bewegung und Physik
|
||||
function updateBall() {
|
||||
// Bewege den Ball
|
||||
ball.x += ball.dx;
|
||||
ball.y += ball.dy;
|
||||
|
||||
// Kollision mit linker oder rechter Wand
|
||||
if (ball.x - ball.radius < 0 || ball.x + ball.radius > canvas.width) {
|
||||
ball.dx = -ball.dx; // Kehre horizontale Richtung um
|
||||
}
|
||||
|
||||
// Kollision mit oberer Wand
|
||||
if (ball.y - ball.radius < 0) {
|
||||
ball.dy = -ball.dy; // Kehre vertikale Richtung um
|
||||
}
|
||||
|
||||
// Ball fällt unten aus dem Bildschirm
|
||||
if (ball.y - ball.radius > canvas.height) {
|
||||
loseLife();
|
||||
}
|
||||
}
|
||||
|
||||
// Kollisionserkennung zwischen Ball und Paddle
|
||||
function checkPaddleCollision() {
|
||||
// Prüfe ob Ball im Bereich des Paddles ist
|
||||
if (ball.y + ball.radius > paddle.y &&
|
||||
ball.y - ball.radius < paddle.y + paddle.height &&
|
||||
ball.x > paddle.x &&
|
||||
ball.x < paddle.x + paddle.width) {
|
||||
|
||||
// Ball prallt ab
|
||||
ball.dy = -Math.abs(ball.dy); // Immer nach oben
|
||||
|
||||
// Erhöhe Punkte
|
||||
score += 10;
|
||||
scoreElement.textContent = score;
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Erhöhe Geschwindigkeit (macht das Spiel schwieriger)
|
||||
ball.dx *= (1 + ball.speedIncrease);
|
||||
ball.dy *= (1 + ball.speedIncrease);
|
||||
|
||||
// Spiele einen Ton (optional - hier nur visuelles Feedback)
|
||||
paddle.color = '#00FF00'; // Kurz grün
|
||||
setTimeout(() => {
|
||||
paddle.color = '#2196F3'; // Zurück zu blau
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
// Funktion wenn ein Leben verloren wird
|
||||
function loseLife() {
|
||||
lives--;
|
||||
livesElement.textContent = lives;
|
||||
|
||||
if (lives <= 0) {
|
||||
gameOver();
|
||||
} else {
|
||||
// Setze Ball zurück
|
||||
ball.x = canvas.width / 2;
|
||||
ball.y = 50;
|
||||
ball.dx = Math.abs(ball.dx) * 0.8; // Reduziere Geschwindigkeit etwas
|
||||
ball.dy = Math.abs(ball.dy) * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// Game Over Funktion
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
finalScoreElement.textContent = score;
|
||||
gameOverScreen.style.display = 'flex';
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 100) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'bounce_beginner',
|
||||
name: 'Bounce Beginner',
|
||||
description: 'Score 100 points in Bounce & Catch',
|
||||
icon: '🏓'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (score >= 500) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'paddle_pro',
|
||||
name: 'Paddle Pro',
|
||||
description: 'Score 500 points in Bounce & Catch',
|
||||
icon: '🎯'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
RENDERING (ZEICHNEN)
|
||||
Funktionen zum Zeichnen der Spielobjekte
|
||||
============================================ */
|
||||
|
||||
// Zeichne den Ball
|
||||
function drawBall() {
|
||||
ctx.beginPath();
|
||||
ctx.arc(ball.x, ball.y, ball.radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = ball.color;
|
||||
ctx.fill();
|
||||
ctx.closePath();
|
||||
|
||||
// Zeichne einen Glanzeffekt
|
||||
ctx.beginPath();
|
||||
ctx.arc(ball.x - 3, ball.y - 3, ball.radius / 3, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
|
||||
ctx.fill();
|
||||
ctx.closePath();
|
||||
}
|
||||
|
||||
// Zeichne das Paddle
|
||||
function drawPaddle() {
|
||||
// Hauptkörper des Paddles
|
||||
ctx.fillStyle = paddle.color;
|
||||
ctx.fillRect(paddle.x, paddle.y, paddle.width, paddle.height);
|
||||
|
||||
// Zeichne einen Rand
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(paddle.x, paddle.y, paddle.width, paddle.height);
|
||||
}
|
||||
|
||||
// Lösche den Canvas und zeichne Hintergrund
|
||||
function clearCanvas() {
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Zeichne ein Gitter für visuellen Effekt
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.05)';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i < canvas.width; i += 50) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i, 0);
|
||||
ctx.lineTo(i, canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let i = 0; i < canvas.height; i += 50) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, i);
|
||||
ctx.lineTo(canvas.width, i);
|
||||
ctx.stroke();
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
HAUPTSPIELSCHLEIFE
|
||||
Wird 60x pro Sekunde aufgerufen
|
||||
============================================ */
|
||||
function gameLoop() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
// 1. Lösche alten Frame
|
||||
clearCanvas();
|
||||
|
||||
// 2. Aktualisiere Spiellogik
|
||||
updateBall();
|
||||
checkPaddleCollision();
|
||||
|
||||
// 3. Zeichne alle Objekte
|
||||
drawBall();
|
||||
drawPaddle();
|
||||
|
||||
// 4. Nächster Frame
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
ZUSÄTZLICHE EFFEKTE
|
||||
Kleine Details die das Spiel besser machen
|
||||
============================================ */
|
||||
|
||||
// Partikeleffekt beim Treffen (optional)
|
||||
function createParticles(x, y) {
|
||||
// Hier könnte man Partikeleffekte hinzufügen
|
||||
// Für dieses Tutorial halten wir es simpel
|
||||
}
|
||||
|
||||
// Debug-Informationen (für Entwickler)
|
||||
console.log('Bounce & Catch Tutorial geladen!');
|
||||
console.log('Canvas-Größe:', canvas.width, 'x', canvas.height);
|
||||
console.log('Dies ist ein Lernspiel um Spieleentwicklung zu verstehen.');
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
710
games/arcade/apps/web/static/games/card_stack_rush.html
Normal file
|
|
@ -0,0 +1,710 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Card Stack Rush</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 10px 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #e74c3c;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.timer-bar {
|
||||
height: 8px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.timer-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #27ae60, #f39c12, #e74c3c);
|
||||
transition: width 0.1s linear;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.game-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 10px;
|
||||
height: calc(100vh - 60px);
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.incoming-card-area {
|
||||
margin-bottom: 20px;
|
||||
height: 180px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 120px;
|
||||
height: 180px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 3rem;
|
||||
font-weight: bold;
|
||||
cursor: grab;
|
||||
transition: all 0.2s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
position: relative;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.card:active {
|
||||
cursor: grabbing;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.card.dragging {
|
||||
opacity: 0.8;
|
||||
transform: scale(1.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.card-suit {
|
||||
font-size: 2.5rem;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.card.red {
|
||||
background: white;
|
||||
color: #e74c3c;
|
||||
border: 2px solid #e74c3c;
|
||||
}
|
||||
|
||||
.card.black {
|
||||
background: white;
|
||||
color: #2c3e50;
|
||||
border: 2px solid #2c3e50;
|
||||
}
|
||||
|
||||
.stacks-container {
|
||||
display: flex;
|
||||
gap: 40px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
width: 140px;
|
||||
height: 200px;
|
||||
border: 3px dashed rgba(255, 255, 255, 0.5);
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.stack-label {
|
||||
position: absolute;
|
||||
top: -25px;
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
font-size: 0.9rem;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.stack.valid-drop {
|
||||
border-color: #27ae60;
|
||||
background: rgba(39, 174, 96, 0.2);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.stack.invalid-drop {
|
||||
border-color: #e74c3c;
|
||||
background: rgba(231, 76, 60, 0.2);
|
||||
animation: shake 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0%, 100% { transform: translateX(0); }
|
||||
25% { transform: translateX(-5px); }
|
||||
75% { transform: translateX(5px); }
|
||||
}
|
||||
|
||||
.stack-cards {
|
||||
position: relative;
|
||||
width: 120px;
|
||||
height: 180px;
|
||||
}
|
||||
|
||||
.stack-card {
|
||||
position: absolute;
|
||||
width: 120px;
|
||||
height: 180px;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2.5rem;
|
||||
font-weight: bold;
|
||||
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.score-popup {
|
||||
position: absolute;
|
||||
font-weight: bold;
|
||||
font-size: 1.2rem;
|
||||
animation: scoreFloat 1s ease-out forwards;
|
||||
pointer-events: none;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
@keyframes scoreFloat {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
}
|
||||
|
||||
.combo-indicator {
|
||||
position: fixed;
|
||||
top: 80px;
|
||||
right: 20px;
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 10px 20px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
display: none;
|
||||
animation: comboAnimation 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes comboAnimation {
|
||||
0% { transform: scale(0.8); opacity: 0; }
|
||||
50% { transform: scale(1.1); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.combo-text {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
color: #e74c3c;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.final-stats {
|
||||
margin: 20px 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.rule-text {
|
||||
color: white;
|
||||
text-align: center;
|
||||
font-size: 1.2rem;
|
||||
margin: 10px 0;
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.stacks-container {
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.stack {
|
||||
width: 100px;
|
||||
height: 150px;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 90px;
|
||||
height: 135px;
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.card-suit {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stack-card {
|
||||
width: 90px;
|
||||
height: 135px;
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.timer-bar {
|
||||
width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<h1>Card Stack Rush</h1>
|
||||
|
||||
<div class="game-controls">
|
||||
<div class="stat-group">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Punkte:</span>
|
||||
<span class="stat-value" id="score">0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Karten:</span>
|
||||
<span class="stat-value" id="cardsPlaced">0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Zeit:</span>
|
||||
<div class="timer-bar">
|
||||
<div class="timer-fill" id="timerFill"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn-new" onclick="newGame()">Neues Spiel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-area">
|
||||
<div class="rule-text" id="ruleText">Sortiere nach Farbe!</div>
|
||||
|
||||
<div class="incoming-card-area" id="incomingArea">
|
||||
</div>
|
||||
|
||||
<div class="stacks-container" id="stacksContainer">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="combo-indicator" id="comboIndicator">
|
||||
<div class="combo-text" id="comboText">Combo x2!</div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="overlay"></div>
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>Zeit abgelaufen!</h2>
|
||||
<div class="final-stats">
|
||||
<p>Endpunktzahl: <strong id="finalScore">0</strong></p>
|
||||
<p>Platzierte Karten: <strong id="finalCards">0</strong></p>
|
||||
<p>Höchste Combo: <strong id="finalCombo">0</strong></p>
|
||||
</div>
|
||||
<button class="btn-new" onclick="newGame()">Neues Spiel</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const suits = ['♠', '♣', '♥', '♦'];
|
||||
const values = ['A', '2', '3', '4', '5', '6', '7', '8', '9', '10', 'J', 'Q', 'K'];
|
||||
const rules = {
|
||||
color: {
|
||||
name: 'Sortiere nach Farbe!',
|
||||
stacks: [
|
||||
{ label: 'Rot', accepts: ['♥', '♦'] },
|
||||
{ label: 'Schwarz', accepts: ['♠', '♣'] }
|
||||
]
|
||||
},
|
||||
suit: {
|
||||
name: 'Sortiere nach Symbol!',
|
||||
stacks: [
|
||||
{ label: '♠', accepts: ['♠'] },
|
||||
{ label: '♣', accepts: ['♣'] },
|
||||
{ label: '♥', accepts: ['♥'] },
|
||||
{ label: '♦', accepts: ['♦'] }
|
||||
]
|
||||
},
|
||||
value: {
|
||||
name: 'Sortiere nach Wert!',
|
||||
stacks: [
|
||||
{ label: 'Niedrig (A-5)', accepts: ['A', '2', '3', '4', '5'] },
|
||||
{ label: 'Mittel (6-10)', accepts: ['6', '7', '8', '9', '10'] },
|
||||
{ label: 'Hoch (J-K)', accepts: ['J', 'Q', 'K'] }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
let score = 0;
|
||||
let cardsPlaced = 0;
|
||||
let combo = 0;
|
||||
let maxCombo = 0;
|
||||
let currentRule = 'color';
|
||||
let gameActive = false;
|
||||
let timeLeft = 45;
|
||||
let timerInterval;
|
||||
let currentCard = null;
|
||||
let isDragging = false;
|
||||
|
||||
function createCard(value, suit) {
|
||||
const card = document.createElement('div');
|
||||
const isRed = suit === '♥' || suit === '♦';
|
||||
card.className = `card ${isRed ? 'red' : 'black'}`;
|
||||
card.draggable = true;
|
||||
card.dataset.value = value;
|
||||
card.dataset.suit = suit;
|
||||
|
||||
card.innerHTML = `
|
||||
<div>${value}</div>
|
||||
<div class="card-suit">${suit}</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('dragstart', handleDragStart);
|
||||
card.addEventListener('dragend', handleDragEnd);
|
||||
card.addEventListener('click', handleCardClick);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function generateRandomCard() {
|
||||
const value = values[Math.floor(Math.random() * values.length)];
|
||||
const suit = suits[Math.floor(Math.random() * suits.length)];
|
||||
return createCard(value, suit);
|
||||
}
|
||||
|
||||
function createStacks() {
|
||||
const container = document.getElementById('stacksContainer');
|
||||
container.innerHTML = '';
|
||||
|
||||
const rule = rules[currentRule];
|
||||
rule.stacks.forEach((stack, index) => {
|
||||
const stackDiv = document.createElement('div');
|
||||
stackDiv.className = 'stack';
|
||||
stackDiv.dataset.stackIndex = index;
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'stack-label';
|
||||
label.textContent = stack.label;
|
||||
|
||||
const cardsDiv = document.createElement('div');
|
||||
cardsDiv.className = 'stack-cards';
|
||||
|
||||
stackDiv.appendChild(label);
|
||||
stackDiv.appendChild(cardsDiv);
|
||||
|
||||
stackDiv.addEventListener('dragover', handleDragOver);
|
||||
stackDiv.addEventListener('drop', handleDrop);
|
||||
stackDiv.addEventListener('dragleave', handleDragLeave);
|
||||
stackDiv.addEventListener('click', handleStackClick);
|
||||
|
||||
container.appendChild(stackDiv);
|
||||
});
|
||||
}
|
||||
|
||||
function handleDragStart(e) {
|
||||
isDragging = true;
|
||||
currentCard = e.target;
|
||||
e.target.classList.add('dragging');
|
||||
}
|
||||
|
||||
function handleDragEnd(e) {
|
||||
isDragging = false;
|
||||
e.target.classList.remove('dragging');
|
||||
}
|
||||
|
||||
function handleDragOver(e) {
|
||||
e.preventDefault();
|
||||
const stack = e.currentTarget;
|
||||
if (isValidDrop(currentCard, stack)) {
|
||||
stack.classList.add('valid-drop');
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragLeave(e) {
|
||||
e.currentTarget.classList.remove('valid-drop');
|
||||
}
|
||||
|
||||
function handleDrop(e) {
|
||||
e.preventDefault();
|
||||
const stack = e.currentTarget;
|
||||
stack.classList.remove('valid-drop');
|
||||
|
||||
if (currentCard && isValidDrop(currentCard, stack)) {
|
||||
placeCard(currentCard, stack);
|
||||
}
|
||||
}
|
||||
|
||||
function handleCardClick(e) {
|
||||
if (!isDragging && !currentCard) {
|
||||
currentCard = e.target.closest('.card');
|
||||
currentCard.style.transform = 'scale(1.1)';
|
||||
currentCard.style.boxShadow = '0 8px 20px rgba(0, 0, 0, 0.3)';
|
||||
}
|
||||
}
|
||||
|
||||
function handleStackClick(e) {
|
||||
if (currentCard && !isDragging) {
|
||||
const stack = e.currentTarget;
|
||||
if (isValidDrop(currentCard, stack)) {
|
||||
placeCard(currentCard, stack);
|
||||
} else {
|
||||
stack.classList.add('invalid-drop');
|
||||
setTimeout(() => stack.classList.remove('invalid-drop'), 300);
|
||||
}
|
||||
currentCard.style.transform = '';
|
||||
currentCard.style.boxShadow = '';
|
||||
currentCard = null;
|
||||
}
|
||||
}
|
||||
|
||||
function isValidDrop(card, stack) {
|
||||
const stackIndex = parseInt(stack.dataset.stackIndex);
|
||||
const rule = rules[currentRule];
|
||||
const stackRule = rule.stacks[stackIndex];
|
||||
|
||||
if (currentRule === 'color') {
|
||||
const isRed = card.dataset.suit === '♥' || card.dataset.suit === '♦';
|
||||
return (stackRule.label === 'Rot' && isRed) ||
|
||||
(stackRule.label === 'Schwarz' && !isRed);
|
||||
} else if (currentRule === 'suit') {
|
||||
return stackRule.accepts.includes(card.dataset.suit);
|
||||
} else if (currentRule === 'value') {
|
||||
return stackRule.accepts.includes(card.dataset.value);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function placeCard(card, stack) {
|
||||
const stackCards = stack.querySelector('.stack-cards');
|
||||
const stackCard = card.cloneNode(true);
|
||||
stackCard.className = 'stack-card ' + (card.classList.contains('red') ? 'red' : 'black');
|
||||
stackCard.style.transform = `translateY(${stackCards.children.length * -2}px)`;
|
||||
stackCards.appendChild(stackCard);
|
||||
|
||||
card.remove();
|
||||
|
||||
cardsPlaced++;
|
||||
combo++;
|
||||
score += 10 * Math.max(1, Math.floor(combo / 3));
|
||||
|
||||
if (combo > maxCombo) maxCombo = combo;
|
||||
|
||||
updateStats();
|
||||
showScorePopup(stack, 10 * Math.max(1, Math.floor(combo / 3)));
|
||||
|
||||
if (combo >= 3 && combo % 3 === 0) {
|
||||
showCombo();
|
||||
}
|
||||
|
||||
nextCard();
|
||||
}
|
||||
|
||||
function showScorePopup(element, points) {
|
||||
const popup = document.createElement('div');
|
||||
popup.className = 'score-popup';
|
||||
popup.textContent = `+${points}`;
|
||||
popup.style.color = points > 10 ? '#27ae60' : '#e74c3c';
|
||||
|
||||
const rect = element.getBoundingClientRect();
|
||||
popup.style.left = rect.left + rect.width / 2 + 'px';
|
||||
popup.style.top = rect.top + 'px';
|
||||
|
||||
document.body.appendChild(popup);
|
||||
setTimeout(() => popup.remove(), 1000);
|
||||
}
|
||||
|
||||
function showCombo() {
|
||||
const indicator = document.getElementById('comboIndicator');
|
||||
const text = document.getElementById('comboText');
|
||||
text.textContent = `${combo}x Combo!`;
|
||||
indicator.style.display = 'block';
|
||||
|
||||
setTimeout(() => {
|
||||
indicator.style.display = 'none';
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
function nextCard() {
|
||||
const incomingArea = document.getElementById('incomingArea');
|
||||
incomingArea.innerHTML = '';
|
||||
|
||||
if (Math.random() < 0.25) {
|
||||
changeRule();
|
||||
}
|
||||
|
||||
const newCard = generateRandomCard();
|
||||
incomingArea.appendChild(newCard);
|
||||
}
|
||||
|
||||
function changeRule() {
|
||||
const ruleKeys = Object.keys(rules);
|
||||
let newRule;
|
||||
do {
|
||||
newRule = ruleKeys[Math.floor(Math.random() * ruleKeys.length)];
|
||||
} while (newRule === currentRule);
|
||||
|
||||
currentRule = newRule;
|
||||
document.getElementById('ruleText').textContent = rules[currentRule].name;
|
||||
createStacks();
|
||||
combo = 0;
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
document.getElementById('score').textContent = score;
|
||||
document.getElementById('cardsPlaced').textContent = cardsPlaced;
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
timeLeft--;
|
||||
const percentage = (timeLeft / 45) * 100;
|
||||
document.getElementById('timerFill').style.width = percentage + '%';
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
endGame();
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
gameActive = true;
|
||||
score = 0;
|
||||
cardsPlaced = 0;
|
||||
combo = 0;
|
||||
maxCombo = 0;
|
||||
timeLeft = 45;
|
||||
currentRule = 'color';
|
||||
|
||||
document.getElementById('ruleText').textContent = rules[currentRule].name;
|
||||
updateStats();
|
||||
createStacks();
|
||||
nextCard();
|
||||
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
gameActive = false;
|
||||
clearInterval(timerInterval);
|
||||
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('finalCards').textContent = cardsPlaced;
|
||||
document.getElementById('finalCombo').textContent = maxCombo;
|
||||
|
||||
document.getElementById('overlay').style.display = 'block';
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
document.getElementById('overlay').style.display = 'none';
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
startGame();
|
||||
}
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
if (currentCard && !e.target.closest('.card') && !e.target.closest('.stack')) {
|
||||
currentCard.style.transform = '';
|
||||
currentCard.style.boxShadow = '';
|
||||
currentCard = null;
|
||||
}
|
||||
});
|
||||
|
||||
startGame();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
180
games/arcade/apps/web/static/games/click_race.html
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Click Race</title>
|
||||
<style>
|
||||
/* Grundlegende Styles für die Seite */
|
||||
body {
|
||||
margin: 0;
|
||||
background: #000;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-family: Arial;
|
||||
}
|
||||
|
||||
/* Container für das Spiel */
|
||||
#game {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Das klickbare Quadrat */
|
||||
#target {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
background: #f00;
|
||||
margin: 20px auto;
|
||||
cursor: pointer;
|
||||
transition: all 0.1s;
|
||||
}
|
||||
|
||||
/* Animation beim Klicken */
|
||||
#target:active {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
/* Restart-Button Style */
|
||||
#restart {
|
||||
display: none;
|
||||
padding: 10px 20px;
|
||||
background: #00ff00;
|
||||
color: #000;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
#restart:hover {
|
||||
background: #00cc00;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game">
|
||||
<h1>CLICK RACE</h1>
|
||||
<p>30 Klicks so schnell wie möglich!</p>
|
||||
<div id="target"></div>
|
||||
<h2 id="info">Klicke zum Starten!</h2>
|
||||
<button id="restart" onclick="restart()">NOCHMAL!</button>
|
||||
</div>
|
||||
<script>
|
||||
// Spielvariablen
|
||||
let clicks = 0; // Anzahl der Klicks
|
||||
let startTime = 0; // Startzeit für die Zeitmessung
|
||||
let gameStarted = false; // Track ob das Spiel läuft
|
||||
|
||||
// DOM-Elemente holen
|
||||
const target = document.getElementById('target');
|
||||
const info = document.getElementById('info');
|
||||
const restartBtn = document.getElementById('restart');
|
||||
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'click-race';
|
||||
|
||||
// Sende Game Loaded Event beim Laden
|
||||
window.addEventListener('load', () => {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
});
|
||||
|
||||
// Klick-Handler für das rote Quadrat
|
||||
target.onclick = () => {
|
||||
// Beim ersten Klick Timer starten
|
||||
if (clicks === 0) {
|
||||
startTime = Date.now();
|
||||
gameStarted = true;
|
||||
}
|
||||
|
||||
// Klicks zählen
|
||||
clicks++;
|
||||
|
||||
// Prüfen ob Spiel noch läuft
|
||||
if (clicks < 30) {
|
||||
// Farbe ändern basierend auf Fortschritt
|
||||
target.style.background = `hsl(${clicks * 12}, 100%, 50%)`;
|
||||
// Anzeige aktualisieren
|
||||
info.textContent = `${30 - clicks} übrig`;
|
||||
} else {
|
||||
// Spiel beendet - Zeit berechnen
|
||||
const time = ((Date.now() - startTime) / 1000).toFixed(2);
|
||||
const timeInMs = Math.round(time * 1000);
|
||||
info.textContent = `FERTIG! Zeit: ${time}s`;
|
||||
|
||||
// Quadrat verstecken und Restart-Button zeigen
|
||||
target.style.display = 'none';
|
||||
restartBtn.style.display = 'inline-block';
|
||||
|
||||
// Sende Score (Zeit in Millisekunden - niedriger ist besser)
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: timeInMs }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (timeInMs < 5000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'speed-demon',
|
||||
name: 'Speed Demon',
|
||||
description: '30 Klicks in unter 5 Sekunden!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (timeInMs < 3000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'lightning-fast',
|
||||
name: 'Blitzschnell',
|
||||
description: '30 Klicks in unter 3 Sekunden!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
gameStarted = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Restart-Funktion
|
||||
function restart() {
|
||||
// Alle Werte zurücksetzen
|
||||
clicks = 0;
|
||||
startTime = 0;
|
||||
gameStarted = false;
|
||||
// UI zurücksetzen
|
||||
target.style.display = 'block';
|
||||
target.style.background = '#f00';
|
||||
restartBtn.style.display = 'none';
|
||||
info.textContent = 'Klicke zum Starten!';
|
||||
}
|
||||
|
||||
// Sende Game Ended Event beim Verlassen
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (gameStarted) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_ENDED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
144
games/arcade/apps/web/static/games/color_memory.html
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Color Memory</title>
|
||||
<style>
|
||||
body {
|
||||
background: #000;
|
||||
color: #fff;
|
||||
text-align: center;
|
||||
font-family: Arial;
|
||||
padding: 50px;
|
||||
}
|
||||
.box {
|
||||
width: 150px;
|
||||
height: 150px;
|
||||
display: inline-block;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
background: #333;
|
||||
transition: 0.3s;
|
||||
}
|
||||
.box:hover {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>COLOR MEMORY</h1>
|
||||
<p id="info">Merke dir die Reihenfolge!</p>
|
||||
<div>
|
||||
<div class="box" onclick="check(0)"></div>
|
||||
<div class="box" onclick="check(1)"></div>
|
||||
<div class="box" onclick="check(2)"></div>
|
||||
<div class="box" onclick="check(3)"></div>
|
||||
</div>
|
||||
<h2 id="score">Level: 1</h2>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'color-memory';
|
||||
|
||||
let sequence = [];
|
||||
let playerSeq = [];
|
||||
let level = 1;
|
||||
let playing = false;
|
||||
const colors = ['#f00', '#0f0', '#00f', '#ff0'];
|
||||
const boxes = document.querySelectorAll('.box');
|
||||
|
||||
function nextLevel() {
|
||||
playerSeq = [];
|
||||
sequence.push(Math.floor(Math.random() * 4));
|
||||
playing = false;
|
||||
document.getElementById('info').textContent = 'Schau zu!';
|
||||
|
||||
sequence.forEach((num, i) => {
|
||||
setTimeout(() => {
|
||||
boxes[num].style.background = colors[num];
|
||||
setTimeout(() => boxes[num].style.background = '#333', 400);
|
||||
}, i * 600 + 600);
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
playing = true;
|
||||
document.getElementById('info').textContent = 'Dein Zug!';
|
||||
}, sequence.length * 600 + 600);
|
||||
}
|
||||
|
||||
function check(num) {
|
||||
if (!playing) return;
|
||||
|
||||
boxes[num].style.background = colors[num];
|
||||
setTimeout(() => boxes[num].style.background = '#333', 200);
|
||||
|
||||
playerSeq.push(num);
|
||||
|
||||
if (playerSeq[playerSeq.length - 1] !== sequence[playerSeq.length - 1]) {
|
||||
document.getElementById('info').textContent = `Game Over! Level ${level} erreicht`;
|
||||
playing = false;
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: level * 100 }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (level >= 10) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'memory_master',
|
||||
name: 'Memory Master',
|
||||
description: 'Reach level 10 in Color Memory',
|
||||
icon: '🧠'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (level >= 15) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'photographic_memory',
|
||||
name: 'Photographic Memory',
|
||||
description: 'Reach level 15 in Color Memory',
|
||||
icon: '📸'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
setTimeout(() => location.reload(), 2000);
|
||||
} else if (playerSeq.length === sequence.length) {
|
||||
level++;
|
||||
document.getElementById('score').textContent = `Level: ${level}`;
|
||||
document.getElementById('info').textContent = 'Richtig!';
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: level * 100 }
|
||||
}, '*');
|
||||
|
||||
setTimeout(nextLevel, 1000);
|
||||
}
|
||||
}
|
||||
|
||||
nextLevel();
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
697
games/arcade/apps/web/static/games/fish_catcher.html
Normal file
|
|
@ -0,0 +1,697 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Fish Catcher</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: linear-gradient(180deg, #87CEEB 0%, #1e90ff 30%, #0066cc 60%, #003d82 100%);
|
||||
color: #fff;
|
||||
font-family: 'Comic Sans MS', cursive;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px solid #fff;
|
||||
border-radius: 15px;
|
||||
box-shadow: 0 0 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.ui {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
font-size: 20px;
|
||||
z-index: 10;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #ffff00;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.lives {
|
||||
color: #ff6b6b;
|
||||
margin-top: 10px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
background: rgba(0, 50, 100, 0.9);
|
||||
padding: 30px;
|
||||
border: 3px solid #fff;
|
||||
border-radius: 20px;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.controls {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
font-size: 14px;
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(145deg, #4CAF50, #45a049);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
margin: 10px;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: 16px;
|
||||
border-radius: 25px;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.timer {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
font-size: 18px;
|
||||
color: #ffff00;
|
||||
font-weight: bold;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas" width="800" height="600"></canvas>
|
||||
|
||||
<div class="ui">
|
||||
<div class="score">🐟 Fische: <span id="score">0</span></div>
|
||||
<div class="lives">❤️ Leben: <span id="lives">3</span></div>
|
||||
</div>
|
||||
|
||||
<div class="timer">
|
||||
⏰ Zeit: <span id="timeLeft">60</span>s
|
||||
</div>
|
||||
|
||||
<div class="controls">
|
||||
A/D oder ← → : Boot bewegen | Maus: Alternative Steuerung
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>🎣 Angeltag beendet!</h2>
|
||||
<p>Gefangene Fische: <span id="finalScore">0</span></p>
|
||||
<p id="rating"></p>
|
||||
<button onclick="restartGame()">🔄 Nochmal angeln</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'fish-catcher';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spiel-Zustand
|
||||
let gameRunning = true;
|
||||
let score = 0;
|
||||
let lives = 3;
|
||||
let timeLeft = 60;
|
||||
let gameTimer;
|
||||
|
||||
// Eingabe
|
||||
const keys = {};
|
||||
let mouseX = canvas.width / 2;
|
||||
|
||||
// Boot (Spieler)
|
||||
const boat = {
|
||||
x: canvas.width / 2 - 50,
|
||||
y: 20,
|
||||
width: 100,
|
||||
height: 40,
|
||||
speed: 6,
|
||||
netWidth: 80,
|
||||
netActive: false,
|
||||
netAnimation: 0
|
||||
};
|
||||
|
||||
// Arrays für Spielobjekte
|
||||
const fish = [];
|
||||
const bubbles = [];
|
||||
const powerups = [];
|
||||
const splashes = [];
|
||||
|
||||
// Wellen für Hintergrund
|
||||
const waves = [];
|
||||
|
||||
// Wellen erstellen
|
||||
function createWaves() {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
waves.push({
|
||||
x: i * 120,
|
||||
y: canvas.height - 60 + Math.sin(i) * 10,
|
||||
amplitude: 8 + Math.random() * 5,
|
||||
frequency: 0.02 + Math.random() * 0.01,
|
||||
offset: Math.random() * Math.PI * 2
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Fisch erstellen
|
||||
function createFish() {
|
||||
const fishTypes = [
|
||||
{ color: '#ff6b35', points: 10, speed: 1, size: 20 }, // Orange Fisch
|
||||
{ color: '#f7931e', points: 15, speed: 1.5, size: 16 }, // Gelber Fisch
|
||||
{ color: '#ff1744', points: 25, speed: 2, size: 12 }, // Roter Fisch (schnell)
|
||||
{ color: '#9c27b0', points: 50, speed: 0.8, size: 25 } // Lila Fisch (groß, langsam)
|
||||
];
|
||||
|
||||
const type = fishTypes[Math.floor(Math.random() * fishTypes.length)];
|
||||
|
||||
fish.push({
|
||||
x: Math.random() * (canvas.width - 40) + 20,
|
||||
y: canvas.height,
|
||||
width: type.size,
|
||||
height: type.size * 0.6,
|
||||
speed: type.speed,
|
||||
color: type.color,
|
||||
points: type.points,
|
||||
wiggle: Math.random() * Math.PI * 2,
|
||||
wiggleSpeed: 0.05 + Math.random() * 0.05
|
||||
});
|
||||
}
|
||||
|
||||
// Power-up erstellen
|
||||
function createPowerup() {
|
||||
const types = ['bignet', 'multiplier', 'timeadd'];
|
||||
const type = types[Math.floor(Math.random() * types.length)];
|
||||
|
||||
powerups.push({
|
||||
x: Math.random() * (canvas.width - 30) + 15,
|
||||
y: canvas.height,
|
||||
width: 25,
|
||||
height: 25,
|
||||
speed: 0.8,
|
||||
type: type,
|
||||
rotation: 0,
|
||||
pulse: 0
|
||||
});
|
||||
}
|
||||
|
||||
// Luftblasen erstellen
|
||||
function createBubbles() {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
bubbles.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: canvas.height,
|
||||
size: Math.random() * 8 + 3,
|
||||
speed: 0.5 + Math.random() * 1,
|
||||
opacity: 0.3 + Math.random() * 0.4
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Splash-Effekt erstellen
|
||||
function createSplash(x, y, color = '#87CEEB') {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
splashes.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: (Math.random() - 0.5) * 8,
|
||||
vy: (Math.random() - 0.5) * 8,
|
||||
size: Math.random() * 4 + 2,
|
||||
life: 20,
|
||||
maxLife: 20,
|
||||
color: color
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Kollisionserkennung
|
||||
function checkCollision(rect1, rect2) {
|
||||
return rect1.x < rect2.x + rect2.width &&
|
||||
rect1.x + rect1.width > rect2.x &&
|
||||
rect1.y < rect2.y + rect2.height &&
|
||||
rect1.y + rect1.height > rect2.y;
|
||||
}
|
||||
|
||||
// Event Listener
|
||||
document.addEventListener('keydown', (e) => {
|
||||
keys[e.key.toLowerCase()] = true;
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', (e) => {
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
mouseX = e.clientX - rect.left;
|
||||
});
|
||||
|
||||
// Boot updaten
|
||||
function updateBoat() {
|
||||
// Tastatur-Steuerung
|
||||
if (keys['a'] || keys['arrowleft']) {
|
||||
boat.x -= boat.speed;
|
||||
}
|
||||
if (keys['d'] || keys['arrowright']) {
|
||||
boat.x += boat.speed;
|
||||
}
|
||||
|
||||
// Maus-Steuerung (sanfter)
|
||||
const targetX = mouseX - boat.width / 2;
|
||||
const diff = targetX - boat.x;
|
||||
boat.x += diff * 0.1;
|
||||
|
||||
// Grenzen
|
||||
if (boat.x < 0) boat.x = 0;
|
||||
if (boat.x + boat.width > canvas.width) boat.x = canvas.width - boat.width;
|
||||
|
||||
// Netz-Animation
|
||||
if (boat.netAnimation > 0) {
|
||||
boat.netAnimation--;
|
||||
boat.netActive = true;
|
||||
} else {
|
||||
boat.netActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Fische updaten
|
||||
function updateFish() {
|
||||
for (let i = fish.length - 1; i >= 0; i--) {
|
||||
const f = fish[i];
|
||||
|
||||
// Bewegung
|
||||
f.y -= f.speed;
|
||||
f.wiggle += f.wiggleSpeed;
|
||||
f.x += Math.sin(f.wiggle) * 0.5;
|
||||
|
||||
// Aus dem Bildschirm entfernt
|
||||
if (f.y + f.height < 0) {
|
||||
fish.splice(i, 1);
|
||||
lives--;
|
||||
createSplash(f.x + f.width/2, 0, '#ff6b6b');
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kollision mit Netz
|
||||
const netArea = {
|
||||
x: boat.x + boat.width/2 - boat.netWidth/2,
|
||||
y: boat.y + boat.height,
|
||||
width: boat.netWidth,
|
||||
height: 60
|
||||
};
|
||||
|
||||
if (checkCollision(f, netArea)) {
|
||||
score += f.points;
|
||||
createSplash(f.x + f.width/2, f.y + f.height/2, f.color);
|
||||
boat.netAnimation = 15;
|
||||
fish.splice(i, 1);
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Power-ups updaten
|
||||
function updatePowerups() {
|
||||
for (let i = powerups.length - 1; i >= 0; i--) {
|
||||
const p = powerups[i];
|
||||
|
||||
p.y -= p.speed;
|
||||
p.rotation += 0.1;
|
||||
p.pulse += 0.15;
|
||||
|
||||
if (p.y + p.height < 0) {
|
||||
powerups.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Kollision mit Boot
|
||||
if (checkCollision(p, boat)) {
|
||||
if (p.type === 'bignet') {
|
||||
boat.netWidth = Math.min(boat.netWidth + 20, 150);
|
||||
} else if (p.type === 'multiplier') {
|
||||
// Nächste 5 Fische doppelte Punkte (vereinfacht)
|
||||
score += 100;
|
||||
} else if (p.type === 'timeadd') {
|
||||
timeLeft += 10;
|
||||
}
|
||||
|
||||
createSplash(p.x + p.width/2, p.y + p.height/2, '#ffff00');
|
||||
powerups.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Blasen updaten
|
||||
function updateBubbles() {
|
||||
for (let i = bubbles.length - 1; i >= 0; i--) {
|
||||
const bubble = bubbles[i];
|
||||
|
||||
bubble.y -= bubble.speed;
|
||||
bubble.x += Math.sin(bubble.y * 0.01) * 0.3;
|
||||
|
||||
if (bubble.y + bubble.size < 0) {
|
||||
bubbles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Splash-Effekte updaten
|
||||
function updateSplashes() {
|
||||
for (let i = splashes.length - 1; i >= 0; i--) {
|
||||
const splash = splashes[i];
|
||||
|
||||
splash.x += splash.vx;
|
||||
splash.y += splash.vy;
|
||||
splash.vy += 0.3; // Schwerkraft
|
||||
splash.life--;
|
||||
|
||||
if (splash.life <= 0) {
|
||||
splashes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichnen
|
||||
function draw() {
|
||||
// Himmel/Wasser Gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
gradient.addColorStop(0, '#87CEEB');
|
||||
gradient.addColorStop(0.3, '#1e90ff');
|
||||
gradient.addColorStop(0.6, '#0066cc');
|
||||
gradient.addColorStop(1, '#003d82');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Wellen zeichnen
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.2)';
|
||||
for (const wave of waves) {
|
||||
ctx.beginPath();
|
||||
for (let x = 0; x < canvas.width; x += 5) {
|
||||
const y = wave.y + Math.sin((x + wave.offset) * wave.frequency) * wave.amplitude;
|
||||
if (x === 0) ctx.moveTo(x, y);
|
||||
else ctx.lineTo(x, y);
|
||||
}
|
||||
ctx.lineTo(canvas.width, canvas.height);
|
||||
ctx.lineTo(0, canvas.height);
|
||||
ctx.fill();
|
||||
wave.offset += 0.02;
|
||||
}
|
||||
|
||||
// Blasen zeichnen
|
||||
for (const bubble of bubbles) {
|
||||
ctx.globalAlpha = bubble.opacity;
|
||||
ctx.fillStyle = '#87CEEB';
|
||||
ctx.beginPath();
|
||||
ctx.arc(bubble.x, bubble.y, bubble.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
|
||||
// Fische zeichnen
|
||||
for (const f of fish) {
|
||||
ctx.save();
|
||||
ctx.translate(f.x + f.width/2, f.y + f.height/2);
|
||||
|
||||
// Fisch-Körper
|
||||
ctx.fillStyle = f.color;
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, f.width/2, f.height/2, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Fisch-Schwanz
|
||||
ctx.fillStyle = f.color;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(-f.width/2, 0);
|
||||
ctx.lineTo(-f.width/2 - 8, -f.height/4);
|
||||
ctx.lineTo(-f.width/2 - 8, f.height/4);
|
||||
ctx.fill();
|
||||
|
||||
// Auge
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.width/4, -f.height/6, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(f.width/4, -f.height/6, 1.5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Power-ups zeichnen
|
||||
for (const p of powerups) {
|
||||
ctx.save();
|
||||
ctx.translate(p.x + p.width/2, p.y + p.height/2);
|
||||
ctx.rotate(p.rotation);
|
||||
|
||||
const pulseSize = p.width + Math.sin(p.pulse) * 3;
|
||||
|
||||
if (p.type === 'bignet') {
|
||||
ctx.fillStyle = '#4CAF50';
|
||||
} else if (p.type === 'multiplier') {
|
||||
ctx.fillStyle = '#ffff00';
|
||||
} else {
|
||||
ctx.fillStyle = '#ff69b4';
|
||||
}
|
||||
|
||||
ctx.fillRect(-pulseSize/2, -pulseSize/2, pulseSize, pulseSize);
|
||||
|
||||
// Symbol
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
if (p.type === 'bignet') ctx.fillText('🕸️', 0, 4);
|
||||
else if (p.type === 'multiplier') ctx.fillText('×2', 0, 4);
|
||||
else ctx.fillText('+T', 0, 4);
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Boot zeichnen
|
||||
ctx.fillStyle = '#8B4513';
|
||||
ctx.fillRect(boat.x, boat.y, boat.width, boat.height);
|
||||
|
||||
// Boot-Details
|
||||
ctx.fillStyle = '#A0522D';
|
||||
ctx.fillRect(boat.x + 10, boat.y + 5, boat.width - 20, boat.height - 10);
|
||||
|
||||
// Netz zeichnen
|
||||
if (boat.netActive || boat.netAnimation > 0) {
|
||||
const netY = boat.y + boat.height;
|
||||
const netX = boat.x + boat.width/2 - boat.netWidth/2;
|
||||
|
||||
ctx.strokeStyle = '#654321';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.globalAlpha = 0.7;
|
||||
|
||||
// Netz-Muster
|
||||
for (let i = 0; i < 6; i++) {
|
||||
for (let j = 0; j < 4; j++) {
|
||||
const x = netX + (i * boat.netWidth/5);
|
||||
const y = netY + (j * 15);
|
||||
ctx.strokeRect(x, y, boat.netWidth/5, 15);
|
||||
}
|
||||
}
|
||||
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Splash-Effekte zeichnen
|
||||
for (const splash of splashes) {
|
||||
ctx.globalAlpha = splash.life / splash.maxLife;
|
||||
ctx.fillStyle = splash.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(splash.x, splash.y, splash.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Spawn-System
|
||||
let fishSpawnTimer = 0;
|
||||
let powerupSpawnTimer = 0;
|
||||
let bubbleSpawnTimer = 0;
|
||||
|
||||
function handleSpawning() {
|
||||
fishSpawnTimer++;
|
||||
powerupSpawnTimer++;
|
||||
bubbleSpawnTimer++;
|
||||
|
||||
// Fische spawnen
|
||||
if (fishSpawnTimer >= 90) {
|
||||
createFish();
|
||||
fishSpawnTimer = 0;
|
||||
}
|
||||
|
||||
// Power-ups spawnen
|
||||
if (powerupSpawnTimer >= 600 && powerups.length < 1) {
|
||||
createPowerup();
|
||||
powerupSpawnTimer = 0;
|
||||
}
|
||||
|
||||
// Blasen spawnen
|
||||
if (bubbleSpawnTimer >= 30) {
|
||||
createBubbles();
|
||||
bubbleSpawnTimer = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// Spiel-Loop
|
||||
function gameLoop() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
updateBoat();
|
||||
updateFish();
|
||||
updatePowerups();
|
||||
updateBubbles();
|
||||
updateSplashes();
|
||||
handleSpawning();
|
||||
|
||||
draw();
|
||||
|
||||
// UI updaten
|
||||
document.getElementById('score').textContent = score;
|
||||
document.getElementById('lives').textContent = lives;
|
||||
document.getElementById('timeLeft').textContent = timeLeft;
|
||||
|
||||
// Spiel beenden
|
||||
if (lives <= 0 || timeLeft <= 0) {
|
||||
gameOver();
|
||||
}
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Timer
|
||||
function startTimer() {
|
||||
gameTimer = setInterval(() => {
|
||||
if (gameRunning && timeLeft > 0) {
|
||||
timeLeft--;
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// Game Over
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
clearInterval(gameTimer);
|
||||
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
|
||||
let rating = '';
|
||||
if (score >= 500) rating = '🏆 Meister-Angler!';
|
||||
else if (score >= 300) rating = '🥈 Profi-Fischer!';
|
||||
else if (score >= 150) rating = '🥉 Guter Fang!';
|
||||
else rating = '🎣 Weiter üben!';
|
||||
|
||||
document.getElementById('rating').textContent = rating;
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 500) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'master_angler',
|
||||
name: 'Master Angler',
|
||||
description: 'Score 500 points in Fish Catcher',
|
||||
icon: '🏆'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (lives === 3 && score >= 300) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'perfect_fishing',
|
||||
name: 'Perfect Fishing',
|
||||
description: 'Score 300 points without losing a life',
|
||||
icon: '🌟'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Neustart
|
||||
function restartGame() {
|
||||
gameRunning = true;
|
||||
score = 0;
|
||||
lives = 3;
|
||||
timeLeft = 60;
|
||||
|
||||
// Arrays leeren
|
||||
fish.length = 0;
|
||||
powerups.length = 0;
|
||||
bubbles.length = 0;
|
||||
splashes.length = 0;
|
||||
|
||||
// Boot zurücksetzen
|
||||
boat.x = canvas.width / 2 - 50;
|
||||
boat.netWidth = 80;
|
||||
boat.netActive = false;
|
||||
boat.netAnimation = 0;
|
||||
|
||||
// Timer zurücksetzen
|
||||
fishSpawnTimer = 0;
|
||||
powerupSpawnTimer = 0;
|
||||
bubbleSpawnTimer = 0;
|
||||
|
||||
clearInterval(gameTimer);
|
||||
startTimer();
|
||||
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Spiel starten
|
||||
createWaves();
|
||||
startTimer();
|
||||
gameLoop();
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
489
games/arcade/apps/web/static/games/flappy_mana.html
Normal file
|
|
@ -0,0 +1,489 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Flappy Mana</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #1a1a2e;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #eee;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 20px;
|
||||
margin-bottom: 10px;
|
||||
letter-spacing: 2px;
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px solid #f39c12;
|
||||
background: linear-gradient(to bottom, #87CEEB 0%, #98D8E8 100%);
|
||||
display: block;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.start-screen {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.start-screen h1 {
|
||||
color: #f39c12;
|
||||
margin-bottom: 20px;
|
||||
font-size: 36px;
|
||||
}
|
||||
|
||||
.start-button {
|
||||
background: #f39c12;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
font-size: 18px;
|
||||
font-family: 'Courier New', monospace;
|
||||
cursor: pointer;
|
||||
margin-top: 20px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.start-button:hover {
|
||||
background: #e67e22;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(26, 26, 46, 0.95);
|
||||
border: 3px solid #f39c12;
|
||||
padding: 30px;
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
color: #f39c12;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
background: #f39c12;
|
||||
color: #1a1a2e;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
margin-top: 15px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
background: #e67e22;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin: 10px 0;
|
||||
font-size: 14px;
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<div class="score">SCORE: <span id="score">0</span></div>
|
||||
<canvas id="gameCanvas" width="400" height="600"></canvas>
|
||||
|
||||
<div class="start-screen" id="startScreen">
|
||||
<h1>FLAPPY MANA</h1>
|
||||
<p class="instructions">Klicke oder drücke SPACE zum Fliegen</p>
|
||||
<p class="instructions">Weiche den Röhren aus!</p>
|
||||
<button class="start-button" onclick="startGame()">START</button>
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>GAME OVER</h2>
|
||||
<div>SCORE: <span id="finalScore">0</span></div>
|
||||
<div>BEST: <span id="bestScore">0</span></div>
|
||||
<button class="restart-btn" onclick="restartGame()">RESTART</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
const scoreElement = document.getElementById('score');
|
||||
const startScreen = document.getElementById('startScreen');
|
||||
const gameOverElement = document.getElementById('gameOver');
|
||||
const finalScoreElement = document.getElementById('finalScore');
|
||||
const bestScoreElement = document.getElementById('bestScore');
|
||||
|
||||
const GAME_ID = 'flappy-mana';
|
||||
|
||||
let gameRunning = false;
|
||||
let gameStarted = false;
|
||||
let score = 0;
|
||||
let bestScore = localStorage.getItem('flappyManaBest') || 0;
|
||||
let animationId = null;
|
||||
|
||||
const bird = {
|
||||
x: 100,
|
||||
y: canvas.height / 2,
|
||||
radius: 15,
|
||||
velocity: 0,
|
||||
gravity: 0.4,
|
||||
jumpPower: -8,
|
||||
color: '#f39c12',
|
||||
rotation: 0
|
||||
};
|
||||
|
||||
const pipes = [];
|
||||
const pipeWidth = 60;
|
||||
const pipeGap = 180;
|
||||
const pipeSpeed = 3;
|
||||
let pipeTimer = 0;
|
||||
|
||||
const particles = [];
|
||||
|
||||
const clouds = [
|
||||
{ x: 100, y: 50, width: 60, height: 30, speed: 0.5 },
|
||||
{ x: 300, y: 100, width: 80, height: 40, speed: 0.3 },
|
||||
{ x: 500, y: 80, width: 70, height: 35, speed: 0.4 }
|
||||
];
|
||||
|
||||
function jump() {
|
||||
if (!gameStarted) {
|
||||
gameStarted = true;
|
||||
gameRunning = true;
|
||||
}
|
||||
if (gameRunning) {
|
||||
bird.velocity = bird.jumpPower;
|
||||
|
||||
for (let i = 0; i < 5; i++) {
|
||||
particles.push({
|
||||
x: bird.x - 10,
|
||||
y: bird.y + Math.random() * 10 - 5,
|
||||
vx: -Math.random() * 2 - 1,
|
||||
vy: Math.random() * 2 - 1,
|
||||
life: 1.0,
|
||||
color: '#fff'
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createPipe() {
|
||||
const minHeight = 100;
|
||||
const maxHeight = canvas.height - pipeGap - minHeight;
|
||||
const topHeight = Math.random() * (maxHeight - minHeight) + minHeight;
|
||||
|
||||
pipes.push({
|
||||
x: canvas.width,
|
||||
topHeight: topHeight,
|
||||
bottomY: topHeight + pipeGap,
|
||||
passed: false
|
||||
});
|
||||
}
|
||||
|
||||
function updateBird() {
|
||||
if (!gameStarted) return;
|
||||
|
||||
bird.velocity += bird.gravity;
|
||||
bird.y += bird.velocity;
|
||||
|
||||
bird.rotation = Math.min(Math.max(bird.velocity * 3, -30), 90);
|
||||
|
||||
if (bird.y - bird.radius < 0) {
|
||||
bird.y = bird.radius;
|
||||
bird.velocity = 0;
|
||||
}
|
||||
|
||||
if (bird.y + bird.radius > canvas.height) {
|
||||
gameOver();
|
||||
}
|
||||
}
|
||||
|
||||
function updatePipes() {
|
||||
if (!gameStarted) return;
|
||||
|
||||
pipeTimer++;
|
||||
if (pipeTimer > 90) {
|
||||
createPipe();
|
||||
pipeTimer = 0;
|
||||
}
|
||||
|
||||
for (let i = pipes.length - 1; i >= 0; i--) {
|
||||
const pipe = pipes[i];
|
||||
pipe.x -= pipeSpeed;
|
||||
|
||||
if (pipe.x + pipeWidth < bird.x && !pipe.passed) {
|
||||
pipe.passed = true;
|
||||
score++;
|
||||
scoreElement.textContent = score;
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
for (let j = 0; j < 10; j++) {
|
||||
particles.push({
|
||||
x: bird.x,
|
||||
y: bird.y,
|
||||
vx: Math.random() * 4 - 2,
|
||||
vy: Math.random() * 4 - 2,
|
||||
life: 1.0,
|
||||
color: '#f39c12'
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (pipe.x + pipeWidth < 0) {
|
||||
pipes.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bird.x + bird.radius > pipe.x &&
|
||||
bird.x - bird.radius < pipe.x + pipeWidth) {
|
||||
if (bird.y - bird.radius < pipe.topHeight ||
|
||||
bird.y + bird.radius > pipe.bottomY) {
|
||||
gameOver();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticles() {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const particle = particles[i];
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.life -= 0.02;
|
||||
|
||||
if (particle.life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateClouds() {
|
||||
clouds.forEach(cloud => {
|
||||
cloud.x -= cloud.speed;
|
||||
if (cloud.x + cloud.width < 0) {
|
||||
cloud.x = canvas.width + Math.random() * 100;
|
||||
cloud.y = Math.random() * 150;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
// Himmel-Gradient
|
||||
const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
|
||||
gradient.addColorStop(0, '#87CEEB');
|
||||
gradient.addColorStop(1, '#98D8E8');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Wolken
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)';
|
||||
clouds.forEach(cloud => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(cloud.x, cloud.y, cloud.width/3, 0, Math.PI * 2);
|
||||
ctx.arc(cloud.x + cloud.width/3, cloud.y, cloud.width/2.5, 0, Math.PI * 2);
|
||||
ctx.arc(cloud.x + cloud.width/1.5, cloud.y, cloud.width/3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
function drawBird() {
|
||||
ctx.save();
|
||||
ctx.translate(bird.x, bird.y);
|
||||
ctx.rotate(bird.rotation * Math.PI / 180);
|
||||
|
||||
ctx.fillStyle = bird.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, bird.radius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.beginPath();
|
||||
ctx.arc(5, -5, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#000';
|
||||
ctx.beginPath();
|
||||
ctx.arc(7, -5, 2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
ctx.fillStyle = '#e67e22';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(bird.radius - 5, 0);
|
||||
ctx.lineTo(bird.radius + 5, 3);
|
||||
ctx.lineTo(bird.radius + 5, -3);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
function drawPipes() {
|
||||
pipes.forEach(pipe => {
|
||||
const gradient = ctx.createLinearGradient(pipe.x, 0, pipe.x + pipeWidth, 0);
|
||||
gradient.addColorStop(0, '#2ecc71');
|
||||
gradient.addColorStop(0.5, '#27ae60');
|
||||
gradient.addColorStop(1, '#229954');
|
||||
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(pipe.x, 0, pipeWidth, pipe.topHeight);
|
||||
ctx.fillRect(pipe.x, pipe.bottomY, pipeWidth, canvas.height - pipe.bottomY);
|
||||
|
||||
ctx.fillStyle = '#27ae60';
|
||||
ctx.fillRect(pipe.x - 5, pipe.topHeight - 30, pipeWidth + 10, 30);
|
||||
ctx.fillRect(pipe.x - 5, pipe.bottomY, pipeWidth + 10, 30);
|
||||
|
||||
ctx.strokeStyle = '#1e7e34';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(pipe.x, 0, pipeWidth, pipe.topHeight);
|
||||
ctx.strokeRect(pipe.x, pipe.bottomY, pipeWidth, canvas.height - pipe.bottomY);
|
||||
});
|
||||
}
|
||||
|
||||
function drawParticles() {
|
||||
particles.forEach(particle => {
|
||||
ctx.globalAlpha = particle.life;
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fillRect(particle.x - 2, particle.y - 2, 4, 4);
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
drawBackground();
|
||||
updateClouds();
|
||||
|
||||
if (gameRunning) {
|
||||
updateBird();
|
||||
updatePipes();
|
||||
}
|
||||
|
||||
updateParticles();
|
||||
|
||||
drawPipes();
|
||||
drawBird();
|
||||
drawParticles();
|
||||
|
||||
animationId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
if (animationId) {
|
||||
cancelAnimationFrame(animationId);
|
||||
animationId = null;
|
||||
}
|
||||
|
||||
if (score > bestScore) {
|
||||
bestScore = score;
|
||||
localStorage.setItem('flappyManaBest', bestScore);
|
||||
}
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
if (score >= 50) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'flappy-expert',
|
||||
name: 'Flappy Experte',
|
||||
description: '50 Röhren gemeistert!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
finalScoreElement.textContent = score;
|
||||
bestScoreElement.textContent = bestScore;
|
||||
gameOverElement.style.display = 'block';
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
startScreen.style.display = 'none';
|
||||
gameRunning = false;
|
||||
gameStarted = false;
|
||||
score = 0;
|
||||
scoreElement.textContent = score;
|
||||
bird.x = 100;
|
||||
bird.y = canvas.height / 2;
|
||||
bird.velocity = 0;
|
||||
bird.rotation = 0;
|
||||
pipes.length = 0;
|
||||
particles.length = 0;
|
||||
pipeTimer = 0;
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
|
||||
if (!animationId) {
|
||||
animationId = requestAnimationFrame(gameLoop);
|
||||
}
|
||||
}
|
||||
|
||||
function restartGame() {
|
||||
gameOverElement.style.display = 'none';
|
||||
startGame();
|
||||
}
|
||||
|
||||
canvas.addEventListener('click', jump);
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault();
|
||||
jump();
|
||||
}
|
||||
});
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
202
games/arcade/apps/web/static/games/game-stats-example.html
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Game Stats Integration Example</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
font-family: Arial, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 800px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #00ff88;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
color: #00ff88;
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 4px;
|
||||
padding: 15px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
code {
|
||||
color: #60a5fa;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #00ff88;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 10px 20px;
|
||||
border-radius: 4px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #00cc6a;
|
||||
}
|
||||
|
||||
.test-buttons {
|
||||
margin-top: 20px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Game Stats Integration Guide</h1>
|
||||
|
||||
<div class="section">
|
||||
<h2>1. Spiel laden</h2>
|
||||
<p>Sende diese Nachricht wenn dein Spiel startet:</p>
|
||||
<pre><code>window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: 'dein-spiel-slug'
|
||||
}, '*');</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>2. Score aktualisieren</h2>
|
||||
<p>Sende diese Nachricht wenn sich der Score ändert:</p>
|
||||
<pre><code>window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'dein-spiel-slug',
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: 1250 }
|
||||
}, '*');</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>3. Game Over</h2>
|
||||
<p>Sende diese Nachricht wenn das Spiel endet:</p>
|
||||
<pre><code>window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'dein-spiel-slug',
|
||||
event: 'GAME_OVER',
|
||||
data: { score: 1250 }
|
||||
}, '*');</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>4. Achievement freischalten</h2>
|
||||
<p>Sende diese Nachricht für Achievements:</p>
|
||||
<pre><code>window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'dein-spiel-slug',
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'first-win',
|
||||
name: 'Erster Sieg',
|
||||
description: 'Gewinne dein erstes Spiel'
|
||||
}
|
||||
}
|
||||
}, '*');</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>5. Spiel beenden</h2>
|
||||
<p>Optional: Sende diese Nachricht beim Verlassen:</p>
|
||||
<pre><code>window.parent.postMessage({
|
||||
type: 'GAME_ENDED',
|
||||
gameId: 'dein-spiel-slug'
|
||||
}, '*');</code></pre>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Test-Buttons</h2>
|
||||
<p>Teste die Integration mit diesen Buttons:</p>
|
||||
<div class="test-buttons">
|
||||
<button onclick="sendGameLoaded()">Game Loaded</button>
|
||||
<button onclick="sendScore(100)">Score: 100</button>
|
||||
<button onclick="sendScore(500)">Score: 500</button>
|
||||
<button onclick="sendGameOver(750)">Game Over (750)</button>
|
||||
<button onclick="sendAchievement()">Achievement</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Example game ID
|
||||
const GAME_ID = 'test-game';
|
||||
|
||||
// Send game loaded event on page load
|
||||
window.addEventListener('load', () => {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
});
|
||||
|
||||
function sendGameLoaded() {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
console.log('Sent: GAME_LOADED');
|
||||
}
|
||||
|
||||
function sendScore(score) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score }
|
||||
}, '*');
|
||||
console.log('Sent: SCORE_UPDATE', score);
|
||||
}
|
||||
|
||||
function sendGameOver(score) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score }
|
||||
}, '*');
|
||||
console.log('Sent: GAME_OVER', score);
|
||||
}
|
||||
|
||||
function sendAchievement() {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'test-achievement',
|
||||
name: 'Test Erfolg',
|
||||
description: 'Du hast den Test-Button gedrückt!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
console.log('Sent: ACHIEVEMENT_UNLOCKED');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
483
games/arcade/apps/web/static/games/gravity_painter.html
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Gravity Painter</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: linear-gradient(135deg, #0a0a0a, #1a1a2e);
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
#gameContainer {
|
||||
position: relative;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
#canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: radial-gradient(circle at center, #0f0f23, #000);
|
||||
}
|
||||
|
||||
#ui {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
color: #fff;
|
||||
z-index: 10;
|
||||
font-size: 18px;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
#instructions {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 20px;
|
||||
color: #aaa;
|
||||
z-index: 10;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#targetPattern {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
border: 2px solid #00ff88;
|
||||
background: rgba(0,255,136,0.1);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.gravity-point {
|
||||
position: absolute;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #ff0066, #ff0066, transparent);
|
||||
pointer-events: none;
|
||||
animation: pulse 2s infinite;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.8; }
|
||||
50% { transform: scale(1.5); opacity: 0.4; }
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hit-effect {
|
||||
position: absolute;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, #00ff88, transparent);
|
||||
pointer-events: none;
|
||||
animation: hit 0.5s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes hit {
|
||||
0% { transform: scale(0); opacity: 1; }
|
||||
100% { transform: scale(2); opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="gameContainer">
|
||||
<canvas id="canvas"></canvas>
|
||||
<div id="ui">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div>Level: <span id="level">1</span></div>
|
||||
<div>Particles: <span id="particles">10</span></div>
|
||||
</div>
|
||||
<div id="instructions">
|
||||
Klicke um Gravitationspunkte zu setzen • Leertaste für Partikel • Treffe die grünen Ziele!
|
||||
</div>
|
||||
<canvas id="targetPattern"></canvas>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'gravity-painter';
|
||||
|
||||
class GravityPainter {
|
||||
constructor() {
|
||||
this.canvas = document.getElementById('canvas');
|
||||
this.ctx = this.canvas.getContext('2d');
|
||||
this.targetCanvas = document.getElementById('targetPattern');
|
||||
this.targetCtx = this.targetCanvas.getContext('2d');
|
||||
|
||||
this.resize();
|
||||
window.addEventListener('resize', () => this.resize());
|
||||
|
||||
this.gravityPoints = [];
|
||||
this.particles = [];
|
||||
this.targets = [];
|
||||
this.score = 0;
|
||||
this.level = 1;
|
||||
this.particlesLeft = 10;
|
||||
this.colors = ['#ff0066', '#00ff88', '#0066ff', '#ffff00', '#ff6600', '#9900ff'];
|
||||
|
||||
this.setupEventListeners();
|
||||
this.generateTargets();
|
||||
this.gameLoop();
|
||||
}
|
||||
|
||||
resize() {
|
||||
this.canvas.width = window.innerWidth;
|
||||
this.canvas.height = window.innerHeight;
|
||||
this.targetCanvas.width = 120;
|
||||
this.targetCanvas.height = 120;
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
this.canvas.addEventListener('click', (e) => {
|
||||
const rect = this.canvas.getBoundingClientRect();
|
||||
const x = e.clientX - rect.left;
|
||||
const y = e.clientY - rect.top;
|
||||
this.addGravityPoint(x, y);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' && this.particlesLeft > 0) {
|
||||
e.preventDefault();
|
||||
this.shootParticle();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
addGravityPoint(x, y) {
|
||||
this.gravityPoints.push({
|
||||
x: x,
|
||||
y: y,
|
||||
strength: 20000,
|
||||
life: 500
|
||||
});
|
||||
}
|
||||
|
||||
shootParticle() {
|
||||
if (this.particlesLeft <= 0) return;
|
||||
|
||||
const startX = 50;
|
||||
const startY = this.canvas.height / 2;
|
||||
const angle = (Math.random() - 0.5) * 0.8;
|
||||
const speed = 2;
|
||||
|
||||
this.particles.push({
|
||||
x: startX,
|
||||
y: startY,
|
||||
vx: Math.cos(angle) * speed,
|
||||
vy: Math.sin(angle) * speed,
|
||||
color: this.colors[Math.floor(Math.random() * this.colors.length)],
|
||||
trail: [],
|
||||
life: 300
|
||||
});
|
||||
|
||||
this.particlesLeft--;
|
||||
document.getElementById('particles').textContent = this.particlesLeft;
|
||||
}
|
||||
|
||||
generateTargets() {
|
||||
this.targets = [];
|
||||
const patterns = [
|
||||
// Kreis
|
||||
() => {
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const angle = (i / 8) * Math.PI * 2;
|
||||
const x = this.canvas.width * 0.7 + Math.cos(angle) * 80;
|
||||
const y = this.canvas.height * 0.5 + Math.sin(angle) * 80;
|
||||
this.targets.push({x, y, hit: false, radius: 15});
|
||||
}
|
||||
},
|
||||
// Stern
|
||||
() => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const angle = (i / 5) * Math.PI * 2;
|
||||
const x = this.canvas.width * 0.7 + Math.cos(angle) * 100;
|
||||
const y = this.canvas.height * 0.5 + Math.sin(angle) * 100;
|
||||
this.targets.push({x, y, hit: false, radius: 15});
|
||||
}
|
||||
},
|
||||
// Spiral
|
||||
() => {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const angle = (i / 10) * Math.PI * 4;
|
||||
const radius = 20 + i * 8;
|
||||
const x = this.canvas.width * 0.7 + Math.cos(angle) * radius;
|
||||
const y = this.canvas.height * 0.5 + Math.sin(angle) * radius;
|
||||
this.targets.push({x, y, hit: false, radius: 12});
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
const pattern = patterns[Math.floor(Math.random() * patterns.length)];
|
||||
pattern();
|
||||
|
||||
this.drawTargetPattern();
|
||||
}
|
||||
|
||||
drawTargetPattern() {
|
||||
this.targetCtx.clearRect(0, 0, 120, 120);
|
||||
this.targetCtx.fillStyle = '#00ff88';
|
||||
|
||||
// Miniaturansicht der Ziele
|
||||
const scaleX = 120 / this.canvas.width;
|
||||
const scaleY = 120 / this.canvas.height;
|
||||
|
||||
this.targets.forEach(target => {
|
||||
const x = target.x * scaleX;
|
||||
const y = target.y * scaleY;
|
||||
|
||||
this.targetCtx.beginPath();
|
||||
this.targetCtx.arc(x, y, 3, 0, Math.PI * 2);
|
||||
this.targetCtx.fill();
|
||||
});
|
||||
}
|
||||
|
||||
update() {
|
||||
// Gravitation Points updaten
|
||||
this.gravityPoints = this.gravityPoints.filter(point => {
|
||||
point.life--;
|
||||
return point.life > 0;
|
||||
});
|
||||
|
||||
// Partikel updaten
|
||||
this.particles.forEach(particle => {
|
||||
// Gravitationseffekt
|
||||
this.gravityPoints.forEach(gp => {
|
||||
const dx = gp.x - particle.x;
|
||||
const dy = gp.y - particle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance > 10 && distance < 400) {
|
||||
const force = gp.strength / (distance * 50);
|
||||
const forceX = (dx / distance) * force * 0.1;
|
||||
const forceY = (dy / distance) * force * 0.1;
|
||||
|
||||
particle.vx += forceX;
|
||||
particle.vy += forceY;
|
||||
}
|
||||
});
|
||||
|
||||
// Trail hinzufügen
|
||||
particle.trail.push({x: particle.x, y: particle.y});
|
||||
if (particle.trail.length > 20) {
|
||||
particle.trail.shift();
|
||||
}
|
||||
|
||||
// Position updaten
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Lebensdauer
|
||||
particle.life--;
|
||||
|
||||
// Kollision mit Zielen
|
||||
this.targets.forEach(target => {
|
||||
if (!target.hit) {
|
||||
const dx = target.x - particle.x;
|
||||
const dy = target.y - particle.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
if (distance < target.radius) {
|
||||
target.hit = true;
|
||||
this.score += 100;
|
||||
document.getElementById('score').textContent = this.score;
|
||||
this.createHitEffect(target.x, target.y);
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: this.score }
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Tote Partikel entfernen
|
||||
this.particles = this.particles.filter(p =>
|
||||
p.life > 0 &&
|
||||
p.x > -50 && p.x < this.canvas.width + 50 &&
|
||||
p.y > -50 && p.y < this.canvas.height + 50
|
||||
);
|
||||
|
||||
// Level prüfen
|
||||
if (this.targets.every(t => t.hit)) {
|
||||
this.nextLevel();
|
||||
} else if (this.particlesLeft <= 0 && this.particles.length === 0) {
|
||||
this.resetLevel();
|
||||
}
|
||||
}
|
||||
|
||||
createHitEffect(x, y) {
|
||||
const effect = document.createElement('div');
|
||||
effect.className = 'hit-effect';
|
||||
effect.style.left = (x - 15) + 'px';
|
||||
effect.style.top = (y - 15) + 'px';
|
||||
document.body.appendChild(effect);
|
||||
|
||||
setTimeout(() => {
|
||||
effect.remove();
|
||||
}, 500);
|
||||
}
|
||||
|
||||
nextLevel() {
|
||||
this.level++;
|
||||
this.particlesLeft = Math.max(5, 15 - this.level);
|
||||
document.getElementById('level').textContent = this.level;
|
||||
document.getElementById('particles').textContent = this.particlesLeft;
|
||||
|
||||
this.gravityPoints = [];
|
||||
this.particles = [];
|
||||
this.generateTargets();
|
||||
}
|
||||
|
||||
resetLevel() {
|
||||
// Game Over wenn keine Partikel mehr
|
||||
if (this.particlesLeft <= 0 && this.particles.length === 0) {
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: this.score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (this.score >= 500) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'gravity_artist',
|
||||
name: 'Gravity Artist',
|
||||
description: 'Score 500 points in Gravity Painter',
|
||||
icon: '🎨'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (this.level >= 5) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'pattern_master',
|
||||
name: 'Pattern Master',
|
||||
description: 'Reach level 5 in Gravity Painter',
|
||||
icon: '🌌'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
this.particlesLeft = Math.max(5, 15 - this.level);
|
||||
document.getElementById('particles').textContent = this.particlesLeft;
|
||||
|
||||
this.gravityPoints = [];
|
||||
this.particles = [];
|
||||
this.targets.forEach(t => t.hit = false);
|
||||
}
|
||||
|
||||
draw() {
|
||||
// Canvas leeren
|
||||
this.ctx.fillStyle = 'rgba(0, 0, 0, 0.1)';
|
||||
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
|
||||
// Gravitationspunkte zeichnen
|
||||
this.gravityPoints.forEach(gp => {
|
||||
const alpha = gp.life / 300;
|
||||
this.ctx.fillStyle = `rgba(255, 0, 102, ${alpha * 0.3})`;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(gp.x, gp.y, 30, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.fillStyle = `rgba(255, 0, 102, ${alpha})`;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(gp.x, gp.y, 8, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
});
|
||||
|
||||
// Partikel und Trails zeichnen
|
||||
this.particles.forEach(particle => {
|
||||
// Trail
|
||||
this.ctx.strokeStyle = particle.color + '66';
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.beginPath();
|
||||
|
||||
particle.trail.forEach((point, index) => {
|
||||
if (index === 0) {
|
||||
this.ctx.moveTo(point.x, point.y);
|
||||
} else {
|
||||
this.ctx.lineTo(point.x, point.y);
|
||||
}
|
||||
});
|
||||
this.ctx.stroke();
|
||||
|
||||
// Partikel
|
||||
this.ctx.fillStyle = particle.color;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(particle.x, particle.y, 3, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
// Glühen
|
||||
this.ctx.fillStyle = particle.color + '44';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(particle.x, particle.y, 8, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
});
|
||||
|
||||
// Ziele zeichnen
|
||||
this.targets.forEach(target => {
|
||||
if (!target.hit) {
|
||||
this.ctx.fillStyle = '#00ff88';
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(target.x, target.y, target.radius, 0, Math.PI * 2);
|
||||
this.ctx.fill();
|
||||
|
||||
this.ctx.strokeStyle = '#00ff88';
|
||||
this.ctx.lineWidth = 2;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.arc(target.x, target.y, target.radius + 5, 0, Math.PI * 2);
|
||||
this.ctx.stroke();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
gameLoop() {
|
||||
this.update();
|
||||
this.draw();
|
||||
requestAnimationFrame(() => this.gameLoop());
|
||||
}
|
||||
}
|
||||
|
||||
// Spiel starten
|
||||
const game = new GravityPainter();
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1124
games/arcade/apps/web/static/games/mana_defense.html
Normal file
966
games/arcade/apps/web/static/games/mana_factory.html
Normal file
|
|
@ -0,0 +1,966 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mana Factory</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #0a0a0a;
|
||||
color: #00ffff;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.main-panel {
|
||||
flex: 1;
|
||||
background: rgba(0, 20, 20, 0.8);
|
||||
border: 2px solid #00ffff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 0 20px #00ffff;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 350px;
|
||||
background: rgba(0, 20, 20, 0.8);
|
||||
border: 2px solid #00ffff;
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 0 20px #00ffff;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
text-align: center;
|
||||
text-shadow: 0 0 10px #00ffff;
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.mana-display {
|
||||
text-align: center;
|
||||
font-size: 36px;
|
||||
margin: 20px 0;
|
||||
text-shadow: 0 0 15px #00ffff;
|
||||
}
|
||||
|
||||
.mana-per-second {
|
||||
text-align: center;
|
||||
font-size: 20px;
|
||||
color: #00cccc;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.click-button {
|
||||
display: block;
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
margin: 30px auto;
|
||||
background: radial-gradient(circle, #00ffff, #006666);
|
||||
border: 3px solid #00ffff;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
font-size: 24px;
|
||||
color: #000;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 0 30px #00ffff;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.click-button:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 40px #00ffff;
|
||||
}
|
||||
|
||||
.click-button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.click-button::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
border-radius: 50%;
|
||||
background: rgba(255, 255, 255, 0.5);
|
||||
transform: translate(-50%, -50%);
|
||||
transition: width 0.6s, height 0.6s;
|
||||
}
|
||||
|
||||
.click-button.pulse::after {
|
||||
width: 300px;
|
||||
height: 300px;
|
||||
}
|
||||
|
||||
.generator {
|
||||
background: rgba(0, 40, 40, 0.6);
|
||||
border: 2px solid #006666;
|
||||
border-radius: 8px;
|
||||
padding: 15px;
|
||||
margin: 10px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.generator:hover {
|
||||
border-color: #00ffff;
|
||||
box-shadow: 0 0 10px #00ffff;
|
||||
}
|
||||
|
||||
.generator.affordable {
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.generator.maxed {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.generator-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.generator-name {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.generator-count {
|
||||
font-size: 24px;
|
||||
color: #ffff00;
|
||||
min-width: 50px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.generator-info {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 14px;
|
||||
color: #00cccc;
|
||||
}
|
||||
|
||||
.generator-cost {
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.generator-cost.affordable {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.tab-container {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 2px solid #00ffff;
|
||||
}
|
||||
|
||||
.tab {
|
||||
padding: 10px 20px;
|
||||
background: rgba(0, 40, 40, 0.6);
|
||||
border: 2px solid #006666;
|
||||
border-bottom: none;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
border-radius: 8px 8px 0 0;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: rgba(0, 60, 60, 0.8);
|
||||
}
|
||||
|
||||
.tab.active {
|
||||
background: rgba(0, 80, 80, 0.9);
|
||||
border-color: #00ffff;
|
||||
color: #00ffff;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.tab-content.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.upgrade {
|
||||
background: rgba(0, 40, 40, 0.6);
|
||||
border: 2px solid #006666;
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 8px 0;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.upgrade:hover:not(.bought) {
|
||||
border-color: #00ffff;
|
||||
box-shadow: 0 0 10px #00ffff;
|
||||
}
|
||||
|
||||
.upgrade.affordable {
|
||||
border-color: #00ff00;
|
||||
}
|
||||
|
||||
.upgrade.bought {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
border-color: #666666;
|
||||
}
|
||||
|
||||
.upgrade-name {
|
||||
font-weight: bold;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upgrade-description {
|
||||
font-size: 12px;
|
||||
color: #00cccc;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.upgrade-cost {
|
||||
font-size: 14px;
|
||||
color: #ff6666;
|
||||
}
|
||||
|
||||
.upgrade-cost.affordable {
|
||||
color: #00ff00;
|
||||
}
|
||||
|
||||
.prestige-button {
|
||||
display: block;
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
margin: 20px 0;
|
||||
background: linear-gradient(45deg, #ff00ff, #00ffff);
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
color: #000;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 0 20px #ff00ff;
|
||||
}
|
||||
|
||||
.prestige-button:hover:not(:disabled) {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 30px #ff00ff;
|
||||
}
|
||||
|
||||
.prestige-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.achievement {
|
||||
background: rgba(0, 40, 40, 0.6);
|
||||
border: 2px solid #666666;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
margin: 8px 0;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.achievement.unlocked {
|
||||
border-color: #ffff00;
|
||||
box-shadow: 0 0 10px #ffff00;
|
||||
}
|
||||
|
||||
.achievement-name {
|
||||
font-weight: bold;
|
||||
color: #ffff00;
|
||||
}
|
||||
|
||||
.achievement-description {
|
||||
font-size: 12px;
|
||||
color: #00cccc;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stats {
|
||||
font-size: 14px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 4px 0;
|
||||
border-bottom: 1px solid #003333;
|
||||
}
|
||||
|
||||
.floating-text {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
color: #00ffff;
|
||||
text-shadow: 0 0 5px #00ffff;
|
||||
animation: float-up 1s ease-out;
|
||||
}
|
||||
|
||||
@keyframes float-up {
|
||||
0% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translateY(-50px);
|
||||
}
|
||||
}
|
||||
|
||||
.particles {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.particle {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #00ffff;
|
||||
border-radius: 50%;
|
||||
opacity: 0.6;
|
||||
animation: particle-fall 10s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes particle-fall {
|
||||
0% {
|
||||
transform: translateY(-100px);
|
||||
}
|
||||
100% {
|
||||
transform: translateY(100vh);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="particles" id="particles"></div>
|
||||
|
||||
<div class="container">
|
||||
<div class="main-panel">
|
||||
<h1>🏭 Mana Factory 🏭</h1>
|
||||
|
||||
<div class="mana-display">
|
||||
💎 <span id="mana">0</span> Mana
|
||||
</div>
|
||||
|
||||
<div class="mana-per-second">
|
||||
<span id="mps">0</span> Mana/Sek
|
||||
</div>
|
||||
|
||||
<button class="click-button" id="clickButton">
|
||||
Kristall<br>Ernten
|
||||
</button>
|
||||
|
||||
<div class="tab-container">
|
||||
<div class="tab active" data-tab="generators">Generatoren</div>
|
||||
<div class="tab" data-tab="upgrades">Upgrades</div>
|
||||
<div class="tab" data-tab="prestige">Aufstieg</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content active" id="generators">
|
||||
<div class="generator" data-generator="fountain">
|
||||
<div class="generator-header">
|
||||
<span class="generator-name">💧 Mana-Brunnen</span>
|
||||
<span class="generator-count">0</span>
|
||||
</div>
|
||||
<div class="generator-info">
|
||||
<span>Produziert: <span class="production">1</span> Mana/s</span>
|
||||
<span class="generator-cost">Kosten: <span class="cost">10</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="generator" data-generator="mine">
|
||||
<div class="generator-header">
|
||||
<span class="generator-name">⛏️ Kristall-Mine</span>
|
||||
<span class="generator-count">0</span>
|
||||
</div>
|
||||
<div class="generator-info">
|
||||
<span>Produziert: <span class="production">10</span> Mana/s</span>
|
||||
<span class="generator-cost">Kosten: <span class="cost">100</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="generator" data-generator="reactor">
|
||||
<div class="generator-header">
|
||||
<span class="generator-name">⚡ Mana-Reaktor</span>
|
||||
<span class="generator-count">0</span>
|
||||
</div>
|
||||
<div class="generator-info">
|
||||
<span>Produziert: <span class="production">100</span> Mana/s</span>
|
||||
<span class="generator-cost">Kosten: <span class="cost">1000</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="generator" data-generator="portal">
|
||||
<div class="generator-header">
|
||||
<span class="generator-name">🌀 Dimensions-Portal</span>
|
||||
<span class="generator-count">0</span>
|
||||
</div>
|
||||
<div class="generator-info">
|
||||
<span>Produziert: <span class="production">1000</span> Mana/s</span>
|
||||
<span class="generator-cost">Kosten: <span class="cost">10000</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="generator" data-generator="nexus">
|
||||
<div class="generator-header">
|
||||
<span class="generator-name">🌟 Mana-Nexus</span>
|
||||
<span class="generator-count">0</span>
|
||||
</div>
|
||||
<div class="generator-info">
|
||||
<span>Produziert: <span class="production">10000</span> Mana/s</span>
|
||||
<span class="generator-cost">Kosten: <span class="cost">100000</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="upgrades">
|
||||
<h3>Verbesserungen</h3>
|
||||
<div id="upgradesList"></div>
|
||||
</div>
|
||||
|
||||
<div class="tab-content" id="prestige">
|
||||
<h3>Aufstieg</h3>
|
||||
<p style="text-align: center; margin: 20px 0;">
|
||||
Setze deinen Fortschritt zurück und erhalte Prestige-Punkte für permanente Boni!
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 24px; color: #ff00ff;">
|
||||
Prestige-Punkte: <span id="prestigePoints">0</span>
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 18px; color: #00ffff;">
|
||||
Nächster Aufstieg: <span id="nextPrestige">0</span> Punkte
|
||||
</p>
|
||||
<p style="text-align: center; font-size: 16px; color: #00cccc;">
|
||||
Multiplikator: x<span id="prestigeMultiplier">1</span>
|
||||
</p>
|
||||
<button class="prestige-button" id="prestigeButton" disabled>
|
||||
Aufstieg durchführen<br>
|
||||
(Benötigt 1,000,000 Mana)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-panel">
|
||||
<h2>Statistiken</h2>
|
||||
<div class="stats">
|
||||
<div class="stats-row">
|
||||
<span>Gesamtes Mana:</span>
|
||||
<span id="totalMana">0</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>Mana durch Klicks:</span>
|
||||
<span id="clickMana">0</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>Mana durch Generatoren:</span>
|
||||
<span id="generatorMana">0</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>Klicks gesamt:</span>
|
||||
<span id="totalClicks">0</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>Spielzeit:</span>
|
||||
<span id="playTime">0:00</span>
|
||||
</div>
|
||||
<div class="stats-row">
|
||||
<span>Aufstiege:</span>
|
||||
<span id="totalPrestiges">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 style="margin-top: 30px;">Erfolge</h3>
|
||||
<div id="achievementsList">
|
||||
<div class="achievement" data-achievement="first-click">
|
||||
<div class="achievement-name">🎯 Erster Klick</div>
|
||||
<div class="achievement-description">Ernte deinen ersten Kristall</div>
|
||||
</div>
|
||||
<div class="achievement" data-achievement="first-generator">
|
||||
<div class="achievement-name">🏗️ Industrialisierung</div>
|
||||
<div class="achievement-description">Kaufe deinen ersten Generator</div>
|
||||
</div>
|
||||
<div class="achievement" data-achievement="hundred-mana">
|
||||
<div class="achievement-name">💯 Hundert!</div>
|
||||
<div class="achievement-description">Erreiche 100 Mana</div>
|
||||
</div>
|
||||
<div class="achievement" data-achievement="thousand-mana">
|
||||
<div class="achievement-name">📈 Tausender</div>
|
||||
<div class="achievement-description">Erreiche 1,000 Mana</div>
|
||||
</div>
|
||||
<div class="achievement" data-achievement="first-prestige">
|
||||
<div class="achievement-name">⭐ Aufgestiegen</div>
|
||||
<div class="achievement-description">Führe deinen ersten Aufstieg durch</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let gameData = {
|
||||
mana: 0,
|
||||
manaPerClick: 1,
|
||||
manaPerSecond: 0,
|
||||
totalMana: 0,
|
||||
clickMana: 0,
|
||||
generatorMana: 0,
|
||||
totalClicks: 0,
|
||||
playTime: 0,
|
||||
prestigePoints: 0,
|
||||
prestigeMultiplier: 1,
|
||||
totalPrestiges: 0,
|
||||
generators: {
|
||||
fountain: { count: 0, baseCost: 10, baseProduction: 1 },
|
||||
mine: { count: 0, baseCost: 100, baseProduction: 10 },
|
||||
reactor: { count: 0, baseCost: 1000, baseProduction: 100 },
|
||||
portal: { count: 0, baseCost: 10000, baseProduction: 1000 },
|
||||
nexus: { count: 0, baseCost: 100000, baseProduction: 10000 }
|
||||
},
|
||||
upgrades: {},
|
||||
achievements: {},
|
||||
lastSave: Date.now(),
|
||||
lastUpdate: Date.now()
|
||||
};
|
||||
|
||||
const upgrades = [
|
||||
{
|
||||
id: 'click1',
|
||||
name: '✨ Verbesserter Griff',
|
||||
description: 'Doppelte Mana pro Klick',
|
||||
cost: 50,
|
||||
effect: () => { gameData.manaPerClick *= 2; }
|
||||
},
|
||||
{
|
||||
id: 'fountain1',
|
||||
name: '💧 Tiefere Brunnen',
|
||||
description: 'Mana-Brunnen sind doppelt so effektiv',
|
||||
cost: 500,
|
||||
requirement: () => gameData.generators.fountain.count >= 5,
|
||||
effect: () => { gameData.generators.fountain.baseProduction *= 2; }
|
||||
},
|
||||
{
|
||||
id: 'mine1',
|
||||
name: '⛏️ Diamant-Spitzhacken',
|
||||
description: 'Kristall-Minen sind doppelt so effektiv',
|
||||
cost: 5000,
|
||||
requirement: () => gameData.generators.mine.count >= 5,
|
||||
effect: () => { gameData.generators.mine.baseProduction *= 2; }
|
||||
},
|
||||
{
|
||||
id: 'click2',
|
||||
name: '🌟 Magische Hände',
|
||||
description: '5x Mana pro Klick',
|
||||
cost: 10000,
|
||||
requirement: () => gameData.totalClicks >= 100,
|
||||
effect: () => { gameData.manaPerClick *= 5; }
|
||||
},
|
||||
{
|
||||
id: 'global1',
|
||||
name: '🌈 Synergie',
|
||||
description: 'Alle Generatoren sind 50% effektiver',
|
||||
cost: 50000,
|
||||
requirement: () => gameData.manaPerSecond >= 100,
|
||||
effect: () => {
|
||||
for (let gen in gameData.generators) {
|
||||
gameData.generators[gen].baseProduction *= 1.5;
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'auto1',
|
||||
name: '🤖 Auto-Klicker',
|
||||
description: 'Generiert 10% deiner Klick-Mana pro Sekunde',
|
||||
cost: 100000,
|
||||
requirement: () => gameData.totalClicks >= 1000,
|
||||
effect: () => { }
|
||||
}
|
||||
];
|
||||
|
||||
function formatNumber(num) {
|
||||
if (num < 1000) return Math.floor(num).toString();
|
||||
if (num < 1000000) return (num / 1000).toFixed(1) + 'K';
|
||||
if (num < 1000000000) return (num / 1000000).toFixed(1) + 'M';
|
||||
if (num < 1000000000000) return (num / 1000000000).toFixed(1) + 'B';
|
||||
return (num / 1000000000000).toFixed(1) + 'T';
|
||||
}
|
||||
|
||||
function formatTime(seconds) {
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
if (hours > 0) {
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
return `${Math.floor(seconds / 60)}:${(seconds % 60).toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function getGeneratorCost(type) {
|
||||
const gen = gameData.generators[type];
|
||||
return Math.floor(gen.baseCost * Math.pow(1.15, gen.count));
|
||||
}
|
||||
|
||||
function getGeneratorProduction(type) {
|
||||
const gen = gameData.generators[type];
|
||||
let production = gen.baseProduction * gen.count;
|
||||
|
||||
if (type === 'fountain' && gameData.upgrades.fountain1) production *= 2;
|
||||
if (type === 'mine' && gameData.upgrades.mine1) production *= 2;
|
||||
if (gameData.upgrades.global1) production *= 1.5;
|
||||
|
||||
return production * gameData.prestigeMultiplier;
|
||||
}
|
||||
|
||||
function calculateManaPerSecond() {
|
||||
let mps = 0;
|
||||
for (let type in gameData.generators) {
|
||||
mps += getGeneratorProduction(type);
|
||||
}
|
||||
|
||||
if (gameData.upgrades.auto1) {
|
||||
mps += gameData.manaPerClick * 0.1;
|
||||
}
|
||||
|
||||
return mps;
|
||||
}
|
||||
|
||||
function updateDisplay() {
|
||||
document.getElementById('mana').textContent = formatNumber(gameData.mana);
|
||||
document.getElementById('mps').textContent = formatNumber(gameData.manaPerSecond);
|
||||
document.getElementById('totalMana').textContent = formatNumber(gameData.totalMana);
|
||||
document.getElementById('clickMana').textContent = formatNumber(gameData.clickMana);
|
||||
document.getElementById('generatorMana').textContent = formatNumber(gameData.generatorMana);
|
||||
document.getElementById('totalClicks').textContent = formatNumber(gameData.totalClicks);
|
||||
document.getElementById('playTime').textContent = formatTime(gameData.playTime);
|
||||
document.getElementById('totalPrestiges').textContent = gameData.totalPrestiges;
|
||||
document.getElementById('prestigePoints').textContent = gameData.prestigePoints;
|
||||
document.getElementById('prestigeMultiplier').textContent = gameData.prestigeMultiplier.toFixed(1);
|
||||
|
||||
const nextPrestige = Math.floor(Math.sqrt(gameData.totalMana / 1000000));
|
||||
document.getElementById('nextPrestige').textContent = nextPrestige;
|
||||
|
||||
const prestigeButton = document.getElementById('prestigeButton');
|
||||
if (gameData.mana >= 1000000) {
|
||||
prestigeButton.disabled = false;
|
||||
} else {
|
||||
prestigeButton.disabled = true;
|
||||
}
|
||||
|
||||
for (let type in gameData.generators) {
|
||||
const gen = gameData.generators[type];
|
||||
const element = document.querySelector(`[data-generator="${type}"]`);
|
||||
const cost = getGeneratorCost(type);
|
||||
|
||||
element.querySelector('.generator-count').textContent = gen.count;
|
||||
element.querySelector('.cost').textContent = formatNumber(cost);
|
||||
element.querySelector('.production').textContent = formatNumber(gen.baseProduction);
|
||||
|
||||
if (gameData.mana >= cost) {
|
||||
element.classList.add('affordable');
|
||||
element.querySelector('.generator-cost').classList.add('affordable');
|
||||
} else {
|
||||
element.classList.remove('affordable');
|
||||
element.querySelector('.generator-cost').classList.remove('affordable');
|
||||
}
|
||||
}
|
||||
|
||||
updateUpgrades();
|
||||
}
|
||||
|
||||
function updateUpgrades() {
|
||||
const upgradesList = document.getElementById('upgradesList');
|
||||
upgradesList.innerHTML = '';
|
||||
|
||||
upgrades.forEach(upgrade => {
|
||||
if (gameData.upgrades[upgrade.id]) return;
|
||||
if (upgrade.requirement && !upgrade.requirement()) return;
|
||||
|
||||
const div = document.createElement('div');
|
||||
div.className = 'upgrade';
|
||||
if (gameData.mana >= upgrade.cost) {
|
||||
div.classList.add('affordable');
|
||||
}
|
||||
|
||||
div.innerHTML = `
|
||||
<div class="upgrade-name">${upgrade.name}</div>
|
||||
<div class="upgrade-description">${upgrade.description}</div>
|
||||
<div class="upgrade-cost ${gameData.mana >= upgrade.cost ? 'affordable' : ''}">
|
||||
Kosten: ${formatNumber(upgrade.cost)} Mana
|
||||
</div>
|
||||
`;
|
||||
|
||||
div.onclick = () => buyUpgrade(upgrade);
|
||||
upgradesList.appendChild(div);
|
||||
});
|
||||
}
|
||||
|
||||
function buyGenerator(type) {
|
||||
const cost = getGeneratorCost(type);
|
||||
if (gameData.mana >= cost) {
|
||||
gameData.mana -= cost;
|
||||
gameData.generators[type].count++;
|
||||
gameData.manaPerSecond = calculateManaPerSecond();
|
||||
|
||||
checkAchievement('first-generator');
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function buyUpgrade(upgrade) {
|
||||
if (gameData.mana >= upgrade.cost && !gameData.upgrades[upgrade.id]) {
|
||||
gameData.mana -= upgrade.cost;
|
||||
gameData.upgrades[upgrade.id] = true;
|
||||
upgrade.effect();
|
||||
gameData.manaPerSecond = calculateManaPerSecond();
|
||||
updateDisplay();
|
||||
}
|
||||
}
|
||||
|
||||
function clickCrystal(event) {
|
||||
const button = document.getElementById('clickButton');
|
||||
const rect = button.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
|
||||
const manaGained = gameData.manaPerClick * gameData.prestigeMultiplier;
|
||||
gameData.mana += manaGained;
|
||||
gameData.totalMana += manaGained;
|
||||
gameData.clickMana += manaGained;
|
||||
gameData.totalClicks++;
|
||||
|
||||
checkAchievement('first-click');
|
||||
|
||||
button.classList.add('pulse');
|
||||
setTimeout(() => button.classList.remove('pulse'), 600);
|
||||
|
||||
const floatingText = document.createElement('div');
|
||||
floatingText.className = 'floating-text';
|
||||
floatingText.textContent = `+${formatNumber(manaGained)}`;
|
||||
floatingText.style.left = `${event.clientX}px`;
|
||||
floatingText.style.top = `${event.clientY}px`;
|
||||
document.body.appendChild(floatingText);
|
||||
|
||||
setTimeout(() => floatingText.remove(), 1000);
|
||||
|
||||
updateDisplay();
|
||||
}
|
||||
|
||||
function doPrestige() {
|
||||
if (gameData.mana >= 1000000) {
|
||||
const prestigeGain = Math.floor(Math.sqrt(gameData.totalMana / 1000000));
|
||||
|
||||
gameData.mana = 0;
|
||||
gameData.totalMana = 0;
|
||||
gameData.clickMana = 0;
|
||||
gameData.generatorMana = 0;
|
||||
gameData.totalClicks = 0;
|
||||
gameData.playTime = 0;
|
||||
|
||||
for (let type in gameData.generators) {
|
||||
gameData.generators[type].count = 0;
|
||||
}
|
||||
|
||||
gameData.upgrades = {};
|
||||
|
||||
gameData.prestigePoints += prestigeGain;
|
||||
gameData.prestigeMultiplier = 1 + (gameData.prestigePoints * 0.1);
|
||||
gameData.totalPrestiges++;
|
||||
|
||||
gameData.manaPerSecond = 0;
|
||||
|
||||
checkAchievement('first-prestige');
|
||||
updateDisplay();
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'mana-factory',
|
||||
event: 'PRESTIGE',
|
||||
data: { prestigePoints: gameData.prestigePoints }
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
function checkAchievement(id) {
|
||||
if (gameData.achievements[id]) return;
|
||||
|
||||
let unlocked = false;
|
||||
|
||||
switch(id) {
|
||||
case 'first-click':
|
||||
unlocked = gameData.totalClicks >= 1;
|
||||
break;
|
||||
case 'first-generator':
|
||||
unlocked = Object.values(gameData.generators).some(g => g.count > 0);
|
||||
break;
|
||||
case 'hundred-mana':
|
||||
unlocked = gameData.totalMana >= 100;
|
||||
break;
|
||||
case 'thousand-mana':
|
||||
unlocked = gameData.totalMana >= 1000;
|
||||
break;
|
||||
case 'first-prestige':
|
||||
unlocked = gameData.totalPrestiges >= 1;
|
||||
break;
|
||||
}
|
||||
|
||||
if (unlocked) {
|
||||
gameData.achievements[id] = true;
|
||||
const element = document.querySelector(`[data-achievement="${id}"]`);
|
||||
if (element) {
|
||||
element.classList.add('unlocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
const now = Date.now();
|
||||
const delta = (now - gameData.lastUpdate) / 1000;
|
||||
|
||||
if (gameData.manaPerSecond > 0) {
|
||||
const manaGained = gameData.manaPerSecond * delta;
|
||||
gameData.mana += manaGained;
|
||||
gameData.totalMana += manaGained;
|
||||
gameData.generatorMana += manaGained;
|
||||
}
|
||||
|
||||
gameData.playTime += delta;
|
||||
gameData.lastUpdate = now;
|
||||
|
||||
checkAchievement('hundred-mana');
|
||||
checkAchievement('thousand-mana');
|
||||
|
||||
updateDisplay();
|
||||
|
||||
if (now - gameData.lastSave > 10000) {
|
||||
saveGame();
|
||||
gameData.lastSave = now;
|
||||
}
|
||||
}
|
||||
|
||||
function saveGame() {
|
||||
localStorage.setItem('manaFactorySave', JSON.stringify(gameData));
|
||||
}
|
||||
|
||||
function loadGame() {
|
||||
const saved = localStorage.getItem('manaFactorySave');
|
||||
if (saved) {
|
||||
const loadedData = JSON.parse(saved);
|
||||
Object.assign(gameData, loadedData);
|
||||
|
||||
const offlineTime = (Date.now() - gameData.lastUpdate) / 1000;
|
||||
const maxOfflineTime = 3600 * 24;
|
||||
const actualOfflineTime = Math.min(offlineTime, maxOfflineTime);
|
||||
|
||||
if (gameData.manaPerSecond > 0 && actualOfflineTime > 1) {
|
||||
const offlineMana = gameData.manaPerSecond * actualOfflineTime * 0.5;
|
||||
gameData.mana += offlineMana;
|
||||
gameData.totalMana += offlineMana;
|
||||
gameData.generatorMana += offlineMana;
|
||||
|
||||
alert(`Willkommen zurück! Du hast ${formatNumber(offlineMana)} Mana während deiner Abwesenheit produziert.`);
|
||||
}
|
||||
|
||||
gameData.lastUpdate = Date.now();
|
||||
gameData.manaPerSecond = calculateManaPerSecond();
|
||||
|
||||
for (let id in gameData.achievements) {
|
||||
if (gameData.achievements[id]) {
|
||||
const element = document.querySelector(`[data-achievement="${id}"]`);
|
||||
if (element) element.classList.add('unlocked');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createParticles() {
|
||||
const particlesContainer = document.getElementById('particles');
|
||||
for (let i = 0; i < 30; i++) {
|
||||
const particle = document.createElement('div');
|
||||
particle.className = 'particle';
|
||||
particle.style.left = Math.random() * 100 + '%';
|
||||
particle.style.animationDelay = Math.random() * 10 + 's';
|
||||
particle.style.animationDuration = (10 + Math.random() * 10) + 's';
|
||||
particlesContainer.appendChild(particle);
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('clickButton').addEventListener('click', clickCrystal);
|
||||
|
||||
document.querySelectorAll('.generator').forEach(element => {
|
||||
element.addEventListener('click', () => {
|
||||
buyGenerator(element.dataset.generator);
|
||||
});
|
||||
});
|
||||
|
||||
document.getElementById('prestigeButton').addEventListener('click', doPrestige);
|
||||
|
||||
document.querySelectorAll('.tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
|
||||
|
||||
tab.classList.add('active');
|
||||
document.getElementById(tab.dataset.tab).classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
saveGame();
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'mana-factory',
|
||||
event: 'GAME_ENDED',
|
||||
data: {
|
||||
totalMana: gameData.totalMana,
|
||||
prestigePoints: gameData.prestigePoints
|
||||
}
|
||||
}, '*');
|
||||
});
|
||||
|
||||
createParticles();
|
||||
loadGame();
|
||||
updateDisplay();
|
||||
setInterval(gameLoop, 100);
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: 'mana-factory'
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
569
games/arcade/apps/web/static/games/mana_runner.html
Normal file
|
|
@ -0,0 +1,569 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mana Runner</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #0a0a0a;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Courier New', monospace;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
border: 2px solid #00ffff;
|
||||
box-shadow: 0 0 20px #00ffff;
|
||||
max-width: 100%;
|
||||
max-height: 100vh;
|
||||
}
|
||||
|
||||
#gameOver {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: #00ffff;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
border: 2px solid #00ffff;
|
||||
box-shadow: 0 0 20px #00ffff;
|
||||
}
|
||||
|
||||
#gameOver h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 32px;
|
||||
text-shadow: 0 0 10px #00ffff;
|
||||
}
|
||||
|
||||
#gameOver p {
|
||||
margin: 10px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
#gameOver button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 30px;
|
||||
font-size: 18px;
|
||||
background: #00ffff;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
#gameOver button:hover {
|
||||
background: #00cccc;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
#startScreen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
color: #00ffff;
|
||||
padding: 30px;
|
||||
border-radius: 10px;
|
||||
text-align: center;
|
||||
border: 2px solid #00ffff;
|
||||
box-shadow: 0 0 20px #00ffff;
|
||||
}
|
||||
|
||||
#startScreen h1 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 36px;
|
||||
text-shadow: 0 0 10px #00ffff;
|
||||
}
|
||||
|
||||
#startScreen p {
|
||||
margin: 10px 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
#startScreen button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 30px;
|
||||
font-size: 20px;
|
||||
background: #00ffff;
|
||||
color: #000;
|
||||
border: none;
|
||||
border-radius: 5px;
|
||||
cursor: pointer;
|
||||
font-family: 'Courier New', monospace;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
#startScreen button:hover {
|
||||
background: #00cccc;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
|
||||
<div id="startScreen">
|
||||
<h1>🏃♂️ Mana Runner 🏃♂️</h1>
|
||||
<p>Sammle Mana-Kristalle und weiche Hindernissen aus!</p>
|
||||
<p><strong>Steuerung:</strong></p>
|
||||
<p>Leertaste = Springen</p>
|
||||
<p>Doppelsprung verfügbar nach 10 Kristallen!</p>
|
||||
<button onclick="startGame()">Spiel Starten</button>
|
||||
</div>
|
||||
|
||||
<div id="gameOver">
|
||||
<h2>Game Over!</h2>
|
||||
<p>Punkte: <span id="finalScore">0</span></p>
|
||||
<p>Kristalle: <span id="finalCrystals">0</span></p>
|
||||
<button onclick="restartGame()">Nochmal spielen</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const startScreen = document.getElementById('startScreen');
|
||||
const gameOverScreen = document.getElementById('gameOver');
|
||||
|
||||
canvas.width = 800;
|
||||
canvas.height = 400;
|
||||
|
||||
let gameStarted = false;
|
||||
let gameRunning = false;
|
||||
let score = 0;
|
||||
let crystals = 0;
|
||||
let highScore = localStorage.getItem('manaRunnerHighScore') || 0;
|
||||
let gameSpeed = 5;
|
||||
let gravity = 0.5;
|
||||
let jumpPower = -12;
|
||||
let doubleJumpUnlocked = false;
|
||||
let canDoubleJump = false;
|
||||
|
||||
const player = {
|
||||
x: 100,
|
||||
y: 200,
|
||||
width: 40,
|
||||
height: 60,
|
||||
velocityY: 0,
|
||||
jumping: false,
|
||||
grounded: false,
|
||||
color: '#00ffff'
|
||||
};
|
||||
|
||||
const ground = {
|
||||
x: 0,
|
||||
y: canvas.height - 60,
|
||||
width: canvas.width,
|
||||
height: 60
|
||||
};
|
||||
|
||||
const obstacles = [];
|
||||
const crystals_array = [];
|
||||
const particles = [];
|
||||
|
||||
let obstacleTimer = 0;
|
||||
let crystalTimer = 0;
|
||||
let backgroundOffset = 0;
|
||||
|
||||
class Particle {
|
||||
constructor(x, y, color) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.vx = (Math.random() - 0.5) * 4;
|
||||
this.vy = (Math.random() - 0.5) * 4;
|
||||
this.size = Math.random() * 3 + 1;
|
||||
this.life = 1;
|
||||
this.color = color;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.vy += 0.1;
|
||||
this.life -= 0.02;
|
||||
this.size *= 0.98;
|
||||
}
|
||||
|
||||
draw() {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = this.life;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.fillRect(this.x, this.y, this.size, this.size);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
class Obstacle {
|
||||
constructor() {
|
||||
this.width = 40;
|
||||
this.height = Math.random() * 80 + 40;
|
||||
this.x = canvas.width;
|
||||
this.y = ground.y - this.height;
|
||||
this.passed = false;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x -= gameSpeed;
|
||||
}
|
||||
|
||||
draw() {
|
||||
ctx.fillStyle = '#ff0066';
|
||||
ctx.fillRect(this.x, this.y, this.width, this.height);
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = '#ff0066';
|
||||
ctx.fillRect(this.x, this.y, this.width, this.height);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
}
|
||||
|
||||
class Crystal {
|
||||
constructor() {
|
||||
this.size = 20;
|
||||
this.x = canvas.width;
|
||||
this.y = Math.random() * (ground.y - 100) + 50;
|
||||
this.collected = false;
|
||||
this.rotation = 0;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x -= gameSpeed;
|
||||
this.rotation += 0.05;
|
||||
}
|
||||
|
||||
draw() {
|
||||
ctx.save();
|
||||
ctx.translate(this.x + this.size/2, this.y + this.size/2);
|
||||
ctx.rotate(this.rotation);
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = '#ffff00';
|
||||
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -this.size/2);
|
||||
ctx.lineTo(this.size/2, 0);
|
||||
ctx.lineTo(0, this.size/2);
|
||||
ctx.lineTo(-this.size/2, 0);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
function drawBackground() {
|
||||
ctx.fillStyle = '#1a0033';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
ctx.fillStyle = '#2a0055';
|
||||
for (let i = 0; i < 5; i++) {
|
||||
let x = (i * 200 - backgroundOffset) % (canvas.width + 200);
|
||||
ctx.fillRect(x, 100, 150, 200);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#00ffff';
|
||||
ctx.font = '20px Courier New';
|
||||
for (let i = 0; i < 20; i++) {
|
||||
let x = (i * 100 - backgroundOffset * 0.5) % (canvas.width + 100);
|
||||
let y = Math.sin(x * 0.01) * 20 + 50;
|
||||
ctx.fillText('✦', x, y);
|
||||
}
|
||||
}
|
||||
|
||||
function drawGround() {
|
||||
ctx.fillStyle = '#004444';
|
||||
ctx.fillRect(ground.x, ground.y, ground.width, ground.height);
|
||||
|
||||
ctx.strokeStyle = '#00ffff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, ground.y);
|
||||
ctx.lineTo(canvas.width, ground.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
ctx.fillStyle = player.color;
|
||||
ctx.fillRect(player.x, player.y, player.width, player.height);
|
||||
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(player.x + 10, player.y + 10, 5, 5);
|
||||
ctx.fillRect(player.x + 25, player.y + 10, 5, 5);
|
||||
|
||||
ctx.fillStyle = '#ff00ff';
|
||||
ctx.fillRect(player.x + 15, player.y + 25, 10, 3);
|
||||
|
||||
if (player.velocityY < 0) {
|
||||
for (let i = 0; i < 3; i++) {
|
||||
particles.push(new Particle(
|
||||
player.x + player.width/2,
|
||||
player.y + player.height,
|
||||
'#00ffff'
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function drawUI() {
|
||||
ctx.fillStyle = '#00ffff';
|
||||
ctx.font = 'bold 24px Courier New';
|
||||
ctx.fillText(`Punkte: ${score}`, 20, 40);
|
||||
ctx.fillText(`Kristalle: ${crystals}`, 20, 70);
|
||||
|
||||
if (doubleJumpUnlocked) {
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.fillText('Doppelsprung freigeschaltet!', 20, 100);
|
||||
}
|
||||
|
||||
ctx.fillStyle = '#ff00ff';
|
||||
ctx.fillText(`High Score: ${highScore}`, canvas.width - 200, 40);
|
||||
}
|
||||
|
||||
function updatePlayer() {
|
||||
player.velocityY += gravity;
|
||||
player.y += player.velocityY;
|
||||
|
||||
if (player.y + player.height >= ground.y) {
|
||||
player.y = ground.y - player.height;
|
||||
player.velocityY = 0;
|
||||
player.grounded = true;
|
||||
player.jumping = false;
|
||||
canDoubleJump = doubleJumpUnlocked;
|
||||
} else {
|
||||
player.grounded = false;
|
||||
}
|
||||
}
|
||||
|
||||
function jump() {
|
||||
if (player.grounded) {
|
||||
player.velocityY = jumpPower;
|
||||
player.jumping = true;
|
||||
canDoubleJump = doubleJumpUnlocked;
|
||||
} else if (canDoubleJump && player.jumping) {
|
||||
player.velocityY = jumpPower;
|
||||
canDoubleJump = false;
|
||||
|
||||
for (let i = 0; i < 10; i++) {
|
||||
particles.push(new Particle(
|
||||
player.x + player.width/2,
|
||||
player.y + player.height,
|
||||
'#ffff00'
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkCollisions() {
|
||||
for (let obstacle of obstacles) {
|
||||
if (player.x < obstacle.x + obstacle.width &&
|
||||
player.x + player.width > obstacle.x &&
|
||||
player.y < obstacle.y + obstacle.height &&
|
||||
player.y + player.height > obstacle.y) {
|
||||
endGame();
|
||||
}
|
||||
|
||||
if (!obstacle.passed && player.x > obstacle.x + obstacle.width) {
|
||||
obstacle.passed = true;
|
||||
score += 10;
|
||||
}
|
||||
}
|
||||
|
||||
for (let crystal of crystals_array) {
|
||||
if (!crystal.collected &&
|
||||
player.x < crystal.x + crystal.size &&
|
||||
player.x + player.width > crystal.x &&
|
||||
player.y < crystal.y + crystal.size &&
|
||||
player.y + player.height > crystal.y) {
|
||||
crystal.collected = true;
|
||||
crystals++;
|
||||
score += 50;
|
||||
|
||||
if (crystals >= 10 && !doubleJumpUnlocked) {
|
||||
doubleJumpUnlocked = true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
particles.push(new Particle(
|
||||
crystal.x + crystal.size/2,
|
||||
crystal.y + crystal.size/2,
|
||||
'#ffff00'
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function updateGame() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
backgroundOffset += gameSpeed * 0.5;
|
||||
updatePlayer();
|
||||
|
||||
obstacleTimer++;
|
||||
if (obstacleTimer > 100 + Math.random() * 50) {
|
||||
obstacles.push(new Obstacle());
|
||||
obstacleTimer = 0;
|
||||
}
|
||||
|
||||
crystalTimer++;
|
||||
if (crystalTimer > 150 + Math.random() * 100) {
|
||||
crystals_array.push(new Crystal());
|
||||
crystalTimer = 0;
|
||||
}
|
||||
|
||||
for (let i = obstacles.length - 1; i >= 0; i--) {
|
||||
obstacles[i].update();
|
||||
if (obstacles[i].x + obstacles[i].width < 0) {
|
||||
obstacles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = crystals_array.length - 1; i >= 0; i--) {
|
||||
if (!crystals_array[i].collected) {
|
||||
crystals_array[i].update();
|
||||
}
|
||||
if (crystals_array[i].x + crystals_array[i].size < 0) {
|
||||
crystals_array.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
particles[i].update();
|
||||
if (particles[i].life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
checkCollisions();
|
||||
|
||||
if (score > 0 && score % 100 === 0) {
|
||||
gameSpeed += 0.1;
|
||||
}
|
||||
}
|
||||
|
||||
function drawGame() {
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
drawBackground();
|
||||
drawGround();
|
||||
|
||||
for (let crystal of crystals_array) {
|
||||
if (!crystal.collected) {
|
||||
crystal.draw();
|
||||
}
|
||||
}
|
||||
|
||||
for (let obstacle of obstacles) {
|
||||
obstacle.draw();
|
||||
}
|
||||
|
||||
for (let particle of particles) {
|
||||
particle.draw();
|
||||
}
|
||||
|
||||
drawPlayer();
|
||||
drawUI();
|
||||
}
|
||||
|
||||
function gameLoop() {
|
||||
updateGame();
|
||||
drawGame();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
gameStarted = true;
|
||||
gameRunning = true;
|
||||
startScreen.style.display = 'none';
|
||||
gameLoop();
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: 'mana-runner'
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
gameRunning = false;
|
||||
|
||||
if (score > highScore) {
|
||||
highScore = score;
|
||||
localStorage.setItem('manaRunnerHighScore', highScore);
|
||||
}
|
||||
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('finalCrystals').textContent = crystals;
|
||||
gameOverScreen.style.display = 'block';
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'mana-runner',
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
}
|
||||
|
||||
function restartGame() {
|
||||
score = 0;
|
||||
crystals = 0;
|
||||
gameSpeed = 5;
|
||||
player.y = 200;
|
||||
player.velocityY = 0;
|
||||
player.grounded = false;
|
||||
obstacles.length = 0;
|
||||
crystals_array.length = 0;
|
||||
particles.length = 0;
|
||||
obstacleTimer = 0;
|
||||
crystalTimer = 0;
|
||||
backgroundOffset = 0;
|
||||
doubleJumpUnlocked = false;
|
||||
canDoubleJump = false;
|
||||
|
||||
gameOverScreen.style.display = 'none';
|
||||
gameRunning = true;
|
||||
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'mana-runner',
|
||||
event: 'GAME_STARTED',
|
||||
data: {}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.code === 'Space' && gameRunning) {
|
||||
e.preventDefault();
|
||||
jump();
|
||||
}
|
||||
});
|
||||
|
||||
canvas.addEventListener('click', () => {
|
||||
if (gameRunning) {
|
||||
jump();
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (gameStarted) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: 'mana-runner',
|
||||
event: 'GAME_ENDED',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
508
games/arcade/apps/web/static/games/memory_card_match.html
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Memory Card Match</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
padding: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.top-bar {
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 15px 20px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-wrap: wrap;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.5rem;
|
||||
color: #667eea;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.game-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat-group {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: bold;
|
||||
color: #667eea;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.difficulty-group {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
background: #f0f0f0;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.difficulty-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #666;
|
||||
padding: 6px 12px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.85rem;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.difficulty-btn:hover {
|
||||
background: rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.difficulty-btn.active {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 20px;
|
||||
font-size: 0.9rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-new:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.game-area {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: calc(100vh - 80px);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 90px;
|
||||
height: 90px;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transform-style: preserve-3d;
|
||||
transition: transform 0.3s ease;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.card:hover:not(.matched):not(.flipped) {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.card.flipped {
|
||||
transform: rotateY(180deg);
|
||||
}
|
||||
|
||||
.card.matched {
|
||||
animation: matchAnimation 0.8s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.card.matched::after {
|
||||
content: '✨';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 3rem;
|
||||
animation: sparkle 0.8s ease;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
@keyframes matchAnimation {
|
||||
0% {
|
||||
transform: scale(1) rotateY(180deg);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
25% {
|
||||
transform: scale(1.15) rotateY(180deg);
|
||||
box-shadow: 0 8px 25px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.2) rotateY(180deg);
|
||||
box-shadow: 0 10px 30px rgba(102, 126, 234, 0.6);
|
||||
filter: brightness(1.2);
|
||||
}
|
||||
75% {
|
||||
transform: scale(1.1) rotateY(180deg);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
100% {
|
||||
transform: scale(1) rotateY(180deg);
|
||||
box-shadow: 0 2px 8px rgba(102, 126, 234, 0.3);
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes sparkle {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1.5) rotate(180deg);
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(2) rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.card-face {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 2.2rem;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.card-front {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.card-back {
|
||||
background: white;
|
||||
transform: rotateY(180deg);
|
||||
border: 2px solid #667eea;
|
||||
}
|
||||
|
||||
.win-message {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: white;
|
||||
padding: 40px;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
|
||||
text-align: center;
|
||||
display: none;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.win-message h2 {
|
||||
color: #667eea;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.win-stats {
|
||||
margin: 20px 0;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: none;
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.top-bar {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.card {
|
||||
width: 70px;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.card-face {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.stat {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.difficulty-btn {
|
||||
font-size: 0.75rem;
|
||||
padding: 5px 10px;
|
||||
}
|
||||
|
||||
.btn-new {
|
||||
font-size: 0.85rem;
|
||||
padding: 6px 15px;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
gap: 10px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="top-bar">
|
||||
<h1>Memory Card Match</h1>
|
||||
|
||||
<div class="game-controls">
|
||||
<div class="stat-group">
|
||||
<div class="stat">
|
||||
<span class="stat-label">Zeit:</span>
|
||||
<span class="stat-value" id="timer">0:00</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Züge:</span>
|
||||
<span class="stat-value" id="moves">0</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<span class="stat-label">Paare:</span>
|
||||
<span class="stat-value"><span id="matches">0</span>/<span id="totalPairs">8</span></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="difficulty-group">
|
||||
<button class="difficulty-btn active" data-difficulty="easy">4x4</button>
|
||||
<button class="difficulty-btn" data-difficulty="medium">6x4</button>
|
||||
<button class="difficulty-btn" data-difficulty="hard">6x6</button>
|
||||
</div>
|
||||
|
||||
<button class="btn-new" onclick="newGame()">Neues Spiel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="game-area">
|
||||
<div class="game-board" id="gameBoard"></div>
|
||||
</div>
|
||||
|
||||
<div class="overlay" id="overlay"></div>
|
||||
<div class="win-message" id="winMessage">
|
||||
<h2>🎉 Gewonnen! 🎉</h2>
|
||||
<div class="win-stats">
|
||||
<p>Zeit: <span id="finalTime"></span></p>
|
||||
<p>Züge: <span id="finalMoves"></span></p>
|
||||
<p>Effizienz: <span id="efficiency"></span>%</p>
|
||||
</div>
|
||||
<button class="btn" onclick="newGame()">Neues Spiel</button>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const emojis = {
|
||||
easy: ['🎮', '🎯', '🎨', '🎭', '🎪', '🎬', '🎰', '🎲'],
|
||||
medium: ['🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮'],
|
||||
hard: ['🍎', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍈', '🍒', '🍑', '🥭', '🍍', '🥥', '🥝', '🍅', '🥑', '🥦']
|
||||
};
|
||||
|
||||
let cards = [];
|
||||
let flippedCards = [];
|
||||
let matchedPairs = 0;
|
||||
let moves = 0;
|
||||
let gameStarted = false;
|
||||
let startTime;
|
||||
let timerInterval;
|
||||
let currentDifficulty = 'easy';
|
||||
let boardSize = { easy: [4, 4], medium: [6, 4], hard: [6, 6] };
|
||||
|
||||
function shuffle(array) {
|
||||
const newArray = [...array];
|
||||
for (let i = newArray.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[newArray[i], newArray[j]] = [newArray[j], newArray[i]];
|
||||
}
|
||||
return newArray;
|
||||
}
|
||||
|
||||
function createBoard() {
|
||||
const board = document.getElementById('gameBoard');
|
||||
board.innerHTML = '';
|
||||
|
||||
const [cols, rows] = boardSize[currentDifficulty];
|
||||
const totalCards = cols * rows;
|
||||
const pairsNeeded = totalCards / 2;
|
||||
|
||||
board.style.gridTemplateColumns = `repeat(${cols}, 1fr)`;
|
||||
|
||||
const selectedEmojis = emojis[currentDifficulty].slice(0, pairsNeeded);
|
||||
const cardPairs = [...selectedEmojis, ...selectedEmojis];
|
||||
cards = shuffle(cardPairs);
|
||||
|
||||
document.getElementById('totalPairs').textContent = pairsNeeded;
|
||||
|
||||
cards.forEach((emoji, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'card';
|
||||
card.dataset.index = index;
|
||||
card.dataset.emoji = emoji;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="card-face card-front">?</div>
|
||||
<div class="card-face card-back">${emoji}</div>
|
||||
`;
|
||||
|
||||
card.addEventListener('click', flipCard);
|
||||
board.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
function flipCard() {
|
||||
if (flippedCards.length >= 2) return;
|
||||
if (this.classList.contains('flipped') || this.classList.contains('matched')) return;
|
||||
|
||||
if (!gameStarted) {
|
||||
startGame();
|
||||
}
|
||||
|
||||
this.classList.add('flipped');
|
||||
flippedCards.push(this);
|
||||
|
||||
if (flippedCards.length === 2) {
|
||||
moves++;
|
||||
document.getElementById('moves').textContent = moves;
|
||||
checkMatch();
|
||||
}
|
||||
}
|
||||
|
||||
function checkMatch() {
|
||||
const [card1, card2] = flippedCards;
|
||||
const match = card1.dataset.emoji === card2.dataset.emoji;
|
||||
|
||||
if (match) {
|
||||
card1.classList.add('matched');
|
||||
card2.classList.add('matched');
|
||||
matchedPairs++;
|
||||
document.getElementById('matches').textContent = matchedPairs;
|
||||
flippedCards = [];
|
||||
|
||||
if (matchedPairs === parseInt(document.getElementById('totalPairs').textContent)) {
|
||||
endGame();
|
||||
}
|
||||
} else {
|
||||
setTimeout(() => {
|
||||
card1.classList.remove('flipped');
|
||||
card2.classList.remove('flipped');
|
||||
flippedCards = [];
|
||||
}, 800);
|
||||
}
|
||||
}
|
||||
|
||||
function startGame() {
|
||||
gameStarted = true;
|
||||
startTime = Date.now();
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
function updateTimer() {
|
||||
const elapsed = Math.floor((Date.now() - startTime) / 1000);
|
||||
const minutes = Math.floor(elapsed / 60);
|
||||
const seconds = elapsed % 60;
|
||||
document.getElementById('timer').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
function endGame() {
|
||||
clearInterval(timerInterval);
|
||||
const finalTime = document.getElementById('timer').textContent;
|
||||
const totalPairs = parseInt(document.getElementById('totalPairs').textContent);
|
||||
const minMoves = totalPairs;
|
||||
const efficiency = Math.round((minMoves / moves) * 100);
|
||||
|
||||
document.getElementById('finalTime').textContent = finalTime;
|
||||
document.getElementById('finalMoves').textContent = moves;
|
||||
document.getElementById('efficiency').textContent = efficiency;
|
||||
|
||||
document.getElementById('overlay').style.display = 'block';
|
||||
document.getElementById('winMessage').style.display = 'block';
|
||||
}
|
||||
|
||||
function newGame() {
|
||||
clearInterval(timerInterval);
|
||||
gameStarted = false;
|
||||
matchedPairs = 0;
|
||||
moves = 0;
|
||||
flippedCards = [];
|
||||
|
||||
document.getElementById('timer').textContent = '0:00';
|
||||
document.getElementById('moves').textContent = '0';
|
||||
document.getElementById('matches').textContent = '0';
|
||||
document.getElementById('overlay').style.display = 'none';
|
||||
document.getElementById('winMessage').style.display = 'none';
|
||||
|
||||
createBoard();
|
||||
}
|
||||
|
||||
document.querySelectorAll('.difficulty-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
document.querySelectorAll('.difficulty-btn').forEach(b => b.classList.remove('active'));
|
||||
this.classList.add('active');
|
||||
currentDifficulty = this.dataset.difficulty;
|
||||
newGame();
|
||||
});
|
||||
});
|
||||
|
||||
newGame();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
886
games/arcade/apps/web/static/games/neon_maze_runner.html
Normal file
|
|
@ -0,0 +1,886 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Neon Maze Runner</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Arial', sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-container {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
padding: 0 10px;
|
||||
width: 600px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.score, .timer, .level {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
text-shadow: 0 0 10px currentColor;
|
||||
}
|
||||
|
||||
.score {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.timer {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.level {
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #00ff88;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.5);
|
||||
background: #0a0a0a;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.game-over, .level-complete, .start-screen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border: 2px solid #00ff88;
|
||||
padding: 30px;
|
||||
text-align: center;
|
||||
display: none;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 50px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.start-screen {
|
||||
display: block;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 20px 0;
|
||||
font-size: 32px;
|
||||
text-shadow: 0 0 20px currentColor;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.level-complete h2 {
|
||||
color: #00ff88;
|
||||
}
|
||||
|
||||
.start-screen h2 {
|
||||
color: #4ecdc4;
|
||||
}
|
||||
|
||||
button {
|
||||
background: #00ff88;
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
transition: all 0.3s;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
background: #00cc6a;
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 0 20px rgba(0, 255, 136, 0.8);
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin: 20px 0;
|
||||
line-height: 1.6;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
.stats {
|
||||
margin: 15px 0;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.collectibles {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.collectible-item {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.collectible-icon {
|
||||
font-size: 30px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.powerup-indicator {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
font-size: 24px;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
}
|
||||
|
||||
.powerup-indicator.active {
|
||||
opacity: 1;
|
||||
animation: pulse 0.5s infinite alternate;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
from { transform: scale(1); }
|
||||
to { transform: scale(1.2); }
|
||||
}
|
||||
|
||||
.trail {
|
||||
position: absolute;
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
background: #00ff88;
|
||||
border-radius: 50%;
|
||||
pointer-events: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
@keyframes fadeTrail {
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<div class="ui-container">
|
||||
<div class="score">PUNKTE: <span id="score">0</span></div>
|
||||
<div class="level">LEVEL: <span id="level">1</span></div>
|
||||
<div class="timer">ZEIT: <span id="timer">60</span>s</div>
|
||||
</div>
|
||||
|
||||
<canvas id="gameCanvas" width="600" height="600"></canvas>
|
||||
|
||||
<div class="powerup-indicator" id="powerupIndicator">⚡</div>
|
||||
|
||||
<div class="start-screen" id="startScreen">
|
||||
<h2>NEON MAZE RUNNER</h2>
|
||||
<div class="instructions">
|
||||
<p><strong>Steuerung:</strong> WASD oder Pfeiltasten</p>
|
||||
<p><strong>Ziel:</strong> Sammle alle Diamanten und finde den Ausgang!</p>
|
||||
<p><strong>Tipp:</strong> Achte auf Power-ups und die Zeit!</p>
|
||||
</div>
|
||||
<div class="collectibles">
|
||||
<div class="collectible-item">
|
||||
<div class="collectible-icon">💎</div>
|
||||
<div>Diamanten<br>+100 Punkte</div>
|
||||
</div>
|
||||
<div class="collectible-item">
|
||||
<div class="collectible-icon">⚡</div>
|
||||
<div>Speed Boost<br>2x Geschwindigkeit</div>
|
||||
</div>
|
||||
<div class="collectible-item">
|
||||
<div class="collectible-icon">🕐</div>
|
||||
<div>Zeitbonus<br>+15 Sekunden</div>
|
||||
</div>
|
||||
</div>
|
||||
<button onclick="startGame()">SPIEL STARTEN</button>
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOverScreen">
|
||||
<h2>GAME OVER</h2>
|
||||
<div class="stats">
|
||||
<p>Erreichte Punkte: <span id="finalScore">0</span></p>
|
||||
<p>Erreichte Level: <span id="finalLevel">1</span></p>
|
||||
</div>
|
||||
<button onclick="restartGame()">NOCHMAL SPIELEN</button>
|
||||
</div>
|
||||
|
||||
<div class="level-complete" id="levelCompleteScreen">
|
||||
<h2>LEVEL GESCHAFFT!</h2>
|
||||
<div class="stats">
|
||||
<p>Level Punkte: <span id="levelScore">0</span></p>
|
||||
<p>Zeit Bonus: <span id="timeBonus">0</span></p>
|
||||
</div>
|
||||
<button onclick="nextLevel()">NÄCHSTES LEVEL</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'neon-maze-runner';
|
||||
|
||||
// Canvas und Kontext
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// UI Elemente
|
||||
const scoreElement = document.getElementById('score');
|
||||
const timerElement = document.getElementById('timer');
|
||||
const levelElement = document.getElementById('level');
|
||||
const startScreen = document.getElementById('startScreen');
|
||||
const gameOverScreen = document.getElementById('gameOverScreen');
|
||||
const levelCompleteScreen = document.getElementById('levelCompleteScreen');
|
||||
const powerupIndicator = document.getElementById('powerupIndicator');
|
||||
|
||||
// Spielkonstanten
|
||||
const CELL_SIZE = 30;
|
||||
const GRID_WIDTH = Math.floor(canvas.width / CELL_SIZE);
|
||||
const GRID_HEIGHT = Math.floor(canvas.height / CELL_SIZE);
|
||||
|
||||
// Spielzustand
|
||||
let maze = [];
|
||||
let player = { x: 1, y: 1 };
|
||||
let exit = { x: 1, y: 1 }; // Wird später gesetzt
|
||||
let diamonds = [];
|
||||
let powerups = [];
|
||||
let score = 0;
|
||||
let level = 1;
|
||||
let timeLeft = 60;
|
||||
let gameRunning = false;
|
||||
let timerInterval = null;
|
||||
let speedBoost = false;
|
||||
let speedBoostTimer = 0;
|
||||
let particles = [];
|
||||
|
||||
// Eingabe
|
||||
let keys = {};
|
||||
let moveTimer = 0;
|
||||
const MOVE_DELAY = 150; // Millisekunden zwischen Bewegungen
|
||||
const BOOSTED_MOVE_DELAY = 75;
|
||||
|
||||
// Eingabe-Handler
|
||||
document.addEventListener('keydown', (e) => {
|
||||
keys[e.key.toLowerCase()] = true;
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
|
||||
// Maze-Generation (Recursive Backtracking)
|
||||
function generateMaze() {
|
||||
// Initialisiere Gitter mit Wänden
|
||||
maze = Array(GRID_HEIGHT).fill().map(() => Array(GRID_WIDTH).fill(1));
|
||||
|
||||
// Startposition
|
||||
const stack = [];
|
||||
const startX = 1;
|
||||
const startY = 1;
|
||||
maze[startY][startX] = 0;
|
||||
stack.push({ x: startX, y: startY });
|
||||
|
||||
// Richtungen
|
||||
const directions = [
|
||||
{ dx: 0, dy: -2 }, // Oben
|
||||
{ dx: 2, dy: 0 }, // Rechts
|
||||
{ dx: 0, dy: 2 }, // Unten
|
||||
{ dx: -2, dy: 0 } // Links
|
||||
];
|
||||
|
||||
while (stack.length > 0) {
|
||||
const current = stack[stack.length - 1];
|
||||
|
||||
// Finde unbesuchte Nachbarn
|
||||
const neighbors = [];
|
||||
for (const dir of directions) {
|
||||
const nx = current.x + dir.dx;
|
||||
const ny = current.y + dir.dy;
|
||||
|
||||
if (nx > 0 && nx < GRID_WIDTH - 1 &&
|
||||
ny > 0 && ny < GRID_HEIGHT - 1 &&
|
||||
maze[ny][nx] === 1) {
|
||||
neighbors.push({ x: nx, y: ny, dx: dir.dx / 2, dy: dir.dy / 2 });
|
||||
}
|
||||
}
|
||||
|
||||
if (neighbors.length > 0) {
|
||||
// Wähle zufälligen Nachbarn
|
||||
const next = neighbors[Math.floor(Math.random() * neighbors.length)];
|
||||
|
||||
// Entferne Wand zwischen current und next
|
||||
maze[current.y + next.dy][current.x + next.dx] = 0;
|
||||
maze[next.y][next.x] = 0;
|
||||
|
||||
stack.push(next);
|
||||
} else {
|
||||
stack.pop();
|
||||
}
|
||||
}
|
||||
|
||||
// Füge viele zusätzliche Pfade hinzu für interessanteres Gameplay
|
||||
const extraPaths = 15 + level * 3;
|
||||
for (let i = 0; i < extraPaths; i++) {
|
||||
const x = Math.floor(Math.random() * (GRID_WIDTH - 2)) + 1;
|
||||
const y = Math.floor(Math.random() * (GRID_HEIGHT - 2)) + 1;
|
||||
if (maze[y][x] === 1) {
|
||||
// Prüfe ob mindestens ein Nachbar ein Pfad ist
|
||||
if (maze[y-1][x] === 0 || maze[y+1][x] === 0 ||
|
||||
maze[y][x-1] === 0 || maze[y][x+1] === 0) {
|
||||
maze[y][x] = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Finde eine gute Position für den Ausgang (weit vom Start entfernt)
|
||||
let maxDistance = 0;
|
||||
let bestExit = { x: GRID_WIDTH - 2, y: GRID_HEIGHT - 2 };
|
||||
|
||||
// Suche nach dem entferntesten erreichbaren Punkt
|
||||
for (let y = 1; y < GRID_HEIGHT - 1; y++) {
|
||||
for (let x = 1; x < GRID_WIDTH - 1; x++) {
|
||||
if (maze[y][x] === 0) {
|
||||
const distance = Math.abs(x - player.x) + Math.abs(y - player.y);
|
||||
if (distance > maxDistance) {
|
||||
maxDistance = distance;
|
||||
bestExit = { x, y };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
exit.x = bestExit.x;
|
||||
exit.y = bestExit.y;
|
||||
maze[exit.y][exit.x] = 0;
|
||||
}
|
||||
|
||||
// Platziere Sammelobjekte
|
||||
function placeCollectibles() {
|
||||
diamonds = [];
|
||||
powerups = [];
|
||||
|
||||
// Anzahl basierend auf Level
|
||||
const diamondCount = 3 + Math.floor(level / 3);
|
||||
const powerupCount = 1 + Math.floor(level / 4);
|
||||
|
||||
// Platziere Diamanten
|
||||
for (let i = 0; i < diamondCount; i++) {
|
||||
let placed = false;
|
||||
while (!placed) {
|
||||
const x = Math.floor(Math.random() * GRID_WIDTH);
|
||||
const y = Math.floor(Math.random() * GRID_HEIGHT);
|
||||
|
||||
if (maze[y][x] === 0 &&
|
||||
!(x === player.x && y === player.y) &&
|
||||
!(x === exit.x && y === exit.y) &&
|
||||
!diamonds.some(d => d.x === x && d.y === y)) {
|
||||
diamonds.push({ x, y, collected: false });
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Platziere Power-ups
|
||||
for (let i = 0; i < powerupCount; i++) {
|
||||
let placed = false;
|
||||
while (!placed) {
|
||||
const x = Math.floor(Math.random() * GRID_WIDTH);
|
||||
const y = Math.floor(Math.random() * GRID_HEIGHT);
|
||||
|
||||
if (maze[y][x] === 0 &&
|
||||
!(x === player.x && y === player.y) &&
|
||||
!(x === exit.x && y === exit.y) &&
|
||||
!diamonds.some(d => d.x === x && d.y === y) &&
|
||||
!powerups.some(p => p.x === x && p.y === y)) {
|
||||
|
||||
const type = Math.random() < 0.7 ? 'speed' : 'time';
|
||||
powerups.push({ x, y, type, collected: false });
|
||||
placed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partikel-Effekt
|
||||
function createParticle(x, y, color, count = 10) {
|
||||
for (let i = 0; i < count; i++) {
|
||||
particles.push({
|
||||
x: x * CELL_SIZE + CELL_SIZE / 2,
|
||||
y: y * CELL_SIZE + CELL_SIZE / 2,
|
||||
vx: (Math.random() - 0.5) * 4,
|
||||
vy: (Math.random() - 0.5) * 4,
|
||||
life: 1,
|
||||
color: color
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Update Partikel
|
||||
function updateParticles() {
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
const p = particles[i];
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life -= 0.02;
|
||||
p.vx *= 0.98;
|
||||
p.vy *= 0.98;
|
||||
|
||||
if (p.life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Bewege Spieler
|
||||
function movePlayer(dx, dy) {
|
||||
const newX = player.x + dx;
|
||||
const newY = player.y + dy;
|
||||
|
||||
// Prüfe Kollision
|
||||
if (newX >= 0 && newX < GRID_WIDTH &&
|
||||
newY >= 0 && newY < GRID_HEIGHT &&
|
||||
maze[newY][newX] === 0) {
|
||||
|
||||
// Trail-Effekt
|
||||
createTrail(player.x * CELL_SIZE + CELL_SIZE / 2,
|
||||
player.y * CELL_SIZE + CELL_SIZE / 2);
|
||||
|
||||
player.x = newX;
|
||||
player.y = newY;
|
||||
|
||||
// Prüfe Diamanten
|
||||
diamonds.forEach(diamond => {
|
||||
if (!diamond.collected && diamond.x === player.x && diamond.y === player.y) {
|
||||
diamond.collected = true;
|
||||
score += 100;
|
||||
scoreElement.textContent = score;
|
||||
createParticle(diamond.x, diamond.y, '#00ff88', 20);
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
}
|
||||
});
|
||||
|
||||
// Prüfe Power-ups
|
||||
powerups.forEach(powerup => {
|
||||
if (!powerup.collected && powerup.x === player.x && powerup.y === player.y) {
|
||||
powerup.collected = true;
|
||||
|
||||
if (powerup.type === 'speed') {
|
||||
speedBoost = true;
|
||||
speedBoostTimer = 5000; // 5 Sekunden
|
||||
powerupIndicator.classList.add('active');
|
||||
createParticle(powerup.x, powerup.y, '#ffff00', 30);
|
||||
} else if (powerup.type === 'time') {
|
||||
timeLeft += 15;
|
||||
timerElement.textContent = timeLeft;
|
||||
createParticle(powerup.x, powerup.y, '#00ffff', 30);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Prüfe Ausgang
|
||||
if (player.x === exit.x && player.y === exit.y) {
|
||||
const allDiamondsCollected = diamonds.every(d => d.collected);
|
||||
if (allDiamondsCollected) {
|
||||
levelComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Trail-Effekt
|
||||
function createTrail(x, y) {
|
||||
const trail = document.createElement('div');
|
||||
trail.className = 'trail';
|
||||
trail.style.left = x + 'px';
|
||||
trail.style.top = y + 'px';
|
||||
trail.style.background = speedBoost ? '#ffff00' : '#ff00ff';
|
||||
document.body.appendChild(trail);
|
||||
|
||||
trail.style.animation = 'fadeTrail 0.5s ease-out forwards';
|
||||
setTimeout(() => trail.remove(), 500);
|
||||
}
|
||||
|
||||
// Update Spiel
|
||||
function update(deltaTime) {
|
||||
if (!gameRunning) return;
|
||||
|
||||
// Update Speed Boost
|
||||
if (speedBoost) {
|
||||
speedBoostTimer -= deltaTime;
|
||||
if (speedBoostTimer <= 0) {
|
||||
speedBoost = false;
|
||||
powerupIndicator.classList.remove('active');
|
||||
}
|
||||
}
|
||||
|
||||
// Spielerbewegung
|
||||
moveTimer -= deltaTime;
|
||||
const currentMoveDelay = speedBoost ? BOOSTED_MOVE_DELAY : MOVE_DELAY;
|
||||
|
||||
if (moveTimer <= 0) {
|
||||
let moved = false;
|
||||
|
||||
if (keys['w'] || keys['arrowup']) {
|
||||
movePlayer(0, -1);
|
||||
moved = true;
|
||||
} else if (keys['s'] || keys['arrowdown']) {
|
||||
movePlayer(0, 1);
|
||||
moved = true;
|
||||
} else if (keys['a'] || keys['arrowleft']) {
|
||||
movePlayer(-1, 0);
|
||||
moved = true;
|
||||
} else if (keys['d'] || keys['arrowright']) {
|
||||
movePlayer(1, 0);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
moveTimer = currentMoveDelay;
|
||||
}
|
||||
}
|
||||
|
||||
// Update Partikel
|
||||
updateParticles();
|
||||
}
|
||||
|
||||
// Zeichne Spiel
|
||||
function draw() {
|
||||
// Clear
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Zeichne Maze
|
||||
for (let y = 0; y < GRID_HEIGHT; y++) {
|
||||
for (let x = 0; x < GRID_WIDTH; x++) {
|
||||
if (maze[y][x] === 1) {
|
||||
// Wand mit Neon-Effekt
|
||||
ctx.fillStyle = '#1a1a2e';
|
||||
ctx.fillRect(x * CELL_SIZE, y * CELL_SIZE, CELL_SIZE, CELL_SIZE);
|
||||
|
||||
// Neon-Rand
|
||||
ctx.strokeStyle = '#16213e';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(x * CELL_SIZE + 0.5, y * CELL_SIZE + 0.5,
|
||||
CELL_SIZE - 1, CELL_SIZE - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichne Ausgang
|
||||
ctx.save();
|
||||
ctx.translate(exit.x * CELL_SIZE + CELL_SIZE / 2,
|
||||
exit.y * CELL_SIZE + CELL_SIZE / 2);
|
||||
|
||||
const allDiamondsCollected = diamonds.every(d => d.collected);
|
||||
if (allDiamondsCollected) {
|
||||
// Animierter Ausgang wenn alle Diamanten gesammelt
|
||||
ctx.rotate(Date.now() * 0.002);
|
||||
ctx.fillStyle = '#00ff88';
|
||||
ctx.fillRect(-CELL_SIZE / 3, -CELL_SIZE / 3, CELL_SIZE * 2/3, CELL_SIZE * 2/3);
|
||||
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = '#00ff88';
|
||||
ctx.fillStyle = '#00ff88';
|
||||
ctx.fillRect(-CELL_SIZE / 4, -CELL_SIZE / 4, CELL_SIZE / 2, CELL_SIZE / 2);
|
||||
ctx.shadowBlur = 0;
|
||||
} else {
|
||||
// Inaktiver Ausgang
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.fillRect(-CELL_SIZE / 3, -CELL_SIZE / 3, CELL_SIZE * 2/3, CELL_SIZE * 2/3);
|
||||
ctx.strokeStyle = '#555';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(-CELL_SIZE / 3, -CELL_SIZE / 3, CELL_SIZE * 2/3, CELL_SIZE * 2/3);
|
||||
}
|
||||
ctx.restore();
|
||||
|
||||
// Zeichne Diamanten
|
||||
diamonds.forEach(diamond => {
|
||||
if (!diamond.collected) {
|
||||
ctx.save();
|
||||
ctx.translate(diamond.x * CELL_SIZE + CELL_SIZE / 2,
|
||||
diamond.y * CELL_SIZE + CELL_SIZE / 2);
|
||||
ctx.rotate(Date.now() * 0.003);
|
||||
|
||||
// Diamant-Form (größer für größere Zellen)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -CELL_SIZE / 2.5);
|
||||
ctx.lineTo(CELL_SIZE / 3, -CELL_SIZE / 5);
|
||||
ctx.lineTo(CELL_SIZE / 3, CELL_SIZE / 5);
|
||||
ctx.lineTo(0, CELL_SIZE / 2.5);
|
||||
ctx.lineTo(-CELL_SIZE / 3, CELL_SIZE / 5);
|
||||
ctx.lineTo(-CELL_SIZE / 3, -CELL_SIZE / 5);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = '#00ff88';
|
||||
ctx.shadowBlur = 15;
|
||||
ctx.shadowColor = '#00ff88';
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
ctx.strokeStyle = '#00cc6a';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.stroke();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Zeichne Power-ups
|
||||
powerups.forEach(powerup => {
|
||||
if (!powerup.collected) {
|
||||
ctx.save();
|
||||
ctx.translate(powerup.x * CELL_SIZE + CELL_SIZE / 2,
|
||||
powerup.y * CELL_SIZE + CELL_SIZE / 2);
|
||||
|
||||
if (powerup.type === 'speed') {
|
||||
// Blitz-Symbol
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = '#ffff00';
|
||||
ctx.font = '20px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('⚡', 0, 0);
|
||||
} else if (powerup.type === 'time') {
|
||||
// Uhr-Symbol
|
||||
ctx.fillStyle = '#00ffff';
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = '#00ffff';
|
||||
ctx.font = '20px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('🕐', 0, 0);
|
||||
}
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
ctx.restore();
|
||||
}
|
||||
});
|
||||
|
||||
// Zeichne Spieler
|
||||
ctx.save();
|
||||
ctx.translate(player.x * CELL_SIZE + CELL_SIZE / 2,
|
||||
player.y * CELL_SIZE + CELL_SIZE / 2);
|
||||
|
||||
// Spieler mit Glow-Effekt (größer für größere Zellen)
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, CELL_SIZE / 2.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = speedBoost ? '#ffff00' : '#ff00ff';
|
||||
ctx.shadowBlur = speedBoost ? 30 : 25;
|
||||
ctx.shadowColor = speedBoost ? '#ffff00' : '#ff00ff';
|
||||
ctx.fill();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Mittlerer Ring
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, CELL_SIZE / 3, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = speedBoost ? '#ffcc00' : '#ff66ff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.stroke();
|
||||
|
||||
// Innerer Kreis
|
||||
ctx.beginPath();
|
||||
ctx.arc(0, 0, CELL_SIZE / 5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
|
||||
// Zeichne Partikel
|
||||
particles.forEach(p => {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = p.life;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, 3, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
});
|
||||
|
||||
// Zeichne verbleibende Diamanten-Anzeige
|
||||
const remainingDiamonds = diamonds.filter(d => !d.collected).length;
|
||||
if (remainingDiamonds > 0) {
|
||||
ctx.fillStyle = '#00ff88';
|
||||
ctx.font = 'bold 16px Arial';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillText(`💎 ${remainingDiamonds}`, 10, 10);
|
||||
}
|
||||
}
|
||||
|
||||
// Game Loop
|
||||
let lastTime = 0;
|
||||
function gameLoop(currentTime) {
|
||||
const deltaTime = currentTime - lastTime;
|
||||
lastTime = currentTime;
|
||||
|
||||
update(deltaTime);
|
||||
draw();
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Timer
|
||||
function updateTimer() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
timeLeft--;
|
||||
timerElement.textContent = timeLeft;
|
||||
|
||||
if (timeLeft <= 0) {
|
||||
gameOver();
|
||||
}
|
||||
}
|
||||
|
||||
// Level abgeschlossen
|
||||
function levelComplete() {
|
||||
gameRunning = false;
|
||||
clearInterval(timerInterval);
|
||||
|
||||
// Berechne Bonus
|
||||
const timeBonus = timeLeft * 10;
|
||||
score += timeBonus;
|
||||
|
||||
document.getElementById('levelScore').textContent = score;
|
||||
document.getElementById('timeBonus').textContent = timeBonus;
|
||||
levelCompleteScreen.style.display = 'block';
|
||||
}
|
||||
|
||||
// Game Over
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
clearInterval(timerInterval);
|
||||
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('finalLevel').textContent = level;
|
||||
gameOverScreen.style.display = 'block';
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 1000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'maze_explorer',
|
||||
name: 'Maze Explorer',
|
||||
description: 'Score 1000 points in Neon Maze Runner',
|
||||
icon: '🌟'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (level >= 10) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'maze_master',
|
||||
name: 'Maze Master',
|
||||
description: 'Reach level 10 in Neon Maze Runner',
|
||||
icon: '🏆'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Starte Spiel
|
||||
function startGame() {
|
||||
startScreen.style.display = 'none';
|
||||
initLevel();
|
||||
gameRunning = true;
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Initialisiere Level
|
||||
function initLevel() {
|
||||
// Reset Speed Boost
|
||||
speedBoost = false;
|
||||
speedBoostTimer = 0;
|
||||
powerupIndicator.classList.remove('active');
|
||||
|
||||
// Zeit basierend auf Level
|
||||
timeLeft = 60 + (level - 1) * 10;
|
||||
timerElement.textContent = timeLeft;
|
||||
levelElement.textContent = level;
|
||||
|
||||
// Generiere neues Maze
|
||||
generateMaze();
|
||||
|
||||
// Setze Spielerposition
|
||||
player = { x: 1, y: 1 };
|
||||
|
||||
// Platziere Sammelobjekte
|
||||
placeCollectibles();
|
||||
|
||||
// Clear particles
|
||||
particles = [];
|
||||
}
|
||||
|
||||
// Nächstes Level
|
||||
function nextLevel() {
|
||||
levelCompleteScreen.style.display = 'none';
|
||||
level++;
|
||||
initLevel();
|
||||
gameRunning = true;
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
// Neustart
|
||||
function restartGame() {
|
||||
gameOverScreen.style.display = 'none';
|
||||
score = 0;
|
||||
level = 1;
|
||||
scoreElement.textContent = score;
|
||||
initLevel();
|
||||
gameRunning = true;
|
||||
timerInterval = setInterval(updateTimer, 1000);
|
||||
}
|
||||
|
||||
// Initialisierung
|
||||
console.log('Neon Maze Runner geladen!');
|
||||
console.log('Ein prozedural generiertes Labyrinth-Spiel mit Sammelobjekten.');
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
636
games/arcade/apps/web/static/games/puzzle_blocks.html
Normal file
|
|
@ -0,0 +1,636 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Puzzle Blocks - Mana Games</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
display: flex;
|
||||
gap: 2rem;
|
||||
align-items: flex-start;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.game-board {
|
||||
position: relative;
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 10px;
|
||||
box-shadow: 0 0 30px rgba(157, 48, 255, 0.3);
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
display: block;
|
||||
background: #0a0a0a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
background: #1a1a1a;
|
||||
border: 2px solid #333;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.info-box h3 {
|
||||
color: #9d30ff;
|
||||
margin-bottom: 0.5rem;
|
||||
font-size: 1.1rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: #fff;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.level, .lines {
|
||||
font-size: 1.2rem;
|
||||
color: #aaa;
|
||||
margin: 0.3rem 0;
|
||||
}
|
||||
|
||||
.next-piece {
|
||||
background: #0a0a0a;
|
||||
border-radius: 4px;
|
||||
padding: 1rem;
|
||||
min-height: 100px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#nextCanvas {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.controls {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.6;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.controls kbd {
|
||||
background: #333;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-family: monospace;
|
||||
color: #9d30ff;
|
||||
margin: 0 0.2rem;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
border: 2px solid #9d30ff;
|
||||
border-radius: 12px;
|
||||
padding: 2rem 3rem;
|
||||
text-align: center;
|
||||
display: none;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 50px rgba(157, 48, 255, 0.5);
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
color: #9d30ff;
|
||||
font-size: 2.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.game-over p {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 1.5rem;
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
background: #9d30ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.8rem 2rem;
|
||||
font-size: 1.1rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 1px;
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
background: #7a20cc;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(157, 48, 255, 0.5);
|
||||
}
|
||||
|
||||
.start-screen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(26, 26, 26, 0.95);
|
||||
border: 2px solid #9d30ff;
|
||||
border-radius: 12px;
|
||||
padding: 2rem 3rem;
|
||||
text-align: center;
|
||||
z-index: 100;
|
||||
box-shadow: 0 0 50px rgba(157, 48, 255, 0.5);
|
||||
}
|
||||
|
||||
.start-screen h1 {
|
||||
color: #9d30ff;
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 0 0 20px rgba(157, 48, 255, 0.5);
|
||||
}
|
||||
|
||||
.start-btn {
|
||||
background: #9d30ff;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 1rem 3rem;
|
||||
font-size: 1.2rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.start-btn:hover {
|
||||
background: #7a20cc;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 20px rgba(157, 48, 255, 0.5);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.game-container {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.side-panel {
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
min-width: auto;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
flex: 1;
|
||||
min-width: 150px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<div class="game-board">
|
||||
<canvas id="gameCanvas"></canvas>
|
||||
<div class="start-screen" id="startScreen">
|
||||
<h1>PUZZLE BLOCKS</h1>
|
||||
<p style="color: #aaa; margin-bottom: 1rem;">Klassisches Tetris-Gameplay</p>
|
||||
<button class="start-btn" onclick="startGame()">SPIEL STARTEN</button>
|
||||
</div>
|
||||
<div class="game-over" id="gameOverScreen">
|
||||
<h2>GAME OVER</h2>
|
||||
<p>Deine Punkte: <span id="finalScore">0</span></p>
|
||||
<button class="restart-btn" onclick="resetGame()">Neues Spiel</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-panel">
|
||||
<div class="info-box">
|
||||
<h3>Punkte</h3>
|
||||
<div class="score" id="score">0</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Level</h3>
|
||||
<div class="level">Level <span id="level">1</span></div>
|
||||
<div class="lines">Linien: <span id="lines">0</span></div>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h3>Nächster Block</h3>
|
||||
<div class="next-piece">
|
||||
<canvas id="nextCanvas"></canvas>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box controls">
|
||||
<h3>Steuerung</h3>
|
||||
<p><kbd>←</kbd><kbd>→</kbd> Bewegen</p>
|
||||
<p><kbd>↓</kbd> Schneller fallen</p>
|
||||
<p><kbd>↑</kbd> Drehen</p>
|
||||
<p><kbd>Space</kbd> Sofort fallen</p>
|
||||
<p><kbd>P</kbd> Pause</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
const nextCanvas = document.getElementById('nextCanvas');
|
||||
const nextCtx = nextCanvas.getContext('2d');
|
||||
|
||||
// Game dimensions
|
||||
const COLS = 10;
|
||||
const ROWS = 20;
|
||||
const BLOCK_SIZE = 30;
|
||||
const NEXT_BLOCK_SIZE = 20;
|
||||
|
||||
canvas.width = COLS * BLOCK_SIZE;
|
||||
canvas.height = ROWS * BLOCK_SIZE;
|
||||
nextCanvas.width = 4 * NEXT_BLOCK_SIZE;
|
||||
nextCanvas.height = 4 * NEXT_BLOCK_SIZE;
|
||||
|
||||
// Tetromino definitions
|
||||
const PIECES = [
|
||||
// I-piece
|
||||
{
|
||||
shape: [[1,1,1,1]],
|
||||
color: '#00f0f0'
|
||||
},
|
||||
// O-piece
|
||||
{
|
||||
shape: [[1,1],[1,1]],
|
||||
color: '#f0f000'
|
||||
},
|
||||
// T-piece
|
||||
{
|
||||
shape: [[0,1,0],[1,1,1]],
|
||||
color: '#a000f0'
|
||||
},
|
||||
// S-piece
|
||||
{
|
||||
shape: [[0,1,1],[1,1,0]],
|
||||
color: '#00f000'
|
||||
},
|
||||
// Z-piece
|
||||
{
|
||||
shape: [[1,1,0],[0,1,1]],
|
||||
color: '#f00000'
|
||||
},
|
||||
// J-piece
|
||||
{
|
||||
shape: [[1,0,0],[1,1,1]],
|
||||
color: '#0000f0'
|
||||
},
|
||||
// L-piece
|
||||
{
|
||||
shape: [[0,0,1],[1,1,1]],
|
||||
color: '#f0a000'
|
||||
}
|
||||
];
|
||||
|
||||
// Game state
|
||||
let board = [];
|
||||
let currentPiece = null;
|
||||
let nextPiece = null;
|
||||
let score = 0;
|
||||
let lines = 0;
|
||||
let level = 1;
|
||||
let dropTime = 1000;
|
||||
let lastDrop = 0;
|
||||
let gameRunning = false;
|
||||
let gamePaused = false;
|
||||
|
||||
// Initialize board
|
||||
function initBoard() {
|
||||
board = Array(ROWS).fill().map(() => Array(COLS).fill(0));
|
||||
}
|
||||
|
||||
// Create a new piece
|
||||
function createPiece() {
|
||||
const piece = PIECES[Math.floor(Math.random() * PIECES.length)];
|
||||
return {
|
||||
shape: piece.shape.map(row => [...row]),
|
||||
color: piece.color,
|
||||
x: Math.floor(COLS / 2) - Math.floor(piece.shape[0].length / 2),
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
|
||||
// Rotate piece
|
||||
function rotatePiece(piece) {
|
||||
const rotated = [];
|
||||
const rows = piece.shape.length;
|
||||
const cols = piece.shape[0].length;
|
||||
|
||||
for (let i = 0; i < cols; i++) {
|
||||
rotated[i] = [];
|
||||
for (let j = rows - 1; j >= 0; j--) {
|
||||
rotated[i].push(piece.shape[j][i]);
|
||||
}
|
||||
}
|
||||
|
||||
return rotated;
|
||||
}
|
||||
|
||||
// Check collision
|
||||
function isValidMove(piece, x, y, shape = piece.shape) {
|
||||
for (let row = 0; row < shape.length; row++) {
|
||||
for (let col = 0; col < shape[row].length; col++) {
|
||||
if (shape[row][col]) {
|
||||
const newX = x + col;
|
||||
const newY = y + row;
|
||||
|
||||
if (newX < 0 || newX >= COLS || newY >= ROWS) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (newY >= 0 && board[newY][newX]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Lock piece to board
|
||||
function lockPiece() {
|
||||
for (let row = 0; row < currentPiece.shape.length; row++) {
|
||||
for (let col = 0; col < currentPiece.shape[row].length; col++) {
|
||||
if (currentPiece.shape[row][col]) {
|
||||
const x = currentPiece.x + col;
|
||||
const y = currentPiece.y + row;
|
||||
if (y >= 0) {
|
||||
board[y][x] = currentPiece.color;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear completed lines
|
||||
function clearLines() {
|
||||
let linesCleared = 0;
|
||||
|
||||
for (let row = ROWS - 1; row >= 0; row--) {
|
||||
if (board[row].every(cell => cell !== 0)) {
|
||||
board.splice(row, 1);
|
||||
board.unshift(Array(COLS).fill(0));
|
||||
linesCleared++;
|
||||
row++; // Check the same row again
|
||||
}
|
||||
}
|
||||
|
||||
if (linesCleared > 0) {
|
||||
lines += linesCleared;
|
||||
score += linesCleared * 100 * level;
|
||||
|
||||
// Bonus for multiple lines
|
||||
if (linesCleared === 4) {
|
||||
score += 400 * level;
|
||||
}
|
||||
|
||||
// Level up every 10 lines
|
||||
level = Math.floor(lines / 10) + 1;
|
||||
dropTime = Math.max(100, 1000 - (level - 1) * 100);
|
||||
|
||||
updateUI();
|
||||
}
|
||||
}
|
||||
|
||||
// Update UI elements
|
||||
function updateUI() {
|
||||
document.getElementById('score').textContent = score;
|
||||
document.getElementById('level').textContent = level;
|
||||
document.getElementById('lines').textContent = lines;
|
||||
}
|
||||
|
||||
// Draw block
|
||||
function drawBlock(ctx, x, y, color, size = BLOCK_SIZE) {
|
||||
ctx.fillStyle = color;
|
||||
ctx.fillRect(x * size, y * size, size - 2, size - 2);
|
||||
|
||||
// Add gradient for 3D effect
|
||||
const gradient = ctx.createLinearGradient(
|
||||
x * size, y * size,
|
||||
x * size + size, y * size + size
|
||||
);
|
||||
gradient.addColorStop(0, 'rgba(255,255,255,0.3)');
|
||||
gradient.addColorStop(1, 'rgba(0,0,0,0.3)');
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x * size, y * size, size - 2, size - 2);
|
||||
}
|
||||
|
||||
// Draw board
|
||||
function drawBoard() {
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw grid
|
||||
ctx.strokeStyle = '#1a1a1a';
|
||||
ctx.lineWidth = 1;
|
||||
for (let i = 0; i <= COLS; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(i * BLOCK_SIZE, 0);
|
||||
ctx.lineTo(i * BLOCK_SIZE, canvas.height);
|
||||
ctx.stroke();
|
||||
}
|
||||
for (let i = 0; i <= ROWS; i++) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, i * BLOCK_SIZE);
|
||||
ctx.lineTo(canvas.width, i * BLOCK_SIZE);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Draw locked pieces
|
||||
for (let row = 0; row < ROWS; row++) {
|
||||
for (let col = 0; col < COLS; col++) {
|
||||
if (board[row][col]) {
|
||||
drawBlock(ctx, col, row, board[row][col]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw piece
|
||||
function drawPiece(ctx, piece, blockSize = BLOCK_SIZE) {
|
||||
for (let row = 0; row < piece.shape.length; row++) {
|
||||
for (let col = 0; col < piece.shape[row].length; col++) {
|
||||
if (piece.shape[row][col]) {
|
||||
drawBlock(ctx, piece.x + col, piece.y + row, piece.color, blockSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Draw next piece
|
||||
function drawNextPiece() {
|
||||
nextCtx.fillStyle = '#0a0a0a';
|
||||
nextCtx.fillRect(0, 0, nextCanvas.width, nextCanvas.height);
|
||||
|
||||
if (nextPiece) {
|
||||
const offsetX = (4 - nextPiece.shape[0].length) / 2;
|
||||
const offsetY = (4 - nextPiece.shape.length) / 2;
|
||||
|
||||
for (let row = 0; row < nextPiece.shape.length; row++) {
|
||||
for (let col = 0; col < nextPiece.shape[row].length; col++) {
|
||||
if (nextPiece.shape[row][col]) {
|
||||
drawBlock(nextCtx, offsetX + col, offsetY + row, nextPiece.color, NEXT_BLOCK_SIZE);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Game over
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('gameOverScreen').style.display = 'block';
|
||||
}
|
||||
|
||||
// Reset game
|
||||
function resetGame() {
|
||||
initBoard();
|
||||
score = 0;
|
||||
lines = 0;
|
||||
level = 1;
|
||||
dropTime = 1000;
|
||||
updateUI();
|
||||
|
||||
currentPiece = createPiece();
|
||||
nextPiece = createPiece();
|
||||
|
||||
document.getElementById('gameOverScreen').style.display = 'none';
|
||||
gameRunning = true;
|
||||
gamePaused = false;
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Start game
|
||||
function startGame() {
|
||||
document.getElementById('startScreen').style.display = 'none';
|
||||
resetGame();
|
||||
}
|
||||
|
||||
// Game loop
|
||||
function gameLoop(timestamp = 0) {
|
||||
if (!gameRunning || gamePaused) return;
|
||||
|
||||
// Auto drop
|
||||
if (timestamp - lastDrop > dropTime) {
|
||||
if (isValidMove(currentPiece, currentPiece.x, currentPiece.y + 1)) {
|
||||
currentPiece.y++;
|
||||
} else {
|
||||
lockPiece();
|
||||
clearLines();
|
||||
|
||||
currentPiece = nextPiece;
|
||||
nextPiece = createPiece();
|
||||
|
||||
if (!isValidMove(currentPiece, currentPiece.x, currentPiece.y)) {
|
||||
gameOver();
|
||||
return;
|
||||
}
|
||||
}
|
||||
lastDrop = timestamp;
|
||||
}
|
||||
|
||||
// Draw everything
|
||||
drawBoard();
|
||||
if (currentPiece) {
|
||||
drawPiece(ctx, currentPiece);
|
||||
}
|
||||
drawNextPiece();
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Keyboard controls
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (!gameRunning || gamePaused) return;
|
||||
|
||||
switch(e.key) {
|
||||
case 'ArrowLeft':
|
||||
if (isValidMove(currentPiece, currentPiece.x - 1, currentPiece.y)) {
|
||||
currentPiece.x--;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowRight':
|
||||
if (isValidMove(currentPiece, currentPiece.x + 1, currentPiece.y)) {
|
||||
currentPiece.x++;
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowDown':
|
||||
if (isValidMove(currentPiece, currentPiece.x, currentPiece.y + 1)) {
|
||||
currentPiece.y++;
|
||||
score++;
|
||||
updateUI();
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ArrowUp':
|
||||
const rotated = rotatePiece(currentPiece);
|
||||
if (isValidMove(currentPiece, currentPiece.x, currentPiece.y, rotated)) {
|
||||
currentPiece.shape = rotated;
|
||||
}
|
||||
break;
|
||||
|
||||
case ' ':
|
||||
// Hard drop
|
||||
while (isValidMove(currentPiece, currentPiece.x, currentPiece.y + 1)) {
|
||||
currentPiece.y++;
|
||||
score += 2;
|
||||
}
|
||||
updateUI();
|
||||
break;
|
||||
|
||||
case 'p':
|
||||
case 'P':
|
||||
gamePaused = !gamePaused;
|
||||
if (!gamePaused) {
|
||||
gameLoop();
|
||||
}
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize
|
||||
initBoard();
|
||||
updateUI();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
138
games/arcade/apps/web/static/games/reaction_test.html
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Reaction Test</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
background: #222;
|
||||
color: #fff;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-family: Arial;
|
||||
text-align: center;
|
||||
}
|
||||
#screen {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 0s;
|
||||
}
|
||||
.red { background: #c33; }
|
||||
.green { background: #3c3; }
|
||||
.blue { background: #247; }
|
||||
h1 { font-size: 48px; margin: 20px; }
|
||||
p { font-size: 24px; margin: 10px; }
|
||||
.best { color: #ffd700; font-size: 20px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="screen" class="blue" onclick="handleClick()">
|
||||
<h1 id="title">REACTION TEST</h1>
|
||||
<p id="info">Klicke wenn der Bildschirm GRÜN wird!</p>
|
||||
<p id="result"></p>
|
||||
<p class="best" id="best"></p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'reaction-test';
|
||||
|
||||
let waiting = false;
|
||||
let startTime = 0;
|
||||
let times = [];
|
||||
let timeout;
|
||||
|
||||
const screen = document.getElementById('screen');
|
||||
const info = document.getElementById('info');
|
||||
const result = document.getElementById('result');
|
||||
const best = document.getElementById('best');
|
||||
|
||||
function start() {
|
||||
screen.className = 'red';
|
||||
info.textContent = 'Warte auf GRÜN...';
|
||||
result.textContent = '';
|
||||
waiting = true;
|
||||
|
||||
timeout = setTimeout(() => {
|
||||
screen.className = 'green';
|
||||
info.textContent = 'KLICK!';
|
||||
startTime = Date.now();
|
||||
}, Math.random() * 4000 + 2000);
|
||||
}
|
||||
|
||||
function handleClick() {
|
||||
if (screen.className === 'blue') {
|
||||
start();
|
||||
} else if (screen.className === 'red' && waiting) {
|
||||
clearTimeout(timeout);
|
||||
screen.className = 'blue';
|
||||
info.textContent = 'Zu früh! Klicke zum Neustart';
|
||||
result.textContent = '❌ Fehlstart!';
|
||||
waiting = false;
|
||||
} else if (screen.className === 'green') {
|
||||
const time = Date.now() - startTime;
|
||||
times.push(time);
|
||||
|
||||
screen.className = 'blue';
|
||||
info.textContent = 'Klicke für nächsten Versuch';
|
||||
result.textContent = `⚡ ${time}ms`;
|
||||
|
||||
const bestTime = Math.min(...times);
|
||||
best.textContent = `Beste Zeit: ${bestTime}ms (${times.length} Versuche)`;
|
||||
|
||||
// Sende Score Update für Statistiken (niedrigere Zeit = bessere Leistung)
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: Math.max(0, 1000 - time) }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (time < 250) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'lightning_reflexes',
|
||||
name: 'Lightning Reflexes',
|
||||
description: 'React in under 250ms',
|
||||
icon: '⚡'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (times.length >= 10 && bestTime < 300) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'consistent_speed',
|
||||
name: 'Consistent Speed',
|
||||
description: 'Best time under 300ms after 10 attempts',
|
||||
icon: '🎯'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
waiting = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
795
games/arcade/apps/web/static/games/rhythm_defender.html
Normal file
|
|
@ -0,0 +1,795 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Rhythm Defender</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: radial-gradient(circle at center, #1a0033, #000);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Arial', sans-serif;
|
||||
color: #fff;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ui-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
width: 800px;
|
||||
padding: 0 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
color: #ff00ff;
|
||||
text-shadow: 0 0 20px #ff00ff;
|
||||
}
|
||||
|
||||
.combo {
|
||||
font-size: 20px;
|
||||
color: #00ffff;
|
||||
text-shadow: 0 0 15px #00ffff;
|
||||
}
|
||||
|
||||
.health {
|
||||
font-size: 20px;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.health-bar {
|
||||
width: 200px;
|
||||
height: 20px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 2px solid #ff6b6b;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.health-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #ff6b6b, #ff4444);
|
||||
transition: width 0.3s ease;
|
||||
box-shadow: 0 0 10px #ff6b6b;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px solid #ff00ff;
|
||||
box-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
|
||||
background: #0a0a0a;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.start-screen, .game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border: 3px solid #ff00ff;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
z-index: 10;
|
||||
box-shadow: 0 0 50px rgba(255, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
.game-over {
|
||||
display: none;
|
||||
}
|
||||
|
||||
h1 {
|
||||
font-size: 48px;
|
||||
margin: 0 0 20px 0;
|
||||
background: linear-gradient(45deg, #ff00ff, #00ffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
text-shadow: 0 0 30px rgba(255, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
h2 {
|
||||
font-size: 36px;
|
||||
margin: 0 0 20px 0;
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.instructions {
|
||||
margin: 20px 0;
|
||||
font-size: 18px;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.key-display {
|
||||
display: inline-flex;
|
||||
gap: 10px;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
.key {
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border: 2px solid #fff;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(45deg, #ff00ff, #ff0080);
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 15px 30px;
|
||||
font-size: 20px;
|
||||
font-weight: bold;
|
||||
cursor: pointer;
|
||||
margin: 10px;
|
||||
border-radius: 30px;
|
||||
text-transform: uppercase;
|
||||
transition: all 0.3s;
|
||||
box-shadow: 0 5px 20px rgba(255, 0, 255, 0.5);
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 30px rgba(255, 0, 255, 0.7);
|
||||
}
|
||||
|
||||
.beat-indicator {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
border: 3px solid #ff00ff;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
background: rgba(255, 0, 255, 0.1);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% { transform: translateX(-50%) scale(1); opacity: 1; }
|
||||
50% { transform: translateX(-50%) scale(1.2); opacity: 0.8; }
|
||||
100% { transform: translateX(-50%) scale(1); opacity: 0; }
|
||||
}
|
||||
|
||||
.pulse {
|
||||
animation: pulse 0.5s ease-out;
|
||||
}
|
||||
|
||||
.perfect-text {
|
||||
position: absolute;
|
||||
font-size: 36px;
|
||||
font-weight: bold;
|
||||
color: #00ff00;
|
||||
text-shadow: 0 0 20px #00ff00;
|
||||
animation: fadeUp 1s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.good-text {
|
||||
position: absolute;
|
||||
font-size: 30px;
|
||||
font-weight: bold;
|
||||
color: #ffff00;
|
||||
text-shadow: 0 0 20px #ffff00;
|
||||
animation: fadeUp 1s ease-out forwards;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes fadeUp {
|
||||
0% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-50px); }
|
||||
}
|
||||
|
||||
.background-pulse {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: radial-gradient(circle at center, transparent, rgba(255, 0, 255, 0.1));
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
@keyframes bgPulse {
|
||||
0% { opacity: 0; }
|
||||
50% { opacity: 1; }
|
||||
100% { opacity: 0; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<div class="ui-top">
|
||||
<div class="score">SCORE: <span id="score">0</span></div>
|
||||
<div class="combo">COMBO: <span id="combo">0</span>x</div>
|
||||
<div class="health-container">
|
||||
<div class="health">LEBEN</div>
|
||||
<div class="health-bar">
|
||||
<div class="health-fill" id="healthFill" style="width: 100%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<canvas id="gameCanvas" width="800" height="500"></canvas>
|
||||
|
||||
<div class="beat-indicator" id="beatIndicator">BEAT</div>
|
||||
<div class="background-pulse" id="bgPulse"></div>
|
||||
|
||||
<div class="start-screen" id="startScreen">
|
||||
<h1>RHYTHM DEFENDER</h1>
|
||||
<div class="instructions">
|
||||
<p>Verteidige dich im Rhythmus der Musik!</p>
|
||||
<p>Drücke die richtigen Tasten im Takt:</p>
|
||||
<div class="key-display">
|
||||
<div class="key" style="border-color: #ff0000;">A</div>
|
||||
<div class="key" style="border-color: #00ff00;">S</div>
|
||||
<div class="key" style="border-color: #0080ff;">D</div>
|
||||
<div class="key" style="border-color: #ffff00;">F</div>
|
||||
</div>
|
||||
<p>Treffe die Noten wenn sie die Ziellinie erreichen!</p>
|
||||
<p><strong>PERFECT</strong> = 100 Punkte + Combo</p>
|
||||
<p><strong>GOOD</strong> = 50 Punkte</p>
|
||||
</div>
|
||||
<button onclick="startGame()">SPIEL STARTEN</button>
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOverScreen">
|
||||
<h2>GAME OVER</h2>
|
||||
<p style="font-size: 24px;">Finaler Score: <span id="finalScore">0</span></p>
|
||||
<p style="font-size: 20px;">Maximale Combo: <span id="maxCombo">0</span></p>
|
||||
<button onclick="restartGame()">NOCHMAL SPIELEN</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'rhythm-defender';
|
||||
|
||||
// Canvas und Context
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// UI Elemente
|
||||
const scoreElement = document.getElementById('score');
|
||||
const comboElement = document.getElementById('combo');
|
||||
const healthFill = document.getElementById('healthFill');
|
||||
const startScreen = document.getElementById('startScreen');
|
||||
const gameOverScreen = document.getElementById('gameOverScreen');
|
||||
const beatIndicator = document.getElementById('beatIndicator');
|
||||
const bgPulse = document.getElementById('bgPulse');
|
||||
|
||||
// Spielkonstanten
|
||||
const LANES = 4;
|
||||
const LANE_WIDTH = canvas.width / LANES;
|
||||
const NOTE_HEIGHT = 20;
|
||||
const NOTE_SPEED = 3;
|
||||
const TARGET_Y = canvas.height - 80;
|
||||
const PERFECT_RANGE = 40;
|
||||
const GOOD_RANGE = 80;
|
||||
const BEAT_INTERVAL = 500; // Millisekunden
|
||||
|
||||
// Spielzustand
|
||||
let notes = [];
|
||||
let score = 0;
|
||||
let combo = 0;
|
||||
let maxCombo = 0;
|
||||
let health = 100;
|
||||
let gameRunning = false;
|
||||
let lastBeatTime = 0;
|
||||
let beatCount = 0;
|
||||
let particles = [];
|
||||
let floatingTexts = [];
|
||||
|
||||
// Tastenzuordnung
|
||||
const laneKeys = ['a', 's', 'd', 'f'];
|
||||
const laneColors = ['#ff0000', '#00ff00', '#0080ff', '#ffff00'];
|
||||
const keyPressed = {};
|
||||
|
||||
// Note Klasse
|
||||
class Note {
|
||||
constructor(lane) {
|
||||
this.lane = lane;
|
||||
this.x = lane * LANE_WIDTH + LANE_WIDTH / 2;
|
||||
this.y = -NOTE_HEIGHT;
|
||||
this.hit = false;
|
||||
this.missed = false;
|
||||
this.color = laneColors[lane];
|
||||
}
|
||||
|
||||
update() {
|
||||
this.y += NOTE_SPEED;
|
||||
|
||||
// Prüfe ob Note verpasst wurde
|
||||
if (this.y > TARGET_Y + GOOD_RANGE && !this.hit && !this.missed) {
|
||||
this.missed = true;
|
||||
missNote();
|
||||
}
|
||||
}
|
||||
|
||||
draw() {
|
||||
if (this.hit || this.missed) return;
|
||||
|
||||
// Note mit Glow-Effekt
|
||||
ctx.save();
|
||||
ctx.translate(this.x, this.y);
|
||||
|
||||
// Äußerer Glow
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = this.color;
|
||||
|
||||
// Note-Form (Diamant)
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -NOTE_HEIGHT);
|
||||
ctx.lineTo(NOTE_HEIGHT, 0);
|
||||
ctx.lineTo(0, NOTE_HEIGHT);
|
||||
ctx.lineTo(-NOTE_HEIGHT, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.fill();
|
||||
|
||||
// Innerer heller Teil
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -NOTE_HEIGHT / 2);
|
||||
ctx.lineTo(NOTE_HEIGHT / 2, 0);
|
||||
ctx.lineTo(0, NOTE_HEIGHT / 2);
|
||||
ctx.lineTo(-NOTE_HEIGHT / 2, 0);
|
||||
ctx.closePath();
|
||||
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.5)';
|
||||
ctx.fill();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Partikel Klasse
|
||||
class Particle {
|
||||
constructor(x, y, color) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
this.vx = (Math.random() - 0.5) * 8;
|
||||
this.vy = (Math.random() - 0.5) * 8;
|
||||
this.life = 1;
|
||||
this.color = color;
|
||||
this.size = Math.random() * 5 + 3;
|
||||
}
|
||||
|
||||
update() {
|
||||
this.x += this.vx;
|
||||
this.y += this.vy;
|
||||
this.vy += 0.2;
|
||||
this.life -= 0.02;
|
||||
this.size *= 0.98;
|
||||
}
|
||||
|
||||
draw() {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = this.life;
|
||||
ctx.fillStyle = this.color;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.shadowColor = this.color;
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.x, this.y, this.size, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Eingabe-Handler
|
||||
document.addEventListener('keydown', (e) => {
|
||||
const key = e.key.toLowerCase();
|
||||
if (!keyPressed[key] && laneKeys.includes(key) && gameRunning) {
|
||||
keyPressed[key] = true;
|
||||
checkHit(laneKeys.indexOf(key));
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
keyPressed[e.key.toLowerCase()] = false;
|
||||
});
|
||||
|
||||
// Note-Generierung basierend auf Rhythmus
|
||||
function generateNotes() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
const currentTime = Date.now();
|
||||
|
||||
// Generiere Noten im Beat
|
||||
if (currentTime - lastBeatTime >= BEAT_INTERVAL) {
|
||||
lastBeatTime = currentTime;
|
||||
beatCount++;
|
||||
|
||||
// Beat-Indikator
|
||||
beatIndicator.classList.add('pulse');
|
||||
setTimeout(() => beatIndicator.classList.remove('pulse'), 400);
|
||||
|
||||
// Hintergrund-Puls
|
||||
bgPulse.style.animation = 'bgPulse 0.5s ease-out';
|
||||
setTimeout(() => bgPulse.style.animation = '', 500);
|
||||
|
||||
// Generiere Noten basierend auf Muster
|
||||
const patterns = [
|
||||
[0], [1], [2], [3], // Einzelne Noten
|
||||
[0, 2], [1, 3], // Doppelnoten
|
||||
[0, 1], [2, 3], // Nebeneinander
|
||||
[0, 3], [1, 2], // Außen/Innen
|
||||
[0, 1, 2], [1, 2, 3], // Dreifach
|
||||
[0, 1, 2, 3] // Alle (selten)
|
||||
];
|
||||
|
||||
// Wähle Muster basierend auf Schwierigkeit
|
||||
const difficulty = Math.min(Math.floor(score / 1000), 5);
|
||||
const maxPatternIndex = Math.min(3 + difficulty * 2, patterns.length - 1);
|
||||
const pattern = patterns[Math.floor(Math.random() * (maxPatternIndex + 1))];
|
||||
|
||||
// Manchmal keine Note für Variation
|
||||
if (Math.random() < 0.8) {
|
||||
pattern.forEach(lane => {
|
||||
notes.push(new Note(lane));
|
||||
createLaneGlow(lane);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lane-Glow-Effekt
|
||||
function createLaneGlow(lane) {
|
||||
const x = lane * LANE_WIDTH + LANE_WIDTH / 2;
|
||||
for (let i = 0; i < 5; i++) {
|
||||
particles.push(new Particle(
|
||||
x + (Math.random() - 0.5) * LANE_WIDTH,
|
||||
0,
|
||||
laneColors[lane]
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Treffer prüfen
|
||||
function checkHit(lane) {
|
||||
let hitNote = null;
|
||||
let hitQuality = null;
|
||||
|
||||
// Finde die nächste Note in der Lane
|
||||
for (const note of notes) {
|
||||
if (note.lane === lane && !note.hit && !note.missed) {
|
||||
const distance = Math.abs(note.y - TARGET_Y);
|
||||
|
||||
if (distance <= PERFECT_RANGE) {
|
||||
hitNote = note;
|
||||
hitQuality = 'perfect';
|
||||
break;
|
||||
} else if (distance <= GOOD_RANGE) {
|
||||
hitNote = note;
|
||||
hitQuality = 'good';
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitNote) {
|
||||
hitNote.hit = true;
|
||||
|
||||
// Punkte und Combo
|
||||
if (hitQuality === 'perfect') {
|
||||
score += 100 + combo * 10;
|
||||
combo++;
|
||||
createFloatingText(hitNote.x, TARGET_Y, 'PERFECT!', '#00ff00');
|
||||
} else {
|
||||
score += 50;
|
||||
combo = 0;
|
||||
createFloatingText(hitNote.x, TARGET_Y, 'GOOD', '#ffff00');
|
||||
}
|
||||
|
||||
maxCombo = Math.max(maxCombo, combo);
|
||||
scoreElement.textContent = score;
|
||||
comboElement.textContent = combo;
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Partikel-Explosion
|
||||
for (let i = 0; i < 20; i++) {
|
||||
particles.push(new Particle(hitNote.x, TARGET_Y, hitNote.color));
|
||||
}
|
||||
|
||||
// Lane-Effekt
|
||||
drawLaneHit(lane);
|
||||
} else {
|
||||
// Verfehlt
|
||||
combo = 0;
|
||||
comboElement.textContent = combo;
|
||||
health = Math.max(0, health - 5);
|
||||
updateHealthBar();
|
||||
}
|
||||
}
|
||||
|
||||
// Note verfehlt
|
||||
function missNote() {
|
||||
combo = 0;
|
||||
comboElement.textContent = combo;
|
||||
health = Math.max(0, health - 10);
|
||||
updateHealthBar();
|
||||
|
||||
if (health <= 0) {
|
||||
gameOver();
|
||||
}
|
||||
}
|
||||
|
||||
// Gesundheitsanzeige aktualisieren
|
||||
function updateHealthBar() {
|
||||
healthFill.style.width = health + '%';
|
||||
if (health <= 30) {
|
||||
healthFill.style.background = 'linear-gradient(90deg, #ff0000, #cc0000)';
|
||||
}
|
||||
}
|
||||
|
||||
// Schwebender Text
|
||||
function createFloatingText(x, y, text, color) {
|
||||
const textElement = document.createElement('div');
|
||||
textElement.className = color === '#00ff00' ? 'perfect-text' : 'good-text';
|
||||
textElement.textContent = text;
|
||||
textElement.style.left = x + 'px';
|
||||
textElement.style.top = y + 'px';
|
||||
document.body.appendChild(textElement);
|
||||
|
||||
setTimeout(() => textElement.remove(), 1000);
|
||||
}
|
||||
|
||||
// Lane-Hit-Effekt
|
||||
function drawLaneHit(lane) {
|
||||
const x = lane * LANE_WIDTH;
|
||||
|
||||
ctx.save();
|
||||
ctx.fillStyle = laneColors[lane];
|
||||
ctx.globalAlpha = 0.3;
|
||||
ctx.fillRect(x, TARGET_Y - 50, LANE_WIDTH, 100);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Update
|
||||
function update() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
// Update Noten
|
||||
for (let i = notes.length - 1; i >= 0; i--) {
|
||||
notes[i].update();
|
||||
|
||||
// Entferne alte Noten
|
||||
if (notes[i].y > canvas.height + NOTE_HEIGHT) {
|
||||
notes.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Update Partikel
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
particles[i].update();
|
||||
if (particles[i].life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Generiere neue Noten
|
||||
generateNotes();
|
||||
}
|
||||
|
||||
// Zeichnen
|
||||
function draw() {
|
||||
// Clear
|
||||
ctx.fillStyle = '#0a0a0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Zeichne Lanes
|
||||
for (let i = 0; i < LANES; i++) {
|
||||
const x = i * LANE_WIDTH;
|
||||
|
||||
// Lane-Linien
|
||||
ctx.strokeStyle = 'rgba(255, 255, 255, 0.1)';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(x, 0);
|
||||
ctx.lineTo(x, canvas.height);
|
||||
ctx.stroke();
|
||||
|
||||
// Lane-Hintergrund (leichter Gradient)
|
||||
const gradient = ctx.createLinearGradient(x, 0, x, canvas.height);
|
||||
gradient.addColorStop(0, 'rgba(255, 255, 255, 0)');
|
||||
gradient.addColorStop(0.8, `${laneColors[i]}20`);
|
||||
gradient.addColorStop(1, `${laneColors[i]}40`);
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.fillRect(x, 0, LANE_WIDTH, canvas.height);
|
||||
}
|
||||
|
||||
// Zeichne Ziellinie
|
||||
ctx.strokeStyle = '#ffffff';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.shadowBlur = 20;
|
||||
ctx.shadowColor = '#ffffff';
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, TARGET_Y);
|
||||
ctx.lineTo(canvas.width, TARGET_Y);
|
||||
ctx.stroke();
|
||||
ctx.shadowBlur = 0;
|
||||
|
||||
// Zeichne Zielzonen
|
||||
for (let i = 0; i < LANES; i++) {
|
||||
const x = i * LANE_WIDTH + LANE_WIDTH / 2;
|
||||
|
||||
// Äußerer Ring (Good-Bereich)
|
||||
ctx.strokeStyle = laneColors[i] + '30';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, TARGET_Y, GOOD_RANGE, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Mittlerer Ring (Perfect-Bereich)
|
||||
ctx.strokeStyle = laneColors[i] + '60';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, TARGET_Y, PERFECT_RANGE, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Innerer Kreis (Zielbereich)
|
||||
ctx.fillStyle = laneColors[i] + '40';
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, TARGET_Y, 15, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Mittelpunkt
|
||||
ctx.fillStyle = laneColors[i];
|
||||
ctx.beginPath();
|
||||
ctx.arc(x, TARGET_Y, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Taste anzeigen
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = 'bold 24px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(laneKeys[i].toUpperCase(), x, TARGET_Y + 40);
|
||||
}
|
||||
|
||||
// Zeichne Noten
|
||||
notes.forEach(note => note.draw());
|
||||
|
||||
// Zeichne Partikel
|
||||
particles.forEach(particle => particle.draw());
|
||||
|
||||
// Combo-Multiplikator anzeigen
|
||||
if (combo >= 10) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#00ffff';
|
||||
ctx.font = 'bold 48px Arial';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.shadowBlur = 30;
|
||||
ctx.shadowColor = '#00ffff';
|
||||
ctx.globalAlpha = 0.3 + Math.sin(Date.now() * 0.005) * 0.2;
|
||||
ctx.fillText(combo + 'x', canvas.width / 2, 100);
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
// Game Loop
|
||||
function gameLoop() {
|
||||
update();
|
||||
draw();
|
||||
|
||||
if (gameRunning) {
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
}
|
||||
|
||||
// Spiel starten
|
||||
function startGame() {
|
||||
startScreen.style.display = 'none';
|
||||
gameRunning = true;
|
||||
score = 0;
|
||||
combo = 0;
|
||||
maxCombo = 0;
|
||||
health = 100;
|
||||
notes = [];
|
||||
particles = [];
|
||||
lastBeatTime = Date.now();
|
||||
beatCount = 0;
|
||||
|
||||
scoreElement.textContent = score;
|
||||
comboElement.textContent = combo;
|
||||
updateHealthBar();
|
||||
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Game Over
|
||||
function gameOver() {
|
||||
gameRunning = false;
|
||||
document.getElementById('finalScore').textContent = score;
|
||||
document.getElementById('maxCombo').textContent = maxCombo;
|
||||
gameOverScreen.style.display = 'block';
|
||||
|
||||
// Sende Game Over Event
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 2000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'rhythm_master',
|
||||
name: 'Rhythm Master',
|
||||
description: 'Score 2000 points in Rhythm Defender',
|
||||
icon: '🎵'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (maxCombo >= 50) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'combo_king',
|
||||
name: 'Combo King',
|
||||
description: 'Achieve a 50x combo in Rhythm Defender',
|
||||
icon: '🔥'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Neustart
|
||||
function restartGame() {
|
||||
gameOverScreen.style.display = 'none';
|
||||
startGame();
|
||||
}
|
||||
|
||||
// Debug
|
||||
console.log('Rhythm Defender geladen!');
|
||||
console.log('Ein Rhythmus-basiertes Verteidigungsspiel.');
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
662
games/arcade/apps/web/static/games/snake_game.html
Normal file
|
|
@ -0,0 +1,662 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Snake Spiel</title>
|
||||
<style>
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ffff;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.score {
|
||||
font-size: 16px;
|
||||
margin-bottom: 5px;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 2px solid #00ffff;
|
||||
background: #000;
|
||||
display: block;
|
||||
image-rendering: pixelated;
|
||||
image-rendering: -moz-crisp-edges;
|
||||
image-rendering: crisp-edges;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(0, 0, 0, 0.95);
|
||||
border: 2px solid #00ffff;
|
||||
padding: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
background: #000;
|
||||
color: #00ffff;
|
||||
border: 1px solid #00ffff;
|
||||
padding: 8px 16px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
background: #00ffff;
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<div class="score">SCORE: <span id="score">0</span></div>
|
||||
<canvas id="gameCanvas" width="400" height="400"></canvas>
|
||||
<div class="game-over" id="gameOver">
|
||||
<div>GAME OVER</div>
|
||||
<div>SCORE: <span id="finalScore">0</span></div>
|
||||
<button class="restart-btn" onclick="restartGame()">RESTART</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ======================== CANVAS UND UI ELEMENTE ========================
|
||||
// Hole die Canvas und 2D Kontext für das Zeichnen des Spiels
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// UI Elemente für Score-Anzeige und Game Over Screen
|
||||
const scoreElement = document.getElementById('score');
|
||||
const gameOverElement = document.getElementById('gameOver');
|
||||
const finalScoreElement = document.getElementById('finalScore');
|
||||
|
||||
// ======================== SPIEL-KONSTANTEN ========================
|
||||
// Größe eines einzelnen Feldes/Kachel in Pixeln
|
||||
const gridSize = 20;
|
||||
// Anzahl der Kacheln in jeder Richtung (20x20 Grid bei 400px Canvas)
|
||||
const tileCount = canvas.width / gridSize;
|
||||
|
||||
// ======================== SPIEL-ZUSTAND ========================
|
||||
// Snake Array - jedes Element ist ein Objekt mit x,y Koordinaten
|
||||
// Index 0 ist der Kopf der Schlange
|
||||
let snake = [{x: 10, y: 10}];
|
||||
|
||||
// Position des Essens als Objekt mit x,y Koordinaten
|
||||
let food = {};
|
||||
|
||||
// Bewegungsrichtung der Schlange (-1, 0, 1 für jede Achse)
|
||||
let dx = 0; // Horizontale Bewegung: -1 = links, 0 = keine, 1 = rechts
|
||||
let dy = 0; // Vertikale Bewegung: -1 = oben, 0 = keine, 1 = unten
|
||||
|
||||
// Aktueller Punktestand
|
||||
let score = 0;
|
||||
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'snake';
|
||||
|
||||
// Flag ob das Spiel läuft oder pausiert/beendet ist
|
||||
let gameRunning = true;
|
||||
|
||||
// Zeit-Management für konstante Bewegungsgeschwindigkeit
|
||||
let lastMoveTime = 0; // Zeitstempel der letzten Bewegung
|
||||
let moveInterval = 120; // Millisekunden zwischen Bewegungen (Start-Geschwindigkeit)
|
||||
|
||||
// ======================== INPUT STEUERUNG ========================
|
||||
// Queue für Tasteneingaben - ermöglicht schnelle Richtungswechsel ohne Verlust
|
||||
// Maximal 3 Eingaben werden gespeichert
|
||||
let inputQueue = [];
|
||||
// Letzte tatsächliche Bewegungsrichtung (verhindert 180° Drehungen)
|
||||
let lastDirection = { dx: 0, dy: 0 };
|
||||
|
||||
// ======================== GAME OVER ANIMATION ========================
|
||||
// Flag ob die Explosions-Animation läuft
|
||||
let gameOverAnimation = false;
|
||||
// Array mit Partikeln für die Explosion
|
||||
let explosionParticles = [];
|
||||
// Startzeit der Animation für Timing
|
||||
let animationStartTime = 0;
|
||||
|
||||
// ======================== BESUCHTE FELDER TRACKING ========================
|
||||
// 2D Array das speichert, wie oft jedes Feld besucht wurde
|
||||
// 0 = unbesucht (schwarz)
|
||||
// 1 = 1x besucht (blau)
|
||||
// 2 = 2x besucht (rot mit Streifen)
|
||||
// 3 = 3x besucht (magenta mit Kreuz) - tödlich!
|
||||
let visitedGrid = Array(tileCount).fill().map(() => Array(tileCount).fill(0));
|
||||
|
||||
// ======================== FARB-PALETTE ========================
|
||||
// Zentrale Definition aller Farben für konsistentes Design
|
||||
// und bessere Performance (weniger String-Allokationen)
|
||||
const COLORS = {
|
||||
background: '#000', // Schwarzer Hintergrund
|
||||
snakeHead: '#00ffff', // Cyan für Schlangenkopf
|
||||
snakeBody: '#0088aa', // Dunkleres Cyan für Körper
|
||||
food: '#ffff00', // Gelb für Essen
|
||||
border: '#ffffff', // Weiße Ränder
|
||||
visited1: '#4444aa', // Blau für 1x besuchte Felder
|
||||
visited2: '#aa4444', // Rot für 2x besuchte Felder
|
||||
visited3: '#aa44aa', // Magenta für 3x besuchte (tödliche) Felder
|
||||
pattern: '#ffffff' // Weiß für Muster auf besuchten Feldern
|
||||
};
|
||||
|
||||
// ======================== ESSEN GENERATION ========================
|
||||
/**
|
||||
* Generiert eine neue zufällige Position für das Essen.
|
||||
* Stellt sicher, dass das Essen nicht auf der Schlange erscheint.
|
||||
*/
|
||||
function generateFood() {
|
||||
// Wiederhole bis eine freie Position gefunden wird
|
||||
do {
|
||||
food = {
|
||||
x: Math.floor(Math.random() * tileCount),
|
||||
y: Math.floor(Math.random() * tileCount)
|
||||
};
|
||||
// Prüfe ob irgendein Schlangen-Segment auf dieser Position ist
|
||||
} while (snake.some(segment => segment.x === food.x && segment.y === food.y));
|
||||
}
|
||||
|
||||
// ======================== HAUPT-GAME-LOOP ========================
|
||||
/**
|
||||
* Die zentrale Game Loop die kontinuierlich läuft.
|
||||
* Wird von requestAnimationFrame aufgerufen für 60 FPS.
|
||||
*
|
||||
* @param {number} currentTime - Aktuelle Zeit in Millisekunden
|
||||
*/
|
||||
function gameLoop(currentTime) {
|
||||
// Spezialbehandlung während der Game Over Animation
|
||||
if (gameOverAnimation) {
|
||||
updateExplosion(currentTime); // Bewege Explosions-Partikel
|
||||
drawGame(); // Zeichne normales Spielfeld
|
||||
drawExplosion(currentTime); // Zeichne Explosion darüber
|
||||
requestAnimationFrame(gameLoop);
|
||||
return;
|
||||
}
|
||||
|
||||
// Beende Loop wenn Spiel nicht läuft
|
||||
if (!gameRunning) return;
|
||||
|
||||
// Verarbeite gespeicherte Tasteneingaben
|
||||
processInputQueue();
|
||||
|
||||
// Bewegung nur in festgelegten Intervallen (nicht jeden Frame)
|
||||
// Dies erzeugt die klassische "ruckartige" Snake-Bewegung
|
||||
if (currentTime - lastMoveTime >= moveInterval) {
|
||||
moveSnake();
|
||||
lastMoveTime = currentTime;
|
||||
}
|
||||
|
||||
// Zeichne jeden Frame für flüssige Darstellung
|
||||
// (auch wenn Bewegung nur alle 120ms erfolgt)
|
||||
drawGame();
|
||||
|
||||
// Nächsten Frame anfordern
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ======================== INPUT VERARBEITUNG ========================
|
||||
/**
|
||||
* Verarbeitet die nächste Eingabe aus der Input-Queue.
|
||||
* Verhindert 180° Drehungen (Rückwärtsbewegung in sich selbst).
|
||||
*/
|
||||
function processInputQueue() {
|
||||
// Keine Eingaben vorhanden
|
||||
if (inputQueue.length === 0) return;
|
||||
|
||||
// Hole und entferne erste Eingabe aus Queue
|
||||
const nextMove = inputQueue.shift();
|
||||
|
||||
// Prüfe ob die Bewegung gültig ist (keine 180° Drehung)
|
||||
// Beispiel: Wenn Schlange nach rechts (dx=1) läuft,
|
||||
// ist links (dx=-1) nicht erlaubt
|
||||
if ((nextMove.dx !== 0 && lastDirection.dx !== -nextMove.dx) ||
|
||||
(nextMove.dy !== 0 && lastDirection.dy !== -nextMove.dy)) {
|
||||
// Setze neue Bewegungsrichtung
|
||||
dx = nextMove.dx;
|
||||
dy = nextMove.dy;
|
||||
// Speichere als letzte Richtung für nächste Prüfung
|
||||
lastDirection = { dx, dy };
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== HAUPT-ZEICHENFUNKTION ========================
|
||||
/**
|
||||
* Zeichnet das gesamte Spiel.
|
||||
* Reihenfolge ist wichtig für korrekte Überlagerung.
|
||||
*/
|
||||
function drawGame() {
|
||||
clearCanvas(); // 1. Lösche alles und zeichne Hintergrund + besuchte Felder
|
||||
drawFood(); // 2. Zeichne Essen
|
||||
drawSnake(); // 3. Zeichne Schlange (über allem anderen)
|
||||
checkCollision(); // 4. Prüfe Kollisionen
|
||||
}
|
||||
|
||||
// ======================== CANVAS LÖSCHEN UND HINTERGRUND ========================
|
||||
/**
|
||||
* Löscht das Canvas und zeichnet den Hintergrund inklusive besuchter Felder.
|
||||
* Optimiert durch Batch-Rendering gleicher Farben.
|
||||
*/
|
||||
function clearCanvas() {
|
||||
// Lösche gesamtes Canvas mit schwarzem Hintergrund
|
||||
ctx.fillStyle = COLORS.background;
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Zeichne alle besuchten Felder nach Level gruppiert
|
||||
// Dies minimiert ctx.fillStyle Änderungen für bessere Performance
|
||||
for (let level = 1; level <= 3; level++) {
|
||||
// Setze Farbe für aktuelles Level
|
||||
ctx.fillStyle = level === 1 ? COLORS.visited1 :
|
||||
level === 2 ? COLORS.visited2 : COLORS.visited3;
|
||||
|
||||
// Durchlaufe gesamtes Grid
|
||||
for (let x = 0; x < tileCount; x++) {
|
||||
for (let y = 0; y < tileCount; y++) {
|
||||
// Zeichne nur wenn Feld dieses Level hat
|
||||
if (visitedGrid[x][y] === level) {
|
||||
// Prüfe ob Schlange auf diesem Feld ist
|
||||
const isSnakeField = snake.some(segment => segment.x === x && segment.y === y);
|
||||
// Zeichne nur wenn Schlange NICHT auf dem Feld ist
|
||||
if (!isSnakeField) {
|
||||
ctx.fillRect(x * gridSize, y * gridSize, gridSize, gridSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Zeichne Muster auf besuchten Feldern (Level 2 und 3)
|
||||
// Alle Muster verwenden die gleiche Farbe für Performance
|
||||
ctx.fillStyle = COLORS.pattern;
|
||||
for (let x = 0; x < tileCount; x++) {
|
||||
for (let y = 0; y < tileCount; y++) {
|
||||
const level = visitedGrid[x][y];
|
||||
const isSnakeField = snake.some(segment => segment.x === x && segment.y === y);
|
||||
|
||||
// Nur Muster für Level 2 und 3, nicht wo Schlange ist
|
||||
if (!isSnakeField && level > 1) {
|
||||
// Berechne Pixel-Position des Feldes
|
||||
const baseX = x * gridSize;
|
||||
const baseY = y * gridSize;
|
||||
|
||||
if (level === 2) {
|
||||
// Vertikale Streifen für Level 2
|
||||
ctx.fillRect(baseX + 5, baseY, 2, gridSize); // Linker Streifen
|
||||
ctx.fillRect(baseX + 13, baseY, 2, gridSize); // Rechter Streifen
|
||||
} else if (level === 3) {
|
||||
// Kreuz-Muster für Level 3 (tödliche Felder)
|
||||
ctx.fillRect(baseX + 8, baseY, 4, gridSize); // Vertikaler Balken
|
||||
ctx.fillRect(baseX, baseY + 8, gridSize, 4); // Horizontaler Balken
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== SCHLANGE ZEICHNEN ========================
|
||||
/**
|
||||
* Zeichnet die Schlange mit unterschiedlichen Farben für Kopf und Körper.
|
||||
* Fügt weiße Ränder für bessere Sichtbarkeit hinzu.
|
||||
*/
|
||||
function drawSnake() {
|
||||
// Verstecke Schlange während Explosions-Animation
|
||||
if (gameOverAnimation) return;
|
||||
|
||||
// Zeichne alle Segmente der Schlange
|
||||
snake.forEach((segment, index) => {
|
||||
// Kopf (index 0) ist heller als Körper
|
||||
ctx.fillStyle = index === 0 ? COLORS.snakeHead : COLORS.snakeBody;
|
||||
ctx.fillRect(segment.x * gridSize, segment.y * gridSize, gridSize, gridSize);
|
||||
});
|
||||
|
||||
// Zeichne Ränder für alle Segmente in einem Durchgang
|
||||
// (Performance-Optimierung: nur einmal Stil setzen)
|
||||
ctx.strokeStyle = COLORS.border;
|
||||
ctx.lineWidth = 1;
|
||||
snake.forEach(segment => {
|
||||
ctx.strokeRect(segment.x * gridSize, segment.y * gridSize, gridSize, gridSize);
|
||||
});
|
||||
}
|
||||
|
||||
// ======================== ESSEN ZEICHNEN ========================
|
||||
/**
|
||||
* Zeichnet das Essen als gelbes Quadrat mit weißem Rand.
|
||||
*/
|
||||
function drawFood() {
|
||||
// Gelbes Quadrat für das Essen
|
||||
ctx.fillStyle = COLORS.food;
|
||||
ctx.fillRect(food.x * gridSize, food.y * gridSize, gridSize, gridSize);
|
||||
|
||||
// Weißer Rand für bessere Sichtbarkeit
|
||||
ctx.strokeStyle = COLORS.border;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(food.x * gridSize, food.y * gridSize, gridSize, gridSize);
|
||||
}
|
||||
|
||||
// ======================== SCHLANGEN-BEWEGUNG ========================
|
||||
/**
|
||||
* Bewegt die Schlange in die aktuelle Richtung.
|
||||
* Behandelt Wrap-Around an den Rändern, Essen-Aufnahme und Kollisionen.
|
||||
*/
|
||||
function moveSnake() {
|
||||
// Keine Bewegung wenn Schlange stillsteht (Spielstart)
|
||||
if (dx === 0 && dy === 0) return;
|
||||
|
||||
// Berechne neue Kopfposition
|
||||
let head = {x: snake[0].x + dx, y: snake[0].y + dy};
|
||||
|
||||
// Wrap-Around: Schlange erscheint auf der anderen Seite
|
||||
if (head.x < 0) head.x = tileCount - 1; // Links raus -> rechts rein
|
||||
if (head.x >= tileCount) head.x = 0; // Rechts raus -> links rein
|
||||
if (head.y < 0) head.y = tileCount - 1; // Oben raus -> unten rein
|
||||
if (head.y >= tileCount) head.y = 0; // Unten raus -> oben rein
|
||||
|
||||
// Prüfe ob neues Feld tödlich ist (3x besucht = rot mit Kreuz)
|
||||
if (visitedGrid[head.x][head.y] === 3) {
|
||||
gameOver();
|
||||
return;
|
||||
}
|
||||
|
||||
// Füge neuen Kopf am Anfang des Arrays hinzu
|
||||
snake.unshift(head);
|
||||
|
||||
// Prüfe ob Essen gegessen wurde
|
||||
if (head.x === food.x && head.y === food.y) {
|
||||
// Essen aufgenommen: Score erhöhen, neues Essen generieren
|
||||
score += 10;
|
||||
scoreElement.textContent = score;
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
generateFood();
|
||||
// Spiel wird schneller (min. 80ms zwischen Bewegungen)
|
||||
moveInterval = Math.max(80, moveInterval - 1);
|
||||
// Schwanz wird NICHT entfernt -> Schlange wächst
|
||||
} else {
|
||||
// Kein Essen: Entferne Schwanz (Schlange bleibt gleich lang)
|
||||
const tail = snake.pop();
|
||||
// Erhöhe Besuchszähler für verlassenes Feld (max. 3)
|
||||
visitedGrid[tail.x][tail.y] = Math.min(3, visitedGrid[tail.x][tail.y] + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== KOLLISIONSPRÜFUNG ========================
|
||||
/**
|
||||
* Prüft ob die Schlange mit sich selbst kollidiert.
|
||||
* Wandkollisionen gibt es nicht (Wrap-Around).
|
||||
*/
|
||||
function checkCollision() {
|
||||
const head = snake[0];
|
||||
// Prüfe Kollision mit jedem Körpersegment (nicht mit Kopf selbst)
|
||||
for (let i = 1; i < snake.length; i++) {
|
||||
if (head.x === snake[i].x && head.y === snake[i].y) {
|
||||
// Schlange hat sich selbst gebissen
|
||||
gameOver();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ======================== GAME OVER BEHANDLUNG ========================
|
||||
/**
|
||||
* Beendet das Spiel und startet die Explosions-Animation.
|
||||
* Erstellt Partikel für jeden Teil der Schlange.
|
||||
*/
|
||||
function gameOver() {
|
||||
// Stoppe Spiellogik
|
||||
gameRunning = false;
|
||||
|
||||
// Sende Game Over Event mit Score für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (score >= 500) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'snake-master',
|
||||
name: 'Snake Meister',
|
||||
description: '500 Punkte in einem Spiel erreicht!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (score >= 1000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'snake-legend',
|
||||
name: 'Snake Legende',
|
||||
description: '1000 Punkte in einem Spiel erreicht!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
// Starte Explosions-Animation
|
||||
gameOverAnimation = true;
|
||||
animationStartTime = performance.now();
|
||||
|
||||
// Erstelle Explosions-Partikel für jedes Schlangen-Segment
|
||||
explosionParticles = [];
|
||||
snake.forEach((segment, index) => {
|
||||
// Berechne Zentrum des Segments in Pixeln
|
||||
const centerX = segment.x * gridSize + gridSize / 2;
|
||||
const centerY = segment.y * gridSize + gridSize / 2;
|
||||
const isHead = index === 0;
|
||||
|
||||
// Erstelle 4 Partikel pro Segment (in 4 Richtungen)
|
||||
for (let i = 0; i < 4; i++) {
|
||||
// Berechne Richtung mit leichter Zufälligkeit
|
||||
const angle = (Math.PI * 2 * i) / 4 + Math.random() * 0.3;
|
||||
const speed = Math.random() * 2 + 2;
|
||||
|
||||
// Erstelle Partikel-Objekt
|
||||
explosionParticles.push({
|
||||
x: centerX, // Start-Position X
|
||||
y: centerY, // Start-Position Y
|
||||
vx: Math.cos(angle) * speed, // Geschwindigkeit X
|
||||
vy: Math.sin(angle) * speed, // Geschwindigkeit Y
|
||||
size: gridSize / 4, // Größe des Partikels
|
||||
life: 1.0, // Lebenszeit (1.0 = 100%)
|
||||
color: isHead ? COLORS.snakeHead : COLORS.snakeBody // Farbe
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Setze finalen Score
|
||||
finalScoreElement.textContent = score;
|
||||
|
||||
// Zeige Game Over Dialog nach 1 Sekunde Animation
|
||||
setTimeout(() => {
|
||||
gameOverElement.style.display = 'block';
|
||||
gameOverAnimation = false;
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
// ======================== EXPLOSIONS-UPDATE ========================
|
||||
/**
|
||||
* Aktualisiert die Positionen und Eigenschaften der Explosions-Partikel.
|
||||
* Wird jeden Frame während der Game Over Animation aufgerufen.
|
||||
*
|
||||
* @param {number} currentTime - Aktuelle Zeit (wird hier nicht verwendet)
|
||||
*/
|
||||
function updateExplosion(currentTime) {
|
||||
// Aktualisiere jeden Partikel
|
||||
explosionParticles.forEach(particle => {
|
||||
// Bewege Partikel basierend auf Geschwindigkeit
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
|
||||
// Reibung: Verlangsame Partikel (5% pro Frame)
|
||||
particle.vx *= 0.95;
|
||||
particle.vy *= 0.95;
|
||||
|
||||
// Reduziere Lebenszeit (2% pro Frame)
|
||||
particle.life -= 0.02;
|
||||
});
|
||||
|
||||
// Entferne "tote" Partikel (life <= 0) aus dem Array
|
||||
explosionParticles = explosionParticles.filter(particle => particle.life > 0);
|
||||
}
|
||||
|
||||
// ======================== EXPLOSIONS-ZEICHNUNG ========================
|
||||
/**
|
||||
* Zeichnet alle Explosions-Partikel.
|
||||
* Verwendet Alpha-Transparenz für Fade-Out Effekt.
|
||||
*
|
||||
* @param {number} currentTime - Aktuelle Zeit (wird hier nicht verwendet)
|
||||
*/
|
||||
function drawExplosion(currentTime) {
|
||||
// Zeichne jeden Partikel
|
||||
explosionParticles.forEach(particle => {
|
||||
// Setze Transparenz basierend auf Lebenszeit (fade out)
|
||||
ctx.globalAlpha = Math.max(0, particle.life);
|
||||
ctx.fillStyle = particle.color;
|
||||
|
||||
// Zeichne Partikel als Quadrat (zentriert um Position)
|
||||
ctx.fillRect(
|
||||
particle.x - particle.size/2, // X-Position (zentriert)
|
||||
particle.y - particle.size/2, // Y-Position (zentriert)
|
||||
particle.size, // Breite
|
||||
particle.size // Höhe
|
||||
);
|
||||
});
|
||||
|
||||
// Setze Alpha auf Standard zurück für nächste Zeichenoperationen
|
||||
ctx.globalAlpha = 1.0;
|
||||
}
|
||||
|
||||
// ======================== SPIEL NEUSTART ========================
|
||||
/**
|
||||
* Setzt alle Spielvariablen zurück und startet ein neues Spiel.
|
||||
* Wird vom Restart-Button aufgerufen.
|
||||
*/
|
||||
function restartGame() {
|
||||
// Setze Schlange auf Startposition zurück
|
||||
snake = [{x: 10, y: 10}];
|
||||
|
||||
// Keine Bewegung zu Beginn
|
||||
dx = 0;
|
||||
dy = 0;
|
||||
|
||||
// Reset Score und Geschwindigkeit
|
||||
score = 0;
|
||||
moveInterval = 120;
|
||||
|
||||
// Leere Input-Queue und Richtungs-Tracking
|
||||
inputQueue = [];
|
||||
lastDirection = { dx: 0, dy: 0 };
|
||||
|
||||
// Beende Animationen
|
||||
gameOverAnimation = false;
|
||||
explosionParticles = [];
|
||||
|
||||
// Update UI
|
||||
scoreElement.textContent = score;
|
||||
gameRunning = true;
|
||||
gameOverElement.style.display = 'none';
|
||||
|
||||
// Reset besuchte Felder (alle auf 0)
|
||||
visitedGrid = Array(tileCount).fill().map(() => Array(tileCount).fill(0));
|
||||
|
||||
// Generiere neues Essen und starte Game Loop
|
||||
generateFood();
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// ======================== TASTATUR-STEUERUNG ========================
|
||||
/**
|
||||
* Event-Listener für Tastatureingaben.
|
||||
* Unterstützt Pfeiltasten und WASD.
|
||||
* Verwendet eine Queue für responsive Steuerung bei schnellen Eingaben.
|
||||
*/
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ignoriere Eingaben wenn Spiel nicht läuft
|
||||
if (!gameRunning) return;
|
||||
|
||||
let newMove = null;
|
||||
|
||||
// Mappe Tasten zu Bewegungsrichtungen
|
||||
switch(e.key) {
|
||||
case 'ArrowUp': // Pfeil nach oben
|
||||
case 'w': // W-Taste
|
||||
case 'W':
|
||||
newMove = {dx: 0, dy: -1}; // Nach oben
|
||||
break;
|
||||
case 'ArrowDown': // Pfeil nach unten
|
||||
case 's': // S-Taste
|
||||
case 'S':
|
||||
newMove = {dx: 0, dy: 1}; // Nach unten
|
||||
break;
|
||||
case 'ArrowLeft': // Pfeil nach links
|
||||
case 'a': // A-Taste
|
||||
case 'A':
|
||||
newMove = {dx: -1, dy: 0}; // Nach links
|
||||
break;
|
||||
case 'ArrowRight': // Pfeil nach rechts
|
||||
case 'd': // D-Taste
|
||||
case 'D':
|
||||
newMove = {dx: 1, dy: 0}; // Nach rechts
|
||||
break;
|
||||
}
|
||||
|
||||
// Füge gültige Bewegung zur Queue hinzu
|
||||
if (newMove && inputQueue.length < 3) { // Max. 3 Eingaben speichern
|
||||
// Verhindere identische aufeinanderfolgende Eingaben
|
||||
const lastInQueue = inputQueue[inputQueue.length - 1];
|
||||
if (!lastInQueue || lastInQueue.dx !== newMove.dx || lastInQueue.dy !== newMove.dy) {
|
||||
inputQueue.push(newMove);
|
||||
}
|
||||
// Verhindere Standard-Scrolling bei Pfeiltasten
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
// ======================== SPIEL INITIALISIERUNG ========================
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
|
||||
// Generiere erstes Essen
|
||||
generateFood();
|
||||
// Starte Game Loop
|
||||
requestAnimationFrame(gameLoop);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
508
games/arcade/apps/web/static/games/space_defender_game.html
Normal file
|
|
@ -0,0 +1,508 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Space Defender</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: linear-gradient(135deg, #0c0c1e 0%, #1a0033 50%, #000814 100%);
|
||||
font-family: 'Courier New', monospace;
|
||||
color: #00ff88;
|
||||
overflow: hidden;
|
||||
height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
border: 2px solid #00ff88;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 30px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
#gameCanvas {
|
||||
background: linear-gradient(180deg, #000814 0%, #001a2e 100%);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.ui {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
left: 10px;
|
||||
font-size: 18px;
|
||||
text-shadow: 0 0 10px #00ff88;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
padding: 30px;
|
||||
border: 2px solid #ff0044;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 0 30px rgba(255, 0, 68, 0.5);
|
||||
display: none;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
color: #ff0044;
|
||||
font-size: 28px;
|
||||
margin-bottom: 15px;
|
||||
text-shadow: 0 0 15px #ff0044;
|
||||
}
|
||||
|
||||
.restart-btn {
|
||||
background: linear-gradient(45deg, #00ff88, #00cc6a);
|
||||
color: #000;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
font-size: 16px;
|
||||
font-weight: bold;
|
||||
border-radius: 25px;
|
||||
cursor: pointer;
|
||||
margin-top: 15px;
|
||||
transition: all 0.3s ease;
|
||||
box-shadow: 0 4px 15px rgba(0, 255, 136, 0.3);
|
||||
}
|
||||
|
||||
.restart-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(0, 255, 136, 0.5);
|
||||
}
|
||||
|
||||
.controls {
|
||||
margin-top: 20px;
|
||||
text-align: center;
|
||||
color: #888;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="800" height="600"></canvas>
|
||||
<div class="ui">
|
||||
<div>Score: <span id="score">0</span></div>
|
||||
<div>Schwierigkeit: <span id="difficulty">1.0</span></div>
|
||||
<div>Zeit: <span id="time">0</span>s</div>
|
||||
</div>
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2>GAME OVER</h2>
|
||||
<p>Finaler Score: <span id="finalScore">0</span></p>
|
||||
<button class="restart-btn" onclick="restartGame()">Nochmal spielen</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="controls">
|
||||
<p>🎮 Steuerung: A/D oder ←/→ zum Bewegen • LEERTASTE zum Schießen</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'space-defender';
|
||||
|
||||
// Spielzustand
|
||||
let gameState = {
|
||||
player: { x: 375, y: 550, width: 50, height: 30, speed: 8 },
|
||||
bullets: [],
|
||||
enemies: [],
|
||||
particles: [],
|
||||
score: 0,
|
||||
lives: 1,
|
||||
gameRunning: true,
|
||||
keys: {},
|
||||
enemySpawnTimer: 0,
|
||||
level: 1,
|
||||
difficulty: 1,
|
||||
timeAlive: 0
|
||||
};
|
||||
|
||||
// Tasteneingaben
|
||||
document.addEventListener('keydown', (e) => {
|
||||
gameState.keys[e.key.toLowerCase()] = true;
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
gameState.keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
|
||||
// Spieler
|
||||
function updatePlayer() {
|
||||
if (gameState.keys['a'] || gameState.keys['arrowleft']) {
|
||||
gameState.player.x -= gameState.player.speed;
|
||||
}
|
||||
if (gameState.keys['d'] || gameState.keys['arrowright']) {
|
||||
gameState.player.x += gameState.player.speed;
|
||||
}
|
||||
|
||||
// Grenzen
|
||||
gameState.player.x = Math.max(0, Math.min(canvas.width - gameState.player.width, gameState.player.x));
|
||||
|
||||
// Schießen
|
||||
if (gameState.keys[' ']) {
|
||||
shootBullet();
|
||||
gameState.keys[' '] = false; // Verhindert Dauerfeuer
|
||||
}
|
||||
}
|
||||
|
||||
function drawPlayer() {
|
||||
const p = gameState.player;
|
||||
|
||||
// Raumschiff-Design
|
||||
ctx.fillStyle = '#00ff88';
|
||||
ctx.fillRect(p.x + 20, p.y, 10, 20);
|
||||
ctx.fillStyle = '#00cc6a';
|
||||
ctx.fillRect(p.x + 15, p.y + 10, 20, 15);
|
||||
ctx.fillStyle = '#0088ff';
|
||||
ctx.fillRect(p.x + 5, p.y + 20, 10, 10);
|
||||
ctx.fillRect(p.x + 35, p.y + 20, 10, 10);
|
||||
|
||||
// Glowing effect
|
||||
ctx.shadowColor = '#00ff88';
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(p.x + 22, p.y + 2, 6, 8);
|
||||
ctx.shadowBlur = 0;
|
||||
}
|
||||
|
||||
// Projektile
|
||||
function shootBullet() {
|
||||
gameState.bullets.push({
|
||||
x: gameState.player.x + gameState.player.width / 2 - 2,
|
||||
y: gameState.player.y,
|
||||
width: 4,
|
||||
height: 10,
|
||||
speed: 12
|
||||
});
|
||||
}
|
||||
|
||||
function updateBullets() {
|
||||
gameState.bullets = gameState.bullets.filter(bullet => {
|
||||
bullet.y -= bullet.speed;
|
||||
return bullet.y > -bullet.height;
|
||||
});
|
||||
}
|
||||
|
||||
function drawBullets() {
|
||||
gameState.bullets.forEach(bullet => {
|
||||
ctx.fillStyle = '#ffff00';
|
||||
ctx.shadowColor = '#ffff00';
|
||||
ctx.shadowBlur = 5;
|
||||
ctx.fillRect(bullet.x, bullet.y, bullet.width, bullet.height);
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Gegner
|
||||
function spawnEnemy() {
|
||||
const baseSpeed = 1 + (gameState.difficulty * 0.3);
|
||||
const types = [
|
||||
{ width: 40, height: 30, speed: baseSpeed * 1.2, color: '#ff0044', points: 10 },
|
||||
{ width: 30, height: 25, speed: baseSpeed * 1.8, color: '#ff8800', points: 15 },
|
||||
{ width: 35, height: 35, speed: baseSpeed * 0.8, color: '#8800ff', points: 20 },
|
||||
{ width: 25, height: 20, speed: baseSpeed * 2.5, color: '#00ff44', points: 25 }, // Schneller grüner Gegner
|
||||
{ width: 50, height: 40, speed: baseSpeed * 0.6, color: '#ff00ff', points: 35 } // Großer langsamer Boss
|
||||
];
|
||||
|
||||
// Mehr Gegnertypen freischalten mit steigender Schwierigkeit
|
||||
const availableTypes = types.slice(0, Math.min(3 + Math.floor(gameState.difficulty / 2), types.length));
|
||||
const type = availableTypes[Math.floor(Math.random() * availableTypes.length)];
|
||||
|
||||
gameState.enemies.push({
|
||||
x: Math.random() * (canvas.width - type.width),
|
||||
y: -type.height,
|
||||
...type
|
||||
});
|
||||
}
|
||||
|
||||
function updateEnemies() {
|
||||
// Schwierigkeit erhöhen über Zeit
|
||||
gameState.timeAlive++;
|
||||
if (gameState.timeAlive % 300 === 0) { // Alle 5 Sekunden
|
||||
gameState.difficulty += 0.5;
|
||||
}
|
||||
|
||||
// Dynamische Spawn-Rate basierend auf Schwierigkeit
|
||||
const spawnRate = Math.max(8 - Math.floor(gameState.difficulty), 3);
|
||||
gameState.enemySpawnTimer++;
|
||||
|
||||
if (gameState.enemySpawnTimer > spawnRate) {
|
||||
spawnEnemy();
|
||||
|
||||
// Bei höherer Schwierigkeit manchmal 2 Gegner gleichzeitig spawnen
|
||||
if (gameState.difficulty > 3 && Math.random() < 0.3) {
|
||||
spawnEnemy();
|
||||
}
|
||||
|
||||
// Bei sehr hoher Schwierigkeit gelegentlich 3 Gegner
|
||||
if (gameState.difficulty > 6 && Math.random() < 0.15) {
|
||||
spawnEnemy();
|
||||
}
|
||||
|
||||
gameState.enemySpawnTimer = 0;
|
||||
}
|
||||
|
||||
// Gegner bewegen
|
||||
gameState.enemies = gameState.enemies.filter(enemy => {
|
||||
enemy.y += enemy.speed;
|
||||
|
||||
// Kollision mit Spieler
|
||||
if (isColliding(enemy, gameState.player)) {
|
||||
createExplosion(enemy.x + enemy.width/2, enemy.y + enemy.height/2);
|
||||
gameState.lives = 0; // Sofortiges Game Over
|
||||
return false;
|
||||
}
|
||||
|
||||
return enemy.y < canvas.height + enemy.height;
|
||||
});
|
||||
}
|
||||
|
||||
function drawEnemies() {
|
||||
gameState.enemies.forEach(enemy => {
|
||||
ctx.fillStyle = enemy.color;
|
||||
ctx.shadowColor = enemy.color;
|
||||
ctx.shadowBlur = 8;
|
||||
|
||||
// Alien-Design
|
||||
ctx.fillRect(enemy.x + 5, enemy.y, enemy.width - 10, enemy.height - 5);
|
||||
ctx.fillRect(enemy.x, enemy.y + 10, enemy.width, enemy.height - 15);
|
||||
|
||||
// Augen
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(enemy.x + 8, enemy.y + 5, 6, 6);
|
||||
ctx.fillRect(enemy.x + enemy.width - 14, enemy.y + 5, 6, 6);
|
||||
|
||||
ctx.shadowBlur = 0;
|
||||
});
|
||||
}
|
||||
|
||||
// Kollisionserkennung
|
||||
function isColliding(rect1, rect2) {
|
||||
return rect1.x < rect2.x + rect2.width &&
|
||||
rect1.x + rect1.width > rect2.x &&
|
||||
rect1.y < rect2.y + rect2.height &&
|
||||
rect1.y + rect1.height > rect2.y;
|
||||
}
|
||||
|
||||
// Kollisionen zwischen Projektilen und Gegnern
|
||||
function checkCollisions() {
|
||||
gameState.bullets.forEach((bullet, bulletIndex) => {
|
||||
gameState.enemies.forEach((enemy, enemyIndex) => {
|
||||
if (isColliding(bullet, enemy)) {
|
||||
// Explosion
|
||||
createExplosion(enemy.x + enemy.width/2, enemy.y + enemy.height/2);
|
||||
|
||||
// Score und Entfernung
|
||||
gameState.score += Math.floor(enemy.points * gameState.difficulty);
|
||||
|
||||
// Sende Score Update für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'SCORE_UPDATE',
|
||||
data: { score: gameState.score }
|
||||
}, '*');
|
||||
|
||||
gameState.bullets.splice(bulletIndex, 1);
|
||||
gameState.enemies.splice(enemyIndex, 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Partikeleffekte
|
||||
function createExplosion(x, y) {
|
||||
for (let i = 0; i < 10; i++) {
|
||||
gameState.particles.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: (Math.random() - 0.5) * 8,
|
||||
vy: (Math.random() - 0.5) * 8,
|
||||
life: 30,
|
||||
color: `hsl(${Math.random() * 60 + 10}, 100%, 60%)`
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function updateParticles() {
|
||||
gameState.particles = gameState.particles.filter(particle => {
|
||||
particle.x += particle.vx;
|
||||
particle.y += particle.vy;
|
||||
particle.life--;
|
||||
particle.vx *= 0.98;
|
||||
particle.vy *= 0.98;
|
||||
return particle.life > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function drawParticles() {
|
||||
gameState.particles.forEach(particle => {
|
||||
ctx.globalAlpha = particle.life / 30;
|
||||
ctx.fillStyle = particle.color;
|
||||
ctx.fillRect(particle.x, particle.y, 3, 3);
|
||||
ctx.globalAlpha = 1;
|
||||
});
|
||||
}
|
||||
|
||||
// Hintergrund mit Sternen
|
||||
function drawBackground() {
|
||||
ctx.fillStyle = '#000814';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Bewegende Sterne
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = (i * 7 + Date.now() * 0.01) % canvas.width;
|
||||
const y = (i * 11 + Date.now() * 0.005) % canvas.height;
|
||||
const opacity = Math.sin(Date.now() * 0.001 + i) * 0.5 + 0.5;
|
||||
|
||||
ctx.globalAlpha = opacity * 0.7;
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// UI Update
|
||||
function updateUI() {
|
||||
document.getElementById('score').textContent = gameState.score;
|
||||
document.getElementById('difficulty').textContent = gameState.difficulty.toFixed(1);
|
||||
document.getElementById('time').textContent = Math.floor(gameState.timeAlive / 60);
|
||||
}
|
||||
|
||||
// Game Over
|
||||
function gameOver() {
|
||||
gameState.gameRunning = false;
|
||||
document.getElementById('finalScore').textContent = gameState.score;
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
|
||||
// Sende Game Over Event mit Score für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: { score: gameState.score }
|
||||
}, '*');
|
||||
|
||||
// Achievement prüfen
|
||||
if (gameState.score >= 1000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'space-defender-1000',
|
||||
name: 'Weltraum Veteran',
|
||||
description: '1000 Punkte erreicht!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (gameState.score >= 5000) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'space-defender-5000',
|
||||
name: 'Weltraum Legende',
|
||||
description: '5000 Punkte erreicht!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (gameState.timeAlive >= 300) { // 5 Minuten
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievement: {
|
||||
id: 'space-survivor',
|
||||
name: 'Überlebenskünstler',
|
||||
description: '5 Minuten überlebt!'
|
||||
}
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
function restartGame() {
|
||||
gameState = {
|
||||
player: { x: 375, y: 550, width: 50, height: 30, speed: 8 },
|
||||
bullets: [],
|
||||
enemies: [],
|
||||
particles: [],
|
||||
score: 0,
|
||||
lives: 1,
|
||||
gameRunning: true,
|
||||
keys: {},
|
||||
enemySpawnTimer: 0,
|
||||
level: 1,
|
||||
difficulty: 1,
|
||||
timeAlive: 0
|
||||
};
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Hauptspiel-Loop
|
||||
function gameLoop() {
|
||||
if (!gameState.gameRunning) return;
|
||||
|
||||
// Update
|
||||
updatePlayer();
|
||||
updateBullets();
|
||||
updateEnemies();
|
||||
updateParticles();
|
||||
checkCollisions();
|
||||
updateUI();
|
||||
|
||||
// Game Over prüfen
|
||||
if (gameState.lives <= 0) {
|
||||
gameOver();
|
||||
return;
|
||||
}
|
||||
|
||||
// Zeichnen
|
||||
drawBackground();
|
||||
drawPlayer();
|
||||
drawBullets();
|
||||
drawEnemies();
|
||||
drawParticles();
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Sende Game Loaded Event für Statistiken
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
|
||||
// Spiel starten
|
||||
gameLoop();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
791
games/arcade/apps/web/static/games/turbo_racer.html
Normal file
|
|
@ -0,0 +1,791 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turbo Racer</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0a;
|
||||
color: #fff;
|
||||
font-family: 'Arial Black', sans-serif;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.game-container {
|
||||
position: relative;
|
||||
filter: drop-shadow(0 0 30px rgba(255, 0, 100, 0.3));
|
||||
}
|
||||
|
||||
canvas {
|
||||
border: 3px solid #ff0066;
|
||||
background: #1a1a1a;
|
||||
box-shadow:
|
||||
inset 0 0 50px rgba(255, 0, 100, 0.1),
|
||||
0 0 30px rgba(0, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.ui {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
font-size: 20px;
|
||||
z-index: 10;
|
||||
text-shadow: 2px 2px 4px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.speed-meter {
|
||||
color: #00ff88;
|
||||
font-size: 28px;
|
||||
margin-bottom: 10px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.lap-counter {
|
||||
color: #ffcc00;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.position {
|
||||
color: #ff0066;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.boost-bar {
|
||||
position: absolute;
|
||||
bottom: 30px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 200px;
|
||||
height: 20px;
|
||||
background: rgba(0,0,0,0.5);
|
||||
border: 2px solid #00ffff;
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.boost-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #00ffff, #ff00ff);
|
||||
width: 100%;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.start-screen {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
background: rgba(0,0,0,0.9);
|
||||
padding: 40px;
|
||||
border: 3px solid #ff0066;
|
||||
border-radius: 20px;
|
||||
box-shadow: 0 0 30px rgba(255,0,100,0.5);
|
||||
}
|
||||
|
||||
.start-screen h1 {
|
||||
font-size: 48px;
|
||||
margin-bottom: 20px;
|
||||
background: linear-gradient(45deg, #ff0066, #00ffff);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
text-shadow: none;
|
||||
}
|
||||
|
||||
.start-screen p {
|
||||
font-size: 18px;
|
||||
margin-bottom: 30px;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
button {
|
||||
padding: 15px 40px;
|
||||
font-size: 24px;
|
||||
font-weight: bold;
|
||||
background: linear-gradient(45deg, #ff0066, #ff3388);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 2px;
|
||||
}
|
||||
|
||||
button:hover {
|
||||
transform: scale(1.1);
|
||||
box-shadow: 0 0 20px rgba(255,0,100,0.5);
|
||||
}
|
||||
|
||||
button:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.game-over {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
text-align: center;
|
||||
background: rgba(0,0,0,0.95);
|
||||
padding: 40px;
|
||||
border: 3px solid #ffcc00;
|
||||
border-radius: 20px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.game-over h2 {
|
||||
font-size: 36px;
|
||||
margin-bottom: 20px;
|
||||
color: #ffcc00;
|
||||
}
|
||||
|
||||
.final-time {
|
||||
font-size: 48px;
|
||||
color: #00ff88;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.controls-info {
|
||||
position: absolute;
|
||||
bottom: 20px;
|
||||
right: 20px;
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
text-align: right;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="game-container">
|
||||
<canvas id="gameCanvas" width="800" height="600"></canvas>
|
||||
|
||||
<div class="ui">
|
||||
<div class="speed-meter">
|
||||
<span id="speed">0</span> km/h
|
||||
</div>
|
||||
<div class="lap-counter">
|
||||
Runde: <span id="lap">0</span>
|
||||
</div>
|
||||
<div class="position">
|
||||
Zeit: <span id="time">0:00</span>
|
||||
</div>
|
||||
<div class="best-lap" style="color: #00ff88; font-size: 18px;">
|
||||
Beste Runde: <span id="bestLap">--:--</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="boost-bar">
|
||||
<div class="boost-fill" id="boostFill"></div>
|
||||
</div>
|
||||
|
||||
<div class="start-screen" id="startScreen">
|
||||
<h1>TURBO RACER</h1>
|
||||
<p>Drift durch die Kurven und stelle Bestzeiten auf!</p>
|
||||
<p>🏁 Endlos-Runden • ⚡ Nitro-Boost • 🏆 Drift-Punkte</p>
|
||||
<button onclick="startGame()">RENNEN STARTEN</button>
|
||||
</div>
|
||||
|
||||
<div class="game-over" id="gameOver">
|
||||
<h2 id="gameOverTitle">ZEIT-HERAUSFORDERUNG BEENDET!</h2>
|
||||
<div class="final-time" id="finalTime">0 Runden</div>
|
||||
<p id="finalPosition">Beste Runde: --:--</p>
|
||||
<button onclick="restartGame()">NOCHMAL FAHREN</button>
|
||||
</div>
|
||||
|
||||
<div class="controls-info">
|
||||
↑↓ oder WS: Gas/Bremse | ←→ oder AD: Lenken | Leertaste: Boost
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Game ID für Statistiken
|
||||
const GAME_ID = 'turbo-racer';
|
||||
|
||||
const canvas = document.getElementById('gameCanvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Spielvariablen
|
||||
let gameRunning = false;
|
||||
let raceStartTime = 0;
|
||||
let currentLap = 1;
|
||||
|
||||
// Eingabe
|
||||
const keys = {};
|
||||
|
||||
// Spieler Auto mit Drift-Physik
|
||||
const player = {
|
||||
x: 400,
|
||||
y: 400,
|
||||
angle: -Math.PI / 2, // Nach oben zeigend
|
||||
velocity: { x: 0, y: 0 },
|
||||
speed: 0,
|
||||
maxSpeed: 4, // Noch langsamer
|
||||
acceleration: 0.2, // Noch sanftere Beschleunigung
|
||||
deceleration: 0.15,
|
||||
turnSpeed: 0.08, // Noch weniger aggressiv
|
||||
width: 30,
|
||||
height: 20,
|
||||
boost: 100,
|
||||
boosting: false,
|
||||
color: '#ff0066',
|
||||
trail: [],
|
||||
driftFactor: 0,
|
||||
driftAngle: 0,
|
||||
lapCount: 0,
|
||||
bestLapTime: null,
|
||||
currentLapStart: 0,
|
||||
lastAngle: 0,
|
||||
crossed: false
|
||||
};
|
||||
|
||||
// Streckenmitte und Radien
|
||||
const trackCenter = { x: 400, y: 300 };
|
||||
const outerRadius = 250;
|
||||
const innerRadius = 120;
|
||||
const trackWidth = outerRadius - innerRadius;
|
||||
|
||||
// Zeit und Runden
|
||||
let currentTime = 0;
|
||||
let lastLapTime = 0;
|
||||
let bestLapTime = Infinity;
|
||||
|
||||
// Partikel für Effekte
|
||||
const particles = [];
|
||||
|
||||
// Sterne für Geschwindigkeitseffekt
|
||||
const stars = [];
|
||||
for (let i = 0; i < 50; i++) {
|
||||
stars.push({
|
||||
x: Math.random() * canvas.width,
|
||||
y: Math.random() * canvas.height,
|
||||
size: Math.random() * 2
|
||||
});
|
||||
}
|
||||
|
||||
// Input Handling
|
||||
document.addEventListener('keydown', (e) => {
|
||||
keys[e.key.toLowerCase()] = true;
|
||||
if (e.key === ' ') e.preventDefault();
|
||||
});
|
||||
|
||||
document.addEventListener('keyup', (e) => {
|
||||
keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
|
||||
// Auto zeichnen
|
||||
function drawCar(car, isPlayer = false) {
|
||||
ctx.save();
|
||||
ctx.translate(car.x, car.y);
|
||||
ctx.rotate(car.angle);
|
||||
|
||||
// Drift-Rauch bei starkem Drift
|
||||
if (isPlayer && player.driftFactor > 0.5) {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = player.driftFactor * 0.3;
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.beginPath();
|
||||
ctx.arc(-car.width/2 - 5, 0, 8, 0, Math.PI * 2);
|
||||
ctx.arc(car.width/2 + 5, 0, 8, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Auto Body
|
||||
ctx.fillStyle = car.color;
|
||||
ctx.fillRect(-car.width/2, -car.height/2, car.width, car.height);
|
||||
|
||||
// Windschutzscheibe
|
||||
ctx.fillStyle = 'rgba(100,100,100,0.8)';
|
||||
ctx.fillRect(-car.width/4, -car.height/3, car.width/2, car.height/3);
|
||||
|
||||
// Räder
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.fillRect(-car.width/2 - 2, -car.height/2 + 2, 4, 6);
|
||||
ctx.fillRect(-car.width/2 - 2, car.height/2 - 8, 4, 6);
|
||||
ctx.fillRect(car.width/2 - 2, -car.height/2 + 2, 4, 6);
|
||||
ctx.fillRect(car.width/2 - 2, car.height/2 - 8, 4, 6);
|
||||
|
||||
// Spieler-Indikator
|
||||
if (isPlayer) {
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(0, -car.height);
|
||||
ctx.lineTo(-5, -car.height - 10);
|
||||
ctx.lineTo(5, -car.height - 10);
|
||||
ctx.closePath();
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
|
||||
// Kreisförmige Strecke zeichnen
|
||||
function drawTrack() {
|
||||
// Hintergrund
|
||||
ctx.fillStyle = '#0a3d0a';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Äußerer Kreis (Strecke)
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.beginPath();
|
||||
ctx.arc(trackCenter.x, trackCenter.y, outerRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Innerer Kreis (Gras)
|
||||
ctx.fillStyle = '#0a3d0a';
|
||||
ctx.beginPath();
|
||||
ctx.arc(trackCenter.x, trackCenter.y, innerRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Streckenbegrenzungen
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 4;
|
||||
ctx.setLineDash([20, 10]);
|
||||
|
||||
// Äußere Linie
|
||||
ctx.beginPath();
|
||||
ctx.arc(trackCenter.x, trackCenter.y, outerRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
// Innere Linie
|
||||
ctx.beginPath();
|
||||
ctx.arc(trackCenter.x, trackCenter.y, innerRadius, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
|
||||
ctx.setLineDash([]);
|
||||
|
||||
// Start/Ziel Linie
|
||||
ctx.strokeStyle = '#fff';
|
||||
ctx.lineWidth = 8;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(trackCenter.x + innerRadius, trackCenter.y);
|
||||
ctx.lineTo(trackCenter.x + outerRadius, trackCenter.y);
|
||||
ctx.stroke();
|
||||
|
||||
// Schachbrettmuster auf Ziellinie
|
||||
const lineWidth = outerRadius - innerRadius;
|
||||
for (let i = 0; i < lineWidth / 10; i++) {
|
||||
for (let j = 0; j < 2; j++) {
|
||||
if ((i + j) % 2 === 0) {
|
||||
ctx.fillStyle = '#fff';
|
||||
ctx.fillRect(trackCenter.x + innerRadius + i * 10, trackCenter.y - 4 + j * 4, 10, 4);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Driftspuren
|
||||
if (player.driftFactor > 0.3) {
|
||||
ctx.globalAlpha = player.driftFactor * 0.3;
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
player.trail.forEach((point, i) => {
|
||||
if (i > 0) {
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(player.trail[i-1].x, player.trail[i-1].y);
|
||||
ctx.lineTo(point.x, point.y);
|
||||
ctx.stroke();
|
||||
}
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Kollisionserkennung mit kreisförmiger Strecke
|
||||
function checkTrackCollision() {
|
||||
const dx = player.x - trackCenter.x;
|
||||
const dy = player.y - trackCenter.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Prüfe ob Auto außerhalb der Strecke
|
||||
if (distance > outerRadius - 15 || distance < innerRadius + 15) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Rundenzählung
|
||||
function checkLapCrossing() {
|
||||
// Prüfe ob Ziellinie überquert wurde
|
||||
const angle = Math.atan2(player.y - trackCenter.y, player.x - trackCenter.x);
|
||||
const normalizedAngle = angle < 0 ? angle + Math.PI * 2 : angle;
|
||||
|
||||
// Ziellinie ist bei 0° (rechts)
|
||||
if (normalizedAngle < 0.1 && player.lastAngle > 6.2) {
|
||||
if (!player.crossed) {
|
||||
player.crossed = true;
|
||||
player.lapCount++;
|
||||
|
||||
// Rundenzeit berechnen
|
||||
if (player.currentLapStart > 0) {
|
||||
const lapTime = currentTime - player.currentLapStart;
|
||||
if (lapTime < bestLapTime) {
|
||||
bestLapTime = lapTime;
|
||||
document.getElementById('bestLap').textContent = formatTime(bestLapTime);
|
||||
}
|
||||
}
|
||||
player.currentLapStart = currentTime;
|
||||
|
||||
document.getElementById('lap').textContent = player.lapCount;
|
||||
|
||||
// Effekt für neue Runde
|
||||
createParticles(player.x, player.y, '#00ff88');
|
||||
}
|
||||
} else if (normalizedAngle > 0.2) {
|
||||
player.crossed = false;
|
||||
}
|
||||
|
||||
player.lastAngle = normalizedAngle;
|
||||
}
|
||||
|
||||
// Hilfsfunktion für Linien-Kreuzung
|
||||
function lineIntersection(x1, y1, x2, y2, x3, y3, x4, y4) {
|
||||
const denom = (x1 - x2) * (y3 - y4) - (y1 - y2) * (x3 - x4);
|
||||
if (denom === 0) return false;
|
||||
|
||||
const t = ((x1 - x3) * (y3 - y4) - (y1 - y3) * (x3 - x4)) / denom;
|
||||
const u = -((x1 - x2) * (y1 - y3) - (y1 - y2) * (x1 - x3)) / denom;
|
||||
|
||||
return t >= 0 && t <= 1 && u >= 0 && u <= 1;
|
||||
}
|
||||
|
||||
// Spieler Update mit Drift-Physik
|
||||
function updatePlayer() {
|
||||
const oldAngle = player.angle;
|
||||
|
||||
// Steuerung
|
||||
if (keys['arrowup'] || keys['w']) {
|
||||
player.speed = Math.min(player.speed + player.acceleration, player.maxSpeed);
|
||||
} else if (keys['arrowdown'] || keys['s']) {
|
||||
player.speed = Math.max(player.speed - player.deceleration * 2, -player.maxSpeed / 2);
|
||||
} else {
|
||||
// Automatisches Abbremsen
|
||||
if (player.speed > 0) {
|
||||
player.speed = Math.max(0, player.speed - player.deceleration);
|
||||
} else {
|
||||
player.speed = Math.min(0, player.speed + player.deceleration);
|
||||
}
|
||||
}
|
||||
|
||||
// Lenken mit Drift
|
||||
let turnAmount = 0;
|
||||
if (Math.abs(player.speed) > 0.5) {
|
||||
if (keys['arrowleft'] || keys['a']) {
|
||||
turnAmount = -player.turnSpeed * (player.speed / player.maxSpeed);
|
||||
player.angle += turnAmount;
|
||||
}
|
||||
if (keys['arrowright'] || keys['d']) {
|
||||
turnAmount = player.turnSpeed * (player.speed / player.maxSpeed);
|
||||
player.angle += turnAmount;
|
||||
}
|
||||
}
|
||||
|
||||
// Drift-Berechnung
|
||||
const angleDiff = Math.abs(player.angle - oldAngle);
|
||||
if (angleDiff > 0.04 && player.speed > 2.5) { // Angepasst für langsamere Geschwindigkeit
|
||||
player.driftFactor = Math.min(1, player.driftFactor + 0.1);
|
||||
player.driftAngle = player.angle - Math.atan2(player.velocity.y, player.velocity.x);
|
||||
} else {
|
||||
player.driftFactor = Math.max(0, player.driftFactor - 0.05);
|
||||
}
|
||||
|
||||
// Velocity mit Drift
|
||||
const targetVx = Math.cos(player.angle) * player.speed;
|
||||
const targetVy = Math.sin(player.angle) * player.speed;
|
||||
|
||||
// Drift-Effekt: Velocity passt sich langsamer an die Richtung an
|
||||
const driftStrength = 0.15 + (1 - player.driftFactor) * 0.1;
|
||||
player.velocity.x += (targetVx - player.velocity.x) * driftStrength;
|
||||
player.velocity.y += (targetVy - player.velocity.y) * driftStrength;
|
||||
|
||||
// Boost
|
||||
if (keys[' '] && player.boost > 0 && player.speed > 2) {
|
||||
player.boosting = true;
|
||||
player.boost -= 2;
|
||||
player.speed = Math.min(player.speed + 0.2, player.maxSpeed * 1.4); // Angepasster Boost für langsamere Geschwindigkeit
|
||||
|
||||
// Boost Partikel
|
||||
createParticles(
|
||||
player.x - Math.cos(player.angle) * 20,
|
||||
player.y - Math.sin(player.angle) * 20,
|
||||
'#00ffff'
|
||||
);
|
||||
} else {
|
||||
player.boosting = false;
|
||||
// Boost regeneriert langsam
|
||||
player.boost = Math.min(100, player.boost + 0.3);
|
||||
}
|
||||
|
||||
// Position update mit Velocity
|
||||
const oldX = player.x;
|
||||
const oldY = player.y;
|
||||
player.x += player.velocity.x;
|
||||
player.y += player.velocity.y;
|
||||
|
||||
// Kollision prüfen mit Abprall-Effekt
|
||||
if (checkTrackCollision()) {
|
||||
// Zurück zur alten Position
|
||||
player.x = oldX;
|
||||
player.y = oldY;
|
||||
|
||||
// Berechne Abprall-Richtung vom Streckenzentrum
|
||||
const dx = player.x - trackCenter.x;
|
||||
const dy = player.y - trackCenter.y;
|
||||
const distance = Math.sqrt(dx * dx + dy * dy);
|
||||
|
||||
// Normalisiere und kehre Richtung um
|
||||
let bounceX = dx / distance;
|
||||
let bounceY = dy / distance;
|
||||
|
||||
// Wenn zu nah am Inneren, kehre um
|
||||
if (distance < innerRadius + 15) {
|
||||
bounceX = -bounceX;
|
||||
bounceY = -bounceY;
|
||||
}
|
||||
|
||||
// Wende Abprall-Kraft an
|
||||
player.velocity.x = bounceX * player.speed * 0.6;
|
||||
player.velocity.y = bounceY * player.speed * 0.6;
|
||||
player.speed *= 0.5;
|
||||
|
||||
// Leichte Drehung beim Aufprall
|
||||
player.angle += (Math.random() - 0.5) * 0.3;
|
||||
|
||||
// Kollisions-Partikel
|
||||
createParticles(player.x, player.y, '#ff0066');
|
||||
}
|
||||
|
||||
// Trail für Driftspuren
|
||||
if (player.driftFactor > 0.3 && player.speed > 3) {
|
||||
player.trail.push({x: player.x, y: player.y, life: 30});
|
||||
if (player.trail.length > 100) {
|
||||
player.trail.shift();
|
||||
}
|
||||
}
|
||||
|
||||
// Rundenzählung
|
||||
checkLapCrossing();
|
||||
|
||||
// UI Update
|
||||
document.getElementById('speed').textContent = Math.floor(Math.abs(player.speed) * 30); // Angepasst für noch langsamere Geschwindigkeit
|
||||
document.getElementById('boostFill').style.width = player.boost + '%';
|
||||
|
||||
// Drift-Anzeige
|
||||
if (player.driftFactor > 0.5) {
|
||||
createParticles(player.x - 10, player.y - 10, '#ffcc00');
|
||||
}
|
||||
}
|
||||
|
||||
// Zeit formatieren
|
||||
function formatTime(ms) {
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
const milliseconds = Math.floor((ms % 1000) / 10);
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}.${milliseconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Partikel erstellen
|
||||
function createParticles(x, y, color) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
particles.push({
|
||||
x: x,
|
||||
y: y,
|
||||
vx: (Math.random() - 0.5) * 4,
|
||||
vy: (Math.random() - 0.5) * 4,
|
||||
life: 20,
|
||||
color: color
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Partikel update
|
||||
function updateParticles() {
|
||||
particles.forEach(p => {
|
||||
p.x += p.vx;
|
||||
p.y += p.vy;
|
||||
p.life--;
|
||||
p.vx *= 0.95;
|
||||
p.vy *= 0.95;
|
||||
});
|
||||
|
||||
// Alte Partikel entfernen
|
||||
for (let i = particles.length - 1; i >= 0; i--) {
|
||||
if (particles[i].life <= 0) {
|
||||
particles.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Partikel zeichnen
|
||||
function drawParticles() {
|
||||
particles.forEach(p => {
|
||||
ctx.globalAlpha = p.life / 20;
|
||||
ctx.fillStyle = p.color;
|
||||
ctx.fillRect(p.x - 2, p.y - 2, 4, 4);
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Sterne für Geschwindigkeitseffekt
|
||||
function drawStars() {
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.3)';
|
||||
stars.forEach(star => {
|
||||
// Bewege Sterne basierend auf Spielergeschwindigkeit
|
||||
star.y += player.speed * 0.5;
|
||||
if (star.y > canvas.height) {
|
||||
star.y = 0;
|
||||
star.x = Math.random() * canvas.width;
|
||||
}
|
||||
|
||||
ctx.globalAlpha = player.speed / player.maxSpeed * 0.5;
|
||||
ctx.fillRect(star.x, star.y, star.size, star.size * 3);
|
||||
});
|
||||
ctx.globalAlpha = 1;
|
||||
}
|
||||
|
||||
// Zeit-Update
|
||||
function updateTime() {
|
||||
if (gameRunning) {
|
||||
currentTime += 16; // ~60 FPS
|
||||
document.getElementById('time').textContent = formatTime(currentTime);
|
||||
}
|
||||
}
|
||||
|
||||
// Game Loop
|
||||
function gameLoop() {
|
||||
if (!gameRunning) return;
|
||||
|
||||
// Clear
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Draw
|
||||
drawStars();
|
||||
drawTrack();
|
||||
|
||||
// Update
|
||||
updatePlayer();
|
||||
updateParticles();
|
||||
updateTime();
|
||||
|
||||
// Draw car
|
||||
drawCar(player, true);
|
||||
|
||||
// Effects
|
||||
drawParticles();
|
||||
|
||||
requestAnimationFrame(gameLoop);
|
||||
}
|
||||
|
||||
// Spiel starten
|
||||
function startGame() {
|
||||
document.getElementById('startScreen').style.display = 'none';
|
||||
gameRunning = true;
|
||||
currentTime = 0;
|
||||
|
||||
// Reset Spieler
|
||||
player.x = trackCenter.x + (innerRadius + outerRadius) / 2;
|
||||
player.y = trackCenter.y;
|
||||
player.angle = -Math.PI / 2; // Nach oben zeigend
|
||||
player.velocity = { x: 0, y: 0 };
|
||||
player.speed = 0;
|
||||
player.boost = 100;
|
||||
player.lapCount = 0;
|
||||
player.currentLapStart = 0;
|
||||
player.bestLapTime = null;
|
||||
player.trail = [];
|
||||
player.driftFactor = 0;
|
||||
player.lastAngle = 0;
|
||||
player.crossed = false;
|
||||
|
||||
bestLapTime = Infinity;
|
||||
document.getElementById('lap').textContent = '0';
|
||||
document.getElementById('bestLap').textContent = '--:--';
|
||||
|
||||
// Event senden
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_LOADED',
|
||||
gameId: GAME_ID
|
||||
}, '*');
|
||||
|
||||
gameLoop();
|
||||
}
|
||||
|
||||
// Rennen beenden (optional - könnte nach X Runden aufgerufen werden)
|
||||
function endRace() {
|
||||
gameRunning = false;
|
||||
|
||||
document.getElementById('gameOverTitle').textContent = '🏆 ZEIT-HERAUSFORDERUNG BEENDET!';
|
||||
document.getElementById('finalTime').textContent = `${player.lapCount} Runden`;
|
||||
document.getElementById('finalPosition').textContent =
|
||||
`Beste Runde: ${bestLapTime === Infinity ? '--:--' : formatTime(bestLapTime)}`;
|
||||
document.getElementById('gameOver').style.display = 'block';
|
||||
|
||||
// Score basierend auf bester Rundenzeit
|
||||
const score = bestLapTime === Infinity ? 0 : Math.max(0, 50000 - bestLapTime);
|
||||
|
||||
// Events senden
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'GAME_OVER',
|
||||
data: {
|
||||
score: Math.floor(score),
|
||||
laps: player.lapCount,
|
||||
bestLap: bestLapTime
|
||||
}
|
||||
}, '*');
|
||||
|
||||
// Achievements
|
||||
if (bestLapTime < 15000) { // Unter 15 Sekunden
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'drift_master',
|
||||
name: 'Drift Master',
|
||||
description: 'Schaffe eine Runde unter 15 Sekunden',
|
||||
icon: '🏎️'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
|
||||
if (player.lapCount >= 10) {
|
||||
window.parent.postMessage({
|
||||
type: 'GAME_EVENT',
|
||||
gameId: GAME_ID,
|
||||
event: 'ACHIEVEMENT_UNLOCKED',
|
||||
data: {
|
||||
achievementId: 'endurance_racer',
|
||||
name: 'Ausdauer-Rennfahrer',
|
||||
description: 'Fahre 10 Runden in einer Session',
|
||||
icon: '🎯'
|
||||
}
|
||||
}, '*');
|
||||
}
|
||||
}
|
||||
|
||||
// Neustart
|
||||
function restartGame() {
|
||||
document.getElementById('gameOver').style.display = 'none';
|
||||
startGame();
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1274
games/arcade/apps/web/static/games/word_scramble.html
Normal file
BIN
games/arcade/apps/web/static/screenshots/asteroid-dash.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
games/arcade/apps/web/static/screenshots/balloon-pop.jpg
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
games/arcade/apps/web/static/screenshots/bounce-catch.jpg
Normal file
|
After Width: | Height: | Size: 40 KiB |
BIN
games/arcade/apps/web/static/screenshots/click-race.jpg
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
games/arcade/apps/web/static/screenshots/color-memory.jpg
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
games/arcade/apps/web/static/screenshots/fish-catcher.jpg
Normal file
|
After Width: | Height: | Size: 68 KiB |
BIN
games/arcade/apps/web/static/screenshots/gravity-painter.jpg
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
games/arcade/apps/web/static/screenshots/neon-maze-runner.jpg
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
games/arcade/apps/web/static/screenshots/reaction-test.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
games/arcade/apps/web/static/screenshots/rhythm-defender.jpg
Normal file
|
After Width: | Height: | Size: 83 KiB |
BIN
games/arcade/apps/web/static/screenshots/snake.jpg
Normal file
|
After Width: | Height: | Size: 69 KiB |
BIN
games/arcade/apps/web/static/screenshots/space-defenders.jpg
Normal file
|
After Width: | Height: | Size: 62 KiB |
21
games/arcade/apps/web/svelte.config.js
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
|
||||
kit: {
|
||||
adapter: adapter({
|
||||
out: 'build',
|
||||
}),
|
||||
prerender: {
|
||||
handleHttpError: ({ path, message }) => {
|
||||
if (path === '/favicon.png') return;
|
||||
throw new Error(message);
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
14
games/arcade/apps/web/tsconfig.json
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
33
games/arcade/apps/web/vite.config.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
import { SvelteKitPWA } from '@vite-pwa/sveltekit';
|
||||
import { createPWAConfig } from '@manacore/shared-pwa';
|
||||
import { MANACORE_SHARED_PACKAGES, getBuildDefines } from '@manacore/shared-vite-config';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
sveltekit(),
|
||||
SvelteKitPWA(
|
||||
createPWAConfig({
|
||||
name: 'Arcade - Browser-Spiele',
|
||||
shortName: 'Arcade',
|
||||
description: 'AI-powered Browser-Games Plattform',
|
||||
themeColor: '#00ff88',
|
||||
preset: 'minimal',
|
||||
})
|
||||
),
|
||||
],
|
||||
server: {
|
||||
port: 5210,
|
||||
strictPort: true,
|
||||
},
|
||||
ssr: {
|
||||
noExternal: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
optimizeDeps: {
|
||||
exclude: [...MANACORE_SHARED_PACKAGES],
|
||||
},
|
||||
define: {
|
||||
...getBuildDefines(),
|
||||
},
|
||||
});
|
||||