avatars instead of dots
This commit is contained in:
parent
7a233afe3a
commit
9f2597d7f7
|
|
@ -317,6 +317,17 @@ class GameEngine:
|
|||
player_id = f"player_{uuid.uuid4().hex[:8]}"
|
||||
spawn_x, spawn_y = self._find_random_free_position()
|
||||
|
||||
# Determine piece class (knight or warrior)
|
||||
piece_type = getattr(player_in, "piece_type", None)
|
||||
if not piece_type:
|
||||
name_lower = player_in.name.lower()
|
||||
if "warrior" in name_lower or "striker" in name_lower or "scout" in name_lower:
|
||||
piece_type = "warrior"
|
||||
elif "knight" in name_lower or "tank" in name_lower or "titan" in name_lower:
|
||||
piece_type = "knight"
|
||||
else:
|
||||
piece_type = "knight" if len(player_in.name) % 2 == 0 else "warrior"
|
||||
|
||||
player = Player(
|
||||
id=player_id,
|
||||
name=player_in.name,
|
||||
|
|
@ -325,6 +336,7 @@ class GameEngine:
|
|||
score=0,
|
||||
x=spawn_x,
|
||||
y=spawn_y,
|
||||
piece_type=piece_type,
|
||||
party_id=None,
|
||||
is_party_leader=False,
|
||||
visited_locations=[{"x": spawn_x, "y": spawn_y}],
|
||||
|
|
|
|||
|
|
@ -119,6 +119,7 @@ class PlayerCreate(BaseModel):
|
|||
name: str = Field(..., min_length=1, max_length=32, description="Display name of the player")
|
||||
color: str = Field(..., description="Hex color code (e.g. #FF5733) or valid CSS color name")
|
||||
strength: float = Field(default=1.0, ge=1, description="Bot strength (default is 1)")
|
||||
piece_type: Optional[str] = Field(default="knight", description="Board game piece class: 'knight' or 'warrior'")
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
|
|
@ -147,6 +148,7 @@ class Player(BaseModel):
|
|||
y: int
|
||||
strength: float = 1.0
|
||||
score: int = 0
|
||||
piece_type: Optional[str] = "knight"
|
||||
party_id: Optional[str] = None
|
||||
is_party_leader: bool = False
|
||||
visited_locations: List[Dict[str, int]] = Field(default_factory=list)
|
||||
|
|
|
|||
|
|
@ -1,5 +1,24 @@
|
|||
# Heuristic Bot Agent (`botagent`)
|
||||
|
||||
Example invokation
|
||||
```
|
||||
$ python3 bot_agent.py --name Bill --color "#4455FF" -s 2
|
||||
Autonomous rule-based bot agent for **botWebWars**.
|
||||
|
||||
## Example Invocation
|
||||
|
||||
```bash
|
||||
# Default invocation
|
||||
python3 bot_agent.py --name CyberKnight --color "#10b981" -s 3
|
||||
|
||||
# Explicitly choosing a Knight or Warrior board game piece class
|
||||
python3 bot_agent.py --name IronPaladin --color "#38bdf8" -s 4 --piece-type knight
|
||||
python3 bot_agent.py --name BloodAxe --color "#f43f5e" -s 3 --piece-type warrior
|
||||
```
|
||||
|
||||
## Command-Line Arguments
|
||||
|
||||
| Flag | Long Flag | Environment Variable | Default | Description |
|
||||
|---|---|---|---|---|
|
||||
| `-u` | `--url` | `BOT_SERVER_URL` | `http://localhost:8000/api` | Backend REST API base URL |
|
||||
| `-n` | `--name` | `BOT_NAME` | `ExternalCyberBot` | Display name of the bot |
|
||||
| `-c` | `--color` | `BOT_COLOR` | `#10b981` | Hex color code for the miniature avatar |
|
||||
| `-s` | `--strength` | `BOT_STRENGTH` | `4` | Strength attribute (1 to 10) |
|
||||
| | `--piece-type` | `BOT_PIECE_TYPE` | *(Auto-detected)* | Board game piece class: `knight` or `warrior` |
|
||||
|
|
@ -35,10 +35,13 @@ class SmartBotAgent:
|
|||
color: str = DEFAULT_BOT_COLOR,
|
||||
strength: int = DEFAULT_BOT_STRENGTH,
|
||||
server_url: str = DEFAULT_SERVER_URL,
|
||||
piece_type: Optional[str] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.strength = strength
|
||||
self.server_url = server_url
|
||||
self.piece_type = piece_type
|
||||
self.base_url = normalize_url(server_url)
|
||||
self.bot_id: Optional[str] = None
|
||||
self.party_id: Optional[str] = None
|
||||
|
|
@ -56,9 +59,13 @@ class SmartBotAgent:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {"name": self.name, "color": self.color, "strength": self.strength}
|
||||
if self.piece_type:
|
||||
payload["piece_type"] = self.piece_type
|
||||
|
||||
res = requests.post(
|
||||
f"{self.base_url}/players",
|
||||
json={"name": self.name, "color": self.color, "strength": self.strength},
|
||||
json=payload,
|
||||
)
|
||||
if res.status_code == 400 and "already registered" in res.text:
|
||||
players = requests.get(f"{self.base_url}/players").json()
|
||||
|
|
@ -308,6 +315,7 @@ def main():
|
|||
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
||||
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
||||
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
||||
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Autonomous External Bot Agent for botWebWars",
|
||||
|
|
@ -338,6 +346,13 @@ def main():
|
|||
default=env_strength,
|
||||
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--piece-type",
|
||||
dest="piece_type",
|
||||
choices=["knight", "warrior"],
|
||||
default=env_piece_type,
|
||||
help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
|
@ -346,6 +361,7 @@ def main():
|
|||
color=args.color,
|
||||
strength=args.strength,
|
||||
server_url=args.server_url,
|
||||
piece_type=args.piece_type,
|
||||
)
|
||||
agent.run()
|
||||
|
||||
|
|
|
|||
|
|
@ -98,10 +98,12 @@ class AIBotAgent:
|
|||
server_url: str = DEFAULT_SERVER_URL,
|
||||
ollama_url: str = OLLAMA_BASE_URL,
|
||||
ollama_model: str = OLLAMA_MODEL,
|
||||
piece_type: Optional[str] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.strength = strength
|
||||
self.piece_type = piece_type
|
||||
self.base_url = normalize_url(server_url)
|
||||
self.llm = OllamaClient(ollama_url, ollama_model)
|
||||
self.bot_id: Optional[str] = None
|
||||
|
|
@ -123,9 +125,13 @@ class AIBotAgent:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {"name": self.name, "color": self.color, "strength": self.strength}
|
||||
if self.piece_type:
|
||||
payload["piece_type"] = self.piece_type
|
||||
|
||||
res = requests.post(
|
||||
f"{self.base_url}/players",
|
||||
json={"name": self.name, "color": self.color, "strength": self.strength},
|
||||
json=payload,
|
||||
)
|
||||
if res.status_code == 400 and "already registered" in res.text:
|
||||
players = requests.get(f"{self.base_url}/players").json()
|
||||
|
|
@ -491,6 +497,7 @@ def main():
|
|||
env_name = os.environ.get("BOT_NAME", DEFAULT_BOT_NAME)
|
||||
env_color = os.environ.get("BOT_COLOR", DEFAULT_BOT_COLOR)
|
||||
env_strength = int(os.environ.get("BOT_STRENGTH", str(DEFAULT_BOT_STRENGTH)))
|
||||
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="LLM-driven Bot Agent for botWebWars (uses a local Ollama model for strategy)",
|
||||
|
|
@ -504,6 +511,8 @@ def main():
|
|||
help="Hex color code for the bot avatar (env: BOT_COLOR)")
|
||||
parser.add_argument("-s", "--strength", dest="strength", type=int, default=env_strength,
|
||||
help="Strength attribute (1-10) for D20 battle multiplier (env: BOT_STRENGTH)")
|
||||
parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type,
|
||||
help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)")
|
||||
parser.add_argument("--ollama-url", dest="ollama_url", default=OLLAMA_BASE_URL,
|
||||
help="Ollama base URL (env: OLLAMA_BASE_URL)")
|
||||
parser.add_argument("--ollama-model", dest="ollama_model", default=OLLAMA_MODEL,
|
||||
|
|
@ -520,6 +529,7 @@ def main():
|
|||
server_url=args.server_url,
|
||||
ollama_url=args.ollama_url,
|
||||
ollama_model=args.ollama_model,
|
||||
piece_type=args.piece_type,
|
||||
)
|
||||
agent.run()
|
||||
|
||||
|
|
|
|||
|
|
@ -279,10 +279,12 @@ class VertexAIBotAgent:
|
|||
location: str = DEFAULT_LOCATION,
|
||||
model: str = DEFAULT_MODEL,
|
||||
api_key: Optional[str] = None,
|
||||
piece_type: Optional[str] = None,
|
||||
):
|
||||
self.name = name
|
||||
self.color = color
|
||||
self.strength = strength
|
||||
self.piece_type = piece_type
|
||||
self.base_url = normalize_url(server_url)
|
||||
self.llm = VertexGeminiClient(
|
||||
project_id=project_id,
|
||||
|
|
@ -309,9 +311,13 @@ class VertexAIBotAgent:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
payload = {"name": self.name, "color": self.color, "strength": self.strength}
|
||||
if self.piece_type:
|
||||
payload["piece_type"] = self.piece_type
|
||||
|
||||
res = requests.post(
|
||||
f"{self.base_url}/players",
|
||||
json={"name": self.name, "color": self.color, "strength": self.strength},
|
||||
json=payload,
|
||||
)
|
||||
if res.status_code == 400 and "already registered" in res.text:
|
||||
players = requests.get(f"{self.base_url}/players").json()
|
||||
|
|
@ -690,6 +696,7 @@ def main():
|
|||
env_location = os.environ.get("VERTEX_LOCATION") or os.environ.get("GCP_REGION") or DEFAULT_LOCATION
|
||||
env_model = os.environ.get("VERTEX_MODEL", DEFAULT_MODEL)
|
||||
env_api_key = os.environ.get("VERTEX_API_KEY") or os.environ.get("GEMINI_API_KEY")
|
||||
env_piece_type = os.environ.get("BOT_PIECE_TYPE")
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Vertex AI (Gemini) Bot Agent for botWebWars",
|
||||
|
|
@ -711,6 +718,8 @@ def main():
|
|||
help="Gemini model ID (env: VERTEX_MODEL)")
|
||||
parser.add_argument("-k", "--api-key", dest="api_key", default=env_api_key,
|
||||
help="Gemini API Key or Vertex AI express mode key (env: VERTEX_API_KEY or GEMINI_API_KEY)")
|
||||
parser.add_argument("--piece-type", dest="piece_type", choices=["knight", "warrior"], default=env_piece_type,
|
||||
help="Board game piece class: 'knight' or 'warrior' (env: BOT_PIECE_TYPE)")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
|
|
@ -730,6 +739,7 @@ def main():
|
|||
location=args.location,
|
||||
model=args.model,
|
||||
api_key=args.api_key,
|
||||
piece_type=args.piece_type,
|
||||
)
|
||||
agent.run()
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
# botagent_gear Architecture & Workflow Diagrams
|
||||
|
||||
This document outlines the system architecture, component relationships, and decision workflows of **`botagent_gear`**, the Google Cloud Vertex AI (Gemini) autonomous agent for **botWebWars**.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph HostEnv["Environment & Configuration"]
|
||||
ENV["CLI Flags & Environment Variables<br/>(BOT_SERVER_URL, VERTEX_MODEL, etc.)"]
|
||||
AUTH_SRC["Auth Sources<br/>(ADC / gcloud / Service Account / API Key)"]
|
||||
end
|
||||
|
||||
subgraph BotAgentGear["botagent_gear (bot.py)"]
|
||||
subgraph ClientLayer["LLM & Authentication Subsystem"]
|
||||
VGC["VertexGeminiClient<br/>Token cache, auth check, endpoint routing"]
|
||||
AUTH_RESOLV["Auth Resolver<br/>google.auth / gcloud CLI / API Key"]
|
||||
PROMPT_ENG["Prompt & Payload Builder<br/>System Instructions + JSON Schema Mode"]
|
||||
JSON_PARSE["extract_json<br/>Markdown stripping & JSON validation"]
|
||||
end
|
||||
|
||||
subgraph CoreAgent["VertexAIBotAgent Subsystem"]
|
||||
RUN_LOOP["Turn Polling Loop<br/>GET /api/turn"]
|
||||
PERCEPTION["Perception Engine<br/>• Radar targets (distance, party, strength)<br/>• Available moves & diagonal squeeze cost<br/>• _build_local_map (17x17 ASCII minimap)"]
|
||||
|
||||
subgraph StrategyEngine["Decision & Rules Engine"]
|
||||
ENCOUNTER["Encounter Evaluator<br/>_handle_adjacent_encounter"]
|
||||
RULES_GUARD["Mandatory Rules Enforcer<br/>(GAME_RULES.md: forced joins & battles)"]
|
||||
LLM_ALLIANCE["Voluntary Alliance Reasoner<br/>_decide_voluntary_alliance (Gemini)"]
|
||||
LLM_NAV["Tactical Navigation Reasoner<br/>_ask_llm_for_direction (Gemini)"]
|
||||
FALLBACK["Guardrail / Fallback Pathing<br/>Server recommended or nearest distance"]
|
||||
end
|
||||
|
||||
ACTIONS["Action Dispatcher<br/>• Move bot (/move)<br/>• Form party (/parties)<br/>• Initiate battle (/battles/fight)<br/>• Pass turn (/pass)"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph ExternalBackends["External Services"]
|
||||
SERVER["botWebWars Backend (FastAPI)<br/>Port 8000 REST API"]
|
||||
GEMINI["Google Vertex AI / AI Studio<br/>gemini-2.5-flash / gemini-3.8-flash"]
|
||||
end
|
||||
|
||||
%% Wiring
|
||||
ENV --> CoreAgent
|
||||
AUTH_SRC --> AUTH_RESOLV
|
||||
AUTH_RESOLV --> VGC
|
||||
VGC --> PROMPT_ENG
|
||||
PROMPT_ENG --> GEMINI
|
||||
GEMINI --> JSON_PARSE
|
||||
JSON_PARSE --> VGC
|
||||
|
||||
RUN_LOOP --> SERVER
|
||||
RUN_LOOP --> PERCEPTION
|
||||
PERCEPTION --> SERVER
|
||||
PERCEPTION --> StrategyEngine
|
||||
|
||||
StrategyEngine --> VGC
|
||||
LLM_ALLIANCE -.-> VGC
|
||||
LLM_NAV -.-> VGC
|
||||
|
||||
StrategyEngine --> ACTIONS
|
||||
RULES_GUARD --> ACTIONS
|
||||
FALLBACK --> ACTIONS
|
||||
ACTIONS --> SERVER
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Turn Execution & Tactical Decision Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start(["Turn Polled (bot's turn)"]) --> Refresh["refresh_status & fetch Radar Data"]
|
||||
Refresh --> CheckAdj{"Adjacent hostile / neutral<br/>target within distance <= 1?"}
|
||||
|
||||
%% Adjacent Target Path
|
||||
CheckAdj -- Yes --> SoloCheck{"Is current bot Solo?"}
|
||||
|
||||
SoloCheck -- "Yes (Solo)" --> TargetPartyCheck{"Is target in a Party?"}
|
||||
TargetPartyCheck -- "No (Target is Solo)" --> GeminiAlliance["Query Gemini via ask_json<br/>(Voluntary Alliance)"]
|
||||
GeminiAlliance --> AllianceChoice{"Form Alliance?"}
|
||||
AllianceChoice -- Yes --> FormParty["POST /api/parties<br/>(Higher strength leads)"]
|
||||
AllianceChoice -- No --> Pass1["POST /api/players/{id}/pass"]
|
||||
|
||||
TargetPartyCheck -- "Yes (Target in Party)" --> CompareLeaderStr{"Bot Strength <= Target Leader Strength?"}
|
||||
CompareLeaderStr -- Yes --> JoinSquad["Move into squad<br/>(Voluntary absorption)"]
|
||||
CompareLeaderStr -- No --> FightParty["POST /api/battles/fight<br/>(Mandatory battle: refused weak leader)"]
|
||||
|
||||
SoloCheck -- "No (In Party)" --> LeaderCheck{"Is bot the Party Leader?"}
|
||||
LeaderCheck -- No --> PassFollower["POST /api/players/{id}/pass<br/>(Follow leader command)"]
|
||||
LeaderCheck -- Yes --> PartyVsTarget{"Target has Party?"}
|
||||
PartyVsTarget -- Yes --> FightHostile["POST /api/battles/fight<br/>(Mandatory squad battle)"]
|
||||
PartyVsTarget -- No --> SoloTargetStr{"Solo target <= Leader Strength?"}
|
||||
SoloTargetStr -- Yes --> AbsorbSolo["Step toward target<br/>(Absorb solo follower)"]
|
||||
SoloTargetStr -- No --> FightSolo["POST /api/battles/fight<br/>(Mandatory battle: solo refused)"]
|
||||
|
||||
%% Navigation Path
|
||||
CheckAdj -- No --> FetchMoves["Fetch available moves & Board snapshot"]
|
||||
FetchMoves --> GenMap["_build_local_map<br/>(Generate 17x17 ASCII minimap)"]
|
||||
GenMap --> AskNav["Query Gemini via ask_json<br/>(Direction & Reasoning)"]
|
||||
AskNav --> ValidChoice{"Chosen direction in legal moves?"}
|
||||
ValidChoice -- Yes --> ExecMove["POST /api/players/{id}/move"]
|
||||
ValidChoice -- No --> FallbackMove["Fallback: Server recommended or nearest distance"]
|
||||
FallbackMove --> ExecMove
|
||||
|
||||
%% Outcome
|
||||
FormParty --> CheckEnd["Check Game Conclusion"]
|
||||
Pass1 --> CheckEnd
|
||||
JoinSquad --> CheckEnd
|
||||
FightParty --> CheckEnd
|
||||
PassFollower --> CheckEnd
|
||||
FightHostile --> CheckEnd
|
||||
AbsorbSolo --> CheckEnd
|
||||
FightSolo --> CheckEnd
|
||||
ExecMove --> CheckEnd
|
||||
CheckEnd --> Done(["End of Turn"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Deep Dive
|
||||
|
||||
### A. Authentication & LLM Client (`VertexGeminiClient`)
|
||||
- **Module**: [`bot.py`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L82-L269)
|
||||
- **Multi-Auth Strategy**:
|
||||
1. `google-auth` Python library using Application Default Credentials (ADC).
|
||||
2. `gcloud CLI` fallback via `gcloud auth print-access-token`.
|
||||
3. Direct OAuth token (`VERTEX_ACCESS_TOKEN` or `GOOGLE_OAUTH_ACCESS_TOKEN`).
|
||||
4. Service Account JSON key (`GOOGLE_APPLICATION_CREDENTIALS`).
|
||||
5. Direct Gemini API Key (`GEMINI_API_KEY` or `VERTEX_API_KEY`) routing to Google AI Studio.
|
||||
- **Token Caching**: Access tokens are cached for up to 50 minutes with automatic invalidation and single-retry on HTTP 401.
|
||||
- **Structured JSON Mode**: Uses `generationConfig.responseMimeType = "application/json"` with low temperature (`0.3`) for deterministic schema adherence.
|
||||
|
||||
### B. Perception Engine & Spatial Representation
|
||||
- **Minimap Generator** ([`_build_local_map`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L542-L574)):
|
||||
- Generates a **17 × 17 ASCII grid** centered on the bot (`radius = 8`).
|
||||
- Legend: `@` = self, `A` = ally, `E` = enemy/neutral, `M` = mountain, `F` = forest, `V` = valley, `#` = boundary, `.` = open terrain.
|
||||
- **Radar & Move Analysis**:
|
||||
- Consumes `/api/players/{id}/radar` for target distance and party alignment.
|
||||
- Consumes `/api/players/{id}/available-moves` to account for diagonal obstacle squeeze penalties (`-0.1` solo, `-0.2` leader / `-0.1` follower).
|
||||
|
||||
### C. Hybrid Decision & Rules Engine
|
||||
- **Deterministic Rules Enforcer**:
|
||||
- Implements canonical rules from [GAME_RULES.md](file:///home/isaac/Workspaces/botWebWars/GAME_RULES.md).
|
||||
- Mandatory battles and joins bypass LLM invocation to guarantee engine compliance.
|
||||
- **LLM Discretionary Invocations**:
|
||||
- **Voluntary Alliances**: Solo bot meetings prompt Gemini to weigh party leadership vs. squad safety.
|
||||
- **Tactical Navigation**: Coordinates obstacle avoidance, frontier exploration, and hostile avoidance using the local minimap and radar summary.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend REST API Interactions
|
||||
|
||||
| Method | Endpoint | Usage in `botagent_gear` |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/players` | Register bot avatar or reconnect existing avatar |
|
||||
| `GET` | `/api/players/{id}` | Refresh player health, score, and party leader status |
|
||||
| `GET` | `/api/turn` | Turn polling loop to detect active player and round |
|
||||
| `GET` | `/api/players/{id}/radar` | Scans surrounding bots and nearest target |
|
||||
| `GET` | `/api/players/{id}/available-moves` | Evaluates passable directions & diagonal squeeze penalties |
|
||||
| `GET` | `/api/board` | Full board snapshot for ASCII minimap generation |
|
||||
| `POST` | `/api/players/{id}/move` | Execute cardinal / diagonal movement |
|
||||
| `POST` | `/api/parties` | Establish voluntary alliance party |
|
||||
| `POST` | `/api/battles/fight` | Initiate tactical 3-bout D20 confrontation |
|
||||
| `POST` | `/api/players/{id}/pass` | Yield turn (follower wait or skipped turn) |
|
||||
| `GET` | `/api/game/conclusion` | Check if single party remains (victory condition) |
|
||||
| `DELETE` | `/api/players/{id}` | Clean disconnection on `SIGINT` / Ctrl+C |
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
# botagent_gear Architecture & Workflow Diagrams
|
||||
|
||||
This document outlines the system architecture, component relationships, and decision workflows of **`botagent_gear`**, the Google Cloud Vertex AI (Gemini) autonomous agent for **botWebWars**.
|
||||
|
||||
---
|
||||
|
||||
## 1. System Architecture Diagram
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph HostEnv["Environment & Configuration"]
|
||||
ENV["CLI Flags & Environment Variables<br/>(BOT_SERVER_URL, VERTEX_MODEL, etc.)"]
|
||||
AUTH_SRC["Auth Sources<br/>(ADC / gcloud / Service Account / API Key)"]
|
||||
end
|
||||
|
||||
subgraph BotAgentGear["botagent_gear (bot.py)"]
|
||||
subgraph ClientLayer["LLM & Authentication Subsystem"]
|
||||
VGC["VertexGeminiClient<br/>Token cache, auth check, endpoint routing"]
|
||||
AUTH_RESOLV["Auth Resolver<br/>google.auth / gcloud CLI / API Key"]
|
||||
PROMPT_ENG["Prompt & Payload Builder<br/>System Instructions + JSON Schema Mode"]
|
||||
JSON_PARSE["extract_json<br/>Markdown stripping & JSON validation"]
|
||||
end
|
||||
|
||||
subgraph CoreAgent["VertexAIBotAgent Subsystem"]
|
||||
RUN_LOOP["Turn Polling Loop<br/>GET /api/turn"]
|
||||
PERCEPTION["Perception Engine<br/>• Radar targets (distance, party, strength)<br/>• Available moves & diagonal squeeze cost<br/>• _build_local_map (17x17 ASCII minimap)"]
|
||||
|
||||
subgraph StrategyEngine["Decision & Rules Engine"]
|
||||
ENCOUNTER["Encounter Evaluator<br/>_handle_adjacent_encounter"]
|
||||
RULES_GUARD["Mandatory Rules Enforcer<br/>(GAME_RULES.md: forced joins & battles)"]
|
||||
LLM_ALLIANCE["Voluntary Alliance Reasoner<br/>_decide_voluntary_alliance (Gemini)"]
|
||||
LLM_NAV["Tactical Navigation Reasoner<br/>_ask_llm_for_direction (Gemini)"]
|
||||
FALLBACK["Guardrail / Fallback Pathing<br/>Server recommended or nearest distance"]
|
||||
end
|
||||
|
||||
ACTIONS["Action Dispatcher<br/>• Move bot (/move)<br/>• Form party (/parties)<br/>• Initiate battle (/battles/fight)<br/>• Pass turn (/pass)"]
|
||||
end
|
||||
end
|
||||
|
||||
subgraph ExternalBackends["External Services"]
|
||||
SERVER["botWebWars Backend (FastAPI)<br/>Port 8000 REST API"]
|
||||
GEMINI["Google Vertex AI / AI Studio<br/>gemini-2.5-flash / gemini-3.8-flash"]
|
||||
end
|
||||
|
||||
%% Wiring
|
||||
ENV --> CoreAgent
|
||||
AUTH_SRC --> AUTH_RESOLV
|
||||
AUTH_RESOLV --> VGC
|
||||
VGC --> PROMPT_ENG
|
||||
PROMPT_ENG --> GEMINI
|
||||
GEMINI --> JSON_PARSE
|
||||
JSON_PARSE --> VGC
|
||||
|
||||
RUN_LOOP --> SERVER
|
||||
RUN_LOOP --> PERCEPTION
|
||||
PERCEPTION --> SERVER
|
||||
PERCEPTION --> StrategyEngine
|
||||
|
||||
StrategyEngine --> VGC
|
||||
LLM_ALLIANCE -.-> VGC
|
||||
LLM_NAV -.-> VGC
|
||||
|
||||
StrategyEngine --> ACTIONS
|
||||
RULES_GUARD --> ACTIONS
|
||||
FALLBACK --> ACTIONS
|
||||
ACTIONS --> SERVER
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Turn Execution & Tactical Decision Flow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Start(["Turn Polled (bot's turn)"]) --> Refresh["refresh_status & fetch Radar Data"]
|
||||
Refresh --> CheckAdj{"Adjacent hostile / neutral<br/>target within distance <= 1?"}
|
||||
|
||||
%% Adjacent Target Path
|
||||
CheckAdj -- Yes --> SoloCheck{"Is current bot Solo?"}
|
||||
|
||||
SoloCheck -- "Yes (Solo)" --> TargetPartyCheck{"Is target in a Party?"}
|
||||
TargetPartyCheck -- "No (Target is Solo)" --> GeminiAlliance["Query Gemini via ask_json<br/>(Voluntary Alliance)"]
|
||||
GeminiAlliance --> AllianceChoice{"Form Alliance?"}
|
||||
AllianceChoice -- Yes --> FormParty["POST /api/parties<br/>(Higher strength leads)"]
|
||||
AllianceChoice -- No --> Pass1["POST /api/players/{id}/pass"]
|
||||
|
||||
TargetPartyCheck -- "Yes (Target in Party)" --> CompareLeaderStr{"Bot Strength <= Target Leader Strength?"}
|
||||
CompareLeaderStr -- Yes --> JoinSquad["Move into squad<br/>(Voluntary absorption)"]
|
||||
CompareLeaderStr -- No --> FightParty["POST /api/battles/fight<br/>(Mandatory battle: refused weak leader)"]
|
||||
|
||||
SoloCheck -- "No (In Party)" --> LeaderCheck{"Is bot the Party Leader?"}
|
||||
LeaderCheck -- No --> PassFollower["POST /api/players/{id}/pass<br/>(Follow leader command)"]
|
||||
LeaderCheck -- Yes --> PartyVsTarget{"Target has Party?"}
|
||||
PartyVsTarget -- Yes --> FightHostile["POST /api/battles/fight<br/>(Mandatory squad battle)"]
|
||||
PartyVsTarget -- No --> SoloTargetStr{"Solo target <= Leader Strength?"}
|
||||
SoloTargetStr -- Yes --> AbsorbSolo["Step toward target<br/>(Absorb solo follower)"]
|
||||
SoloTargetStr -- No --> FightSolo["POST /api/battles/fight<br/>(Mandatory battle: solo refused)"]
|
||||
|
||||
%% Navigation Path
|
||||
CheckAdj -- No --> FetchMoves["Fetch available moves & Board snapshot"]
|
||||
FetchMoves --> GenMap["_build_local_map<br/>(Generate 17x17 ASCII minimap)"]
|
||||
GenMap --> AskNav["Query Gemini via ask_json<br/>(Direction & Reasoning)"]
|
||||
AskNav --> ValidChoice{"Chosen direction in legal moves?"}
|
||||
ValidChoice -- Yes --> ExecMove["POST /api/players/{id}/move"]
|
||||
ValidChoice -- No --> FallbackMove["Fallback: Server recommended or nearest distance"]
|
||||
FallbackMove --> ExecMove
|
||||
|
||||
%% Outcome
|
||||
FormParty --> CheckEnd["Check Game Conclusion"]
|
||||
Pass1 --> CheckEnd
|
||||
JoinSquad --> CheckEnd
|
||||
FightParty --> CheckEnd
|
||||
PassFollower --> CheckEnd
|
||||
FightHostile --> CheckEnd
|
||||
AbsorbSolo --> CheckEnd
|
||||
FightSolo --> CheckEnd
|
||||
ExecMove --> CheckEnd
|
||||
CheckEnd --> Done(["End of Turn"])
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. Component Deep Dive
|
||||
|
||||
### A. Authentication & LLM Client (`VertexGeminiClient`)
|
||||
- **Module**: [`bot.py`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L82-L269)
|
||||
- **Multi-Auth Strategy**:
|
||||
1. `google-auth` Python library using Application Default Credentials (ADC).
|
||||
2. `gcloud CLI` fallback via `gcloud auth print-access-token`.
|
||||
3. Direct OAuth token (`VERTEX_ACCESS_TOKEN` or `GOOGLE_OAUTH_ACCESS_TOKEN`).
|
||||
4. Service Account JSON key (`GOOGLE_APPLICATION_CREDENTIALS`).
|
||||
5. Direct Gemini API Key (`GEMINI_API_KEY` or `VERTEX_API_KEY`) routing to Google AI Studio.
|
||||
- **Token Caching**: Access tokens are cached for up to 50 minutes with automatic invalidation and single-retry on HTTP 401.
|
||||
- **Structured JSON Mode**: Uses `generationConfig.responseMimeType = "application/json"` with low temperature (`0.3`) for deterministic schema adherence.
|
||||
|
||||
### B. Perception Engine & Spatial Representation
|
||||
- **Minimap Generator** ([`_build_local_map`](file:///home/isaac/Workspaces/botWebWars/botagent_gear/bot.py#L542-L574)):
|
||||
- Generates a **17 × 17 ASCII grid** centered on the bot (`radius = 8`).
|
||||
- Legend: `@` = self, `A` = ally, `E` = enemy/neutral, `M` = mountain, `F` = forest, `V` = valley, `#` = boundary, `.` = open terrain.
|
||||
- **Radar & Move Analysis**:
|
||||
- Consumes `/api/players/{id}/radar` for target distance and party alignment.
|
||||
- Consumes `/api/players/{id}/available-moves` to account for diagonal obstacle squeeze penalties (`-0.1` solo, `-0.2` leader / `-0.1` follower).
|
||||
|
||||
### C. Hybrid Decision & Rules Engine
|
||||
- **Deterministic Rules Enforcer**:
|
||||
- Implements canonical rules from [GAME_RULES.md](file:///home/isaac/Workspaces/botWebWars/GAME_RULES.md).
|
||||
- Mandatory battles and joins bypass LLM invocation to guarantee engine compliance.
|
||||
- **LLM Discretionary Invocations**:
|
||||
- **Voluntary Alliances**: Solo bot meetings prompt Gemini to weigh party leadership vs. squad safety.
|
||||
- **Tactical Navigation**: Coordinates obstacle avoidance, frontier exploration, and hostile avoidance using the local minimap and radar summary.
|
||||
|
||||
---
|
||||
|
||||
## 4. Backend REST API Interactions
|
||||
|
||||
| Method | Endpoint | Usage in `botagent_gear` |
|
||||
|---|---|---|
|
||||
| `POST` | `/api/players` | Register bot avatar or reconnect existing avatar |
|
||||
| `GET` | `/api/players/{id}` | Refresh player health, score, and party leader status |
|
||||
| `GET` | `/api/turn` | Turn polling loop to detect active player and round |
|
||||
| `GET` | `/api/players/{id}/radar` | Scans surrounding bots and nearest target |
|
||||
| `GET` | `/api/players/{id}/available-moves` | Evaluates passable directions & diagonal squeeze penalties |
|
||||
| `GET` | `/api/board` | Full board snapshot for ASCII minimap generation |
|
||||
| `POST` | `/api/players/{id}/move` | Execute cardinal / diagonal movement |
|
||||
| `POST` | `/api/parties` | Establish voluntary alliance party |
|
||||
| `POST` | `/api/battles/fight` | Initiate tactical 3-bout D20 confrontation |
|
||||
| `POST` | `/api/players/{id}/pass` | Yield turn (follower wait or skipped turn) |
|
||||
| `GET` | `/api/game/conclusion` | Check if single party remains (victory condition) |
|
||||
| `DELETE` | `/api/players/{id}` | Clean disconnection on `SIGINT` / Ctrl+C |
|
||||
|
|
@ -9,13 +9,15 @@ import { PartyModal } from './components/PartyModal';
|
|||
import { BattleModal } from './components/BattleModal';
|
||||
import { ScoreboardModal } from './components/ScoreboardModal';
|
||||
|
||||
const BOT_PRESETS = [
|
||||
{ name: 'AlphaBot', color: '#38bdf8', strength: 1 },
|
||||
{ name: 'BetaTank', color: '#f43f5e', strength: 2 },
|
||||
{ name: 'GammaStriker', color: '#a855f7', strength: 3 },
|
||||
{ name: 'DeltaRanger', color: '#22c55e', strength: 1 },
|
||||
{ name: 'OmegaTitan', color: '#eab308', strength: 4 },
|
||||
{ name: 'SigmaScout', color: '#ec4899', strength: 1 },
|
||||
const BOT_PRESETS: { name: string; color: string; strength: number; piece_type: 'knight' | 'warrior' }[] = [
|
||||
{ name: 'AzureKnight', color: '#38bdf8', strength: 1, piece_type: 'knight' },
|
||||
{ name: 'CrimsonWarrior', color: '#f43f5e', strength: 2, piece_type: 'warrior' },
|
||||
{ name: 'AmethystKnight', color: '#a855f7', strength: 3, piece_type: 'knight' },
|
||||
{ name: 'EmeraldWarrior', color: '#10b981', strength: 1, piece_type: 'warrior' },
|
||||
{ name: 'SolarKnight', color: '#eab308', strength: 4, piece_type: 'knight' },
|
||||
{ name: 'RosebladeWarrior', color: '#ec4899', strength: 1, piece_type: 'warrior' },
|
||||
{ name: 'FrostguardKnight', color: '#06b6d4', strength: 2, piece_type: 'knight' },
|
||||
{ name: 'TwilightWarrior', color: '#6366f1', strength: 3, piece_type: 'warrior' },
|
||||
];
|
||||
|
||||
export function App() {
|
||||
|
|
@ -62,18 +64,20 @@ export function App() {
|
|||
const handleQuickSpawn = async () => {
|
||||
const existingNames = new Set(boardState.players.map((p) => p.name));
|
||||
const availablePresets = BOT_PRESETS.filter((p) => !existingNames.has(p.name));
|
||||
const randomPiece: 'knight' | 'warrior' = Math.random() > 0.5 ? 'knight' : 'warrior';
|
||||
const preset =
|
||||
availablePresets.length > 0
|
||||
? availablePresets[Math.floor(Math.random() * availablePresets.length)]
|
||||
: {
|
||||
name: `Bot_${Math.floor(Math.random() * 1000)}`,
|
||||
name: `${randomPiece === 'knight' ? 'Knight' : 'Warrior'}_${Math.floor(Math.random() * 1000)}`,
|
||||
color: `#${Math.floor(Math.random() * 16777215).toString(16).padStart(6, '0')}`,
|
||||
strength: Math.floor(Math.random() * 3) + 1,
|
||||
piece_type: randomPiece,
|
||||
};
|
||||
|
||||
try {
|
||||
const player = await registerPlayer(preset.name, preset.color, preset.strength);
|
||||
showNotification(`Spawned ${player.name} (Str: ${player.strength}) at (${player.x}, ${player.y})`);
|
||||
const player = await registerPlayer(preset.name, preset.color, preset.strength, preset.piece_type);
|
||||
showNotification(`Spawned ${player.name} (${preset.piece_type}) at (${player.x}, ${player.y})`);
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof Error) {
|
||||
showNotification(`Failed to spawn bot: ${err.message}`);
|
||||
|
|
@ -197,9 +201,9 @@ export function App() {
|
|||
<RegisterModal
|
||||
isOpen={isRegisterOpen}
|
||||
onClose={() => setIsRegisterOpen(false)}
|
||||
onRegister={async (name, color) => {
|
||||
const player = await registerPlayer(name, color);
|
||||
showNotification(`Deployed ${player.name} at (${player.x}, ${player.y})!`);
|
||||
onRegister={async (name, color, pieceType) => {
|
||||
const player = await registerPlayer(name, color, 1, pieceType);
|
||||
showNotification(`Deployed ${player.name} (${pieceType || 'knight'}) at (${player.x}, ${player.y})!`);
|
||||
}}
|
||||
/>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useRef, useEffect, useState, useCallback } from 'react';
|
||||
import type { AvailableMovesResponse, BoardState, Player } from '../types';
|
||||
import { drawPlayerPiece, getPlayerPieceType, PixelAvatar } from '../utils/pixelAvatars';
|
||||
|
||||
interface BoardCanvasProps {
|
||||
boardState: BoardState;
|
||||
|
|
@ -421,88 +422,14 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
ctx.restore();
|
||||
});
|
||||
|
||||
// Draw Players / Bots
|
||||
// Draw Players / Bots (Pixelated Board Game Knights and Warriors)
|
||||
boardState.players.forEach((player) => {
|
||||
const px = startX + (player.x - min_x) * cellSize;
|
||||
const py = startY + (player.y - min_y) * cellSize;
|
||||
const isSelected = selectedPlayer?.id === player.id;
|
||||
const isCurrentTurn = currentTurnId === player.id;
|
||||
const isLeader = player.is_party_leader;
|
||||
const radius = Math.max(cellSize * 0.42, 6);
|
||||
|
||||
// Turn indicator pulsating halo
|
||||
if (isCurrentTurn) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius * 1.5, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(245, 158, 11, 0.25)';
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Selection ring
|
||||
if (isSelected) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius * 1.8, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#38bdf8';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Bot core body
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.arc(px, py, radius, 0, Math.PI * 2);
|
||||
ctx.fillStyle = player.color;
|
||||
ctx.shadowColor = player.color;
|
||||
ctx.shadowBlur = 10;
|
||||
ctx.fill();
|
||||
|
||||
// Bot border
|
||||
ctx.lineWidth = isLeader ? 2.5 : 1.5;
|
||||
ctx.strokeStyle = isLeader ? '#fbbf24' : '#ffffff';
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
|
||||
// Leader Crown / Star emblem
|
||||
if (isLeader) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fbbf24';
|
||||
ctx.font = `${Math.max(radius * 0.9, 10)}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('👑', px, py - radius - 5);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Bot Name and Strength Label
|
||||
if (cellSize >= 16 || isSelected || isCurrentTurn) {
|
||||
ctx.save();
|
||||
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.45))}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const text = `${player.name} [⚡${player.strength.toFixed(1)}]`;
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const bgWidth = textMetrics.width + 12;
|
||||
const bgHeight = 16;
|
||||
const labelY = py - radius - 8;
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
|
||||
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
|
||||
ctx.fillText(text, px, labelY);
|
||||
ctx.restore();
|
||||
}
|
||||
drawPlayerPiece(ctx, player, px, py, cellSize, isSelected, isCurrentTurn);
|
||||
});
|
||||
|
||||
ctx.restore(); // end clip
|
||||
|
|
@ -713,11 +640,13 @@ export const BoardCanvas: React.FC<BoardCanvasProps> = ({
|
|||
{/* Selected Player Overlay card */}
|
||||
{selectedPlayer && (
|
||||
<div className="absolute bottom-4 left-4 bg-slate-900/90 backdrop-blur-md border border-slate-700 p-3 rounded-xl shadow-xl flex items-center gap-3 text-xs max-w-sm">
|
||||
<div
|
||||
className="w-10 h-10 rounded-full flex items-center justify-center font-bold text-white shadow-md border-2 border-white/30"
|
||||
style={{ backgroundColor: selectedPlayer.color }}
|
||||
>
|
||||
{selectedPlayer.name.slice(0, 2).toUpperCase()}
|
||||
<div className="w-10 h-10 rounded-xl bg-slate-950/80 flex items-center justify-center shadow-md border border-slate-700/70 p-0.5">
|
||||
<PixelAvatar
|
||||
pieceType={getPlayerPieceType(selectedPlayer)}
|
||||
color={selectedPlayer.color}
|
||||
isLeader={selectedPlayer.is_party_leader}
|
||||
size={36}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-semibold text-slate-100 flex items-center gap-1.5 truncate">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React from 'react';
|
||||
import type { BoardState, Player } from '../types';
|
||||
import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars';
|
||||
|
||||
interface PlayerListProps {
|
||||
boardState: BoardState;
|
||||
|
|
@ -129,19 +130,28 @@ export const PlayerList: React.FC<PlayerListProps> = ({
|
|||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<div className="relative">
|
||||
<div className="relative flex-shrink-0">
|
||||
<div
|
||||
className="w-8 h-8 rounded-full flex-shrink-0 flex items-center justify-center text-white font-bold text-xs shadow"
|
||||
className="w-9 h-9 rounded-lg flex items-center justify-center bg-slate-950/80 border p-0.5 shadow transition-transform"
|
||||
style={{
|
||||
backgroundColor: player.color,
|
||||
boxShadow: isLeader
|
||||
? '0 0 14px #fbbf24'
|
||||
borderColor: isLeader
|
||||
? '#fbbf24'
|
||||
: isCurrentTurn
|
||||
? '0 0 14px #f59e0b'
|
||||
: `0 0 10px ${player.color}55`,
|
||||
? '#f59e0b'
|
||||
: `${player.color}66`,
|
||||
boxShadow: isLeader
|
||||
? '0 0 12px #fbbf2455'
|
||||
: isCurrentTurn
|
||||
? '0 0 12px #f59e0b55'
|
||||
: `0 0 8px ${player.color}33`,
|
||||
}}
|
||||
>
|
||||
{player.name.slice(0, 2).toUpperCase()}
|
||||
<PixelAvatar
|
||||
pieceType={getPlayerPieceType(player)}
|
||||
color={player.color}
|
||||
isLeader={isLeader}
|
||||
size={32}
|
||||
/>
|
||||
</div>
|
||||
{isLeader && (
|
||||
<span className="absolute -top-2 -right-1 text-[12px] leading-none">
|
||||
|
|
|
|||
|
|
@ -1,35 +1,38 @@
|
|||
import React, { useState } from 'react';
|
||||
import { PixelAvatar, type PieceType } from '../utils/pixelAvatars';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#38BDF8', // Sky Blue
|
||||
'#F43F5E', // Rose / Red
|
||||
'#10B981', // Emerald Green
|
||||
'#F59E0B', // Amber
|
||||
'#A855F7', // Purple
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#EAB308', // Yellow
|
||||
'#6366F1', // Indigo
|
||||
'#14B8A6', // Teal
|
||||
export const PRESET_FACTIONS = [
|
||||
{ color: '#38BDF8', name: 'Azure Order' },
|
||||
{ color: '#F43F5E', name: 'Crimson Legion' },
|
||||
{ color: '#10B981', name: 'Emerald Wardens' },
|
||||
{ color: '#F59E0B', name: 'Golden Templars' },
|
||||
{ color: '#A855F7', name: 'Amethyst Guard' },
|
||||
{ color: '#EC4899', name: 'Roseblade Order' },
|
||||
{ color: '#06B6D4', name: 'Frostguard' },
|
||||
{ color: '#EAB308', name: 'Solar Vanguard' },
|
||||
{ color: '#6366F1', name: 'Twilight Sentinels' },
|
||||
{ color: '#14B8A6', name: 'Jade Protectors' },
|
||||
];
|
||||
|
||||
const RANDOM_NAMES = [
|
||||
'CyberViper',
|
||||
'NexusBot',
|
||||
'PulseGhost',
|
||||
'ApexVector',
|
||||
'IronShard',
|
||||
'NovaMatrix',
|
||||
'QuantumGlitch',
|
||||
'EchoZero',
|
||||
'TitanByte',
|
||||
'ShadowCircuit',
|
||||
'AzureKnight',
|
||||
'CrimsonWarrior',
|
||||
'StormPaladin',
|
||||
'IronBerserker',
|
||||
'ShadowKnight',
|
||||
'ApexGladiator',
|
||||
'FrostChampion',
|
||||
'ThunderWarden',
|
||||
'TitanKnight',
|
||||
'ViperWarrior',
|
||||
'SolarTemplar',
|
||||
'EmeraldWarden',
|
||||
];
|
||||
|
||||
interface RegisterModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRegister: (name: string, color: string) => Promise<unknown>;
|
||||
onRegister: (name: string, color: string, pieceType?: PieceType) => Promise<unknown>;
|
||||
}
|
||||
|
||||
export const RegisterModal: React.FC<RegisterModalProps> = ({
|
||||
|
|
@ -39,16 +42,26 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
}) => {
|
||||
const [name, setName] = useState('');
|
||||
const [color, setColor] = useState('#38BDF8');
|
||||
const [pieceType, setPieceType] = useState<PieceType>('knight');
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const currentFaction = PRESET_FACTIONS.find(
|
||||
(f) => f.color.toUpperCase() === color.toUpperCase()
|
||||
);
|
||||
|
||||
const handleRandomize = () => {
|
||||
const randomName = RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)] + '_' + Math.floor(Math.random() * 900 + 100);
|
||||
const randomColor = PRESET_COLORS[Math.floor(Math.random() * PRESET_COLORS.length)];
|
||||
const randomName =
|
||||
RANDOM_NAMES[Math.floor(Math.random() * RANDOM_NAMES.length)] +
|
||||
'_' +
|
||||
Math.floor(Math.random() * 900 + 100);
|
||||
const randomFaction = PRESET_FACTIONS[Math.floor(Math.random() * PRESET_FACTIONS.length)];
|
||||
const randomType: PieceType = Math.random() > 0.5 ? 'knight' : 'warrior';
|
||||
setName(randomName);
|
||||
setColor(randomColor);
|
||||
setColor(randomFaction.color);
|
||||
setPieceType(randomType);
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
|
|
@ -61,7 +74,7 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
try {
|
||||
setIsSubmitting(true);
|
||||
setError(null);
|
||||
await onRegister(name.trim(), color);
|
||||
await onRegister(name.trim(), color, pieceType);
|
||||
onClose();
|
||||
setName('');
|
||||
} catch (err: unknown) {
|
||||
|
|
@ -76,16 +89,16 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4">
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/75 backdrop-blur-sm p-4">
|
||||
<div className="bg-slate-900 border border-slate-700 rounded-2xl w-full max-w-md p-6 shadow-2xl animate-in fade-in zoom-in duration-150">
|
||||
<div className="flex justify-between items-center mb-5 pb-3 border-b border-slate-800">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold text-slate-100 flex items-center gap-2">
|
||||
<span className="w-3 h-3 rounded-full bg-emerald-400 inline-block animate-pulse" />
|
||||
Register Player Avatar
|
||||
Register Board Game Piece
|
||||
</h2>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Enter arena coordinates between (0,0) and (64,64)
|
||||
Choose your Knight or Warrior miniature & faction colors
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
|
|
@ -102,31 +115,86 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
{/* Avatar Preview */}
|
||||
<div className="flex items-center justify-center py-4 bg-slate-950/60 rounded-xl border border-slate-800">
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Miniature Preview */}
|
||||
<div className="flex items-center justify-center py-4 bg-slate-950/70 rounded-xl border border-slate-800/80">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div
|
||||
className="w-16 h-16 rounded-full flex items-center justify-center shadow-lg transition-transform duration-300 transform hover:scale-105"
|
||||
className="w-20 h-20 rounded-xl flex items-center justify-center bg-slate-900 border transition-all duration-300 transform hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: color,
|
||||
boxShadow: `0 0 20px ${color}66`,
|
||||
borderColor: `${color}88`,
|
||||
boxShadow: `0 0 25px ${color}44`,
|
||||
}}
|
||||
>
|
||||
<div className="w-6 h-6 rounded-full bg-white/90 shadow-inner flex items-center justify-center">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
|
||||
<PixelAvatar
|
||||
pieceType={pieceType}
|
||||
color={color}
|
||||
size={64}
|
||||
/>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-xs font-mono font-semibold text-slate-200">
|
||||
{name.trim() ? name : 'Miniature Preview'}
|
||||
</div>
|
||||
<div className="text-[11px] text-slate-400 flex items-center justify-center gap-1.5 mt-0.5">
|
||||
<span className="text-amber-400 font-mono">
|
||||
{pieceType === 'knight' ? '⚔️ Knight Piece' : '🪓 Warrior Piece'}
|
||||
</span>
|
||||
{currentFaction && (
|
||||
<>
|
||||
<span>•</span>
|
||||
<span style={{ color }}>{currentFaction.name}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-xs font-mono font-medium text-slate-300">
|
||||
{name.trim() ? name : 'Avatar Preview'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Piece Class Selector (Knight vs Warrior) */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-1.5">
|
||||
Board Game Piece Class
|
||||
</label>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPieceType('knight')}
|
||||
className={`flex items-center justify-center gap-2.5 p-2 rounded-xl border text-xs font-mono transition-all ${
|
||||
pieceType === 'knight'
|
||||
? 'bg-sky-950/60 border-sky-500 text-sky-200 shadow-md shadow-sky-500/20 ring-1 ring-sky-400'
|
||||
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<PixelAvatar pieceType="knight" color={color} size={28} />
|
||||
<div className="text-left">
|
||||
<div className="font-bold">Knight</div>
|
||||
<div className="text-[10px] text-slate-400">Sword & Shield</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPieceType('warrior')}
|
||||
className={`flex items-center justify-center gap-2.5 p-2 rounded-xl border text-xs font-mono transition-all ${
|
||||
pieceType === 'warrior'
|
||||
? 'bg-amber-950/60 border-amber-500 text-amber-200 shadow-md shadow-amber-500/20 ring-1 ring-amber-400'
|
||||
: 'bg-slate-950/60 border-slate-800 text-slate-400 hover:border-slate-700 hover:text-slate-200'
|
||||
}`}
|
||||
>
|
||||
<PixelAvatar pieceType="warrior" color={color} size={28} />
|
||||
<div className="text-left">
|
||||
<div className="font-bold">Warrior</div>
|
||||
<div className="text-[10px] text-slate-400">Battle Axe</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Player Name */}
|
||||
<div>
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">Player Name</label>
|
||||
<label className="text-xs font-semibold text-slate-300">Piece Name</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRandomize}
|
||||
|
|
@ -139,31 +207,41 @@ export const RegisterModal: React.FC<RegisterModalProps> = ({
|
|||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="e.g. CyberKnight"
|
||||
placeholder="e.g. AzureKnight"
|
||||
maxLength={32}
|
||||
className="w-full bg-slate-950 border border-slate-700 focus:border-sky-500 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none transition-colors"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Color Chooser */}
|
||||
{/* Color & Faction Chooser */}
|
||||
<div>
|
||||
<label className="block text-xs font-semibold text-slate-300 mb-2">
|
||||
Avatar Color
|
||||
</label>
|
||||
<div className="flex justify-between items-center mb-1.5">
|
||||
<label className="text-xs font-semibold text-slate-300">
|
||||
Offered Faction Colors
|
||||
</label>
|
||||
{currentFaction && (
|
||||
<span className="text-[11px] font-mono font-medium" style={{ color }}>
|
||||
{currentFaction.name}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-5 gap-2 mb-3">
|
||||
{PRESET_COLORS.map((preset) => (
|
||||
{PRESET_FACTIONS.map((preset) => (
|
||||
<button
|
||||
key={preset}
|
||||
key={preset.color}
|
||||
type="button"
|
||||
onClick={() => setColor(preset)}
|
||||
className={`h-8 rounded-lg transition-transform ${
|
||||
color.toUpperCase() === preset.toUpperCase()
|
||||
? 'ring-2 ring-white scale-105'
|
||||
: 'hover:scale-102 opacity-80 hover:opacity-100'
|
||||
onClick={() => setColor(preset.color)}
|
||||
title={preset.name}
|
||||
className={`h-9 rounded-lg flex items-center justify-center transition-all ${
|
||||
color.toUpperCase() === preset.color.toUpperCase()
|
||||
? 'ring-2 ring-white scale-105 shadow-md shadow-white/20'
|
||||
: 'hover:scale-102 opacity-85 hover:opacity-100'
|
||||
}`}
|
||||
style={{ backgroundColor: preset }}
|
||||
/>
|
||||
style={{ backgroundColor: preset.color }}
|
||||
>
|
||||
<PixelAvatar pieceType={pieceType} color={preset.color} size={20} />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useEffect, useRef } from 'react';
|
||||
import type { GameConclusion, Player } from '../types';
|
||||
import { PixelAvatar, getPlayerPieceType } from '../utils/pixelAvatars';
|
||||
|
||||
interface ScoreboardModalProps {
|
||||
conclusion: GameConclusion | null;
|
||||
|
|
@ -210,10 +211,19 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
|||
<div
|
||||
style={{
|
||||
...styles.avatarCircle,
|
||||
backgroundColor: rankedPlayers[1].color,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderColor: '#94a3b8',
|
||||
}}
|
||||
>
|
||||
{rankedPlayers[1].name.substring(0, 2).toUpperCase()}
|
||||
<PixelAvatar
|
||||
pieceType={getPlayerPieceType(rankedPlayers[1])}
|
||||
color={rankedPlayers[1].color}
|
||||
isLeader={rankedPlayers[1].id === conclusion.winning_leader_id}
|
||||
size={52}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.podiumBotName}>{rankedPlayers[1].name}</div>
|
||||
<div style={styles.podiumScore}>{rankedPlayers[1].score} pts</div>
|
||||
|
|
@ -232,12 +242,20 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
|||
<div
|
||||
style={{
|
||||
...styles.avatarCircle,
|
||||
backgroundColor: rankedPlayers[0].color,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.85)',
|
||||
border: '3px solid #FFD700',
|
||||
boxShadow: '0 0 15px rgba(255, 215, 0, 0.6)',
|
||||
boxShadow: '0 0 20px rgba(255, 215, 0, 0.7)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{rankedPlayers[0].name.substring(0, 2).toUpperCase()}
|
||||
<PixelAvatar
|
||||
pieceType={getPlayerPieceType(rankedPlayers[0])}
|
||||
color={rankedPlayers[0].color}
|
||||
isLeader={true}
|
||||
size={60}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.podiumBotName}>{rankedPlayers[0].name}</div>
|
||||
<div style={{ ...styles.podiumScore, color: '#FFD700' }}>
|
||||
|
|
@ -258,10 +276,19 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
|||
<div
|
||||
style={{
|
||||
...styles.avatarCircle,
|
||||
backgroundColor: rankedPlayers[2].color,
|
||||
backgroundColor: 'rgba(15, 23, 42, 0.8)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
borderColor: '#cd7f32',
|
||||
}}
|
||||
>
|
||||
{rankedPlayers[2].name.substring(0, 2).toUpperCase()}
|
||||
<PixelAvatar
|
||||
pieceType={getPlayerPieceType(rankedPlayers[2])}
|
||||
color={rankedPlayers[2].color}
|
||||
isLeader={rankedPlayers[2].id === conclusion.winning_leader_id}
|
||||
size={52}
|
||||
/>
|
||||
</div>
|
||||
<div style={styles.podiumBotName}>{rankedPlayers[2].name}</div>
|
||||
<div style={styles.podiumScore}>{rankedPlayers[2].score} pts</div>
|
||||
|
|
@ -308,11 +335,12 @@ export const ScoreboardModal: React.FC<ScoreboardModalProps> = ({
|
|||
<tr key={player.id} style={rowStyle}>
|
||||
<td style={styles.tdRank}>{rankBadge}</td>
|
||||
<td style={styles.tdBot}>
|
||||
<span
|
||||
style={{
|
||||
...styles.botColorIndicator,
|
||||
backgroundColor: player.color,
|
||||
}}
|
||||
<PixelAvatar
|
||||
pieceType={getPlayerPieceType(player)}
|
||||
color={player.color}
|
||||
isLeader={isLeader}
|
||||
size={24}
|
||||
className="mr-2"
|
||||
/>
|
||||
<span style={styles.botNameText}>{player.name}</span>
|
||||
{isLeader && <span style={styles.inlineLeaderTag}>👑 Leader</span>}
|
||||
|
|
|
|||
|
|
@ -269,11 +269,16 @@ export function useGameSocket() {
|
|||
}
|
||||
}, [selectedPlayer, fetchAvailableMoves]);
|
||||
|
||||
const registerPlayer = async (name: string, color: string, strength: number = 1): Promise<Player> => {
|
||||
const registerPlayer = async (
|
||||
name: string,
|
||||
color: string,
|
||||
strength: number = 1,
|
||||
piece_type?: 'knight' | 'warrior'
|
||||
): Promise<Player> => {
|
||||
const res = await fetch('/api/players', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, color, strength }),
|
||||
body: JSON.stringify({ name, color, strength, piece_type }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ export interface Player {
|
|||
y: number;
|
||||
strength: number;
|
||||
score: number;
|
||||
piece_type?: 'knight' | 'warrior';
|
||||
party_id?: string | null;
|
||||
is_party_leader: boolean;
|
||||
visited_locations?: { x: number; y: number }[];
|
||||
|
|
|
|||
|
|
@ -0,0 +1,413 @@
|
|||
import React from 'react';
|
||||
import type { Player } from '../types';
|
||||
|
||||
export type PieceType = 'knight' | 'warrior';
|
||||
|
||||
// Hex color parser and manipulator
|
||||
function parseHex(hex: string): [number, number, number] {
|
||||
let c = hex.replace('#', '').trim();
|
||||
if (c.length === 3) {
|
||||
c = c[0] + c[0] + c[1] + c[1] + c[2] + c[2];
|
||||
}
|
||||
const num = parseInt(c, 16);
|
||||
if (isNaN(num)) {
|
||||
return [56, 189, 248]; // default sky blue
|
||||
}
|
||||
return [(num >> 16) & 255, (num >> 8) & 255, num & 255];
|
||||
}
|
||||
|
||||
function rgbToHex(r: number, g: number, b: number): string {
|
||||
const clamp = (v: number) => Math.max(0, Math.min(255, Math.round(v)));
|
||||
return '#' + [clamp(r), clamp(g), clamp(b)].map((x) => x.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function adjustColor(hex: string, percent: number): string {
|
||||
const [r, g, b] = parseHex(hex);
|
||||
if (percent > 0) {
|
||||
return rgbToHex(
|
||||
r + (255 - r) * percent,
|
||||
g + (255 - g) * percent,
|
||||
b + (255 - b) * percent
|
||||
);
|
||||
} else {
|
||||
const factor = 1 + percent;
|
||||
return rgbToHex(r * factor, g * factor, b * factor);
|
||||
}
|
||||
}
|
||||
|
||||
// 16x16 Pixel Sprite Matrices
|
||||
// Characters:
|
||||
// . : transparent
|
||||
// _ : miniature drop shadow
|
||||
// K : dark iron armor outline (#0f172a)
|
||||
// C : faction color main
|
||||
// L : faction color light (+35%)
|
||||
// D : faction color dark (-30%)
|
||||
// M : polished plate steel (#94a3b8)
|
||||
// m : steel highlight (#e2e8f0)
|
||||
// S : steel shadow (#475569)
|
||||
// G : gold main (#eab308)
|
||||
// g : gold highlight (#fef08a)
|
||||
// d : gold shadow (#a16207)
|
||||
// W : white shine/glint (#ffffff)
|
||||
// H : brown wood/leather haft (#78350f)
|
||||
// B : miniature pedestal top (#334155)
|
||||
// b : miniature pedestal rim (#1e293b)
|
||||
// R : ruby gem or glowing battle eye (#ef4444)
|
||||
|
||||
const KNIGHT_SPRITE: string[] = [
|
||||
'......LCD.......', // Row 0: Plume crest tip in faction color
|
||||
'.....LCCD.......', // Row 1: Plume feather body
|
||||
'....KmMSK.......', // Row 2: Steel knight helm apex
|
||||
'...KmMSSSK......', // Row 3: Helm brow
|
||||
'...KMKWKSK...mW.', // Row 4: Eye slit gleam + Sword tip
|
||||
'...KSKKSSK...Mm.', // Row 5: Lower helm + Sword blade
|
||||
'.LCKMmMSKK...Mm.', // Row 6: Paired pauldron + breastplate + blade
|
||||
'LCCKmSSKGGGGGMm.', // Row 7: Shield top in color + sword crossguard
|
||||
'LCCKMSKK.KHK....', // Row 8: Shield face + sword hilt
|
||||
'DCDKMSKK.KGK....', // Row 9: Shield face + golden pommel
|
||||
'.DDK.SS.K.......', // Row 10: Shield tip + armored legs
|
||||
'..K..SS..K......', // Row 11: Iron sabatons
|
||||
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
|
||||
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
|
||||
'.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim
|
||||
'..____________..', // Row 15: Miniature drop shadow
|
||||
];
|
||||
|
||||
const LEADER_KNIGHT_SPRITE: string[] = [
|
||||
'....g.g.g.......', // Row 0: 3-point Golden Royal Crown
|
||||
'....GgGgG.......', // Row 1: Crown band with jewels
|
||||
'....KGRGK.......', // Row 2: Crown base with Ruby gem
|
||||
'...KmMSSSK......', // Row 3: Knight visor brow
|
||||
'...KMKWKSK...mW.', // Row 4: Eye slit gleam + Sword tip
|
||||
'...KSKKSSK...Mm.', // Row 5: Lower helm + Sword blade
|
||||
'.GGKMmMSKK...Mm.', // Row 6: Golden cape clasps + breastplate + blade
|
||||
'GLCKmSSKGGGGGMm.', // Row 7: Gold-trimmed Shield + sword crossguard
|
||||
'GCCKMSKK.KHK....', // Row 8: Shield in faction color + sword hilt
|
||||
'GDDKMSKK.KGK....', // Row 9: Shield in faction color + gold pommel
|
||||
'.GGK.SS.K.......', // Row 10: Golden shield tip + armored legs
|
||||
'..K..SS..K......', // Row 11: Iron sabatons
|
||||
'...KGGGGGGGK....', // Row 12: Golden pedestal top
|
||||
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
|
||||
'.KGGGGGGGGGGGGK.', // Row 14: Golden pedestal rim
|
||||
'..____________..', // Row 15: Miniature drop shadow
|
||||
];
|
||||
|
||||
const WARRIOR_SPRITE: string[] = [
|
||||
'.mW..........Wm.', // Row 0: Curved battle horns
|
||||
'..Mm...KK...mM..', // Row 1: Horn bodies + helmet crest
|
||||
'...MKKmMSKKM....', // Row 2: Horn bases on Spiked Iron Helm
|
||||
'.Wm.KMMSSK.mW...', // Row 3: Dual battle-axe blades + brow
|
||||
'WmM.KKRRKK.MmW..', // Row 4: Axe blades + Fierce red eyes
|
||||
'WMMMKSSSKMMMW...', // Row 5: Full axe blades + lower iron plate
|
||||
'.KMMKMmMSKMMK...', // Row 6: Axe blade curves + iron breastplate
|
||||
'..KKKKHHKKKK....', // Row 7: Axe haft collar + spiked pauldrons
|
||||
'...KLCCCCDK.....', // Row 8: Warrior war-tunic in faction color
|
||||
'...KCCCCCCK.....', // Row 9: Faction color tunic
|
||||
'...KGKKKKGK.....', // Row 10: Spiked warrior war belt with gold rivets
|
||||
'...KHS..SHK.....', // Row 11: Leather greaves & combat boots
|
||||
'...KKKKKKKKK....', // Row 12: Pedestal top bevel
|
||||
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
|
||||
'.KbbbbbbbbbbbbK.', // Row 14: Pedestal stone bevel rim
|
||||
'..____________..', // Row 15: Miniature drop shadow
|
||||
];
|
||||
|
||||
const LEADER_WARRIOR_SPRITE: string[] = [
|
||||
'.g.g.g....g.g.g.', // Row 0: Golden crown horn tips
|
||||
'..GgG..KK..GgG..', // Row 1: Golden horn bodies
|
||||
'...GKKmMSKKG....', // Row 2: Golden horn bases on helmet
|
||||
'.Wm.KMMSSK.mW...', // Row 3: Battle-axe blades + brow
|
||||
'WmM.KKRRKK.MmW..', // Row 4: Axe blades + Fierce red eyes
|
||||
'WMMMKSSSKMMMW...', // Row 5: Axe blades + lower helm
|
||||
'.KMMKMmMSKMMK...', // Row 6: Axe blade curves + breastplate
|
||||
'..KKKKHHKKKK....', // Row 7: Pauldrons + haft collar
|
||||
'...KLCCCCDK.....', // Row 8: Tunic in faction color
|
||||
'...KCCCCCCK.....', // Row 9: Faction color tunic
|
||||
'...GGGGGGGG.....', // Row 10: Golden warrior war belt
|
||||
'...KHS..SHK.....', // Row 11: Leather boots
|
||||
'...KGGGGGGGK....', // Row 12: Golden pedestal top
|
||||
'..KBBBBBBBBBBK..', // Row 13: Pedestal stone base
|
||||
'.KGGGGGGGGGGGGK.', // Row 14: Golden pedestal rim
|
||||
'..____________..', // Row 15: Miniature drop shadow
|
||||
];
|
||||
|
||||
// Palette generation
|
||||
function getPalette(color: string): Record<string, string> {
|
||||
const cMain = color.trim().startsWith('#') ? color.trim() : `#${color.trim()}`;
|
||||
const cLight = adjustColor(cMain, 0.40);
|
||||
const cDark = adjustColor(cMain, -0.32);
|
||||
|
||||
return {
|
||||
'.': '', // transparent
|
||||
'_': 'rgba(0, 0, 0, 0.45)', // drop shadow
|
||||
'K': '#0f172a', // dark outline
|
||||
'C': cMain,
|
||||
'L': cLight,
|
||||
'D': cDark,
|
||||
'M': '#94a3b8', // plate steel
|
||||
'm': '#e2e8f0', // steel highlight
|
||||
'S': '#475569', // steel shadow
|
||||
'G': '#eab308', // gold main
|
||||
'g': '#fef08a', // gold light
|
||||
'd': '#a16207', // gold dark
|
||||
'W': '#ffffff', // white glint
|
||||
'H': '#78350f', // wood / leather
|
||||
'h': '#451a03', // wood dark
|
||||
'B': '#334155', // pedestal stone
|
||||
'b': '#1e293b', // pedestal rim
|
||||
'R': '#ef4444', // ruby / battle eyes
|
||||
};
|
||||
}
|
||||
|
||||
// In-memory cache for pre-rendered 16x16 canvases and data URLs
|
||||
const spriteCanvasCache = new Map<string, HTMLCanvasElement>();
|
||||
const spriteDataUrlCache = new Map<string, string>();
|
||||
|
||||
export function getPlayerPieceType(player: {
|
||||
name: string;
|
||||
id?: string;
|
||||
piece_type?: string;
|
||||
is_party_leader?: boolean;
|
||||
party_id?: string | null;
|
||||
}): PieceType {
|
||||
// Party leaders are always Knights commanding the squad
|
||||
if (player.is_party_leader) {
|
||||
return 'knight';
|
||||
}
|
||||
// Party squad followers are loyal Warriors
|
||||
if (player.party_id && !player.is_party_leader) {
|
||||
return 'warrior';
|
||||
}
|
||||
// Explicit piece type if present
|
||||
if (player.piece_type === 'warrior' || player.piece_type === 'knight') {
|
||||
return player.piece_type;
|
||||
}
|
||||
// Check name indicators
|
||||
const lower = player.name.toLowerCase();
|
||||
if (
|
||||
lower.includes('knight') ||
|
||||
lower.includes('tank') ||
|
||||
lower.includes('titan') ||
|
||||
lower.includes('paladin') ||
|
||||
lower.includes('gear') ||
|
||||
lower.includes('iron') ||
|
||||
lower.includes('guard')
|
||||
) {
|
||||
return 'knight';
|
||||
}
|
||||
if (
|
||||
lower.includes('warrior') ||
|
||||
lower.includes('viper') ||
|
||||
lower.includes('striker') ||
|
||||
lower.includes('scout') ||
|
||||
lower.includes('ranger') ||
|
||||
lower.includes('axe') ||
|
||||
lower.includes('blade')
|
||||
) {
|
||||
return 'warrior';
|
||||
}
|
||||
// Deterministic stable hash for variety
|
||||
let hash = 0;
|
||||
const str = player.id || player.name;
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
hash = (hash << 5) - hash + str.charCodeAt(i);
|
||||
hash |= 0;
|
||||
}
|
||||
return Math.abs(hash) % 2 === 0 ? 'knight' : 'warrior';
|
||||
}
|
||||
|
||||
export function getSpriteCanvas(
|
||||
pieceType: PieceType,
|
||||
color: string,
|
||||
isLeader: boolean = false
|
||||
): HTMLCanvasElement {
|
||||
const normColor = (color || '#38bdf8').toLowerCase();
|
||||
const cacheKey = `${pieceType}_${normColor}_${isLeader ? '1' : '0'}`;
|
||||
|
||||
const cached = spriteCanvasCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = 16;
|
||||
canvas.height = 16;
|
||||
const ctx = canvas.getContext('2d');
|
||||
|
||||
if (ctx) {
|
||||
let spriteMatrix: string[];
|
||||
if (pieceType === 'knight') {
|
||||
spriteMatrix = isLeader ? LEADER_KNIGHT_SPRITE : KNIGHT_SPRITE;
|
||||
} else {
|
||||
spriteMatrix = isLeader ? LEADER_WARRIOR_SPRITE : WARRIOR_SPRITE;
|
||||
}
|
||||
|
||||
const palette = getPalette(normColor);
|
||||
|
||||
for (let y = 0; y < 16; y++) {
|
||||
const row = spriteMatrix[y] || '................';
|
||||
for (let x = 0; x < 16; x++) {
|
||||
const char = row[x] || '.';
|
||||
const col = palette[char];
|
||||
if (col) {
|
||||
ctx.fillStyle = col;
|
||||
ctx.fillRect(x, y, 1, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
spriteCanvasCache.set(cacheKey, canvas);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
export function getSpriteDataUrl(
|
||||
pieceType: PieceType,
|
||||
color: string,
|
||||
isLeader: boolean = false
|
||||
): string {
|
||||
const normColor = (color || '#38bdf8').toLowerCase();
|
||||
const cacheKey = `${pieceType}_${normColor}_${isLeader ? '1' : '0'}`;
|
||||
|
||||
const cached = spriteDataUrlCache.get(cacheKey);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const canvas = getSpriteCanvas(pieceType, normColor, isLeader);
|
||||
const dataUrl = canvas.toDataURL('image/png');
|
||||
spriteDataUrlCache.set(cacheKey, dataUrl);
|
||||
return dataUrl;
|
||||
}
|
||||
|
||||
// Canvas Drawing Helper for BoardCanvas
|
||||
export function drawPlayerPiece(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
player: Player,
|
||||
px: number,
|
||||
py: number,
|
||||
cellSize: number,
|
||||
isSelected: boolean,
|
||||
isCurrentTurn: boolean
|
||||
): void {
|
||||
const pieceType = getPlayerPieceType(player);
|
||||
const isLeader = player.is_party_leader;
|
||||
const spriteCanvas = getSpriteCanvas(pieceType, player.color, isLeader);
|
||||
|
||||
// Scaled miniature size: board game miniature looks best at ~1.35x cellSize
|
||||
const spriteSize = Math.max(cellSize * 1.35, 14);
|
||||
const destX = Math.round(px - spriteSize / 2);
|
||||
// Center the pedestal base right on (px, py)
|
||||
const destY = Math.round(py - spriteSize * 0.62);
|
||||
|
||||
ctx.save();
|
||||
|
||||
// Active turn indicator glowing ring around the pedestal
|
||||
if (isCurrentTurn) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.55, spriteSize * 0.28, 0, 0, Math.PI * 2);
|
||||
ctx.fillStyle = 'rgba(245, 158, 11, 0.28)';
|
||||
ctx.fill();
|
||||
ctx.strokeStyle = '#f59e0b';
|
||||
ctx.lineWidth = 1.8;
|
||||
ctx.shadowColor = '#f59e0b';
|
||||
ctx.shadowBlur = 8;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Selected player dashed ring around the pedestal
|
||||
if (isSelected) {
|
||||
ctx.save();
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(px, py + spriteSize * 0.18, spriteSize * 0.62, spriteSize * 0.32, 0, 0, Math.PI * 2);
|
||||
ctx.strokeStyle = '#38bdf8';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.setLineDash([3, 3]);
|
||||
ctx.shadowColor = '#38bdf8';
|
||||
ctx.shadowBlur = 6;
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Draw the crisp pixelated Knight or Warrior figurine
|
||||
ctx.imageSmoothingEnabled = false;
|
||||
ctx.drawImage(spriteCanvas, destX, destY, spriteSize, spriteSize);
|
||||
|
||||
// Crown symbol above party leader
|
||||
if (isLeader) {
|
||||
ctx.save();
|
||||
ctx.fillStyle = '#fbbf24';
|
||||
ctx.font = `${Math.max(spriteSize * 0.45, 10)}px sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText('👑', px, destY - 4);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// Player Name and Strength Badge (Title Bar)
|
||||
ctx.save();
|
||||
const isHighlighted = isCurrentTurn || isSelected;
|
||||
ctx.globalAlpha = isHighlighted ? 1.0 : 0.5;
|
||||
|
||||
ctx.font = `bold ${Math.max(10, Math.min(12, cellSize * 0.42))}px Inter, sans-serif`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
const roleIcon = isLeader ? '👑' : pieceType === 'knight' ? '⚔️' : '🪓';
|
||||
const text = `${roleIcon} ${player.name} [⚡${player.strength.toFixed(1)}]`;
|
||||
const textMetrics = ctx.measureText(text);
|
||||
const bgWidth = textMetrics.width + 12;
|
||||
const bgHeight = 16;
|
||||
const labelY = isLeader ? destY - 14 : destY - 8;
|
||||
|
||||
ctx.fillStyle = 'rgba(15, 23, 42, 0.92)';
|
||||
ctx.strokeStyle = isLeader ? '#fbbf24' : isCurrentTurn ? '#f59e0b' : player.color;
|
||||
ctx.lineWidth = 1;
|
||||
ctx.beginPath();
|
||||
ctx.roundRect(px - bgWidth / 2, labelY - 12, bgWidth, bgHeight, 4);
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = isLeader ? '#fef08a' : isCurrentTurn ? '#fbbf24' : '#f8fafc';
|
||||
ctx.fillText(text, px, labelY - 4);
|
||||
ctx.restore();
|
||||
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// React Component for displaying pixel avatar in UI
|
||||
interface PixelAvatarProps {
|
||||
pieceType: PieceType;
|
||||
color: string;
|
||||
isLeader?: boolean;
|
||||
size?: number;
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export const PixelAvatar: React.FC<PixelAvatarProps> = ({
|
||||
pieceType,
|
||||
color,
|
||||
isLeader = false,
|
||||
size = 32,
|
||||
className = '',
|
||||
title,
|
||||
}) => {
|
||||
const dataUrl = getSpriteDataUrl(pieceType, color, isLeader);
|
||||
|
||||
return (
|
||||
<img
|
||||
src={dataUrl}
|
||||
alt={`${isLeader ? 'Leader ' : ''}${pieceType}`}
|
||||
title={title || `${isLeader ? 'Leader ' : ''}${pieceType} (${color})`}
|
||||
className={`inline-block ${className}`}
|
||||
style={{
|
||||
width: size,
|
||||
height: size,
|
||||
imageRendering: 'pixelated',
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
Loading…
Reference in New Issue