feat: implement basic RPG mechanics with boss counter-attack
- Add Player and Boss data models (models.py) - Implement command processing via regex matching (attack/heal/status) - Add boss counter-attack mechanism (15 dmg per player attack) - Add player death/respawn system (lose half gold, respawn full HP) - Update frontend with boss HP bar, player stats panel, quick action buttons - Add colored event log (red for attack, green for heal) - Change port from 8000 to 8080 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,63 +1,76 @@
|
|||||||
"""
|
"""
|
||||||
Core Game Engine - The "Heartbeat" of the game.
|
Core Game Engine - The "Heartbeat" of the game.
|
||||||
Runs the main game loop and coordinates between comments and agents.
|
Manages game state, processes commands, and coordinates broadcasts.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import random
|
import re
|
||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
from .schemas import GameEvent, EventType
|
from .schemas import GameEvent, EventType
|
||||||
from .agents import BaseAgent, RuleBasedAgent
|
from .models import Player, Boss
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .server import ConnectionManager
|
from .server import ConnectionManager
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Command patterns for string matching
|
||||||
|
ATTACK_PATTERN = re.compile(r"(attack|攻击|打|砍|杀)", re.IGNORECASE)
|
||||||
|
HEAL_PATTERN = re.compile(r"(heal|治疗|回血|加血|恢复)", re.IGNORECASE)
|
||||||
|
STATUS_PATTERN = re.compile(r"(status|查询|状态|信息)", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Game constants
|
||||||
|
ATTACK_DAMAGE = 10
|
||||||
|
ATTACK_GOLD_REWARD = 10
|
||||||
|
HEAL_AMOUNT = 10
|
||||||
|
BOSS_COUNTER_DAMAGE = 15 # Boss反击伤害
|
||||||
|
|
||||||
|
|
||||||
class GameEngine:
|
class GameEngine:
|
||||||
"""
|
"""
|
||||||
The core game engine that runs the main game loop.
|
The core game engine that manages RPG state and game loop.
|
||||||
|
|
||||||
Simulates live comments, processes them through agents,
|
Manages players, boss, and processes commands through string matching.
|
||||||
and broadcasts responses to connected clients.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, connection_manager: "ConnectionManager") -> None:
|
def __init__(self, connection_manager: "ConnectionManager") -> None:
|
||||||
"""
|
"""
|
||||||
Initialize the game engine.
|
Initialize the game engine with state storage.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
connection_manager: The WebSocket connection manager for broadcasting
|
connection_manager: The WebSocket connection manager for broadcasting
|
||||||
"""
|
"""
|
||||||
self._manager = connection_manager
|
self._manager = connection_manager
|
||||||
self._agent: BaseAgent = RuleBasedAgent(name="Guardian")
|
|
||||||
self._running = False
|
self._running = False
|
||||||
self._tick_count = 0
|
self._tick_count = 0
|
||||||
self._tick_interval = 2.0 # seconds
|
self._tick_interval = 2.0 # seconds
|
||||||
|
|
||||||
# Mock comment templates
|
# Game state
|
||||||
self._mock_users = ["User123", "GamerPro", "DragonSlayer", "NightOwl", "StarGazer"]
|
self.players: dict[str, Player] = {}
|
||||||
self._mock_actions = ["Attack!", "Heal me!", "Run away!", "Help!", "Fire spell!", "Magic blast!"]
|
self.boss = Boss(name="Dragon", hp=1000, max_hp=1000)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def is_running(self) -> bool:
|
def is_running(self) -> bool:
|
||||||
"""Check if the engine is currently running."""
|
"""Check if the engine is currently running."""
|
||||||
return self._running
|
return self._running
|
||||||
|
|
||||||
def _generate_mock_comment(self) -> tuple[str, str]:
|
def _get_or_create_player(self, username: str) -> Player:
|
||||||
"""
|
"""
|
||||||
Generate a mock live comment.
|
Get existing player or create new one.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
username: The player's username
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
Tuple of (username, comment_text)
|
Player instance
|
||||||
"""
|
"""
|
||||||
user = random.choice(self._mock_users)
|
if username not in self.players:
|
||||||
action = random.choice(self._mock_actions)
|
self.players[username] = Player(name=username)
|
||||||
return user, action
|
logger.info(f"New player registered: {username}")
|
||||||
|
return self.players[username]
|
||||||
|
|
||||||
async def _broadcast_event(self, event_type: str, data: dict) -> None:
|
async def _broadcast_event(self, event_type: str, data: dict) -> None:
|
||||||
"""
|
"""
|
||||||
@@ -74,57 +87,201 @@ class GameEngine:
|
|||||||
)
|
)
|
||||||
await self._manager.broadcast(event)
|
await self._manager.broadcast(event)
|
||||||
|
|
||||||
|
async def _broadcast_boss_update(self) -> None:
|
||||||
|
"""Broadcast current boss status to all clients."""
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.BOSS_UPDATE,
|
||||||
|
{
|
||||||
|
"boss_name": self.boss.name,
|
||||||
|
"boss_hp": self.boss.hp,
|
||||||
|
"boss_max_hp": self.boss.max_hp,
|
||||||
|
"boss_hp_percentage": self.boss.hp_percentage
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_attack(self, player: Player) -> None:
|
||||||
|
"""
|
||||||
|
Handle attack command. Boss will counter-attack!
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The attacking player
|
||||||
|
"""
|
||||||
|
if not self.boss.is_alive:
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.SYSTEM,
|
||||||
|
{"message": f"Boss is already defeated! Waiting for respawn..."}
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Player attacks boss
|
||||||
|
damage = self.boss.take_damage(ATTACK_DAMAGE)
|
||||||
|
player.add_gold(ATTACK_GOLD_REWARD)
|
||||||
|
|
||||||
|
# Boss counter-attacks player
|
||||||
|
counter_damage = player.take_damage(BOSS_COUNTER_DAMAGE)
|
||||||
|
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.ATTACK,
|
||||||
|
{
|
||||||
|
"user": player.name,
|
||||||
|
"damage": damage,
|
||||||
|
"counter_damage": counter_damage,
|
||||||
|
"gold_earned": ATTACK_GOLD_REWARD,
|
||||||
|
"boss_hp": self.boss.hp,
|
||||||
|
"boss_max_hp": self.boss.max_hp,
|
||||||
|
"player_hp": player.hp,
|
||||||
|
"player_max_hp": player.max_hp,
|
||||||
|
"player_gold": player.gold,
|
||||||
|
"message": f"⚔️ {player.name} attacked {self.boss.name}! "
|
||||||
|
f"Dealt {damage} dmg, received {counter_damage} counter-attack. "
|
||||||
|
f"HP: {player.hp}/{player.max_hp} | Boss: {self.boss.hp}/{self.boss.max_hp}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._broadcast_boss_update()
|
||||||
|
|
||||||
|
# Check if player died
|
||||||
|
if not player.is_alive:
|
||||||
|
await self._handle_player_death(player)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Check if boss defeated
|
||||||
|
if not self.boss.is_alive:
|
||||||
|
await self._handle_boss_defeated()
|
||||||
|
|
||||||
|
async def _handle_player_death(self, player: Player) -> None:
|
||||||
|
"""Handle player death - respawn with full HP but lose half gold."""
|
||||||
|
lost_gold = player.gold // 2
|
||||||
|
player.gold -= lost_gold
|
||||||
|
player.hp = player.max_hp # Respawn with full HP
|
||||||
|
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.SYSTEM,
|
||||||
|
{
|
||||||
|
"user": player.name,
|
||||||
|
"player_hp": player.hp,
|
||||||
|
"player_max_hp": player.max_hp,
|
||||||
|
"player_gold": player.gold,
|
||||||
|
"message": f"💀 {player.name} was slain by {self.boss.name}! "
|
||||||
|
f"Lost {lost_gold} gold. Respawned with full HP."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_boss_defeated(self) -> None:
|
||||||
|
"""Handle boss defeat and reset."""
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.BOSS_DEFEATED,
|
||||||
|
{
|
||||||
|
"boss_name": self.boss.name,
|
||||||
|
"message": f"🎉 {self.boss.name} has been defeated! A new boss will spawn soon..."
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Reset boss after short delay
|
||||||
|
await asyncio.sleep(3.0)
|
||||||
|
self.boss.reset()
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.SYSTEM,
|
||||||
|
{"message": f"⚔️ {self.boss.name} has respawned with full HP!"}
|
||||||
|
)
|
||||||
|
await self._broadcast_boss_update()
|
||||||
|
|
||||||
|
async def _handle_heal(self, player: Player) -> None:
|
||||||
|
"""
|
||||||
|
Handle heal command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The healing player
|
||||||
|
"""
|
||||||
|
healed = player.heal(HEAL_AMOUNT)
|
||||||
|
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.HEAL,
|
||||||
|
{
|
||||||
|
"user": player.name,
|
||||||
|
"healed": healed,
|
||||||
|
"player_hp": player.hp,
|
||||||
|
"player_max_hp": player.max_hp,
|
||||||
|
"message": f"{player.name} healed themselves. "
|
||||||
|
f"Restored {healed} HP. HP: {player.hp}/{player.max_hp}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def _handle_status(self, player: Player) -> None:
|
||||||
|
"""
|
||||||
|
Handle status query command.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
player: The querying player
|
||||||
|
"""
|
||||||
|
await self._broadcast_event(
|
||||||
|
EventType.STATUS,
|
||||||
|
{
|
||||||
|
"user": player.name,
|
||||||
|
"player_hp": player.hp,
|
||||||
|
"player_max_hp": player.max_hp,
|
||||||
|
"player_gold": player.gold,
|
||||||
|
"boss_name": self.boss.name,
|
||||||
|
"boss_hp": self.boss.hp,
|
||||||
|
"boss_max_hp": self.boss.max_hp,
|
||||||
|
"message": f"📊 {player.name}'s Status - HP: {player.hp}/{player.max_hp}, "
|
||||||
|
f"Gold: {player.gold} | Boss {self.boss.name}: {self.boss.hp}/{self.boss.max_hp}"
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def process_comment(self, user: str, message: str) -> None:
|
async def process_comment(self, user: str, message: str) -> None:
|
||||||
"""
|
"""
|
||||||
Process a comment (real or mock) through the agent pipeline.
|
Process a comment through string matching command system.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
user: Username of the commenter
|
user: Username of the commenter
|
||||||
message: The comment text
|
message: The comment text
|
||||||
"""
|
"""
|
||||||
# Broadcast the incoming comment
|
# Get or create player
|
||||||
|
player = self._get_or_create_player(user)
|
||||||
|
|
||||||
|
# Broadcast the incoming comment first
|
||||||
await self._broadcast_event(
|
await self._broadcast_event(
|
||||||
EventType.COMMENT,
|
EventType.COMMENT,
|
||||||
{"user": user, "message": message}
|
{"user": user, "message": message}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process through agent
|
# Process command through string matching
|
||||||
full_comment = f"{user}: {message}"
|
if ATTACK_PATTERN.search(message):
|
||||||
response = self._agent.process_input(full_comment)
|
await self._handle_attack(player)
|
||||||
|
elif HEAL_PATTERN.search(message):
|
||||||
# Broadcast agent response
|
await self._handle_heal(player)
|
||||||
await self._broadcast_event(
|
elif STATUS_PATTERN.search(message):
|
||||||
EventType.AGENT_RESPONSE,
|
await self._handle_status(player)
|
||||||
{"agent": self._agent.name, "response": response}
|
# If no command matched, treat as regular chat (no action needed)
|
||||||
)
|
|
||||||
|
|
||||||
async def _game_loop(self) -> None:
|
async def _game_loop(self) -> None:
|
||||||
"""
|
"""
|
||||||
The main game loop - runs continuously while engine is active.
|
The main game loop - runs continuously while engine is active.
|
||||||
|
|
||||||
Every tick:
|
Every tick broadcasts current game state.
|
||||||
1. Simulates a mock live comment
|
|
||||||
2. Passes it to the agent
|
|
||||||
3. Broadcasts the response
|
|
||||||
"""
|
"""
|
||||||
logger.info("Game loop started")
|
logger.info("Game loop started")
|
||||||
|
|
||||||
|
# Broadcast initial boss state
|
||||||
|
await self._broadcast_boss_update()
|
||||||
|
|
||||||
while self._running:
|
while self._running:
|
||||||
self._tick_count += 1
|
self._tick_count += 1
|
||||||
|
|
||||||
# Broadcast tick event
|
# Broadcast tick event with game state
|
||||||
await self._broadcast_event(
|
await self._broadcast_event(
|
||||||
EventType.TICK,
|
EventType.TICK,
|
||||||
{"tick": self._tick_count}
|
{
|
||||||
|
"tick": self._tick_count,
|
||||||
|
"boss_hp": self.boss.hp,
|
||||||
|
"boss_max_hp": self.boss.max_hp,
|
||||||
|
"player_count": len(self.players)
|
||||||
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
# Generate and process mock comment
|
logger.debug(f"Tick {self._tick_count}: Boss HP {self.boss.hp}/{self.boss.max_hp}")
|
||||||
user, message = self._generate_mock_comment()
|
|
||||||
logger.info(f"Tick {self._tick_count}: {user} says '{message}'")
|
|
||||||
|
|
||||||
await self.process_comment(user, message)
|
|
||||||
|
|
||||||
# Wait for next tick
|
|
||||||
await asyncio.sleep(self._tick_interval)
|
await asyncio.sleep(self._tick_interval)
|
||||||
|
|
||||||
logger.info("Game loop stopped")
|
logger.info("Game loop stopped")
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ Configures the application, WebSocket routes, and lifecycle events.
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
from fastapi.staticfiles import StaticFiles
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
|
||||||
from .server import ConnectionManager
|
from .server import ConnectionManager
|
||||||
from .engine import GameEngine
|
from .engine import GameEngine
|
||||||
@@ -24,6 +27,9 @@ logger = logging.getLogger(__name__)
|
|||||||
manager = ConnectionManager()
|
manager = ConnectionManager()
|
||||||
engine = GameEngine(manager)
|
engine = GameEngine(manager)
|
||||||
|
|
||||||
|
# Frontend path
|
||||||
|
FRONTEND_DIR = Path(__file__).parent.parent.parent / "frontend"
|
||||||
|
|
||||||
|
|
||||||
@asynccontextmanager
|
@asynccontextmanager
|
||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
@@ -55,9 +61,14 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
allow_headers=["*"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
async def root():
|
async def root():
|
||||||
|
"""Serve the debug client page."""
|
||||||
|
return FileResponse(FRONTEND_DIR / "debug_client.html")
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/health")
|
||||||
|
async def health():
|
||||||
"""Health check endpoint."""
|
"""Health check endpoint."""
|
||||||
return {
|
return {
|
||||||
"status": "running",
|
"status": "running",
|
||||||
@@ -100,3 +111,7 @@ async def websocket_endpoint(websocket: WebSocket):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"WebSocket error: {e}")
|
logger.error(f"WebSocket error: {e}")
|
||||||
manager.disconnect(websocket)
|
manager.disconnect(websocket)
|
||||||
|
|
||||||
|
|
||||||
|
# Mount static files (must be after all routes)
|
||||||
|
app.mount("/static", StaticFiles(directory=FRONTEND_DIR), name="static")
|
||||||
|
|||||||
66
backend/app/models.py
Normal file
66
backend/app/models.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
Game entity models for The Island.
|
||||||
|
Defines Player and Boss data structures.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class Player(BaseModel):
|
||||||
|
"""
|
||||||
|
Represents a player in the game world.
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
hp: int = Field(default=100, ge=0)
|
||||||
|
max_hp: int = Field(default=100, gt=0)
|
||||||
|
gold: int = Field(default=0, ge=0)
|
||||||
|
|
||||||
|
def take_damage(self, amount: int) -> int:
|
||||||
|
"""Apply damage to player, returns actual damage dealt."""
|
||||||
|
actual = min(amount, self.hp)
|
||||||
|
self.hp -= actual
|
||||||
|
return actual
|
||||||
|
|
||||||
|
def heal(self, amount: int) -> int:
|
||||||
|
"""Heal player, returns actual HP restored."""
|
||||||
|
actual = min(amount, self.max_hp - self.hp)
|
||||||
|
self.hp += actual
|
||||||
|
return actual
|
||||||
|
|
||||||
|
def add_gold(self, amount: int) -> None:
|
||||||
|
"""Add gold to player."""
|
||||||
|
self.gold += amount
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
"""Check if player is alive."""
|
||||||
|
return self.hp > 0
|
||||||
|
|
||||||
|
|
||||||
|
class Boss(BaseModel):
|
||||||
|
"""
|
||||||
|
Represents a boss enemy in the game.
|
||||||
|
"""
|
||||||
|
name: str
|
||||||
|
hp: int = Field(ge=0)
|
||||||
|
max_hp: int = Field(gt=0)
|
||||||
|
|
||||||
|
def take_damage(self, amount: int) -> int:
|
||||||
|
"""Apply damage to boss, returns actual damage dealt."""
|
||||||
|
actual = min(amount, self.hp)
|
||||||
|
self.hp -= actual
|
||||||
|
return actual
|
||||||
|
|
||||||
|
def reset(self) -> None:
|
||||||
|
"""Reset boss to full HP."""
|
||||||
|
self.hp = self.max_hp
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_alive(self) -> bool:
|
||||||
|
"""Check if boss is alive."""
|
||||||
|
return self.hp > 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def hp_percentage(self) -> float:
|
||||||
|
"""Get HP as percentage."""
|
||||||
|
return (self.hp / self.max_hp) * 100 if self.max_hp > 0 else 0
|
||||||
@@ -16,6 +16,13 @@ class EventType(str, Enum):
|
|||||||
TICK = "tick"
|
TICK = "tick"
|
||||||
SYSTEM = "system"
|
SYSTEM = "system"
|
||||||
ERROR = "error"
|
ERROR = "error"
|
||||||
|
# RPG-specific events
|
||||||
|
ATTACK = "attack"
|
||||||
|
HEAL = "heal"
|
||||||
|
STATUS = "status"
|
||||||
|
BOSS_UPDATE = "boss_update"
|
||||||
|
BOSS_DEFEATED = "boss_defeated"
|
||||||
|
PLAYER_UPDATE = "player_update"
|
||||||
|
|
||||||
|
|
||||||
class GameEvent(BaseModel):
|
class GameEvent(BaseModel):
|
||||||
|
|||||||
189
frontend/app.js
189
frontend/app.js
@@ -1,10 +1,17 @@
|
|||||||
/**
|
/**
|
||||||
* The Island - Debug Client JavaScript
|
* The Island - Debug Client JavaScript
|
||||||
* Handles WebSocket connection and UI interactions
|
* Handles WebSocket connection, UI interactions, and game state display
|
||||||
*/
|
*/
|
||||||
|
|
||||||
let ws = null;
|
let ws = null;
|
||||||
const WS_URL = 'ws://localhost:8000/ws';
|
const WS_URL = 'ws://localhost:8080/ws';
|
||||||
|
|
||||||
|
// Player state (tracked from server events)
|
||||||
|
let playerState = {
|
||||||
|
hp: 100,
|
||||||
|
maxHp: 100,
|
||||||
|
gold: 0
|
||||||
|
};
|
||||||
|
|
||||||
// DOM Elements
|
// DOM Elements
|
||||||
const statusDot = document.getElementById('statusDot');
|
const statusDot = document.getElementById('statusDot');
|
||||||
@@ -14,6 +21,17 @@ const eventLog = document.getElementById('eventLog');
|
|||||||
const usernameInput = document.getElementById('username');
|
const usernameInput = document.getElementById('username');
|
||||||
const messageInput = document.getElementById('message');
|
const messageInput = document.getElementById('message');
|
||||||
const autoScrollCheckbox = document.getElementById('autoScroll');
|
const autoScrollCheckbox = document.getElementById('autoScroll');
|
||||||
|
const hideTicksCheckbox = document.getElementById('hideTicks');
|
||||||
|
|
||||||
|
// Boss UI Elements
|
||||||
|
const bossName = document.getElementById('bossName');
|
||||||
|
const bossHpText = document.getElementById('bossHpText');
|
||||||
|
const bossHealthBar = document.getElementById('bossHealthBar');
|
||||||
|
const bossHealthLabel = document.getElementById('bossHealthLabel');
|
||||||
|
|
||||||
|
// Player UI Elements
|
||||||
|
const playerHpDisplay = document.getElementById('playerHp');
|
||||||
|
const playerGoldDisplay = document.getElementById('playerGold');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle WebSocket connection
|
* Toggle WebSocket connection
|
||||||
@@ -59,7 +77,7 @@ function connect() {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
const data = JSON.parse(event.data);
|
||||||
logEvent(data);
|
handleGameEvent(data);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to parse message:', e);
|
console.error('Failed to parse message:', e);
|
||||||
}
|
}
|
||||||
@@ -67,7 +85,132 @@ function connect() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send a mock comment to the server
|
* Handle incoming game events
|
||||||
|
*/
|
||||||
|
function handleGameEvent(event) {
|
||||||
|
const eventType = event.event_type;
|
||||||
|
const data = event.data || {};
|
||||||
|
|
||||||
|
// Update UI based on event type
|
||||||
|
switch (eventType) {
|
||||||
|
case 'boss_update':
|
||||||
|
updateBossUI(data);
|
||||||
|
break;
|
||||||
|
case 'attack':
|
||||||
|
updateBossFromAttack(data);
|
||||||
|
updatePlayerFromEvent(data);
|
||||||
|
break;
|
||||||
|
case 'heal':
|
||||||
|
updatePlayerFromEvent(data);
|
||||||
|
break;
|
||||||
|
case 'status':
|
||||||
|
updatePlayerFromEvent(data);
|
||||||
|
updateBossFromStatus(data);
|
||||||
|
break;
|
||||||
|
case 'system':
|
||||||
|
// Handle player death/respawn updates
|
||||||
|
if (data.user && data.player_hp !== undefined) {
|
||||||
|
updatePlayerFromEvent(data);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'tick':
|
||||||
|
if (data.boss_hp !== undefined) {
|
||||||
|
updateBossUI({
|
||||||
|
boss_hp: data.boss_hp,
|
||||||
|
boss_max_hp: data.boss_max_hp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the event
|
||||||
|
logEvent(event);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update Boss health bar UI
|
||||||
|
*/
|
||||||
|
function updateBossUI(data) {
|
||||||
|
if (data.boss_name) {
|
||||||
|
bossName.textContent = data.boss_name;
|
||||||
|
}
|
||||||
|
if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) {
|
||||||
|
const hp = data.boss_hp;
|
||||||
|
const maxHp = data.boss_max_hp;
|
||||||
|
const percentage = maxHp > 0 ? (hp / maxHp) * 100 : 0;
|
||||||
|
|
||||||
|
bossHpText.textContent = `HP: ${hp} / ${maxHp}`;
|
||||||
|
bossHealthBar.style.width = `${percentage}%`;
|
||||||
|
bossHealthLabel.textContent = `${Math.round(percentage)}%`;
|
||||||
|
|
||||||
|
// Change color based on HP percentage
|
||||||
|
if (percentage <= 25) {
|
||||||
|
bossHealthBar.style.background = 'linear-gradient(90deg, #ff2222 0%, #ff4444 100%)';
|
||||||
|
} else if (percentage <= 50) {
|
||||||
|
bossHealthBar.style.background = 'linear-gradient(90deg, #ff6600 0%, #ff8844 100%)';
|
||||||
|
} else {
|
||||||
|
bossHealthBar.style.background = 'linear-gradient(90deg, #ff4444 0%, #ff6666 100%)';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update boss from attack event
|
||||||
|
*/
|
||||||
|
function updateBossFromAttack(data) {
|
||||||
|
if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) {
|
||||||
|
updateBossUI({
|
||||||
|
boss_hp: data.boss_hp,
|
||||||
|
boss_max_hp: data.boss_max_hp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update boss from status event
|
||||||
|
*/
|
||||||
|
function updateBossFromStatus(data) {
|
||||||
|
if (data.boss_hp !== undefined && data.boss_max_hp !== undefined) {
|
||||||
|
updateBossUI({
|
||||||
|
boss_name: data.boss_name,
|
||||||
|
boss_hp: data.boss_hp,
|
||||||
|
boss_max_hp: data.boss_max_hp
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update player stats from event data
|
||||||
|
*/
|
||||||
|
function updatePlayerFromEvent(data) {
|
||||||
|
const currentUser = usernameInput.value.trim() || 'Anonymous';
|
||||||
|
|
||||||
|
// Only update if this event is for the current user
|
||||||
|
if (data.user !== currentUser) return;
|
||||||
|
|
||||||
|
if (data.player_hp !== undefined) {
|
||||||
|
playerState.hp = data.player_hp;
|
||||||
|
}
|
||||||
|
if (data.player_max_hp !== undefined) {
|
||||||
|
playerState.maxHp = data.player_max_hp;
|
||||||
|
}
|
||||||
|
if (data.player_gold !== undefined) {
|
||||||
|
playerState.gold = data.player_gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlayerUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update player stats UI
|
||||||
|
*/
|
||||||
|
function updatePlayerUI() {
|
||||||
|
playerHpDisplay.textContent = `${playerState.hp}/${playerState.maxHp}`;
|
||||||
|
playerGoldDisplay.textContent = playerState.gold;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a comment/command to the server
|
||||||
*/
|
*/
|
||||||
function sendComment() {
|
function sendComment() {
|
||||||
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
@@ -92,6 +235,25 @@ function sendComment() {
|
|||||||
messageInput.value = '';
|
messageInput.value = '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Quick action buttons
|
||||||
|
*/
|
||||||
|
function quickAction(action) {
|
||||||
|
if (!ws || ws.readyState !== WebSocket.OPEN) {
|
||||||
|
alert('Not connected to server');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = usernameInput.value.trim() || 'Anonymous';
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
action: 'send_comment',
|
||||||
|
payload: { user, message: action }
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format timestamp for display
|
* Format timestamp for display
|
||||||
*/
|
*/
|
||||||
@@ -110,10 +272,17 @@ function formatEventData(eventType, data) {
|
|||||||
case 'agent_response':
|
case 'agent_response':
|
||||||
return data.response;
|
return data.response;
|
||||||
case 'tick':
|
case 'tick':
|
||||||
return `Tick #${data.tick}`;
|
return `Tick #${data.tick} | Players: ${data.player_count || 0}`;
|
||||||
case 'system':
|
case 'system':
|
||||||
case 'error':
|
case 'error':
|
||||||
return data.message;
|
return data.message;
|
||||||
|
case 'attack':
|
||||||
|
case 'heal':
|
||||||
|
case 'status':
|
||||||
|
case 'boss_defeated':
|
||||||
|
return data.message;
|
||||||
|
case 'boss_update':
|
||||||
|
return `Boss ${data.boss_name}: ${data.boss_hp}/${data.boss_max_hp} (${Math.round(data.boss_hp_percentage)}%)`;
|
||||||
default:
|
default:
|
||||||
return JSON.stringify(data);
|
return JSON.stringify(data);
|
||||||
}
|
}
|
||||||
@@ -127,6 +296,16 @@ function logEvent(event) {
|
|||||||
const timestamp = event.timestamp || Date.now() / 1000;
|
const timestamp = event.timestamp || Date.now() / 1000;
|
||||||
const data = event.data || {};
|
const data = event.data || {};
|
||||||
|
|
||||||
|
// Skip tick events if checkbox is checked
|
||||||
|
if (eventType === 'tick' && hideTicksCheckbox && hideTicksCheckbox.checked) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip boss_update events to reduce log noise (they're reflected in the UI)
|
||||||
|
if (eventType === 'boss_update') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = `event ${eventType}`;
|
div.className = `event ${eventType}`;
|
||||||
div.innerHTML = `
|
div.innerHTML = `
|
||||||
|
|||||||
@@ -51,6 +51,88 @@
|
|||||||
background: #44ff44;
|
background: #44ff44;
|
||||||
box-shadow: 0 0 10px rgba(68, 255, 68, 0.5);
|
box-shadow: 0 0 10px rgba(68, 255, 68, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boss Health Bar */
|
||||||
|
.boss-panel {
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
border: 1px solid rgba(255, 68, 68, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.boss-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.boss-name {
|
||||||
|
color: #ff6666;
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.boss-hp-text {
|
||||||
|
color: #ffaaaa;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
.health-bar-container {
|
||||||
|
width: 100%;
|
||||||
|
height: 30px;
|
||||||
|
background: rgba(0, 0, 0, 0.4);
|
||||||
|
border-radius: 15px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.health-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, #ff4444 0%, #ff6666 100%);
|
||||||
|
border-radius: 15px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
.health-bar-label {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
font-weight: bold;
|
||||||
|
text-shadow: 1px 1px 2px rgba(0,0,0,0.8);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Player Stats Panel */
|
||||||
|
.player-panel {
|
||||||
|
background: rgba(0, 212, 255, 0.1);
|
||||||
|
border: 1px solid rgba(0, 212, 255, 0.3);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 20px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.player-header {
|
||||||
|
color: #00d4ff;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
.player-stats {
|
||||||
|
display: flex;
|
||||||
|
gap: 30px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.stat-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
.stat-value {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
.panels {
|
.panels {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -104,6 +186,19 @@
|
|||||||
background: #666;
|
background: #666;
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
.quick-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.quick-btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.quick-btn.attack { background: #ff6666; }
|
||||||
|
.quick-btn.heal { background: #44ff88; }
|
||||||
|
.quick-btn.status { background: #ffaa44; }
|
||||||
.controls {
|
.controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 15px;
|
gap: 15px;
|
||||||
@@ -117,7 +212,7 @@
|
|||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.event-log {
|
.event-log {
|
||||||
height: 400px;
|
height: 350px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: rgba(0, 0, 0, 0.4);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -133,9 +228,15 @@
|
|||||||
}
|
}
|
||||||
.event.comment { border-color: #ffaa00; background: rgba(255, 170, 0, 0.1); }
|
.event.comment { border-color: #ffaa00; background: rgba(255, 170, 0, 0.1); }
|
||||||
.event.agent_response { border-color: #00ff88; background: rgba(0, 255, 136, 0.1); }
|
.event.agent_response { border-color: #00ff88; background: rgba(0, 255, 136, 0.1); }
|
||||||
.event.tick { border-color: #888; background: rgba(136, 136, 136, 0.1); }
|
.event.tick { border-color: #888; background: rgba(136, 136, 136, 0.05); opacity: 0.6; }
|
||||||
.event.system { border-color: #00d4ff; background: rgba(0, 212, 255, 0.1); }
|
.event.system { border-color: #00d4ff; background: rgba(0, 212, 255, 0.1); }
|
||||||
.event.error { border-color: #ff4444; background: rgba(255, 68, 68, 0.1); }
|
.event.error { border-color: #ff4444; background: rgba(255, 68, 68, 0.1); }
|
||||||
|
/* RPG Event Styles */
|
||||||
|
.event.attack { border-color: #ff4444; background: rgba(255, 68, 68, 0.15); color: #ff8888; }
|
||||||
|
.event.heal { border-color: #44ff88; background: rgba(68, 255, 136, 0.15); color: #88ffaa; }
|
||||||
|
.event.status { border-color: #ffaa44; background: rgba(255, 170, 68, 0.1); }
|
||||||
|
.event.boss_update { border-color: #ff6666; background: rgba(255, 102, 102, 0.1); }
|
||||||
|
.event.boss_defeated { border-color: #ffdd00; background: rgba(255, 221, 0, 0.2); color: #ffee88; font-weight: bold; }
|
||||||
.event-time { color: #888; font-size: 11px; }
|
.event-time { color: #888; font-size: 11px; }
|
||||||
.event-type { font-weight: bold; text-transform: uppercase; font-size: 11px; }
|
.event-type { font-weight: bold; text-transform: uppercase; font-size: 11px; }
|
||||||
.event-data { margin-top: 5px; }
|
.event-data { margin-top: 5px; }
|
||||||
@@ -153,14 +254,52 @@
|
|||||||
<button id="connectBtn" onclick="toggleConnection()">Connect</button>
|
<button id="connectBtn" onclick="toggleConnection()">Connect</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Boss Health Panel -->
|
||||||
|
<div class="boss-panel">
|
||||||
|
<div class="boss-header">
|
||||||
|
<span class="boss-name" id="bossName">Dragon</span>
|
||||||
|
<span class="boss-hp-text" id="bossHpText">HP: 1000 / 1000</span>
|
||||||
|
</div>
|
||||||
|
<div class="health-bar-container">
|
||||||
|
<div class="health-bar" id="bossHealthBar" style="width: 100%"></div>
|
||||||
|
<span class="health-bar-label" id="bossHealthLabel">100%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Player Stats Panel -->
|
||||||
|
<div class="player-panel">
|
||||||
|
<div class="player-header">Your Stats</div>
|
||||||
|
<div class="player-stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-icon">❤️</span>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value" id="playerHp">100/100</div>
|
||||||
|
<div class="stat-label">HP</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-icon">💰</span>
|
||||||
|
<div>
|
||||||
|
<div class="stat-value" id="playerGold">0</div>
|
||||||
|
<div class="stat-label">Gold</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="panels">
|
<div class="panels">
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
<h2>Send Mock Comment</h2>
|
<h2>Send Command</h2>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input type="text" id="username" placeholder="Username" value="TestUser">
|
<input type="text" id="username" placeholder="Username" value="TestUser">
|
||||||
<input type="text" id="message" placeholder="Message (try: Attack!, Heal, Help...)">
|
<input type="text" id="message" placeholder="Message (attack, heal, status...)">
|
||||||
<button onclick="sendComment()">Send</button>
|
<button onclick="sendComment()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<button class="quick-btn attack" onclick="quickAction('attack')">⚔️ Attack</button>
|
||||||
|
<button class="quick-btn heal" onclick="quickAction('heal')">💚 Heal</button>
|
||||||
|
<button class="quick-btn status" onclick="quickAction('status')">📊 Status</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="panel">
|
<div class="panel">
|
||||||
@@ -168,6 +307,7 @@
|
|||||||
<div class="controls">
|
<div class="controls">
|
||||||
<button onclick="clearLog()">Clear Log</button>
|
<button onclick="clearLog()">Clear Log</button>
|
||||||
<label><input type="checkbox" id="autoScroll" checked> Auto-scroll</label>
|
<label><input type="checkbox" id="autoScroll" checked> Auto-scroll</label>
|
||||||
|
<label><input type="checkbox" id="hideTicks"> Hide ticks</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="event-log" id="eventLog"></div>
|
<div class="event-log" id="eventLog"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user