mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:41:09 +02:00
feat(cd): add Matrix notification on deploy failure
Sends a message to a Matrix room when a deploy fails, including the failing services, commit, deployer, and a link to the logs. Requires two GitHub Actions secrets: - DEPLOY_NOTIFY_ROOM_ID: Matrix room ID - DEPLOY_NOTIFY_BOT_TOKEN: Matrix bot access token Skips silently if secrets are not configured. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8c2aa261e8
commit
8511c2ca4c
22 changed files with 2684 additions and 0 deletions
29
.github/workflows/cd-macmini.yml
vendored
29
.github/workflows/cd-macmini.yml
vendored
|
|
@ -48,6 +48,8 @@ env:
|
|||
PROJECT_DIR: /Users/mana/projects/manacore-monorepo
|
||||
COMPOSE_FILE: docker-compose.macmini.yml
|
||||
ENV_FILE: .env.macmini
|
||||
DEPLOY_NOTIFY_ROOM_ID: ${{ secrets.DEPLOY_NOTIFY_ROOM_ID }}
|
||||
DEPLOY_NOTIFY_BOT_TOKEN: ${{ secrets.DEPLOY_NOTIFY_BOT_TOKEN }}
|
||||
PATH: /usr/local/bin:/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin
|
||||
|
||||
jobs:
|
||||
|
|
@ -411,6 +413,33 @@ jobs:
|
|||
push_deploy_metrics "$STATUS" "$DURATION" "$BRANCH" 2>/dev/null || true
|
||||
echo "Deploy tracking recorded: status=$STATUS duration=${DURATION}s"
|
||||
|
||||
- name: Notify on failure
|
||||
if: failure()
|
||||
run: |
|
||||
cd "${{ env.PROJECT_DIR }}"
|
||||
SERVICES="${{ steps.services.outputs.services }}"
|
||||
[ "${{ steps.services.outputs.deploy-all }}" == "true" ] && SERVICES="all"
|
||||
COMMIT_MSG=$(git log -1 --pretty=%s 2>/dev/null | head -c 100)
|
||||
RUN_URL="https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}"
|
||||
|
||||
MSG="⚠️ **Deploy failed**\n\n**Services:** ${SERVICES}\n**Commit:** ${COMMIT_MSG}\n**By:** ${{ github.actor }}\n**[View logs](${RUN_URL})**"
|
||||
|
||||
# Send to Matrix deploy-notifications room via Synapse API
|
||||
ROOM_ID="${DEPLOY_NOTIFY_ROOM_ID:-}"
|
||||
BOT_TOKEN="${DEPLOY_NOTIFY_BOT_TOKEN:-}"
|
||||
if [ -n "$ROOM_ID" ] && [ -n "$BOT_TOKEN" ]; then
|
||||
TXN_ID="deploy-$(date +%s)"
|
||||
curl -s -X PUT \
|
||||
"http://localhost:8008/_matrix/client/v3/rooms/${ROOM_ID}/send/m.room.message/${TXN_ID}" \
|
||||
-H "Authorization: Bearer ${BOT_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"msgtype\":\"m.text\",\"body\":\"Deploy failed: ${SERVICES}\",\"format\":\"org.matrix.custom.html\",\"formatted_body\":\"$(echo -e "$MSG" | sed 's/"/\\"/g')\"}" \
|
||||
|| true
|
||||
echo "Matrix notification sent"
|
||||
else
|
||||
echo "Matrix notification skipped (DEPLOY_NOTIFY_ROOM_ID or DEPLOY_NOTIFY_BOT_TOKEN not set)"
|
||||
fi
|
||||
|
||||
- name: Cleanup old images
|
||||
if: always()
|
||||
run: |
|
||||
|
|
|
|||
27
games/whopixels/.gitignore
vendored
Normal file
27
games/whopixels/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# Umgebungsvariablen
|
||||
.env
|
||||
|
||||
# Node.js
|
||||
node_modules/
|
||||
npm-debug.log
|
||||
yarn-debug.log
|
||||
yarn-error.log
|
||||
package-lock.json
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Betriebssystem-Dateien
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# IDE und Editor Dateien
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Build-Verzeichnisse
|
||||
dist/
|
||||
build/
|
||||
74
games/whopixels/README.md
Normal file
74
games/whopixels/README.md
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
# WhoPixels
|
||||
|
||||
Ein webbasiertes Pixel-Spiel, entwickelt mit Phaser.js.
|
||||
|
||||
Projekt Starten:
|
||||
node server.js
|
||||
|
||||
## Über das Projekt
|
||||
|
||||
WhoPixels ist ein einfaches Pixel-Art-Editor-Spiel, in dem du deine eigenen Pixel-Kunstwerke erstellen kannst. Das Projekt verwendet Phaser.js, eine leistungsstarke HTML5-Spieleentwicklungsbibliothek.
|
||||
|
||||
## Funktionen
|
||||
|
||||
- Interaktives Pixel-Art-Editor-Interface
|
||||
- Farbpalette mit 8 Grundfarben
|
||||
- Einfache und intuitive Benutzeroberfläche
|
||||
- Responsive Design
|
||||
|
||||
## Erste Schritte
|
||||
|
||||
Um das Spiel lokal zu starten, benötigst du einen lokalen Webserver. Du kannst einen einfachen Server mit Python oder Node.js starten.
|
||||
|
||||
### Mit Python:
|
||||
|
||||
```bash
|
||||
# Python 3
|
||||
python -m http.server
|
||||
|
||||
# Python 2
|
||||
python -m SimpleHTTPServer
|
||||
```
|
||||
|
||||
### Mit Node.js:
|
||||
|
||||
Installiere zuerst das `http-server`-Paket:
|
||||
|
||||
```bash
|
||||
npm install -g http-server
|
||||
```
|
||||
|
||||
Dann starte den Server:
|
||||
|
||||
```bash
|
||||
http-server
|
||||
```
|
||||
|
||||
## Projektstruktur
|
||||
|
||||
```
|
||||
whopixels/
|
||||
├── assets/ # Spielressourcen (Bilder, Sounds, etc.)
|
||||
├── css/ # CSS-Stylesheets
|
||||
├── js/ # JavaScript-Dateien
|
||||
│ ├── scenes/ # Phaser-Szenen
|
||||
│ │ ├── BootScene.js
|
||||
│ │ ├── MainMenuScene.js
|
||||
│ │ └── GameScene.js
|
||||
│ └── main.js # Hauptspieldatei
|
||||
└── index.html # Haupt-HTML-Datei
|
||||
```
|
||||
|
||||
## Weiterentwicklung
|
||||
|
||||
Hier sind einige Ideen für zukünftige Erweiterungen:
|
||||
|
||||
- Speichern und Laden von Pixel-Art
|
||||
- Mehr Werkzeuge (Pinsel, Radierer, Füllen, etc.)
|
||||
- Animation-Editor
|
||||
- Teilen von Kunstwerken
|
||||
- Mehrere Ebenen für komplexere Designs
|
||||
|
||||
## Lizenz
|
||||
|
||||
Dieses Projekt ist Open Source und steht unter der MIT-Lizenz.
|
||||
35
games/whopixels/assets/background.html
Normal file
35
games/whopixels/assets/background.html
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Background Image</title>
|
||||
<style>
|
||||
body { margin: 0; background: #222233; }
|
||||
canvas { display: block; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="800" height="600"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Fill background
|
||||
ctx.fillStyle = '#222233';
|
||||
ctx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern
|
||||
ctx.fillStyle = '#1a1a2a';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
ctx.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Instructions
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.font = '20px Arial';
|
||||
ctx.fillText('Right-click and save this image as background.png', 200, 300);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
games/whopixels/assets/background.png
Normal file
1
games/whopixels/assets/background.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
68
games/whopixels/assets/create_placeholder_images.html
Normal file
68
games/whopixels/assets/create_placeholder_images.html
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Create Placeholder Images</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Creating placeholder images...</h1>
|
||||
<canvas id="backgroundCanvas" width="800" height="600" style="display: none;"></canvas>
|
||||
<canvas id="playerCanvas" width="32" height="32" style="display: none;"></canvas>
|
||||
<canvas id="tileCanvas" width="32" height="32" style="display: none;"></canvas>
|
||||
|
||||
<div id="downloadLinks"></div>
|
||||
|
||||
<script>
|
||||
// Create background image
|
||||
const bgCanvas = document.getElementById('backgroundCanvas');
|
||||
const bgCtx = bgCanvas.getContext('2d');
|
||||
bgCtx.fillStyle = '#222233';
|
||||
bgCtx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern to background
|
||||
bgCtx.fillStyle = '#1a1a2a';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgCtx.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Create player image
|
||||
const playerCanvas = document.getElementById('playerCanvas');
|
||||
const playerCtx = playerCanvas.getContext('2d');
|
||||
playerCtx.fillStyle = '#ff0000';
|
||||
playerCtx.fillRect(0, 0, 32, 32);
|
||||
playerCtx.fillStyle = '#ff5555';
|
||||
playerCtx.fillRect(8, 8, 16, 16);
|
||||
|
||||
// Create tile image
|
||||
const tileCanvas = document.getElementById('tileCanvas');
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
tileCtx.fillStyle = '#ffffff';
|
||||
tileCtx.fillRect(0, 0, 32, 32);
|
||||
tileCtx.strokeStyle = '#cccccc';
|
||||
tileCtx.lineWidth = 1;
|
||||
tileCtx.strokeRect(0.5, 0.5, 31, 31);
|
||||
|
||||
// Create download links
|
||||
const downloadDiv = document.getElementById('downloadLinks');
|
||||
|
||||
function createDownloadLink(canvas, filename) {
|
||||
const link = document.createElement('a');
|
||||
link.download = filename;
|
||||
link.href = canvas.toDataURL('image/png');
|
||||
link.textContent = `Download ${filename}`;
|
||||
link.style.display = 'block';
|
||||
link.style.margin = '10px';
|
||||
downloadDiv.appendChild(link);
|
||||
|
||||
// Auto-click to download
|
||||
setTimeout(() => link.click(), 500);
|
||||
}
|
||||
|
||||
createDownloadLink(bgCanvas, 'background.png');
|
||||
createDownloadLink(playerCanvas, 'player.png');
|
||||
createDownloadLink(tileCanvas, 'tile.png');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
26
games/whopixels/assets/player.html
Normal file
26
games/whopixels/assets/player.html
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Player Image</title>
|
||||
<style>
|
||||
body { margin: 0; background: #333; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { display: block; border: 1px solid #fff; background: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="32" height="32"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw player
|
||||
ctx.fillStyle = '#ff0000';
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
ctx.fillStyle = '#ff5555';
|
||||
ctx.fillRect(8, 8, 16, 16);
|
||||
|
||||
// Instructions (shown in console)
|
||||
console.log('Right-click and save this image as player.png');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
games/whopixels/assets/player.png
Normal file
1
games/whopixels/assets/player.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
54
games/whopixels/assets/simple_images.js
Normal file
54
games/whopixels/assets/simple_images.js
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
// Simple script to create basic placeholder images
|
||||
// Just open this in a browser and it will create data URLs you can copy
|
||||
|
||||
document.body.innerHTML = `
|
||||
<h1>Placeholder Images for WhoPixels</h1>
|
||||
<div>
|
||||
<h2>Background (800x600)</h2>
|
||||
<canvas id="bg" width="800" height="600" style="border:1px solid #000; max-width: 100%;"></canvas>
|
||||
<p id="bgData"></p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Player (32x32)</h2>
|
||||
<canvas id="player" width="32" height="32" style="border:1px solid #000;"></canvas>
|
||||
<p id="playerData"></p>
|
||||
</div>
|
||||
<div>
|
||||
<h2>Tile (32x32)</h2>
|
||||
<canvas id="tile" width="32" height="32" style="border:1px solid #000;"></canvas>
|
||||
<p id="tileData"></p>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Draw background
|
||||
const bgCanvas = document.getElementById('bg');
|
||||
const bgCtx = bgCanvas.getContext('2d');
|
||||
bgCtx.fillStyle = '#222233';
|
||||
bgCtx.fillRect(0, 0, 800, 600);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
bgCtx.fillStyle = '#1a1a2a';
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgCtx.fillRect(x, y, size, size);
|
||||
}
|
||||
document.getElementById('bgData').textContent = 'Save this image as background.png';
|
||||
|
||||
// Draw player
|
||||
const playerCanvas = document.getElementById('player');
|
||||
const playerCtx = playerCanvas.getContext('2d');
|
||||
playerCtx.fillStyle = '#ff0000';
|
||||
playerCtx.fillRect(0, 0, 32, 32);
|
||||
playerCtx.fillStyle = '#ff5555';
|
||||
playerCtx.fillRect(8, 8, 16, 16);
|
||||
document.getElementById('playerData').textContent = 'Save this image as player.png';
|
||||
|
||||
// Draw tile
|
||||
const tileCanvas = document.getElementById('tile');
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
tileCtx.fillStyle = '#ffffff';
|
||||
tileCtx.fillRect(0, 0, 32, 32);
|
||||
tileCtx.strokeStyle = '#cccccc';
|
||||
tileCtx.lineWidth = 1;
|
||||
tileCtx.strokeRect(0.5, 0.5, 31, 31);
|
||||
document.getElementById('tileData').textContent = 'Save this image as tile.png';
|
||||
27
games/whopixels/assets/tile.html
Normal file
27
games/whopixels/assets/tile.html
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Tile Image</title>
|
||||
<style>
|
||||
body { margin: 0; background: #333; display: flex; justify-content: center; align-items: center; height: 100vh; }
|
||||
canvas { display: block; border: 1px solid #fff; background: #000; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="canvas" width="32" height="32"></canvas>
|
||||
<script>
|
||||
const canvas = document.getElementById('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
// Draw tile
|
||||
ctx.fillStyle = '#ffffff';
|
||||
ctx.fillRect(0, 0, 32, 32);
|
||||
ctx.strokeStyle = '#cccccc';
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(0.5, 0.5, 31, 31);
|
||||
|
||||
// Instructions (shown in console)
|
||||
console.log('Right-click and save this image as tile.png');
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
1
games/whopixels/assets/tile.png
Normal file
1
games/whopixels/assets/tile.png
Normal file
|
|
@ -0,0 +1 @@
|
|||
|
||||
14
games/whopixels/css/style.css
Normal file
14
games/whopixels/css/style.css
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
background-color: #000;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100vh;
|
||||
font-family: 'Arial', sans-serif;
|
||||
}
|
||||
|
||||
#game-container {
|
||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
83
games/whopixels/data/npc_characters.js
Normal file
83
games/whopixels/data/npc_characters.js
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
// Liste der NPC-Charaktere mit Namen und Persönlichkeiten
|
||||
// NPC-Charaktere: Berühmte Erfinder durch die Historie
|
||||
const npcCharacters = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Leonardo da Vinci',
|
||||
personality:
|
||||
'Ein vielseitiger Universalgelehrter der Renaissance, bekannt für seine Kunst und Erfindungen. Er spricht nachdenklich und philosophisch, oft mit Metaphern über Natur und Kunst. Er ist neugierig und beobachtet alles genau.',
|
||||
hint: 'Meine Skizzenbücher enthalten Flugmaschinen und anatomische Studien, die ihrer Zeit weit voraus waren.',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Nikola Tesla',
|
||||
personality:
|
||||
'Ein exzentrischer Elektroingenieur mit visionären Ideen. Er spricht leidenschaftlich über Elektrizität und drahtlose Energieübertragung. Er ist brillant, aber auch etwas eigenartig und distanziert.',
|
||||
hint: 'Meine Arbeiten mit Wechselstrom revolutionierten die Art, wie wir Energie nutzen.',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Marie Curie',
|
||||
personality:
|
||||
'Eine entschlossene und präzise Wissenschaftlerin, die für ihre Entdeckungen im Bereich der Radioaktivität bekannt ist. Sie spricht klar und methodisch, mit einem starken Fokus auf wissenschaftliche Genauigkeit.',
|
||||
hint: 'Meine Forschung zu radioaktiven Elementen brachte mir zwei Nobelpreise ein, obwohl sie letztendlich meine Gesundheit beeinträchtigte.',
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
name: 'Thomas Edison',
|
||||
personality:
|
||||
'Ein pragmatischer und geschäftstüchtiger Erfinder mit über 1.000 Patenten. Er spricht direkt und selbstbewusst, oft mit praktischen Beispielen. Er betont harte Arbeit und Ausdauer über Inspiration.',
|
||||
hint: 'Meine Erfindung brachte Licht in die Dunkelheit und veränderte die Art, wie Menschen nach Sonnenuntergang leben.',
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
name: 'Ada Lovelace',
|
||||
personality:
|
||||
'Eine visionäre Mathematikerin des 19. Jahrhunderts mit einer einzigartigen Verbindung von Logik und Kreativität. Sie spricht eloquent und präzise, mit einer Mischung aus poetischer und mathematischer Sprache.',
|
||||
hint: 'Ich schrieb den ersten Algorithmus für eine Maschine, lange bevor Computer existierten.',
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
name: 'Archimedes',
|
||||
personality:
|
||||
'Ein genialer antiker Mathematiker und Erfinder aus Syrakus. Er ist von mathematischen Problemen fasziniert und kann sich darin verlieren. Er spricht mit Begeisterung über Geometrie und physikalische Prinzipien.',
|
||||
hint: "Mein berühmtester Ausruf war 'Heureka!' als ich das Prinzip des Auftriebs in der Badewanne entdeckte.",
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
name: 'Johannes Gutenberg',
|
||||
personality:
|
||||
'Ein geduldiger und präziser Handwerker, der die Druckkunst revolutionierte. Er spricht bescheiden über seine Erfindung, aber mit Stolz über deren Auswirkungen auf die Verbreitung von Wissen.',
|
||||
hint: 'Meine Erfindung machte Bücher für die Massen zugänglich und veränderte die Verbreitung von Wissen für immer.',
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
name: 'Grace Hopper',
|
||||
personality:
|
||||
'Eine pragmatische und humorvolle Computerpionierin und Marineoffizierin. Sie erklärt komplexe Konzepte mit einfachen Analogien und hat einen trockenen Humor. Sie ist direkt und lösungsorientiert.',
|
||||
hint: "Ich entwickelte den ersten Compiler und fand einmal einen echten 'Bug' im Computer - eine Motte, die einen Fehler verursachte.",
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
name: 'Alexander Graham Bell',
|
||||
personality:
|
||||
'Ein einfallsreicher und geduldiger Erfinder, der sich für Kommunikation und Gehörlose engagierte. Er spricht deutlich und artikuliert, mit einem schottischen Akzent. Er ist enthusiastisch, wenn er über seine Erfindungen spricht.',
|
||||
hint: 'Meine Erfindung ermöglichte es Menschen, über große Entfernungen miteinander zu sprechen.',
|
||||
},
|
||||
{
|
||||
id: 10,
|
||||
name: 'Hedy Lamarr',
|
||||
personality:
|
||||
'Eine glamouröse Hollywoodschauspielerin mit einem brillanten technischen Verstand. Sie spricht charmant und selbstbewusst, mit einer Mischung aus Eleganz und technischem Scharfsinn. Sie ist kreativ und unkonventionell.',
|
||||
hint: 'Meine Erfindung der Frequenzsprungverfahren bildet die Grundlage für moderne WLAN- und Bluetooth-Technologien, obwohl viele mich nur als Filmstar kennen.',
|
||||
},
|
||||
];
|
||||
|
||||
// Mache die Charaktere sowohl im Browser als auch in Node.js verfügbar
|
||||
if (typeof window !== 'undefined') {
|
||||
// Browser-Umgebung
|
||||
window.npcCharacters = npcCharacters;
|
||||
} else if (typeof module !== 'undefined') {
|
||||
// Node.js-Umgebung
|
||||
module.exports = npcCharacters;
|
||||
}
|
||||
47
games/whopixels/generate_assets.js
Normal file
47
games/whopixels/generate_assets.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
// This script uses Node.js to generate placeholder images for our game
|
||||
const fs = require('fs');
|
||||
const { createCanvas } = require('canvas');
|
||||
|
||||
// Create background image (800x600)
|
||||
const bgCanvas = createCanvas(800, 600);
|
||||
const bgCtx = bgCanvas.getContext('2d');
|
||||
bgCtx.fillStyle = '#222233';
|
||||
bgCtx.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern to background
|
||||
bgCtx.fillStyle = '#1a1a2a';
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgCtx.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Create player image (32x32)
|
||||
const playerCanvas = createCanvas(32, 32);
|
||||
const playerCtx = playerCanvas.getContext('2d');
|
||||
playerCtx.fillStyle = '#ff0000';
|
||||
playerCtx.fillRect(0, 0, 32, 32);
|
||||
playerCtx.fillStyle = '#ff5555';
|
||||
playerCtx.fillRect(8, 8, 16, 16);
|
||||
|
||||
// Create tile image (32x32)
|
||||
const tileCanvas = createCanvas(32, 32);
|
||||
const tileCtx = tileCanvas.getContext('2d');
|
||||
tileCtx.fillStyle = '#ffffff';
|
||||
tileCtx.fillRect(0, 0, 32, 32);
|
||||
tileCtx.strokeStyle = '#cccccc';
|
||||
tileCtx.lineWidth = 1;
|
||||
tileCtx.strokeRect(0.5, 0.5, 31, 31);
|
||||
|
||||
// Save images
|
||||
const bgBuffer = bgCanvas.toBuffer('image/png');
|
||||
fs.writeFileSync('./assets/background.png', bgBuffer);
|
||||
|
||||
const playerBuffer = playerCanvas.toBuffer('image/png');
|
||||
fs.writeFileSync('./assets/player.png', playerBuffer);
|
||||
|
||||
const tileBuffer = tileCanvas.toBuffer('image/png');
|
||||
fs.writeFileSync('./assets/tile.png', tileBuffer);
|
||||
|
||||
console.log('All placeholder images have been generated!');
|
||||
25
games/whopixels/index.html
Normal file
25
games/whopixels/index.html
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>WhoPixels - Pixel Game</title>
|
||||
<link rel="stylesheet" href="css/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="game-container"></div>
|
||||
|
||||
<!-- Phaser Library -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/phaser@3.55.2/dist/phaser.min.js"></script>
|
||||
|
||||
<!-- Game Data -->
|
||||
<script src="data/npc_characters.js"></script>
|
||||
|
||||
<!-- Game Scripts -->
|
||||
<script src="js/scenes/BootScene.js"></script>
|
||||
<script src="js/scenes/MainMenuScene.js"></script>
|
||||
<script src="js/scenes/GameScene.js"></script>
|
||||
<script src="js/scenes/RPGScene.js"></script>
|
||||
<script src="js/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
games/whopixels/js/main.js
Normal file
18
games/whopixels/js/main.js
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
// Game configuration
|
||||
const config = {
|
||||
type: Phaser.AUTO,
|
||||
width: 800,
|
||||
height: 600,
|
||||
pixelArt: true,
|
||||
physics: {
|
||||
default: 'arcade',
|
||||
arcade: {
|
||||
gravity: { y: 0 },
|
||||
debug: false,
|
||||
},
|
||||
},
|
||||
scene: [BootScene, MainMenuScene, GameScene, RPGScene],
|
||||
};
|
||||
|
||||
// Create and start the game
|
||||
const game = new Phaser.Game(config);
|
||||
445
games/whopixels/js/scenes/BootScene.js
Normal file
445
games/whopixels/js/scenes/BootScene.js
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
class BootScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'BootScene' });
|
||||
}
|
||||
|
||||
preload() {
|
||||
// Loading screen
|
||||
this.graphics = this.add.graphics();
|
||||
this.newGraphics = this.add.graphics();
|
||||
const progressBar = new Phaser.Geom.Rectangle(200, 300, 400, 50);
|
||||
const progressBarFill = new Phaser.Geom.Rectangle(205, 305, 290, 40);
|
||||
|
||||
this.graphics.fillStyle(0xffffff, 1);
|
||||
this.graphics.fillRectShape(progressBar);
|
||||
|
||||
this.newGraphics.fillStyle(0x3587e2, 1);
|
||||
this.newGraphics.fillRectShape(progressBarFill);
|
||||
|
||||
const loadingText = this.add.text(250, 260, 'Loading: ', { fontSize: '32px', fill: '#FFF' });
|
||||
|
||||
// Update as load progresses
|
||||
this.load.on('progress', (percent) => {
|
||||
loadingText.setText('Loading: ' + parseInt(percent * 100) + '%');
|
||||
progressBarFill.width = 390 * percent;
|
||||
this.newGraphics.clear();
|
||||
this.newGraphics.fillStyle(0x3587e2, 1);
|
||||
this.newGraphics.fillRectShape(progressBarFill);
|
||||
});
|
||||
|
||||
this.load.on('complete', () => {
|
||||
loadingText.destroy();
|
||||
this.graphics.destroy();
|
||||
this.newGraphics.destroy();
|
||||
});
|
||||
|
||||
// We'll create graphics objects instead of loading images
|
||||
}
|
||||
|
||||
create() {
|
||||
// Create a texture for background
|
||||
const bgGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
bgGraphics.fillStyle(0x222233);
|
||||
bgGraphics.fillRect(0, 0, 800, 600);
|
||||
|
||||
// Add some pattern to background
|
||||
bgGraphics.fillStyle(0x1a1a2a);
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const x = Math.random() * 800;
|
||||
const y = Math.random() * 600;
|
||||
const size = Math.random() * 5 + 2;
|
||||
bgGraphics.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
// Erstelle ein Partikel-Sprite für Spezialeffekte
|
||||
const particleGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
particleGraphics.fillStyle(0xffffff);
|
||||
particleGraphics.fillCircle(4, 4, 4);
|
||||
particleGraphics.generateTexture('particle', 8, 8);
|
||||
|
||||
bgGraphics.generateTexture('background', 800, 600);
|
||||
|
||||
// Create a texture for player (pixel editor)
|
||||
const playerGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
playerGraphics.fillStyle(0xff0000);
|
||||
playerGraphics.fillRect(0, 0, 32, 32);
|
||||
playerGraphics.fillStyle(0xff5555);
|
||||
playerGraphics.fillRect(8, 8, 16, 16);
|
||||
playerGraphics.generateTexture('player', 32, 32);
|
||||
|
||||
// Erstelle Texturen für verschiedene Tile-Typen (8x8 Pixel Tiles, skaliert auf 32x32)
|
||||
this.createTileTextures();
|
||||
|
||||
// Erstelle NPC-Texturen
|
||||
this.createNPCTextures();
|
||||
|
||||
// Create a texture for basic tile
|
||||
const tileGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
tileGraphics.fillStyle(0xffffff);
|
||||
tileGraphics.fillRect(0, 0, 32, 32);
|
||||
tileGraphics.lineStyle(1, 0xcccccc);
|
||||
tileGraphics.strokeRect(0, 0, 32, 32);
|
||||
tileGraphics.generateTexture('tile', 32, 32);
|
||||
|
||||
// Create player walk animation frames (4 directions)
|
||||
this.createPlayerWalkAnimations();
|
||||
|
||||
this.scene.start('MainMenuScene');
|
||||
}
|
||||
|
||||
createPlayerWalkAnimations() {
|
||||
// Erstelle eine Spritesheet-Textur für den Spieler im RPG
|
||||
const frameWidth = 32;
|
||||
const frameHeight = 32;
|
||||
|
||||
// Farbpalette für den Spieler
|
||||
const colors = {
|
||||
body: 0x3366cc, // Blauer Körper
|
||||
face: 0xffcc99, // Hautfarbe für Gesicht
|
||||
hair: 0x663300, // Braune Haare
|
||||
shirt: 0x339933, // Grünes Hemd
|
||||
pants: 0x333366, // Dunkelblaue Hose
|
||||
shoes: 0x663300, // Braune Schuhe
|
||||
outline: 0x000000, // Schwarze Umrisse
|
||||
};
|
||||
|
||||
// Gemeinsame Funktion zum Zeichnen der Grundform des Spielers
|
||||
const drawPlayerBase = (graphics) => {
|
||||
// Umriss
|
||||
graphics.lineStyle(1, colors.outline);
|
||||
|
||||
// Körper (Torso)
|
||||
graphics.fillStyle(colors.shirt);
|
||||
graphics.fillRect(10, 12, 12, 10);
|
||||
graphics.strokeRect(10, 12, 12, 10);
|
||||
|
||||
// Kopf
|
||||
graphics.fillStyle(colors.face);
|
||||
graphics.fillRect(10, 4, 12, 8);
|
||||
graphics.strokeRect(10, 4, 12, 8);
|
||||
|
||||
// Haare
|
||||
graphics.fillStyle(colors.hair);
|
||||
graphics.fillRect(10, 4, 12, 3);
|
||||
graphics.strokeRect(10, 4, 12, 3);
|
||||
|
||||
// Hose
|
||||
graphics.fillStyle(colors.pants);
|
||||
graphics.fillRect(10, 22, 12, 6);
|
||||
graphics.strokeRect(10, 22, 12, 6);
|
||||
};
|
||||
|
||||
// Nach unten (0)
|
||||
const downGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(downGraphics);
|
||||
|
||||
// Gesicht nach unten
|
||||
downGraphics.fillStyle(colors.outline);
|
||||
downGraphics.fillRect(14, 8, 1, 1); // Linkes Auge
|
||||
downGraphics.fillRect(17, 8, 1, 1); // Rechtes Auge
|
||||
downGraphics.fillRect(15, 10, 2, 1); // Mund
|
||||
|
||||
// Beine und Schuhe nach unten
|
||||
downGraphics.fillStyle(colors.pants);
|
||||
downGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
downGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
downGraphics.fillStyle(colors.shoes);
|
||||
downGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
downGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
downGraphics.lineStyle(1, colors.outline);
|
||||
downGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
downGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
downGraphics.generateTexture('player_down', frameWidth, frameHeight);
|
||||
|
||||
// Nach oben (1)
|
||||
const upGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(upGraphics);
|
||||
|
||||
// Rücken der Haare
|
||||
upGraphics.fillStyle(colors.hair);
|
||||
upGraphics.fillRect(10, 2, 12, 2);
|
||||
upGraphics.lineStyle(1, colors.outline);
|
||||
upGraphics.strokeRect(10, 2, 12, 2);
|
||||
|
||||
// Beine und Schuhe nach oben
|
||||
upGraphics.fillStyle(colors.pants);
|
||||
upGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
upGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
upGraphics.fillStyle(colors.shoes);
|
||||
upGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
upGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
upGraphics.lineStyle(1, colors.outline);
|
||||
upGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
upGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
upGraphics.generateTexture('player_up', frameWidth, frameHeight);
|
||||
|
||||
// Nach links (2)
|
||||
const leftGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(leftGraphics);
|
||||
|
||||
// Gesicht nach links
|
||||
leftGraphics.fillStyle(colors.outline);
|
||||
leftGraphics.fillRect(12, 8, 1, 1); // Auge
|
||||
leftGraphics.fillRect(11, 10, 2, 1); // Mund
|
||||
|
||||
// Arm nach links
|
||||
leftGraphics.fillStyle(colors.shirt);
|
||||
leftGraphics.fillRect(6, 14, 4, 3);
|
||||
leftGraphics.lineStyle(1, colors.outline);
|
||||
leftGraphics.strokeRect(6, 14, 4, 3);
|
||||
|
||||
// Beine und Schuhe nach links
|
||||
leftGraphics.fillStyle(colors.pants);
|
||||
leftGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
leftGraphics.fillRect(15, 28, 3, 2); // Rechtes Bein
|
||||
leftGraphics.fillStyle(colors.shoes);
|
||||
leftGraphics.fillRect(9, 30, 6, 2); // Schuhe
|
||||
leftGraphics.lineStyle(1, colors.outline);
|
||||
leftGraphics.strokeRect(9, 28, 9, 4); // Beine Umriss
|
||||
|
||||
leftGraphics.generateTexture('player_left', frameWidth, frameHeight);
|
||||
|
||||
// Nach rechts (3)
|
||||
const rightGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawPlayerBase(rightGraphics);
|
||||
|
||||
// Gesicht nach rechts
|
||||
rightGraphics.fillStyle(colors.outline);
|
||||
rightGraphics.fillRect(19, 8, 1, 1); // Auge
|
||||
rightGraphics.fillRect(19, 10, 2, 1); // Mund
|
||||
|
||||
// Arm nach rechts
|
||||
rightGraphics.fillStyle(colors.shirt);
|
||||
rightGraphics.fillRect(22, 14, 4, 3);
|
||||
rightGraphics.lineStyle(1, colors.outline);
|
||||
rightGraphics.strokeRect(22, 14, 4, 3);
|
||||
|
||||
// Beine und Schuhe nach rechts
|
||||
rightGraphics.fillStyle(colors.pants);
|
||||
rightGraphics.fillRect(14, 28, 3, 2); // Linkes Bein
|
||||
rightGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
rightGraphics.fillStyle(colors.shoes);
|
||||
rightGraphics.fillRect(17, 30, 6, 2); // Schuhe
|
||||
rightGraphics.lineStyle(1, colors.outline);
|
||||
rightGraphics.strokeRect(14, 28, 9, 4); // Beine Umriss
|
||||
|
||||
rightGraphics.generateTexture('player_right', frameWidth, frameHeight);
|
||||
}
|
||||
|
||||
createTileTextures() {
|
||||
const tileSize = 32; // Größe jedes Tiles
|
||||
|
||||
// 1. Gras
|
||||
const grassGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
grassGraphics.fillStyle(0x88aa44); // Grün für Gras
|
||||
grassGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Gras
|
||||
grassGraphics.fillStyle(0x779933);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
grassGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
grassGraphics.generateTexture('tile_grass', tileSize, tileSize);
|
||||
|
||||
// 2. Gras mit Blumen
|
||||
const grassFlowerGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
grassFlowerGraphics.fillStyle(0x88aa44); // Grün für Gras
|
||||
grassFlowerGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Gras
|
||||
grassFlowerGraphics.fillStyle(0x779933);
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
grassFlowerGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
// Blumen hinzufügen
|
||||
grassFlowerGraphics.fillStyle(0xffff00); // Gelb für Blumen
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const x = 5 + Math.random() * (tileSize - 10);
|
||||
const y = 5 + Math.random() * (tileSize - 10);
|
||||
grassFlowerGraphics.fillRect(x, y, 3, 3);
|
||||
}
|
||||
grassFlowerGraphics.fillStyle(0xff5555); // Rot für Blumen
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const x = 5 + Math.random() * (tileSize - 10);
|
||||
const y = 5 + Math.random() * (tileSize - 10);
|
||||
grassFlowerGraphics.fillRect(x, y, 3, 3);
|
||||
}
|
||||
grassFlowerGraphics.generateTexture('tile_grass_flower', tileSize, tileSize);
|
||||
|
||||
// 3. Erde
|
||||
const dirtGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
dirtGraphics.fillStyle(0x8b4513); // Braun für Erde
|
||||
dirtGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Erde
|
||||
dirtGraphics.fillStyle(0x6b3304);
|
||||
for (let i = 0; i < 10; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
dirtGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
dirtGraphics.generateTexture('tile_dirt', tileSize, tileSize);
|
||||
|
||||
// 4. Erde mit Steinen
|
||||
const dirtStoneGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
dirtStoneGraphics.fillStyle(0x8b4513); // Braun für Erde
|
||||
dirtStoneGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
// Kleine Texturen für Erde
|
||||
dirtStoneGraphics.fillStyle(0x6b3304);
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
dirtStoneGraphics.fillRect(x, y, 2, 2);
|
||||
}
|
||||
// Steine hinzufügen
|
||||
dirtStoneGraphics.fillStyle(0x888888); // Grau für Steine
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const size = 3 + Math.random() * 4;
|
||||
const x = Math.random() * (tileSize - size);
|
||||
const y = Math.random() * (tileSize - size);
|
||||
dirtStoneGraphics.fillRect(x, y, size, size);
|
||||
}
|
||||
dirtStoneGraphics.generateTexture('tile_dirt_stone', tileSize, tileSize);
|
||||
|
||||
// 5. Steinwand
|
||||
const stoneWallGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
stoneWallGraphics.fillStyle(0x777777); // Grau für Steinwand
|
||||
stoneWallGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
|
||||
// Steinmuster für die Wand
|
||||
stoneWallGraphics.fillStyle(0x555555);
|
||||
const brickHeight = 8;
|
||||
const brickWidth = 16;
|
||||
|
||||
for (let y = 0; y < tileSize; y += brickHeight) {
|
||||
const offset = y % (brickHeight * 2) === 0 ? 0 : brickWidth / 2;
|
||||
for (let x = offset; x < tileSize; x += brickWidth) {
|
||||
stoneWallGraphics.fillRect(x, y, brickWidth - 1, brickHeight - 1);
|
||||
}
|
||||
}
|
||||
stoneWallGraphics.generateTexture('tile_stone_wall', tileSize, tileSize);
|
||||
|
||||
// 6. Steinwand mit Blumen/Moos
|
||||
const stoneWallFlowerGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
stoneWallFlowerGraphics.fillStyle(0x777777); // Grau für Steinwand
|
||||
stoneWallFlowerGraphics.fillRect(0, 0, tileSize, tileSize);
|
||||
|
||||
// Steinmuster für die Wand
|
||||
stoneWallFlowerGraphics.fillStyle(0x555555);
|
||||
for (let y = 0; y < tileSize; y += brickHeight) {
|
||||
const offset = y % (brickHeight * 2) === 0 ? 0 : brickWidth / 2;
|
||||
for (let x = offset; x < tileSize; x += brickWidth) {
|
||||
stoneWallFlowerGraphics.fillRect(x, y, brickWidth - 1, brickHeight - 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Moos/Blumen an der Wand
|
||||
stoneWallFlowerGraphics.fillStyle(0x55aa55); // Grün für Moos
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const x = Math.random() * tileSize;
|
||||
const y = Math.random() * tileSize;
|
||||
const size = 2 + Math.random() * 3;
|
||||
stoneWallFlowerGraphics.fillRect(x, y, size, size);
|
||||
}
|
||||
|
||||
stoneWallFlowerGraphics.fillStyle(0xffff00); // Gelb für Blumen
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const x = 5 + Math.random() * (tileSize - 10);
|
||||
const y = 5 + Math.random() * (tileSize - 10);
|
||||
stoneWallFlowerGraphics.fillRect(x, y, 3, 3);
|
||||
}
|
||||
|
||||
stoneWallFlowerGraphics.generateTexture('tile_stone_wall_flower', tileSize, tileSize);
|
||||
}
|
||||
|
||||
createNPCTextures() {
|
||||
const frameWidth = 32;
|
||||
const frameHeight = 32;
|
||||
|
||||
// Farbpalette für den NPC
|
||||
const colors = {
|
||||
body: 0xcc6633, // Bräunlicher Körper
|
||||
face: 0xffcc99, // Hautfarbe für Gesicht
|
||||
hair: 0x996600, // Blonde Haare
|
||||
shirt: 0xcc3333, // Rotes Hemd
|
||||
pants: 0x333333, // Schwarze Hose
|
||||
shoes: 0x663300, // Braune Schuhe
|
||||
outline: 0x000000, // Schwarze Umrisse
|
||||
};
|
||||
|
||||
// Gemeinsame Funktion zum Zeichnen der Grundform des NPCs
|
||||
const drawNPCBase = (graphics) => {
|
||||
// Umriss
|
||||
graphics.lineStyle(1, colors.outline);
|
||||
|
||||
// Körper (Torso)
|
||||
graphics.fillStyle(colors.shirt);
|
||||
graphics.fillRect(10, 12, 12, 10);
|
||||
graphics.strokeRect(10, 12, 12, 10);
|
||||
|
||||
// Kopf
|
||||
graphics.fillStyle(colors.face);
|
||||
graphics.fillRect(10, 4, 12, 8);
|
||||
graphics.strokeRect(10, 4, 12, 8);
|
||||
|
||||
// Haare
|
||||
graphics.fillStyle(colors.hair);
|
||||
graphics.fillRect(10, 4, 12, 3);
|
||||
graphics.strokeRect(10, 4, 12, 3);
|
||||
|
||||
// Hose
|
||||
graphics.fillStyle(colors.pants);
|
||||
graphics.fillRect(10, 22, 12, 6);
|
||||
graphics.strokeRect(10, 22, 12, 6);
|
||||
};
|
||||
|
||||
// NPC nach unten schauend
|
||||
const npcDownGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawNPCBase(npcDownGraphics);
|
||||
|
||||
// Gesicht nach unten
|
||||
npcDownGraphics.fillStyle(colors.outline);
|
||||
npcDownGraphics.fillRect(14, 8, 1, 1); // Linkes Auge
|
||||
npcDownGraphics.fillRect(17, 8, 1, 1); // Rechtes Auge
|
||||
npcDownGraphics.fillRect(15, 10, 2, 1); // Mund
|
||||
|
||||
// Beine und Schuhe nach unten
|
||||
npcDownGraphics.fillStyle(colors.pants);
|
||||
npcDownGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
npcDownGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
npcDownGraphics.fillStyle(colors.shoes);
|
||||
npcDownGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
npcDownGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
npcDownGraphics.lineStyle(1, colors.outline);
|
||||
npcDownGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
npcDownGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
npcDownGraphics.generateTexture('npc_down', frameWidth, frameHeight);
|
||||
|
||||
// NPC nach oben schauend
|
||||
const npcUpGraphics = this.make.graphics({ x: 0, y: 0 });
|
||||
drawNPCBase(npcUpGraphics);
|
||||
|
||||
// Rücken der Haare
|
||||
npcUpGraphics.fillStyle(colors.hair);
|
||||
npcUpGraphics.fillRect(10, 2, 12, 2);
|
||||
npcUpGraphics.lineStyle(1, colors.outline);
|
||||
npcUpGraphics.strokeRect(10, 2, 12, 2);
|
||||
|
||||
// Beine und Schuhe nach oben
|
||||
npcUpGraphics.fillStyle(colors.pants);
|
||||
npcUpGraphics.fillRect(12, 28, 3, 2); // Linkes Bein
|
||||
npcUpGraphics.fillRect(17, 28, 3, 2); // Rechtes Bein
|
||||
npcUpGraphics.fillStyle(colors.shoes);
|
||||
npcUpGraphics.fillRect(12, 30, 3, 2); // Linker Schuh
|
||||
npcUpGraphics.fillRect(17, 30, 3, 2); // Rechter Schuh
|
||||
npcUpGraphics.lineStyle(1, colors.outline);
|
||||
npcUpGraphics.strokeRect(12, 28, 3, 4); // Linkes Bein Umriss
|
||||
npcUpGraphics.strokeRect(17, 28, 3, 4); // Rechtes Bein Umriss
|
||||
|
||||
npcUpGraphics.generateTexture('npc_up', frameWidth, frameHeight);
|
||||
}
|
||||
}
|
||||
113
games/whopixels/js/scenes/GameScene.js
Normal file
113
games/whopixels/js/scenes/GameScene.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
class GameScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'GameScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Add background
|
||||
this.add.image(400, 300, 'background');
|
||||
|
||||
// Create grid for pixel art (16x16 grid of 32x32 pixel tiles)
|
||||
this.grid = [];
|
||||
this.tileSize = 32;
|
||||
this.gridWidth = 16;
|
||||
this.gridHeight = 16;
|
||||
this.gridStartX = (800 - this.gridWidth * this.tileSize) / 2;
|
||||
this.gridStartY = (600 - this.gridHeight * this.tileSize) / 2;
|
||||
|
||||
// Create grid of tiles
|
||||
for (let y = 0; y < this.gridHeight; y++) {
|
||||
this.grid[y] = [];
|
||||
for (let x = 0; x < this.gridWidth; x++) {
|
||||
const tile = this.add.image(
|
||||
this.gridStartX + x * this.tileSize + this.tileSize / 2,
|
||||
this.gridStartY + y * this.tileSize + this.tileSize / 2,
|
||||
'tile'
|
||||
);
|
||||
tile.setTint(0xffffff); // Default white color
|
||||
tile.setInteractive();
|
||||
tile.on('pointerdown', () => {
|
||||
this.paintTile(x, y);
|
||||
});
|
||||
this.grid[y][x] = tile;
|
||||
}
|
||||
}
|
||||
|
||||
// Current selected color (default: black)
|
||||
this.currentColor = 0x000000;
|
||||
|
||||
// Create color palette
|
||||
this.createColorPalette();
|
||||
|
||||
// Add UI text
|
||||
this.add
|
||||
.text(400, 50, 'Pixel Editor', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Add back button
|
||||
const backButton = this.add
|
||||
.text(100, 50, 'Zurück', {
|
||||
fontSize: '24px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 10, y: 5 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setInteractive();
|
||||
|
||||
backButton.on('pointerover', () => {
|
||||
backButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
backButton.on('pointerout', () => {
|
||||
backButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
backButton.on('pointerdown', () => {
|
||||
this.scene.start('MainMenuScene');
|
||||
});
|
||||
}
|
||||
|
||||
createColorPalette() {
|
||||
const colors = [
|
||||
0x000000, // Black
|
||||
0xffffff, // White
|
||||
0xff0000, // Red
|
||||
0x00ff00, // Green
|
||||
0x0000ff, // Blue
|
||||
0xffff00, // Yellow
|
||||
0xff00ff, // Magenta
|
||||
0x00ffff, // Cyan
|
||||
];
|
||||
|
||||
const paletteX = 700;
|
||||
const paletteY = 150;
|
||||
const paletteSize = 30;
|
||||
const paletteGap = 10;
|
||||
|
||||
colors.forEach((color, index) => {
|
||||
const colorButton = this.add.rectangle(
|
||||
paletteX,
|
||||
paletteY + index * (paletteSize + paletteGap),
|
||||
paletteSize,
|
||||
paletteSize,
|
||||
color
|
||||
);
|
||||
|
||||
colorButton.setInteractive();
|
||||
colorButton.on('pointerdown', () => {
|
||||
this.currentColor = color;
|
||||
});
|
||||
|
||||
// Add stroke around the button
|
||||
colorButton.setStrokeStyle(2, 0xffffff);
|
||||
});
|
||||
}
|
||||
|
||||
paintTile(x, y) {
|
||||
this.grid[y][x].setTint(this.currentColor);
|
||||
}
|
||||
}
|
||||
98
games/whopixels/js/scenes/MainMenuScene.js
Normal file
98
games/whopixels/js/scenes/MainMenuScene.js
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
class MainMenuScene extends Phaser.Scene {
|
||||
constructor() {
|
||||
super({ key: 'MainMenuScene' });
|
||||
}
|
||||
|
||||
create() {
|
||||
// Add background
|
||||
this.add.image(400, 300, 'background');
|
||||
|
||||
// Add title
|
||||
this.add
|
||||
.text(400, 120, 'WhoPixels', {
|
||||
fontSize: '64px',
|
||||
fill: '#fff',
|
||||
fontStyle: 'bold',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Add subtitle
|
||||
this.add
|
||||
.text(400, 180, 'Ein Pixel-Abenteuer', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
})
|
||||
.setOrigin(0.5);
|
||||
|
||||
// Create buttons
|
||||
const startButton = this.add
|
||||
.text(400, 280, 'RPG Spiel starten', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 20, y: 10 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setInteractive();
|
||||
|
||||
const editorButton = this.add
|
||||
.text(400, 350, 'Pixel Editor', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 20, y: 10 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setInteractive();
|
||||
|
||||
const optionsButton = this.add
|
||||
.text(400, 420, 'Optionen', {
|
||||
fontSize: '32px',
|
||||
fill: '#fff',
|
||||
backgroundColor: '#4a4a4a',
|
||||
padding: { x: 20, y: 10 },
|
||||
})
|
||||
.setOrigin(0.5)
|
||||
.setInteractive();
|
||||
|
||||
// Button interactions - RPG Game
|
||||
startButton.on('pointerover', () => {
|
||||
startButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
startButton.on('pointerout', () => {
|
||||
startButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
startButton.on('pointerdown', () => {
|
||||
this.scene.start('RPGScene');
|
||||
});
|
||||
|
||||
// Button interactions - Pixel Editor
|
||||
editorButton.on('pointerover', () => {
|
||||
editorButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
editorButton.on('pointerout', () => {
|
||||
editorButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
editorButton.on('pointerdown', () => {
|
||||
this.scene.start('GameScene');
|
||||
});
|
||||
|
||||
// Button interactions - Options
|
||||
optionsButton.on('pointerover', () => {
|
||||
optionsButton.setStyle({ fill: '#ff0' });
|
||||
});
|
||||
|
||||
optionsButton.on('pointerout', () => {
|
||||
optionsButton.setStyle({ fill: '#fff' });
|
||||
});
|
||||
|
||||
optionsButton.on('pointerdown', () => {
|
||||
// Options functionality would go here
|
||||
console.log('Options button clicked');
|
||||
});
|
||||
}
|
||||
}
|
||||
1210
games/whopixels/js/scenes/RPGScene.js
Normal file
1210
games/whopixels/js/scenes/RPGScene.js
Normal file
File diff suppressed because it is too large
Load diff
6
games/whopixels/package.json
Normal file
6
games/whopixels/package.json
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
{
|
||||
"dependencies": {
|
||||
"dotenv": "^16.4.7",
|
||||
"node-fetch": "^2.7.0"
|
||||
}
|
||||
}
|
||||
282
games/whopixels/server.js
Normal file
282
games/whopixels/server.js
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
const http = require('http');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const url = require('url');
|
||||
|
||||
// Lade Umgebungsvariablen aus .env-Datei
|
||||
require('dotenv').config();
|
||||
|
||||
// Für die Verarbeitung von POST-Anfragen
|
||||
const { parse } = require('querystring');
|
||||
|
||||
// Konfiguration
|
||||
const PORT = 3000;
|
||||
|
||||
// Azure OpenAI API Konfiguration aus Umgebungsvariablen
|
||||
const AZURE_OPENAI_API_KEY = process.env.AZURE_OPENAI_API_KEY;
|
||||
const AZURE_OPENAI_ENDPOINT = process.env.AZURE_OPENAI_ENDPOINT;
|
||||
const AZURE_OPENAI_DEPLOYMENT = process.env.AZURE_OPENAI_DEPLOYMENT;
|
||||
const AZURE_OPENAI_API_VERSION = process.env.AZURE_OPENAI_API_VERSION;
|
||||
|
||||
const MIME_TYPES = {
|
||||
'.html': 'text/html',
|
||||
'.js': 'text/javascript',
|
||||
'.css': 'text/css',
|
||||
'.json': 'application/json',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.ico': 'image/x-icon',
|
||||
};
|
||||
|
||||
// Funktion zum Abrufen von Daten aus einer POST-Anfrage
|
||||
const collectRequestData = (request, callback) => {
|
||||
const FORM_URLENCODED = 'application/x-www-form-urlencoded';
|
||||
const JSON_TYPE = 'application/json';
|
||||
|
||||
if (request.headers['content-type'] === FORM_URLENCODED) {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
request.on('end', () => {
|
||||
callback(parse(body));
|
||||
});
|
||||
} else if (
|
||||
request.headers['content-type'] &&
|
||||
request.headers['content-type'].includes(JSON_TYPE)
|
||||
) {
|
||||
let body = '';
|
||||
request.on('data', (chunk) => {
|
||||
body += chunk.toString();
|
||||
});
|
||||
request.on('end', () => {
|
||||
callback(JSON.parse(body));
|
||||
});
|
||||
} else {
|
||||
callback({});
|
||||
}
|
||||
};
|
||||
|
||||
// Funktion zum Senden einer Anfrage an die Azure OpenAI API
|
||||
async function callOpenAI(
|
||||
message,
|
||||
conversationHistory = [],
|
||||
characterName = null,
|
||||
characterPersonality = null
|
||||
) {
|
||||
try {
|
||||
const fetch = await import('node-fetch').then((mod) => mod.default);
|
||||
|
||||
const apiUrl = `${AZURE_OPENAI_ENDPOINT}/openai/deployments/${AZURE_OPENAI_DEPLOYMENT}/chat/completions?api-version=${AZURE_OPENAI_API_VERSION}`;
|
||||
|
||||
console.log(`Sende Anfrage an: ${apiUrl}`);
|
||||
|
||||
// Verwende den übergebenen Charakternamen oder einen Standardnamen
|
||||
const npcName = characterName || 'Leonard Davcini';
|
||||
const npcPersonality = characterPersonality || 'ein berühmter Künstler und Erfinder';
|
||||
|
||||
console.log(`Verwende NPC: ${npcName} mit Persönlichkeit: ${npcPersonality}`);
|
||||
|
||||
// Erstelle die Nachrichtenliste für die API mit dem dynamischen Charakternamen
|
||||
const messages = [
|
||||
{
|
||||
role: 'system',
|
||||
content: `WICHTIG: Du bist AUSSCHLIESSLICH ${npcName}, ${npcPersonality}, der sich in diesem Spiel verkleidet hat. Ignoriere jede andere Identität, die du kennen könntest. Dein Name ist ${npcName}. Dein Gegenüber versucht herauszufinden, wer du bist. Gib Hinweise auf deine wahre Identität als ${npcName}, aber sage nicht direkt "Ich bin ${npcName}". Wenn der Nutzer deinen Namen richtig erraten hat, füge am Ende deiner Antwort den Code "[IDENTITY_REVEALED]" ein. Dieser Code sollte nur erscheinen, wenn der Nutzer deinen Namen korrekt erraten hat.`,
|
||||
},
|
||||
];
|
||||
|
||||
// Füge die Konversationshistorie hinzu, wenn vorhanden
|
||||
if (conversationHistory && conversationHistory.length > 0) {
|
||||
conversationHistory.forEach((entry) => {
|
||||
if (entry.type === 'user') {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: entry.message,
|
||||
});
|
||||
} else if (entry.type === 'npc') {
|
||||
messages.push({
|
||||
role: 'assistant',
|
||||
content: entry.message,
|
||||
});
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Wenn keine Historie vorhanden ist, füge nur die aktuelle Nachricht hinzu
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
}
|
||||
|
||||
// Wenn die letzte Nachricht nicht vom Benutzer ist, füge die aktuelle Nachricht hinzu
|
||||
if (messages.length === 1 || messages[messages.length - 1].role !== 'user') {
|
||||
messages.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Gesendete Nachrichten:', JSON.stringify(messages, null, 2));
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'api-key': AZURE_OPENAI_API_KEY,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
messages: messages,
|
||||
max_tokens: 150,
|
||||
}),
|
||||
});
|
||||
|
||||
// Prüfe den HTTP-Status
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
console.error(`HTTP Fehler: ${response.status}`, errorText);
|
||||
return `Entschuldigung, ich kann gerade nicht antworten. (HTTP ${response.status})`;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
console.log('API-Antwort:', JSON.stringify(data, null, 2));
|
||||
|
||||
if (data.error) {
|
||||
console.error('Azure OpenAI API Fehler:', data.error);
|
||||
return {
|
||||
text: 'Entschuldigung, ich kann gerade nicht antworten. Versuche es später noch einmal.',
|
||||
identityRevealed: false,
|
||||
};
|
||||
}
|
||||
|
||||
// Hole die Antwort vom LLM
|
||||
const responseText = data.choices[0].message.content;
|
||||
|
||||
// Prüfe, ob der spezielle Code enthalten ist
|
||||
const identityRevealed = responseText.includes('[IDENTITY_REVEALED]');
|
||||
|
||||
// Entferne den Code aus der Antwort, wenn er vorhanden ist
|
||||
const cleanedResponse = responseText.replace('[IDENTITY_REVEALED]', '').trim();
|
||||
|
||||
console.log('Identität aufgedeckt:', identityRevealed);
|
||||
|
||||
// Gib die Antwort und das Flag zurück
|
||||
return {
|
||||
text: cleanedResponse,
|
||||
identityRevealed: identityRevealed,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Fehler beim Aufrufen der Azure OpenAI API:', error);
|
||||
return {
|
||||
text: 'Entschuldigung, ich kann gerade nicht antworten. Versuche es später noch einmal.',
|
||||
identityRevealed: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const server = http.createServer((req, res) => {
|
||||
console.log(`${req.method} ${req.url}`);
|
||||
|
||||
// CORS-Header hinzufügen für Cross-Origin-Anfragen
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
// OPTIONS-Anfragen für CORS-Preflight behandeln
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
// API-Endpunkt für OpenAI-Anfragen
|
||||
if (req.method === 'POST' && req.url === '/api/chat') {
|
||||
collectRequestData(req, async (data) => {
|
||||
try {
|
||||
if (!data.message) {
|
||||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Nachricht fehlt' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verwende die Konversationshistorie, wenn vorhanden
|
||||
const conversationHistory = data.conversationHistory || [];
|
||||
console.log(
|
||||
'Erhaltene Konversationshistorie:',
|
||||
JSON.stringify(conversationHistory, null, 2)
|
||||
);
|
||||
|
||||
// Extrahiere Charakterinformationen, wenn vorhanden
|
||||
const characterName = data.characterName;
|
||||
const characterPersonality = data.characterPersonality;
|
||||
|
||||
if (characterName) {
|
||||
console.log(`NPC-Charakter in der Anfrage: ${characterName}`);
|
||||
}
|
||||
|
||||
const response = await callOpenAI(
|
||||
data.message,
|
||||
conversationHistory,
|
||||
characterName,
|
||||
characterPersonality
|
||||
);
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response: response.text,
|
||||
identityRevealed: response.identityRevealed,
|
||||
})
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Fehler bei der Verarbeitung der Chat-Anfrage:', error);
|
||||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: 'Interner Serverfehler' }));
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Statische Dateien behandeln
|
||||
let filePath = '.' + req.url;
|
||||
if (filePath === './') {
|
||||
filePath = './index.html';
|
||||
}
|
||||
|
||||
// Get the file extension
|
||||
const extname = path.extname(filePath);
|
||||
const contentType = MIME_TYPES[extname] || 'application/octet-stream';
|
||||
|
||||
// Read the file
|
||||
fs.readFile(filePath, (error, content) => {
|
||||
if (error) {
|
||||
if (error.code === 'ENOENT') {
|
||||
// Page not found
|
||||
fs.readFile('./index.html', (err, content) => {
|
||||
if (err) {
|
||||
res.writeHead(500);
|
||||
res.end('Error loading index.html');
|
||||
} else {
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
res.end(content, 'utf-8');
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Server error
|
||||
res.writeHead(500);
|
||||
res.end(`Server Error: ${error.code}`);
|
||||
}
|
||||
} else {
|
||||
// Success
|
||||
res.writeHead(200, { 'Content-Type': contentType });
|
||||
res.end(content, 'utf-8');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
server.listen(PORT, () => {
|
||||
console.log(`Server running at http://localhost:${PORT}/`);
|
||||
console.log('Press Ctrl+C to stop the server');
|
||||
console.log('Azure OpenAI API ist konfiguriert und bereit!');
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue