diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..e56ceee39bf4c972ddd29993ba3d6994476fda09 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +__pycache__/ +*.pyc +.env +.venv/ +venv/ +.git/ +.gitignore +*.log diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..73bae98eba945556475096768c3f13804bee080d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,111 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +image/background/background.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/arena.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/command_centre.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/communication_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/convention_centre.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/forrest.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/gas_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/greenhouse.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/hangar.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/hospital.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/launchpad.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/living_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/repair_shop.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/robot_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/solar_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D_interia/weapon_shop.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/arena.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/arena.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/command_centre.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/command_centre.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/communication_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/communication_station.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/convention_centre.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/convention_centre.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/forrest.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/forrest.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/gas_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/gas_station.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/greenhouse.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/greenhouse.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/hangar.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/hangar.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/hospital.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/hospital.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/launchpad.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/launchpad.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/living_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/living_area.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/repair_shop.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/repair_shop.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/robot_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/robot_station.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/solar_station.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/solar_station.png filter=lfs diff=lfs merge=lfs -text +image/building_2D/weapon_shop.jpeg filter=lfs diff=lfs merge=lfs -text +image/building_2D/weapon_shop.png filter=lfs diff=lfs merge=lfs -text +image/building/car_park.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/command_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/docker.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/fuel_storage_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/gathering_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/green_house_experimental.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/laboratory.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/launching_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/living_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/meeting_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/mine.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/mining_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/planting_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/recreation_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/satellite.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/sleep_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/stand_alone_mining_unit.jpeg filter=lfs diff=lfs merge=lfs -text +image/building/stargate.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/cleaner_full.png filter=lfs diff=lfs merge=lfs -text +image/character/cleaner.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/cleaner.png filter=lfs diff=lfs merge=lfs -text +image/character/communicator_full.png filter=lfs diff=lfs merge=lfs -text +image/character/communicator.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/communicator.png filter=lfs diff=lfs merge=lfs -text +image/character/courier_full.png filter=lfs diff=lfs merge=lfs -text +image/character/courier.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/courier.png filter=lfs diff=lfs merge=lfs -text +image/character/farmer_full.png filter=lfs diff=lfs merge=lfs -text +image/character/farmer.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/farmer.png filter=lfs diff=lfs merge=lfs -text +image/character/humanoid_robot_full.png filter=lfs diff=lfs merge=lfs -text +image/character/humanoid_robot.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/humanoid_robot.png filter=lfs diff=lfs merge=lfs -text +image/character/Main_female_character_full.png filter=lfs diff=lfs merge=lfs -text +image/character/Main_female_character.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/Main_female_character.png filter=lfs diff=lfs merge=lfs -text +image/character/Main_male_character_full.png filter=lfs diff=lfs merge=lfs -text +image/character/Main_male_character.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/Main_male_character.png filter=lfs diff=lfs merge=lfs -text +image/character/mechanic_full.png filter=lfs diff=lfs merge=lfs -text +image/character/mechanic.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/mechanic.png filter=lfs diff=lfs merge=lfs -text +image/character/medic_full.png filter=lfs diff=lfs merge=lfs -text +image/character/medic.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/medic.png filter=lfs diff=lfs merge=lfs -text +image/character/navigator_full.png filter=lfs diff=lfs merge=lfs -text +image/character/navigator.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/navigator.png filter=lfs diff=lfs merge=lfs -text +image/character/pilot_full.png filter=lfs diff=lfs merge=lfs -text +image/character/pilot.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/pilot.png filter=lfs diff=lfs merge=lfs -text +image/character/wheel_robot_full.png filter=lfs diff=lfs merge=lfs -text +image/character/wheel_robot.jpeg filter=lfs diff=lfs merge=lfs -text +image/character/wheel_robot.png filter=lfs diff=lfs merge=lfs -text +image/interia/isolated_automation_area.jpeg filter=lfs diff=lfs merge=lfs -text +image/interia/laboratory_place.jpeg filter=lfs diff=lfs merge=lfs -text +image/interia/sleeping_place.jpeg filter=lfs diff=lfs merge=lfs -text +image/mission/huge_automated_cargo_truck.jpeg filter=lfs diff=lfs merge=lfs -text +image/mission/huge_mobile_truck.jpeg filter=lfs diff=lfs merge=lfs -text +image/mission/huge_robot.jpeg filter=lfs diff=lfs merge=lfs -text +image/mission/huge_rocket.jpeg filter=lfs diff=lfs merge=lfs -text +image/mission/space_station.jpeg filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5fdc14c1971332b45efdb053bad9693653bcb78e --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +.env +.venv/ +venv/ +.DS_Store +*.log +deepseek_key.txt +memory/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..df2c8ff27249f0743074c3281a4541f0864fa7c0 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,17 @@ +# Hugging Face Spaces (Docker SDK) - Lunar Village RPG +FROM python:3.11-slim + +ENV PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PORT=7860 + +WORKDIR /app + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +EXPOSE 7860 + +CMD ["python", "app.py"] diff --git a/README.md b/README.md index 0f7d9cb0902fc4c45e44375471b60b2a4550c73a..509b385f396e90ccd037c23b18743aac730e2c07 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,98 @@ ---- -title: MoonVillage -emoji: ๐Ÿจ -colorFrom: purple -colorTo: green -sdk: static -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +--- +title: Lunar Village +emoji: ๐ŸŒ• +colorFrom: indigo +colorTo: yellow +sdk: docker +app_port: 7860 +pinned: false +license: mit +--- + +# ๐ŸŒ• Lunar Village โ€” Moon Colony RPG + +A **SmallVill-style** online RPG set on the Moon. Walk around a lunar colony, +chat with AI-driven crew members (powered by **DeepSeek**), gather resources, +and complete the colony's mega-projects: a **huge robot**, a **mobile truck**, +an **automated cargo hauler**, a **heavy rocket**, and an **orbital space station**. + +Playable on desktop and **mobile** (touch joystick + tap-to-interact). + +**Created by Andy Kong** + +--- + +## ๐ŸŽฎ How to play + +- **Move:** on-screen joystick (mobile) or `WASD` / arrow keys (desktop). +- **Interact:** walk up to a crew member and tap the ๐Ÿ’ฌ button (or press `E` / `Space`) to talk. +- **Gather:** walk up to a building like the Mine or Greenhouse and tap the โ›๏ธ button to collect resources. +- **Build:** open the ๐Ÿ—๏ธ **Missions** panel to spend resources and construct the mega-projects. + +## ๐Ÿ” DeepSeek API key (important) + +The game talks to DeepSeek through a **server-side proxy** (`/api/chat`) so your +API key is never exposed to players. The key is read from an environment +variable and should be stored as a **Space Secret**, never committed to code. + +On Hugging Face: +1. Open your Space โ†’ **Settings** โ†’ **Variables and secrets**. +2. Add a **secret** named `DEEPSEEK_API_KEY` with your key value. +3. (Optional) add `DEEPSEEK_MODEL` (default `deepseek-chat`) and + `DEEPSEEK_BASE_URL` (default `https://api.deepseek.com`). + +> โš ๏ธ If you shared your key anywhere public (chat, screenshots, commits), +> regenerate it in the DeepSeek console before deploying. + +If no key is set, the game still runs using built-in offline personality +replies, so you can test the whole experience without any credentials. + +## ๐Ÿš€ Deploy to Hugging Face Spaces + +1. Create a new **Space** โ†’ SDK: **Docker**. +2. Upload this whole folder (including `app.py`, `Dockerfile`, `requirements.txt`, + `static/`, and `image/`). +3. Add the `DEEPSEEK_API_KEY` secret (see above). +4. The Space builds the Docker image and serves the game on port `7860`. + +## ๐Ÿ–ฅ๏ธ Run locally + +```bash +pip install -r requirements.txt +# optional: enable AI +set DEEPSEEK_API_KEY=your_key_here # Windows (cmd) +# $env:DEEPSEEK_API_KEY="your_key_here" # Windows (PowerShell) +# export DEEPSEEK_API_KEY=your_key_here # macOS/Linux +python app.py +``` + +Then open http://localhost:7860 + +## ๐Ÿ“ Project structure + +``` +Kiro_RPG/ +โ”œโ”€ app.py # FastAPI backend + DeepSeek proxy +โ”œโ”€ requirements.txt +โ”œโ”€ Dockerfile # Hugging Face Docker Space +โ”œโ”€ image/ # game art (buildings, characters, missions) +โ””โ”€ static/ + โ”œโ”€ index.html + โ”œโ”€ css/style.css + โ””โ”€ js/ + โ”œโ”€ config.js # world / buildings / characters / missions data + โ”œโ”€ ai.js # AI chat client + โ”œโ”€ ui.js # HUD, dialog, missions + โ”œโ”€ game.js # canvas engine, player, NPCs + โ””โ”€ boot.js # title screen +``` + +## ๐Ÿงฉ Tech + +- **Backend:** FastAPI + httpx (async DeepSeek proxy) +- **Frontend:** vanilla HTML5 Canvas + JS (no build step, loads fast on mobile) +- **AI:** DeepSeek Chat (OpenAI-compatible API) + +--- + +Created by **Andy Kong**. diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..731e106e37af181e4d6d1b4f06bce99f73f8cd19 --- /dev/null +++ b/app.py @@ -0,0 +1,338 @@ +""" +Lunar Village - a SmallVill-style online RPG set on the Moon. +FastAPI backend: serves the static game and proxies AI chat to DeepSeek. + +The DeepSeek API key is read from the DEEPSEEK_API_KEY environment variable +(set it as a Secret in your Hugging Face Space). It is NEVER sent to the client. + +Created by Andy Kong. +""" +import os +import re +import json +import logging +from datetime import datetime, timezone +from typing import List, Optional + +import httpx +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles +from fastapi.responses import FileResponse, JSONResponse +from pydantic import BaseModel, Field + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("lunar-village") + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) +STATIC_DIR = os.path.join(BASE_DIR, "static") +IMAGE_DIR = os.path.join(BASE_DIR, "image") +MEMORY_DIR = os.path.join(BASE_DIR, "memory") +os.makedirs(MEMORY_DIR, exist_ok=True) + +DATA_DIR = os.path.join(BASE_DIR, "data") +os.makedirs(DATA_DIR, exist_ok=True) +INVENTORY_PATH = os.path.join(DATA_DIR, "inventory.json") + + +def load_inventory() -> dict: + if os.path.isfile(INVENTORY_PATH): + try: + with open(INVENTORY_PATH, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: # noqa: BLE001 + pass + return {"resources": {}, "updated": None} + + +def save_inventory(resources: dict) -> dict: + data = { + "resources": {k: float(v) for k, v in resources.items()}, + "updated": datetime.now(timezone.utc).isoformat(timespec="seconds"), + } + with open(INVENTORY_PATH, "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + return data + +MAX_MEMORY_ENTRIES = 60 # keep the most recent N per character +MEMORY_IN_PROMPT = 10 # how many recent memories to feed the AI + + +def _safe_id(cid: str) -> str: + return re.sub(r"[^a-zA-Z0-9_-]", "", cid or "")[:64] + + +def _mem_path(cid: str) -> str: + return os.path.join(MEMORY_DIR, f"{_safe_id(cid)}.json") + + +def load_memory(cid: str) -> dict: + path = _mem_path(cid) + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as f: + return json.load(f) + except Exception: # noqa: BLE001 + pass + return {"id": _safe_id(cid), "name": "", "entries": []} + + +def save_memory(cid: str, data: dict) -> None: + try: + with open(_mem_path(cid), "w", encoding="utf-8") as f: + json.dump(data, f, ensure_ascii=False, indent=2) + except Exception as exc: # noqa: BLE001 + logger.warning("Could not save memory for %s: %s", cid, exc) + + +def append_memory(cid: str, text: str, name: str = "") -> dict: + data = load_memory(cid) + if name and not data.get("name"): + data["name"] = name + data.setdefault("entries", []).append({ + "t": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "text": text.strip(), + }) + data["entries"] = data["entries"][-MAX_MEMORY_ENTRIES:] + save_memory(cid, data) + return data + + +def memory_prompt(cid: str) -> str: + data = load_memory(cid) + entries = data.get("entries", [])[-MEMORY_IN_PROMPT:] + if not entries: + return "" + lines = "\n".join(f"- {e['text']}" for e in entries) + return ( + "\n\nYour memories so far (oldest first, act consistently with them, " + "and follow any standing orders from the Commander):\n" + lines + ) + +DEEPSEEK_API_KEY = os.environ.get("DEEPSEEK_API_KEY", "").strip() +# Also try loading from a local key file (not uploaded to Hugging Face) +_KEY_FILE = os.path.join(BASE_DIR, "deepseek_key.txt") +if not DEEPSEEK_API_KEY and os.path.isfile(_KEY_FILE): + with open(_KEY_FILE, "r", encoding="utf-8") as _f: + DEEPSEEK_API_KEY = _f.read().strip() + +DEEPSEEK_BASE_URL = os.environ.get("DEEPSEEK_BASE_URL", "https://api.deepseek.com").rstrip("/") +DEEPSEEK_MODEL = os.environ.get("DEEPSEEK_MODEL", "deepseek-chat") + +# Allow a user-provided key at runtime (set via /api/set-key) +_USER_KEY = {"key": ""} + +app = FastAPI(title="Lunar Village", version="1.0.0") + +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], +) + + +class ChatMessage(BaseModel): + role: str + content: str + + +class ChatRequest(BaseModel): + system: str = Field(default="", description="System / persona prompt for the NPC.") + messages: List[ChatMessage] = Field(default_factory=list) + temperature: float = 1.0 + max_tokens: int = 220 + character_id: Optional[str] = None # if set, that character's memory is injected + character_name: Optional[str] = None + + +class MemoryEntryRequest(BaseModel): + text: str + name: Optional[str] = None + + +class CharacterSeed(BaseModel): + id: str + name: Optional[str] = None + persona: Optional[str] = None + + +class MemoryInitRequest(BaseModel): + characters: List[CharacterSeed] = Field(default_factory=list) + + +class InventoryRequest(BaseModel): + resources: dict = Field(default_factory=dict) + + +class ChatResponse(BaseModel): + reply: str + fallback: bool = False + + +# A small offline personality-flavoured fallback so the game is still playable +# without an API key (e.g. local testing or if DeepSeek is unreachable). +FALLBACK_LINES = { + "farmer": "The greenhouse crops are holding up, but I need more water reclaimed from the ice mine.", + "mechanic": "Give me spare parts and I'll keep the rovers running. That huge truck won't build itself.", + "medic": "Radiation exposure is the real enemy up here. Stay inside during solar flares.", + "pilot": "The launch window is tight. We need fuel stabilised before the rocket can fly.", + "navigator": "I've been charting safe routes across the mare. Fewer craters, fewer breakdowns.", + "communicator": "Signal to Earth has a two-second delay. Patience, commander.", + "courier": "I move supplies between the domes. Tell me where you need them.", + "cleaner": "Moon dust gets everywhere. It clogs the airlocks if I don't keep at it.", + "humanoid_robot": "Directive acknowledged. Awaiting task assignment, commander.", + "wheel_robot": "Beep. Rolling out to the mining area for another load of ore.", + "default": "Welcome to the colony, commander. There's plenty of work to keep us all alive up here.", +} + + +def build_fallback(system: str, messages: List[ChatMessage]) -> str: + text = (system + " " + " ".join(m.content for m in messages)).lower() + for key, line in FALLBACK_LINES.items(): + if key != "default" and key in text: + return line + return FALLBACK_LINES["default"] + + +@app.get("/api/health") +async def health(): + active_key = _USER_KEY["key"] or DEEPSEEK_API_KEY + return {"ok": True, "ai_enabled": bool(active_key)} + + +class SetKeyRequest(BaseModel): + key: str = "" + + +@app.post("/api/set-key") +async def set_key(req: SetKeyRequest): + """Allow a user to provide their own DeepSeek API key for this session.""" + _USER_KEY["key"] = req.key.strip() + return {"ok": True, "ai_enabled": bool(_USER_KEY["key"] or DEEPSEEK_API_KEY)} + + +@app.post("/api/memory/init") +async def memory_init(req: MemoryInitRequest): + """Ensure a memory file exists for every character (called once on game start).""" + created = [] + for c in req.characters: + cid = _safe_id(c.id) + if not cid: + continue + if not os.path.isfile(_mem_path(cid)): + seed = {"id": cid, "name": c.name or "", "entries": []} + if c.persona: + seed["entries"].append({ + "t": datetime.now(timezone.utc).isoformat(timespec="seconds"), + "text": f"I am {c.name or cid}, {c.persona}", + }) + save_memory(cid, seed) + created.append(cid) + return {"ok": True, "created": created, "total": len(req.characters)} + + +@app.get("/api/memory") +async def memory_list(): + ids = [f[:-5] for f in os.listdir(MEMORY_DIR) if f.endswith(".json")] + return {"characters": sorted(ids)} + + +@app.get("/api/memory/{cid}") +async def memory_get(cid: str): + return load_memory(cid) + + +@app.post("/api/memory/{cid}") +async def memory_post(cid: str, req: MemoryEntryRequest): + if not req.text.strip(): + raise HTTPException(status_code=400, detail="empty memory text") + data = append_memory(cid, req.text, req.name or "") + return {"ok": True, "count": len(data.get("entries", []))} + + +@app.get("/api/inventory") +async def inventory_get(): + """Return the colony's accumulated resources (persisted to data/inventory.json).""" + return load_inventory() + + +@app.post("/api/inventory") +async def inventory_post(req: InventoryRequest): + data = save_inventory(req.resources) + return {"ok": True, **data} + + +@app.post("/api/chat", response_model=ChatResponse) +async def chat(req: ChatRequest): + # Build the system prompt, injecting this character's memory if provided. + system = req.system + if req.character_id: + system += memory_prompt(req.character_id) + + # Determine the active API key (user-provided takes priority) + active_key = _USER_KEY["key"] or DEEPSEEK_API_KEY + + # No key configured -> graceful offline fallback so the game still works. + if not active_key: + return ChatResponse(reply=build_fallback(req.system, req.messages), fallback=True) + + payload_messages = [] + if system: + payload_messages.append({"role": "system", "content": system}) + for m in req.messages: + payload_messages.append({"role": m.role, "content": m.content}) + + payload = { + "model": DEEPSEEK_MODEL, + "messages": payload_messages, + "temperature": req.temperature, + "max_tokens": req.max_tokens, + "stream": False, + } + headers = { + "Authorization": f"Bearer {active_key}", + "Content-Type": "application/json", + } + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{DEEPSEEK_BASE_URL}/chat/completions", + json=payload, + headers=headers, + ) + if resp.status_code != 200: + logger.warning("DeepSeek error %s: %s", resp.status_code, resp.text[:300]) + return ChatResponse(reply=build_fallback(req.system, req.messages), fallback=True) + data = resp.json() + reply = data["choices"][0]["message"]["content"].strip() + return ChatResponse(reply=reply, fallback=False) + except Exception as exc: # noqa: BLE001 + logger.exception("DeepSeek request failed: %s", exc) + return ChatResponse(reply=build_fallback(req.system, req.messages), fallback=True) + + +# Serve game images and static assets. +if os.path.isdir(IMAGE_DIR): + app.mount("/image", StaticFiles(directory=IMAGE_DIR), name="image") + + +@app.get("/") +async def index(): + index_path = os.path.join(STATIC_DIR, "index.html") + if os.path.isfile(index_path): + # never cache the HTML so versioned asset URLs are always picked up + return FileResponse(index_path, headers={"Cache-Control": "no-cache, no-store, must-revalidate"}) + return JSONResponse({"error": "index.html not found"}, status_code=404) + + +if os.path.isdir(STATIC_DIR): + app.mount("/", StaticFiles(directory=STATIC_DIR, html=True), name="static") + + +if __name__ == "__main__": + import uvicorn + + port = int(os.environ.get("PORT", "7860")) + uvicorn.run("app:app", host="0.0.0.0", port=port, reload=False) diff --git a/data/inventory.json b/data/inventory.json new file mode 100644 index 0000000000000000000000000000000000000000..41e0db4ddddd1b5e6de3a5682ed81b03fcdd8ee7 --- /dev/null +++ b/data/inventory.json @@ -0,0 +1,11 @@ +{ + "resources": { + "ore": 153.90000000000174, + "metal": 14.600000000000168, + "fuel": 177.35000000000232, + "food": 0.2, + "energy": 272.90000000000657, + "parts": 175.2999999999926 + }, + "updated": "2026-07-12T14:21:08+00:00" +} \ No newline at end of file diff --git a/image/background/background.jpeg b/image/background/background.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..16ec52b57dbbe6683d81b3e47e4f4c0ac41ce9c2 --- /dev/null +++ b/image/background/background.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8718ae2c02852aede8ddf6e18fd979ada4e9a01ef0e847b735d59f2393692a2e +size 138658 diff --git a/image/building/car_park.jpeg b/image/building/car_park.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..d21044a5c2e5efb667f348349f42d89371a2d39a --- /dev/null +++ b/image/building/car_park.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2ac69f15d8f31713bf2f9bddf2a0bfc18c103afb3981804db0804ba3b56844a4 +size 398325 diff --git a/image/building/command_area.jpeg b/image/building/command_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1ba42cf3d518daa51052017c7895836636f1354a --- /dev/null +++ b/image/building/command_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:74af7fe21034cf1e2bbda972a3e146d05acf506f76cde852e224f91b0cce526e +size 262167 diff --git a/image/building/docker.jpeg b/image/building/docker.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..e82193625a7c8e46a8156ba3631ae190d79176c8 --- /dev/null +++ b/image/building/docker.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed3b65ee7fd6542df2b5847bd74a462793822542e31a5e8f94ff168122b562dd +size 248997 diff --git a/image/building/fuel_storage_area.jpeg b/image/building/fuel_storage_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..9f2070ea20edbc6b59a044396623e35aad8c1ef8 --- /dev/null +++ b/image/building/fuel_storage_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:39ca1f5623c298441f34e0af5ef8e2507a64b96ac4dee03f995cc4cd1c5447c0 +size 250712 diff --git a/image/building/gathering_area.jpeg b/image/building/gathering_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7bc7d33c68a776089bbc5be5b92fbd81681d3c63 --- /dev/null +++ b/image/building/gathering_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cf28b0f36caac1db5accd09c8f0aebd9479dddc6c670e0b7cc22e008d41e3d8f +size 347204 diff --git a/image/building/green_house_experimental.jpeg b/image/building/green_house_experimental.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..383795ece2509c50e74dcf2b44e7a0cba9843e5c --- /dev/null +++ b/image/building/green_house_experimental.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b28ea321a89c7874ebf6692de1a7c9edbaaea985bfe6eca6e6caa3a453c64c1f +size 419022 diff --git a/image/building/laboratory.jpeg b/image/building/laboratory.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..aecd6f9a7d777cd7445c95d1a35b62b4bd2fb3dd --- /dev/null +++ b/image/building/laboratory.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84c655ce945672861c34058e24e1d70370a73bd7b302f44cfb50be33d2ff5fa5 +size 232520 diff --git a/image/building/launching_area.jpeg b/image/building/launching_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c2455ccadeaff934b04cf03facc997f69396421f --- /dev/null +++ b/image/building/launching_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ccac701ca02310fea0b3e0cbe2aa67fe5fc87f0a36204054d09085f2741b614f +size 276610 diff --git a/image/building/living_area.jpeg b/image/building/living_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..8c0c2499a5b16bd391cf2abfc59c80ba35dbc2c7 --- /dev/null +++ b/image/building/living_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eba23050bb67b5cab6982180fc087737a4d7bc342474c71bf8a3b1ae6304bd44 +size 400319 diff --git a/image/building/meeting_area.jpeg b/image/building/meeting_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..9bc31bdfd4189a1b06c5be6a9aeb3542736e93b8 --- /dev/null +++ b/image/building/meeting_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:11fe175fda9bf35d35e755a7aba31251f011d5d3226c05879930b3eb8778150e +size 280751 diff --git a/image/building/mine.jpeg b/image/building/mine.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..45cdbc7a3eb111370da2c15fbc48151e13b72f0e --- /dev/null +++ b/image/building/mine.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e3a8b7d3b8e6d8e77d10093217d9843067a472ed34aa9d141e953b1e5c8f5b1f +size 269865 diff --git a/image/building/mining_area.jpeg b/image/building/mining_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..155b586ca8bd9aec046d34a415c321204cb1f4f2 --- /dev/null +++ b/image/building/mining_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dcbf14333e8acf92b4a60f9d46517d19038512a3c4842ff77a734380489b5843 +size 406062 diff --git a/image/building/planting_area.jpeg b/image/building/planting_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7a21faea678d067602f89f7a841ecd94a00ed6a4 --- /dev/null +++ b/image/building/planting_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a05b4ec72fd6d676413bf4d3bb72d76692361edca77af10086c235336c6d9b59 +size 330495 diff --git a/image/building/recreation_area.jpeg b/image/building/recreation_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..5aec1a7fe8f55108985f59881001fd50b478273c --- /dev/null +++ b/image/building/recreation_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e56003bfe11200dfb05a88164b907c046bdd01c214934d9077502ef378a5043a +size 381017 diff --git a/image/building/satellite.jpeg b/image/building/satellite.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a002f2113cb9a2f7858a305bd6e7b5acbff5a78a --- /dev/null +++ b/image/building/satellite.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:066198660d8f534792e7e5ca09d65b179f39465fe7fc5c80648d362baca2b0f2 +size 211025 diff --git a/image/building/sleep_area.jpeg b/image/building/sleep_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7f412a2d233cca2e7c03a1b35cf2a9224fc5cc8a --- /dev/null +++ b/image/building/sleep_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f23f82832f76bf9f78f422bb4fa61063ede8da1022cd96a696738d3b3623381d +size 386639 diff --git a/image/building/stand_alone_mining_unit.jpeg b/image/building/stand_alone_mining_unit.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c76aee84aa62f64d6d3c54649f6b14ba70b2668b --- /dev/null +++ b/image/building/stand_alone_mining_unit.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f9a3d51b8ee491281e8dda91ea0e1511ddba06a98ad8eecef522804ff6adef01 +size 259051 diff --git a/image/building/stargate.jpeg b/image/building/stargate.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..47de190dc2b7ac822f1bf8c1efe0392531f1bf6f --- /dev/null +++ b/image/building/stargate.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:116ff80630536e3b6ec6bc6d29d110898c2e37bac9668424ff643f3611adceb2 +size 195638 diff --git a/image/building_2D/arena.jpeg b/image/building_2D/arena.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..8dfc288cffe65663099dcc278a1d461d48d3571b --- /dev/null +++ b/image/building_2D/arena.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4630c9c3b4e229f6a6db104ad3f0352eac4a7348634f2e61353647627878f020 +size 317827 diff --git a/image/building_2D/arena.png b/image/building_2D/arena.png new file mode 100644 index 0000000000000000000000000000000000000000..f1f51e88486191f9550ff42519a53317ce6201c2 --- /dev/null +++ b/image/building_2D/arena.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f78241fd01629b58ac10bae678865adae32b0302817e956123d84e6ae5e8b286 +size 1136978 diff --git a/image/building_2D/command_centre.jpeg b/image/building_2D/command_centre.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..13ae6b7c1536ea5a6b0eb63577943546fca66e4b --- /dev/null +++ b/image/building_2D/command_centre.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:277ea39518e7286eafeac7ac9cbeac699b387c1da266925503c17d18b9d149cc +size 301797 diff --git a/image/building_2D/command_centre.png b/image/building_2D/command_centre.png new file mode 100644 index 0000000000000000000000000000000000000000..b4c23cc754fd81ba7bdaaffa0dd06349bb5bb4c4 --- /dev/null +++ b/image/building_2D/command_centre.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:af14e4f271d652128b74b6743affe637ba7487f04d0f82a828162658b3f98f02 +size 977672 diff --git a/image/building_2D/communication_station.jpeg b/image/building_2D/communication_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..523104e0dde78844bc4276ce056b0e94bac3427a --- /dev/null +++ b/image/building_2D/communication_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:70ea3f2c983bcd259f4f7e35e150458464906b61e1ec6cc19e760c026d183946 +size 238732 diff --git a/image/building_2D/communication_station.png b/image/building_2D/communication_station.png new file mode 100644 index 0000000000000000000000000000000000000000..54a3c413891ffe2d0bd1f1d24bc170f6ac494db4 --- /dev/null +++ b/image/building_2D/communication_station.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5eb7c34570326e1ef580db757216fa7627b23ca57b61e3674899e98a749da256 +size 619307 diff --git a/image/building_2D/convention_centre.jpeg b/image/building_2D/convention_centre.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..742a0244aeabf6a1149f6c6bd8aad873c0075d0c --- /dev/null +++ b/image/building_2D/convention_centre.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5f2c293529735afa647cf08c6d75b21fbc62894f1e22ec1e8cd86534fac611d8 +size 316790 diff --git a/image/building_2D/convention_centre.png b/image/building_2D/convention_centre.png new file mode 100644 index 0000000000000000000000000000000000000000..ff887d40b7b293f0a49439910bb498b945c0a856 --- /dev/null +++ b/image/building_2D/convention_centre.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8a455e578e0bcf577b02c9121d67455daab95eac0bc006f37d28d3c2779c04f6 +size 1288970 diff --git a/image/building_2D/forrest.jpeg b/image/building_2D/forrest.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..96518d45d76c2558e6b8fe486d35a86a4730a909 --- /dev/null +++ b/image/building_2D/forrest.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:906ead1cb4b4f4561ccbfa4eaff60c2f848850fe031cd878754e12faf00b36fc +size 297147 diff --git a/image/building_2D/forrest.png b/image/building_2D/forrest.png new file mode 100644 index 0000000000000000000000000000000000000000..4dd1c50e4012171cfb9e7396496fc0c9396ce851 --- /dev/null +++ b/image/building_2D/forrest.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b741340d46032835c8b09be4bda203cc74eb8c245a9c45ff5dab13eb2c7a2cd +size 1049523 diff --git a/image/building_2D/gas_station.jpeg b/image/building_2D/gas_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f9a71322eca2ad93f4c48c5584495a1d382afa74 --- /dev/null +++ b/image/building_2D/gas_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eec02b247aec7f48278f9f18828030ebc4181f216943ce1b7b7ab68105d34533 +size 243701 diff --git a/image/building_2D/gas_station.png b/image/building_2D/gas_station.png new file mode 100644 index 0000000000000000000000000000000000000000..f4d9c1ad9ee36bffeb326b357838b229e42559b6 --- /dev/null +++ b/image/building_2D/gas_station.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:840150eb1c75672c38720e3d3bb65c8b2261e54e61a0828bbcbf9aefdbc8aba4 +size 1185812 diff --git a/image/building_2D/greenhouse.jpeg b/image/building_2D/greenhouse.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..718a785307a21bbdc1ae34341fe32d8dee4ec4ed --- /dev/null +++ b/image/building_2D/greenhouse.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fa0ac9836b8f85c9a7cf2eb75d9be66274c0aba484f5420df0db12173a9d9100 +size 315769 diff --git a/image/building_2D/greenhouse.png b/image/building_2D/greenhouse.png new file mode 100644 index 0000000000000000000000000000000000000000..178439a3c397576137ea91aae7c641854830a160 --- /dev/null +++ b/image/building_2D/greenhouse.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4b82ff68262ed7803afbe7b76eedf5a003a7240cddd900747bedb0db0d2505f +size 1378803 diff --git a/image/building_2D/hangar.jpeg b/image/building_2D/hangar.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..d9034bf827749508669a843022992fe28aa0eeee --- /dev/null +++ b/image/building_2D/hangar.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4f3c917f59731e4cb4ec778196c60e982f504bdd6bd7de8650ae22836bd94876 +size 301075 diff --git a/image/building_2D/hangar.png b/image/building_2D/hangar.png new file mode 100644 index 0000000000000000000000000000000000000000..ae699bfebeef8a4ee1a6191557764f37efb95715 --- /dev/null +++ b/image/building_2D/hangar.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1bf7555f3fffb3d4c254b0ba767474e4e9fbc02c1e2e93d6fcf587fe25dc246a +size 1052022 diff --git a/image/building_2D/hospital.jpeg b/image/building_2D/hospital.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..bacd541949e05ad052ea2e70e59c58c8dc34aff7 --- /dev/null +++ b/image/building_2D/hospital.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:df951ab4270b4f7bf37b5c7034a8c8d49226d35b94da6440953377f0f5404638 +size 303640 diff --git a/image/building_2D/hospital.png b/image/building_2D/hospital.png new file mode 100644 index 0000000000000000000000000000000000000000..910e22219b726db40cf499654881b3919feec882 --- /dev/null +++ b/image/building_2D/hospital.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66a83f09c5a3e292678606c74a26254571770bde96c574aafb545f5ff287aeeb +size 1159783 diff --git a/image/building_2D/launchpad.jpeg b/image/building_2D/launchpad.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..784f2a17cad2e2631f95d78128955cfd5279ef25 --- /dev/null +++ b/image/building_2D/launchpad.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a73a95493a08694da8bff3b06dfe5f7356d69458e0f58facd0cc57ca968a8d89 +size 285241 diff --git a/image/building_2D/launchpad.png b/image/building_2D/launchpad.png new file mode 100644 index 0000000000000000000000000000000000000000..b2e6de2ed289de69d60fa04cb10d7334c29d1630 --- /dev/null +++ b/image/building_2D/launchpad.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfdc7ca4ab8a1c93dae733930f8a205d2517d2fb0120ac2ba91dfe6df1724a1a +size 973898 diff --git a/image/building_2D/living_area.jpeg b/image/building_2D/living_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..03603b0a3dda95880cf3f8f19136885aa282a482 --- /dev/null +++ b/image/building_2D/living_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aafd0f25b7d94c377c9b5f0751a6e6f66809dd2701be3816c9586ce101164462 +size 310677 diff --git a/image/building_2D/living_area.png b/image/building_2D/living_area.png new file mode 100644 index 0000000000000000000000000000000000000000..2c05ec81995b4fdbbfb0347882c33b1668b12866 --- /dev/null +++ b/image/building_2D/living_area.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:dff09679d77f306a87cabf17e343fde81c50956f68047ea5f015abeeaba5efbe +size 1165188 diff --git a/image/building_2D/repair_shop.jpeg b/image/building_2D/repair_shop.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..26e603604ff4d9a71f1ed598790bf6e9b7cf87eb --- /dev/null +++ b/image/building_2D/repair_shop.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6460cc5791b908bd991279a0284bc91d31d9221c65bb4a76b55bff521c877b2f +size 301016 diff --git a/image/building_2D/repair_shop.png b/image/building_2D/repair_shop.png new file mode 100644 index 0000000000000000000000000000000000000000..fee47caa0390026ac01bdde6a0d4cee91d26066b --- /dev/null +++ b/image/building_2D/repair_shop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7fade5850a323e6f0affaa5300747e792db0a38e663237e3d5509da7a50975d1 +size 993766 diff --git a/image/building_2D/robot_station.jpeg b/image/building_2D/robot_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..af0e30fc6fe60862b02d23297f9631df02fbfb23 --- /dev/null +++ b/image/building_2D/robot_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5aab9840419cdd7db3b6f7c39e5377b0b63e4731c879155c798b363d3e0c3bf1 +size 354936 diff --git a/image/building_2D/robot_station.png b/image/building_2D/robot_station.png new file mode 100644 index 0000000000000000000000000000000000000000..926b09a7a893a60457fd2141c1816d1b082a9495 --- /dev/null +++ b/image/building_2D/robot_station.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc65a58459f1da0fd6ca84f6d5f0f060a17b9b9b5ba914816624afb4e5716de4 +size 1382095 diff --git a/image/building_2D/solar_station.jpeg b/image/building_2D/solar_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..84607b78652b8efaa9946074e86b8b81585cc723 --- /dev/null +++ b/image/building_2D/solar_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e09382787c58b0a254d493551a644f8ef92bf890e6c780dd7bc5d40f6afcbdb +size 259282 diff --git a/image/building_2D/solar_station.png b/image/building_2D/solar_station.png new file mode 100644 index 0000000000000000000000000000000000000000..972e1fa2a828dca56615996cdf90c7f189816b1b --- /dev/null +++ b/image/building_2D/solar_station.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f51c89846885e659668e4763bda3042d8cd33a0e9f02a5e4dfb412ac56f96bd +size 821503 diff --git a/image/building_2D/weapon_shop.jpeg b/image/building_2D/weapon_shop.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..95c6c7e48c526f2e7733eefeb3d5e6e8d1bf7777 --- /dev/null +++ b/image/building_2D/weapon_shop.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d34837ab61fee459c34ad4934ec76eeabd86bd0e7821df3d16d8448d0129f4a7 +size 274515 diff --git a/image/building_2D/weapon_shop.png b/image/building_2D/weapon_shop.png new file mode 100644 index 0000000000000000000000000000000000000000..7255c6a9ccb785a18ed7c949203e6425f3e64724 --- /dev/null +++ b/image/building_2D/weapon_shop.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:faa38f40a24e9fe749dfb629a50b1825eaacdba61bb85dedfa7488241c602717 +size 822058 diff --git a/image/building_2D_interia/arena.jpeg b/image/building_2D_interia/arena.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..630308f8bf525d795919a90b4f02de54f06fb1f1 --- /dev/null +++ b/image/building_2D_interia/arena.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1fc32b4e6bb4f5be8a7a10fefb8ac787cacfcc85d59d6d4a063b551259c064fe +size 351567 diff --git a/image/building_2D_interia/command_centre.jpeg b/image/building_2D_interia/command_centre.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..6f5f60a5dd7e976864450b26356fcf5c186355e9 --- /dev/null +++ b/image/building_2D_interia/command_centre.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15808a8932bc7c2fcfb03935747318779c32a79a318f6d5b1750e80f0bd11638 +size 422448 diff --git a/image/building_2D_interia/communication_station.jpeg b/image/building_2D_interia/communication_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..58c50de5700321e38c21c8002a08af99dfe99737 --- /dev/null +++ b/image/building_2D_interia/communication_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b40395dfc2dec3c3fdd724ab5ef3fd112cc53947e929014944f38b7d383c5579 +size 427707 diff --git a/image/building_2D_interia/convention_centre.jpeg b/image/building_2D_interia/convention_centre.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..e8862310d5bf55936fc4bfa9b99bc6c528546149 --- /dev/null +++ b/image/building_2D_interia/convention_centre.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:913440ae1ebd2c6ab45324c57f42073520d49339b1c4f993f269b28f4f55f524 +size 390061 diff --git a/image/building_2D_interia/forrest.jpeg b/image/building_2D_interia/forrest.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..640ccf2d248943b1e858791a49a12d124469cc12 --- /dev/null +++ b/image/building_2D_interia/forrest.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:220ec2ed147331ee12d7e7fe401bb42eece8af894a0f22faf86639e7aede349a +size 492314 diff --git a/image/building_2D_interia/gas_station.jpeg b/image/building_2D_interia/gas_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..629bef244d08e4cddc85c6729147ccc32dbc4b4f --- /dev/null +++ b/image/building_2D_interia/gas_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5254f741055c3ab10c4e39f681c250c96334bbb95af07085c7e2bf802bf723b5 +size 394446 diff --git a/image/building_2D_interia/greenhouse.jpeg b/image/building_2D_interia/greenhouse.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..9356b5273a7a4a4694a5446bcd7c8bb53f434dec --- /dev/null +++ b/image/building_2D_interia/greenhouse.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb28785370f04fab84dcc3be9612b74c492448a9effcffd686bebe242ad1dc2 +size 563206 diff --git a/image/building_2D_interia/hangar.jpeg b/image/building_2D_interia/hangar.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c951083af16417b03cb40ea00478966b0ae640ba --- /dev/null +++ b/image/building_2D_interia/hangar.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f487dbdf9d9ae5febcce18a83aa74a2d143622aad6b88d21139e4224a931f68 +size 434221 diff --git a/image/building_2D_interia/hospital.jpeg b/image/building_2D_interia/hospital.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..4b176fa065375c86614fffb411f8f2b4933024a7 --- /dev/null +++ b/image/building_2D_interia/hospital.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d1d0a6aaf4042089b3fe26f57c27af0d9ec5467798e705fb807e0a93bd6d2c46 +size 337525 diff --git a/image/building_2D_interia/launchpad.jpeg b/image/building_2D_interia/launchpad.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..3663077672688f90aa2bd0f2bc2fa86a4f1f8072 --- /dev/null +++ b/image/building_2D_interia/launchpad.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c2290a324a0504a03894225c7641c2bdcf0893f7c33576cf161e5d9b7e103980 +size 483745 diff --git a/image/building_2D_interia/living_area.jpeg b/image/building_2D_interia/living_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7ea85563d7aedd8e08916b7a6ff2641ba9c1d8df --- /dev/null +++ b/image/building_2D_interia/living_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9748f19a7084a7db6fd8ad11a12b538aa2ff21c2d4a78c7c623d2bbdef17a32f +size 396715 diff --git a/image/building_2D_interia/repair_shop.jpeg b/image/building_2D_interia/repair_shop.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..01f4dc03a7b239dbf0356e7875257c37bc352069 --- /dev/null +++ b/image/building_2D_interia/repair_shop.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc3c75c9fefa6a511b345601ba77bb6196dc56aef4296ab63c603eaaf1ba4b92 +size 413352 diff --git a/image/building_2D_interia/robot_station.jpeg b/image/building_2D_interia/robot_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..0f4680c69495402d67c585a9d0580c8a4e860012 --- /dev/null +++ b/image/building_2D_interia/robot_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb3ecd0702823429b66afd58529c158d702b4e49c3e02994d4fc9f01c3b01b9b +size 488707 diff --git a/image/building_2D_interia/solar_station.jpeg b/image/building_2D_interia/solar_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c55eaa35863abb1b09d9dbceaffab8f85a1ef685 --- /dev/null +++ b/image/building_2D_interia/solar_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3472117be30a54a9ff1a3cb4a86b3be3892ce3b226dc3f2af673ac2fef8f248b +size 391448 diff --git a/image/building_2D_interia/weapon_shop.jpeg b/image/building_2D_interia/weapon_shop.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a3c9122b5b0faea7ff9352049d04519288a98561 --- /dev/null +++ b/image/building_2D_interia/weapon_shop.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4bd9f70c7b080c49d4548b3942dd9ba7605aa5d4a8a247860aff28ece8cd92f4 +size 453652 diff --git a/image/character/Main_female_character.jpeg b/image/character/Main_female_character.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..5fd3408ee40d2378ce205f78ea57ba9a1107c910 --- /dev/null +++ b/image/character/Main_female_character.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6bdb5cac48cc97b629b2431bd0ef6a50483890b6a9c27605307d77a7b9d57b7 +size 245938 diff --git a/image/character/Main_female_character.png b/image/character/Main_female_character.png new file mode 100644 index 0000000000000000000000000000000000000000..98d28ec984a329da48910cbb3a0fc9bacafb87c9 --- /dev/null +++ b/image/character/Main_female_character.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b979d98f30b2069f5aedac172560c44f59cdefbe57b3fc822254c90e5be1eddc +size 1205710 diff --git a/image/character/Main_female_character_face.png b/image/character/Main_female_character_face.png new file mode 100644 index 0000000000000000000000000000000000000000..039a0b4cc6bde4b3a3649cc97ee3b72da18d8450 Binary files /dev/null and b/image/character/Main_female_character_face.png differ diff --git a/image/character/Main_female_character_full.png b/image/character/Main_female_character_full.png new file mode 100644 index 0000000000000000000000000000000000000000..2eb9213f423425fc8698157ed86f13927730b57d --- /dev/null +++ b/image/character/Main_female_character_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d40962018f6e2d21a15b56a78b409e24374f3dd1aed7a30f25e87ca315e45c43 +size 490746 diff --git a/image/character/Main_male_character.jpeg b/image/character/Main_male_character.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..fe1c1bf793f3054d06c458a701b03c807a1e38fe --- /dev/null +++ b/image/character/Main_male_character.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e4ca4911b0500083a212e4949f36d1ff1b66f8e6289dd6c060ddd617c166c00 +size 267947 diff --git a/image/character/Main_male_character.png b/image/character/Main_male_character.png new file mode 100644 index 0000000000000000000000000000000000000000..05927d849b0aa603277cafb99f6ea18fea1be56b --- /dev/null +++ b/image/character/Main_male_character.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d506edaad333768e694a56a903dd557087bb8e8db0389bcf036fa68b5c536eca +size 1375138 diff --git a/image/character/Main_male_character_face.png b/image/character/Main_male_character_face.png new file mode 100644 index 0000000000000000000000000000000000000000..6332ae6243ff14533a5e9d2316942856e1ce30b4 Binary files /dev/null and b/image/character/Main_male_character_face.png differ diff --git a/image/character/Main_male_character_full.png b/image/character/Main_male_character_full.png new file mode 100644 index 0000000000000000000000000000000000000000..a491a049740c31dccad9a7f42ff6517b5cdc161f --- /dev/null +++ b/image/character/Main_male_character_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a69f099c373bfb50486266cdd77c1e8c69a133fbd1635a2430757dd37a6fcc0 +size 543961 diff --git a/image/character/cleaner.jpeg b/image/character/cleaner.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..d9b31e19ec8239466f361bd9b4ad989f11e25e6b --- /dev/null +++ b/image/character/cleaner.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3385fdf05d006da1947f344c53f341501cdd75a42f6a82ae13c950cce86eab2b +size 270483 diff --git a/image/character/cleaner.png b/image/character/cleaner.png new file mode 100644 index 0000000000000000000000000000000000000000..7d5a457575876908cde35c4a463c82b5ddba1431 --- /dev/null +++ b/image/character/cleaner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1aa7e6550bd13a9ac4d95322dacdf0864a5a8ef4124f3da96eb33b033bafbb6b +size 1366723 diff --git a/image/character/cleaner_face.png b/image/character/cleaner_face.png new file mode 100644 index 0000000000000000000000000000000000000000..a63603c1ae01b9cd512034dd10cad0e2d43e7417 Binary files /dev/null and b/image/character/cleaner_face.png differ diff --git a/image/character/cleaner_full.png b/image/character/cleaner_full.png new file mode 100644 index 0000000000000000000000000000000000000000..ddd69a32c3f57b8c20f469935fbe43ab639a64b5 --- /dev/null +++ b/image/character/cleaner_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4487621f8b192a137a3e1f2c69fa7b0a204a08c705c31d4247857c98a6b3cca2 +size 547985 diff --git a/image/character/communicator.jpeg b/image/character/communicator.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..6b6121404957cf3860fe0afbd0bc924f7aaee75a --- /dev/null +++ b/image/character/communicator.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd6e6d2a6505f0b4e40d07e56db0809ed970b0a2c1e8034bd11508b39905c65d +size 248520 diff --git a/image/character/communicator.png b/image/character/communicator.png new file mode 100644 index 0000000000000000000000000000000000000000..3a8b9eed40e1b801813f6acb3bb82b9a043760ea --- /dev/null +++ b/image/character/communicator.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fdda5c4bdedf0bc1c2d16ebec628f675cab8d9bea0af703ae69945af866d3fe0 +size 1283320 diff --git a/image/character/communicator_face.png b/image/character/communicator_face.png new file mode 100644 index 0000000000000000000000000000000000000000..8015fe7276308b1a7f9515961f91aac79fb49e8a Binary files /dev/null and b/image/character/communicator_face.png differ diff --git a/image/character/communicator_full.png b/image/character/communicator_full.png new file mode 100644 index 0000000000000000000000000000000000000000..5d400ae2f769f4415f3a98b7c7297f60657f4a93 --- /dev/null +++ b/image/character/communicator_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2264e05e3c03d780a951f4821e580f8d18b08370a3651b0633991a3faffb9f71 +size 458046 diff --git a/image/character/courier.jpeg b/image/character/courier.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..c55bcb07e6dd9d390bf49a7ee7c957ac4a85197c --- /dev/null +++ b/image/character/courier.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fb1af775363e87e5a7e85777a2dbff551e987bf349dbf2c44c027892508ef87a +size 249603 diff --git a/image/character/courier.png b/image/character/courier.png new file mode 100644 index 0000000000000000000000000000000000000000..2c16de028137f6812a3e91b3f3b68d8e212b6f3e --- /dev/null +++ b/image/character/courier.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cf8154acf7909b1c3b4e996ae5526e214f68cc5dbc0955dba202df040dfc140 +size 1230838 diff --git a/image/character/courier_face.png b/image/character/courier_face.png new file mode 100644 index 0000000000000000000000000000000000000000..0e9a4992f62feb74d2cecc54f26698cd6dfbb4f7 Binary files /dev/null and b/image/character/courier_face.png differ diff --git a/image/character/courier_full.png b/image/character/courier_full.png new file mode 100644 index 0000000000000000000000000000000000000000..1742f6d9908cde819d2877325ac0bba4a63b4742 --- /dev/null +++ b/image/character/courier_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4598f9f3ab2253d0852cf93e9795c3f73e37920adf759d1d49b0da8985b26ec5 +size 444722 diff --git a/image/character/farmer.jpeg b/image/character/farmer.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..6b9c89694d33a9399e3ea7d77376736d0aeb6a0f --- /dev/null +++ b/image/character/farmer.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90bd3ab0a6ada06d656a06dcb633204b4426e8675fdf419f302d3b2163daa660 +size 252649 diff --git a/image/character/farmer.png b/image/character/farmer.png new file mode 100644 index 0000000000000000000000000000000000000000..7c010c29aeaefb10843a2a2c1f06934eab13af63 --- /dev/null +++ b/image/character/farmer.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4f372e2cef87a261cc14d39a22c6e87fbc166d5f148c22fd2f40734ca0f3bca +size 1341824 diff --git a/image/character/farmer_face.png b/image/character/farmer_face.png new file mode 100644 index 0000000000000000000000000000000000000000..58d5e1dd3e026be50b9fa7291bc07a46164cffa5 Binary files /dev/null and b/image/character/farmer_face.png differ diff --git a/image/character/farmer_full.png b/image/character/farmer_full.png new file mode 100644 index 0000000000000000000000000000000000000000..1e0887cd26077e2991d649c3373bc97d83528e7b --- /dev/null +++ b/image/character/farmer_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8111c10ee26982d45457c4067afa08d6681a607f2aa69537a23021fa58331fae +size 470692 diff --git a/image/character/humanoid_robot.jpeg b/image/character/humanoid_robot.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..8dddbbffecfa281bc3c8b208ccd534d384fe5288 --- /dev/null +++ b/image/character/humanoid_robot.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ff5a3023b256af8c5114f4a5bdccc7071b702fff851d2d7609f95beaf1e73d0f +size 229943 diff --git a/image/character/humanoid_robot.png b/image/character/humanoid_robot.png new file mode 100644 index 0000000000000000000000000000000000000000..b511642ac9ba9a66234480997e68e78dc42e9321 --- /dev/null +++ b/image/character/humanoid_robot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1f9f1b7e22a2444c1719a65405c6e9dc6bb76f3344954bb022da1caeacff0365 +size 1038164 diff --git a/image/character/humanoid_robot_face.png b/image/character/humanoid_robot_face.png new file mode 100644 index 0000000000000000000000000000000000000000..6f7b42df4a58db3e0a5d711e895cb52766dcac83 Binary files /dev/null and b/image/character/humanoid_robot_face.png differ diff --git a/image/character/humanoid_robot_full.png b/image/character/humanoid_robot_full.png new file mode 100644 index 0000000000000000000000000000000000000000..b849becec6822cf22daa57320efcd1f0278f168f --- /dev/null +++ b/image/character/humanoid_robot_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28b2463870bc2862f930a887f0b08d75d8c5f33d83e6425ed23b9c4c34b44c11 +size 383744 diff --git a/image/character/mechanic.jpeg b/image/character/mechanic.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..4c7ca3c959ba306d73ea2783e11605e609b021a4 --- /dev/null +++ b/image/character/mechanic.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9e78e00d42431733671365eab04595ce41787150db08e158109415648bf88ebb +size 277858 diff --git a/image/character/mechanic.png b/image/character/mechanic.png new file mode 100644 index 0000000000000000000000000000000000000000..9d53bd0da3f22c2c82c555e031c09ffa21f508f7 --- /dev/null +++ b/image/character/mechanic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:55a3e30237987eca9ef5fe4b0d3e624ab737ab815e2f3f203b26e8201d4a74f2 +size 1400964 diff --git a/image/character/mechanic_face.png b/image/character/mechanic_face.png new file mode 100644 index 0000000000000000000000000000000000000000..0c6eefa65e3442e09456b3e6f6d249e2f81c489d Binary files /dev/null and b/image/character/mechanic_face.png differ diff --git a/image/character/mechanic_full.png b/image/character/mechanic_full.png new file mode 100644 index 0000000000000000000000000000000000000000..d2c3e70dc69f1d5957c36c3ee7334cc8337cd6d4 --- /dev/null +++ b/image/character/mechanic_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c70c7b31001ca13024fd94e7cacbc756064016697ea68ee3e28605e0587ab7dd +size 478162 diff --git a/image/character/medic.jpeg b/image/character/medic.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..cdf3f40eba717a42d866cfe654e17426e2fc80f5 --- /dev/null +++ b/image/character/medic.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a021ed997ef03801cc761bcdbcc9e1b5e1ee0e097bc5a219baf952e070051eb7 +size 265823 diff --git a/image/character/medic.png b/image/character/medic.png new file mode 100644 index 0000000000000000000000000000000000000000..f37634ad10c7a2988611166311fa52bf0beebd43 --- /dev/null +++ b/image/character/medic.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5797b6996dcaebc89c5b4294f067e96afdd39d8bdbd6e641dc472cd955745cad +size 1338425 diff --git a/image/character/medic_face.png b/image/character/medic_face.png new file mode 100644 index 0000000000000000000000000000000000000000..7a3f9d1edd75771b5dcbbb2bf87d245dd2334577 Binary files /dev/null and b/image/character/medic_face.png differ diff --git a/image/character/medic_full.png b/image/character/medic_full.png new file mode 100644 index 0000000000000000000000000000000000000000..43e61939d9fd1431682270f1101722bb755bc89b --- /dev/null +++ b/image/character/medic_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bd2a349739ea1e5d1b94a5f959c000b6f47312f6ad28c833ed9ab4594918cfad +size 528154 diff --git a/image/character/navigator.jpeg b/image/character/navigator.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..1b7d6fd7ef7ede809f352f2c252941b9289881ce --- /dev/null +++ b/image/character/navigator.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:85f24f080f3aa92c2132440af3e94bb78e3c8ecc0863b31bced9ffd86624a58f +size 242223 diff --git a/image/character/navigator.png b/image/character/navigator.png new file mode 100644 index 0000000000000000000000000000000000000000..5f1c81623b77313ed61a5bef123763b11a393707 --- /dev/null +++ b/image/character/navigator.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fbf3b5fd8f95c8e564d9d24448a9d0ed0b095b9721ceae9266f6bbcf121e5482 +size 1158505 diff --git a/image/character/navigator_face.png b/image/character/navigator_face.png new file mode 100644 index 0000000000000000000000000000000000000000..e2d46b63c216194b44e54dbf32a0f42ef3518de9 Binary files /dev/null and b/image/character/navigator_face.png differ diff --git a/image/character/navigator_full.png b/image/character/navigator_full.png new file mode 100644 index 0000000000000000000000000000000000000000..e4f638a91dd5be049d831bb0cc6cd2e9105e672a --- /dev/null +++ b/image/character/navigator_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a493cbbf15694086738cb8e283b98bdaad281ab1dcefcacbb53c1fb757975591 +size 399419 diff --git a/image/character/pilot.jpeg b/image/character/pilot.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..910538d47ffeceee642438333ac5938eabcf1af4 --- /dev/null +++ b/image/character/pilot.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b52897bba828c535ce1c3bc19a3715b6620834ac66eeaa2eb277ad834758c551 +size 252430 diff --git a/image/character/pilot.png b/image/character/pilot.png new file mode 100644 index 0000000000000000000000000000000000000000..529cf779a159a02e74aba99a0db63ae731a925ea --- /dev/null +++ b/image/character/pilot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:831ead9112fb4b66a193863126efefce87869039abfd52cc639f5f261dbc4107 +size 1305207 diff --git a/image/character/pilot_face.png b/image/character/pilot_face.png new file mode 100644 index 0000000000000000000000000000000000000000..eb52fc4904bf47bd8ace0bdf13bd3d3c2e5fbe09 Binary files /dev/null and b/image/character/pilot_face.png differ diff --git a/image/character/pilot_full.png b/image/character/pilot_full.png new file mode 100644 index 0000000000000000000000000000000000000000..f1e36d411ad6f92875edacf68e0a55f21718433b --- /dev/null +++ b/image/character/pilot_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:18ba973ca20636e29a8558426ea345c97aa7fb5ef3e2653d3178445c6389e2aa +size 544020 diff --git a/image/character/wheel_robot.jpeg b/image/character/wheel_robot.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f30a1ae93495c292a908acaaa133bb21a4712062 --- /dev/null +++ b/image/character/wheel_robot.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bc65d55329f3285ef103343b60f20be768637119a534bde93be215451662bbad +size 233638 diff --git a/image/character/wheel_robot.png b/image/character/wheel_robot.png new file mode 100644 index 0000000000000000000000000000000000000000..948d9b43b177369028a8d5085bdd991a5153b587 --- /dev/null +++ b/image/character/wheel_robot.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fe41845c62a6de8c47c8b60511bb24a3564b82107e25133c614dc64a21bc2280 +size 1057422 diff --git a/image/character/wheel_robot_face.png b/image/character/wheel_robot_face.png new file mode 100644 index 0000000000000000000000000000000000000000..5fd64ccb63f18c88c430c786735eb3fc883b0798 Binary files /dev/null and b/image/character/wheel_robot_face.png differ diff --git a/image/character/wheel_robot_full.png b/image/character/wheel_robot_full.png new file mode 100644 index 0000000000000000000000000000000000000000..8961d346e27bf7193e6b6dd295e20e3852e94461 --- /dev/null +++ b/image/character/wheel_robot_full.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d140e58266f06fb8a178f30417193e66fb0bb880558a654572cd57fcb46e508e +size 477002 diff --git a/image/interia/isolated_automation_area.jpeg b/image/interia/isolated_automation_area.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..eff321e16d72c28358860e84798233d0148411b4 --- /dev/null +++ b/image/interia/isolated_automation_area.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f263a32d6390a0ab5035f3f89c6f83d63217cc2b3e9387a49fedf4f3b25aa3f1 +size 440248 diff --git a/image/interia/laboratory_place.jpeg b/image/interia/laboratory_place.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..f9d941948016c417112a7a18d09a490a42f6c330 --- /dev/null +++ b/image/interia/laboratory_place.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be1a5c06b192786a2b7160e61def1b2cc57f7d012b9b1f563504f1a4323384d2 +size 335697 diff --git a/image/interia/sleeping_place.jpeg b/image/interia/sleeping_place.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..7dc3f47379b58409546acc69b8e8d71b1166d843 --- /dev/null +++ b/image/interia/sleeping_place.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:535fbdf3c5e58e68c267a96a8f4aeaba2ef1308efa2f6387dd186634842631ad +size 345728 diff --git a/image/mission/huge_automated_cargo_truck.jpeg b/image/mission/huge_automated_cargo_truck.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..002e7a6315812ec3b4181ec45afb7d3e369a4982 --- /dev/null +++ b/image/mission/huge_automated_cargo_truck.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a4ce88266331c0512b0a1bc9f6796ea9f99bff0e6899a3fae5bf62110b4e363 +size 375365 diff --git a/image/mission/huge_mobile_truck.jpeg b/image/mission/huge_mobile_truck.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..a56124997a0dfd21b51e00f5d3ace5fabe6dfff8 --- /dev/null +++ b/image/mission/huge_mobile_truck.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:706afd05ee151d43c17c683f168bf67869d26976b71aaa04112aa56f10229033 +size 338763 diff --git a/image/mission/huge_robot.jpeg b/image/mission/huge_robot.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..bc682d58aa7374bf7b233155a583f4ba05455b7b --- /dev/null +++ b/image/mission/huge_robot.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1a7ea3d52ecb2d81730246522983f75e4a5afc2e368ae07b0f3a89b9fb355b72 +size 296320 diff --git a/image/mission/huge_rocket.jpeg b/image/mission/huge_rocket.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..3f4b0754450bdf8d194a3fa601e08da6fbffecb0 --- /dev/null +++ b/image/mission/huge_rocket.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:13d08b3929b16d6a0ad7f7e8207f9760411b3c7e8ae186e5989722fd5bab0e4d +size 273281 diff --git a/image/mission/space_station.jpeg b/image/mission/space_station.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..8998715cecd871d5caf0467f84d34a16f6112fb9 --- /dev/null +++ b/image/mission/space_station.jpeg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:042e045d56851dded7516f0a3821c18cd53c0f8fb0ca77653d737b2cb8756ab5 +size 252304 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..6ed08a2d7fd2909569e3be96458c9ee05fac45ec --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +fastapi==0.115.0 +uvicorn[standard]==0.30.6 +httpx==0.27.2 +pydantic==2.9.2 diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000000000000000000000000000000000000..9e403c0f05cc359ad27f4f2356b46ee587ef6952 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,395 @@ +/* Lunar Village - styles (mobile-first, responsive) + Created by Andy Kong */ + +* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; } +html, body { + height: 100%; overflow: hidden; + background: #05060f; + color: #e8e8f0; + font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; + touch-action: none; + user-select: none; +} +#game { position: fixed; inset: 0; display: block; } + +/* ---------- HUD ---------- */ +#hud { position: fixed; inset: 0; pointer-events: none; z-index: 10; } +#topbar { + position: absolute; top: 0; left: 0; right: 0; + display: flex; justify-content: space-between; align-items: center; + gap: 8px; padding: env(safe-area-inset-top, 8px) 10px 8px; + padding-top: max(env(safe-area-inset-top), 8px); +} +#resources { + display: flex; flex-wrap: wrap; gap: 6px; + background: rgba(10, 12, 24, 0.65); + backdrop-filter: blur(6px); + border: 1px solid rgba(255,255,255,0.08); + border-radius: 12px; padding: 6px 8px; +} +.res { display: inline-flex; align-items: center; gap: 3px; font-size: 13px; font-weight: 600; } +.res-ic { font-size: 14px; } +.pill-btn { + pointer-events: auto; + background: linear-gradient(135deg, #6c5ce7, #4834d4); + color: #fff; border: none; border-radius: 12px; + padding: 9px 12px; font-size: 13px; font-weight: 700; cursor: pointer; + box-shadow: 0 4px 14px rgba(76, 52, 212, 0.4); + white-space: nowrap; +} +.pill-btn:active { transform: scale(0.96); } +.topbar-btns { display: flex; gap: 8px; align-items: center; } +.pill-btn.ghost { + background: rgba(10, 12, 24, 0.65); border: 1px solid rgba(124,246,255,0.35); + box-shadow: none; color: #cffaff; +} +.pill-btn.profile { + background: linear-gradient(135deg, #ffd166, #f0932b); + color: #1a1200; + box-shadow: 0 4px 14px rgba(240,147,43,0.4); +} +.pill-btn.announce { + background: linear-gradient(135deg, #ff7675, #d63031); + box-shadow: 0 4px 14px rgba(214, 48, 49, 0.4); +} + +/* Broadcast banner */ +#broadcast-banner { + position: fixed; top: 0; left: 0; right: 0; z-index: 45; + display: flex; align-items: center; justify-content: center; + padding: 14px 16px; padding-top: max(env(safe-area-inset-top), 14px); + background: linear-gradient(180deg, rgba(214,48,49,0.95), rgba(214,48,49,0)); + color: #fff; font-weight: 700; font-size: 15px; text-align: center; + transform: translateY(-120%); transition: transform 0.4s ease; pointer-events: none; + text-shadow: 0 1px 3px rgba(0,0,0,0.5); +} +#broadcast-banner.show { transform: translateY(0); } + +/* Announcement composer */ +.announce-card { + width: 100%; max-width: 480px; max-height: 86vh; + background: linear-gradient(160deg, #1e1420, #0c0d1a); + border: 1px solid rgba(255, 118, 117, 0.3); + border-radius: 18px; display: flex; flex-direction: column; overflow: hidden; +} +.announce-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); } +.announce-head h2 { font-size: 18px; } +.announce-sub { font-size: 13px; color: #aab; padding: 12px 16px 0; } +.announce-input-row { display: flex; gap: 8px; padding: 12px 16px; } +#announce-input { + flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; padding: 12px; color: #fff; font-size: 15px; outline: none; +} +#announce-send { + background: linear-gradient(135deg, #ff7675, #d63031); border: none; color: #fff; + font-weight: 700; padding: 0 16px; border-radius: 12px; cursor: pointer; font-size: 14px; +} +#announce-send:disabled { opacity: 0.5; cursor: not-allowed; } +#announce-send:active:not(:disabled) { transform: scale(0.95); } +.announce-quick { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 16px 8px; } +.quick-chip { + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); + color: #cdd; border-radius: 20px; padding: 6px 10px; font-size: 12px; cursor: pointer; +} +.quick-chip:active { transform: scale(0.95); } +.announce-results { overflow-y: auto; padding: 4px 16px 16px; display: flex; flex-direction: column; gap: 8px; } +.ann-row { display: flex; gap: 10px; align-items: flex-start; } +.ann-av { + width: 34px; height: 34px; border-radius: 50%; flex: 0 0 auto; + background-color: #12142a; background-size: cover; background-position: center; + border: 2px solid #ff7675; +} +.ann-body { flex: 1; min-width: 0; } +.ann-name { font-size: 12px; font-weight: 700; color: #ffb0af; } +.ann-reply { font-size: 13px; color: #e8e8f0; line-height: 1.35; } +.ann-reply.pending { color: #889; font-style: italic; } +.ann-move-note { + background: rgba(124,246,255,0.12); border: 1px solid rgba(124,246,255,0.3); + color: #cffaff; font-size: 12px; font-weight: 600; padding: 8px 10px; border-radius: 10px; +} + +#zoom-controls { + position: absolute; right: 12px; top: 62px; + display: flex; flex-direction: column; gap: 6px; pointer-events: auto; +} +.zoom-ctl { + width: 40px; height: 40px; border-radius: 10px; font-size: 22px; font-weight: 700; + background: rgba(10, 12, 24, 0.7); color: #fff; border: 1px solid rgba(255,255,255,0.15); + cursor: pointer; line-height: 1; +} +.zoom-ctl:active { transform: scale(0.92); } + +#action-hint { + position: absolute; bottom: 108px; left: 50%; transform: translateX(-50%); + background: rgba(10, 12, 24, 0.7); padding: 5px 12px; border-radius: 20px; + font-size: 13px; font-weight: 600; color: #ffd166; + border: 1px solid rgba(255,209,102,0.3); +} +#action-hint:empty { display: none; } + +/* ---------- Mobile controls ---------- */ +#joystick { + position: fixed; bottom: 28px; left: 22px; + width: 118px; height: 118px; border-radius: 50%; + background: rgba(255,255,255,0.06); + border: 2px solid rgba(255,255,255,0.14); + z-index: 20; touch-action: none; +} +#joystick-knob { + position: absolute; top: 50%; left: 50%; + width: 52px; height: 52px; margin: -26px 0 0 -26px; border-radius: 50%; + background: radial-gradient(circle at 35% 35%, #9b8cff, #4834d4); + box-shadow: 0 4px 12px rgba(0,0,0,0.4); +} +#action-btn { + position: fixed; bottom: 40px; right: 26px; + width: 78px; height: 78px; border-radius: 50%; + font-size: 30px; border: none; cursor: pointer; + background: linear-gradient(135deg, #ffd166, #f0932b); + box-shadow: 0 6px 20px rgba(240, 147, 43, 0.5); + z-index: 20; display: none; +} +#action-btn.show { display: block; } +#action-btn:active { transform: scale(0.93); } +#enter-btn { + position: fixed; bottom: 130px; right: 26px; + padding: 10px 18px; border-radius: 14px; + font-size: 15px; font-weight: 700; border: none; cursor: pointer; + background: linear-gradient(135deg, #7cf6ff, #38bdf8); + color: #0c1020; + box-shadow: 0 6px 20px rgba(56, 189, 248, 0.4); + z-index: 20; display: none; +} +#enter-btn.show { display: block; } +#enter-btn:active { transform: scale(0.93); } + +/* ---------- Toast ---------- */ +#toast { + position: fixed; top: 64px; left: 50%; transform: translateX(-50%) translateY(-20px); + background: rgba(12, 14, 28, 0.92); color: #fff; + padding: 10px 16px; border-radius: 12px; font-size: 13px; font-weight: 600; + border: 1px solid rgba(124, 246, 255, 0.3); + opacity: 0; transition: all 0.25s ease; z-index: 40; pointer-events: none; + max-width: 90vw; text-align: center; +} +#toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } + +/* ---------- Modals ---------- */ +.modal { + position: fixed; inset: 0; z-index: 50; + background: rgba(3, 4, 12, 0.72); backdrop-filter: blur(4px); + display: none; align-items: center; justify-content: center; padding: 14px; +} +.modal.show { display: flex; } +.close-x { + background: rgba(255,255,255,0.08); border: none; color: #fff; + width: 34px; height: 34px; border-radius: 50%; font-size: 16px; cursor: pointer; + flex: 0 0 auto; +} +.close-x:active { transform: scale(0.9); } + +/* ---------- Dialog ---------- */ +.dialog-card { + width: 100%; max-width: 460px; height: min(78vh, 620px); + background: linear-gradient(160deg, #16182c, #0c0d1a); + border: 1px solid rgba(124, 246, 255, 0.18); + border-radius: 18px; display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); +} +.dialog-head { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.08); } +.dialog-avatar { + width: 46px; height: 46px; border-radius: 50%; + background-color: #12142a; + background-size: cover; background-position: center; background-repeat: no-repeat; + border: 2px solid #7cf6ff; flex: 0 0 auto; +} +.dialog-title { flex: 1; min-width: 0; } +#dialog-name { font-weight: 700; font-size: 16px; } +.role { font-size: 12px; color: #9aa; text-transform: capitalize; } +.ai-status { font-size: 11px; color: #7cf6ff; background: rgba(124,246,255,0.1); padding: 3px 7px; border-radius: 8px; } +.chat-log { flex: 1; overflow-y: auto; padding: 12px; display: flex; flex-direction: column; gap: 8px; } +.chat-line { max-width: 82%; padding: 9px 12px; border-radius: 14px; font-size: 14px; line-height: 1.4; word-wrap: break-word; } +.chat-line.npc { align-self: flex-start; background: rgba(124, 246, 255, 0.12); border: 1px solid rgba(124,246,255,0.2); border-bottom-left-radius: 4px; } +.chat-line.me { align-self: flex-end; background: linear-gradient(135deg, #6c5ce7, #4834d4); border-bottom-right-radius: 4px; } +.chat-line.typing { opacity: 0.6; font-style: italic; } +.chat-input-row { display: flex; gap: 8px; padding: 10px; border-top: 1px solid rgba(255,255,255,0.08); } +#chat-input { + flex: 1; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.12); + border-radius: 12px; padding: 11px 12px; color: #fff; font-size: 15px; outline: none; +} +#chat-send { + background: linear-gradient(135deg, #ffd166, #f0932b); border: none; color: #1a1200; + font-weight: 700; padding: 0 16px; border-radius: 12px; cursor: pointer; font-size: 14px; +} +#chat-send:active { transform: scale(0.95); } + +/* ---------- About panel ---------- */ +.pill-btn.about { + background: rgba(10, 12, 24, 0.65); border: 1px solid rgba(255,255,255,0.2); + box-shadow: none; color: #e8e8f0; +} +.about-card { + width: 100%; max-width: 600px; max-height: 85vh; + background: linear-gradient(160deg, #16182c, #0c0d1a); + border: 1px solid rgba(124, 246, 255, 0.22); + border-radius: 18px; display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); +} +.about-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); flex: 0 0 auto; } +.about-head h2 { font-size: 18px; } +.about-body { overflow-y: auto; padding: 16px; display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto; min-height: 0; } +.about-section { + border: 1px solid rgba(255,255,255,0.08); border-radius: 12px; overflow: visible; + flex: 0 0 auto; +} +.about-toggle { + width: 100%; background: rgba(255,255,255,0.04); border: none; color: #e8e8f0; + padding: 12px 14px; font-size: 14px; font-weight: 700; text-align: left; cursor: pointer; + display: flex; align-items: center; gap: 8px; border-radius: 12px; +} +.about-toggle:hover { background: rgba(255,255,255,0.07); } +.about-toggle .arrow { transition: transform 0.2s; font-size: 12px; } +.about-toggle.open .arrow { transform: rotate(90deg); } +.about-content { display: none; padding: 10px 14px 14px; font-size: 13px; line-height: 1.6; color: #cdd; } +.about-content.open { display: block; } +.about-content p { margin: 0 0 8px; } +.about-content ul { margin: 4px 0 8px; padding-left: 18px; } +.about-content li { margin: 4px 0; } +.about-content .char-row { display: flex; gap: 8px; align-items: center; margin: 6px 0; } +.about-content .char-dot { width: 8px; height: 8px; border-radius: 50%; background: #7cf6ff; flex: 0 0 auto; } +.about-content strong { color: #ffd166; } +.about-content em { color: #7cf6ff; font-style: normal; } + +/* ---------- Building interior view ---------- */ +.interior-card { + width: 100%; max-width: 800px; max-height: 88vh; + background: #0c0d1a; + border: 1px solid rgba(124, 246, 255, 0.22); + border-radius: 18px; display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.7); +} +.interior-head { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); } +.interior-head h2 { font-size: 16px; } +.interior-scene { position: relative; width: 100%; aspect-ratio: 16/9; overflow: hidden; } +#interior-canvas { width: 100%; height: 100%; display: block; } + +/* ---------- Character ability window ---------- */ +.char-card { + width: 100%; max-width: 440px; max-height: 88vh; + background: linear-gradient(160deg, #16182c, #0c0d1a); + border: 1px solid rgba(124, 246, 255, 0.22); + border-radius: 18px; display: flex; flex-direction: column; overflow: hidden; + box-shadow: 0 20px 60px rgba(0,0,0,0.6); +} +.char-head { display: flex; align-items: center; gap: 10px; padding: 12px; border-bottom: 1px solid rgba(255,255,255,0.08); } +.char-title { flex: 1; min-width: 0; } +#char-name { font-weight: 800; font-size: 17px; } +.char-body { overflow-y: auto; padding: 12px 14px; display: flex; flex-direction: column; gap: 8px; } +.stat-bar-row { display: flex; align-items: center; gap: 8px; } +.stat-bar-label { width: 26px; font-size: 12px; font-weight: 700; color: #cdd; } +.stat-bar { flex: 1; height: 12px; background: rgba(255,255,255,0.08); border-radius: 7px; overflow: hidden; } +.stat-bar-fill { height: 100%; border-radius: 7px; transition: width 0.4s ease; } +.stat-bar-num { width: 62px; text-align: right; font-size: 11px; color: #aab; font-variant-numeric: tabular-nums; } +.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; margin: 4px 0; } +.stat-chip { display: flex; justify-content: space-between; background: rgba(255,255,255,0.05); border: 1px solid rgba(255,255,255,0.08); border-radius: 9px; padding: 6px 9px; font-size: 12px; } +.stat-chip b { color: #ffd166; } +.stat-line { display: flex; gap: 8px; font-size: 13px; line-height: 1.35; } +.stat-line .k { flex: 0 0 92px; color: #8ab; font-weight: 600; } +.stat-line .v { flex: 1; color: #e8e8f0; } +.stat-section { margin-top: 6px; font-size: 13px; font-weight: 800; color: #7cf6ff; border-top: 1px solid rgba(255,255,255,0.08); padding-top: 8px; } +.rel-list, .fight-list { display: flex; flex-direction: column; gap: 4px; } +.rel-row, .fight-row { display: flex; justify-content: space-between; gap: 8px; font-size: 12px; background: rgba(255,255,255,0.04); border-radius: 8px; padding: 5px 9px; } +.rel-row .ally, .fight-row .win { color: #6ee7a8; } +.rel-row .rival, .fight-row .loss { color: #ff9a9a; } +.fight-row { justify-content: flex-start; gap: 10px; } +.muted { font-size: 12px; color: #778; padding: 2px 2px; } +.tag.injured { background: rgba(248,113,113,0.2); color: #ffb4b4; padding: 1px 6px; border-radius: 6px; font-size: 11px; } +.char-actions { display: flex; flex-wrap: wrap; gap: 6px; padding: 10px 12px; border-top: 1px solid rgba(255,255,255,0.08); } +.cbtn { + flex: 1 1 auto; min-width: 88px; background: rgba(255,255,255,0.07); + border: 1px solid rgba(255,255,255,0.14); color: #e8e8f0; border-radius: 10px; + padding: 9px 10px; font-size: 13px; font-weight: 700; cursor: pointer; +} +.cbtn.talk { background: linear-gradient(135deg, #ffd166, #f0932b); color: #1a1200; border: none; } +.cbtn:active { transform: scale(0.96); } + +/* ---------- Missions ---------- */ +.missions-card { + width: 100%; max-width: 720px; max-height: 86vh; + background: linear-gradient(160deg, #16182c, #0c0d1a); + border: 1px solid rgba(255, 209, 102, 0.2); + border-radius: 18px; display: flex; flex-direction: column; overflow: hidden; +} +.missions-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid rgba(255,255,255,0.08); } +.missions-head h2 { font-size: 18px; } +.missions-list { overflow-y: auto; padding: 14px; display: grid; grid-template-columns: 1fr; gap: 12px; } +@media (min-width: 560px) { .missions-list { grid-template-columns: 1fr 1fr; } } +.mission-card { + display: flex; gap: 12px; background: rgba(255,255,255,0.04); + border: 1px solid rgba(255,255,255,0.08); border-radius: 14px; padding: 10px; overflow: hidden; +} +.mission-card.done { border-color: rgba(46, 213, 115, 0.4); } +.mission-img { width: 92px; height: 92px; border-radius: 10px; background-size: cover; background-position: center; flex: 0 0 auto; } +.mission-body { flex: 1; min-width: 0; } +.mission-body h3 { font-size: 15px; margin-bottom: 3px; } +.mission-body p { font-size: 12px; color: #aab; line-height: 1.35; margin-bottom: 6px; } +.mission-cost { display: flex; flex-wrap: wrap; gap: 5px; margin-bottom: 8px; } +.cost { font-size: 12px; padding: 2px 6px; border-radius: 6px; background: rgba(255,255,255,0.06); } +.cost.ok { color: #6ee7a8; } .cost.no { color: #ff7675; } +.build-btn { + background: linear-gradient(135deg, #6c5ce7, #4834d4); color: #fff; border: none; + border-radius: 10px; padding: 8px 14px; font-weight: 700; font-size: 13px; cursor: pointer; +} +.build-btn:disabled { opacity: 0.4; cursor: not-allowed; } +.build-btn:active:not(:disabled) { transform: scale(0.96); } +.mission-status { font-size: 12px; font-weight: 700; color: #ffd166; } +.mission-card.done .mission-status { color: #6ee7a8; } +.progress { height: 8px; background: rgba(255,255,255,0.1); border-radius: 6px; overflow: hidden; margin-bottom: 5px; } +.progress .bar { height: 100%; background: linear-gradient(90deg, #ffd166, #f0932b); transition: width 0.5s ease; } +.credit-small { text-align: center; font-size: 11px; color: #667; padding: 8px; } + +/* ---------- Title ---------- */ +#title { + position: fixed; inset: 0; z-index: 100; display: none; + align-items: center; justify-content: center; padding: 20px; + background: radial-gradient(circle at 50% 30%, #1a1330, #05060f 70%); + overflow-y: auto; +} +#title.show { display: flex; } +.title-inner { text-align: center; max-width: 480px; width: 100%; } +.title-badge { font-size: 64px; filter: drop-shadow(0 0 24px rgba(255,209,102,0.5)); } +#title h1 { font-size: clamp(30px, 8vw, 46px); letter-spacing: 4px; margin: 6px 0; background: linear-gradient(135deg, #ffd166, #7cf6ff); -webkit-background-clip: text; background-clip: text; color: transparent; } +.tagline { color: #aab; font-size: 14px; line-height: 1.5; margin-bottom: 20px; } +.avatar-select { display: flex; gap: 16px; justify-content: center; margin-bottom: 20px; flex-wrap: wrap; } +.avatar-opt { cursor: pointer; padding: 8px; border-radius: 14px; border: 2px solid transparent; transition: all 0.2s; } +.avatar-opt.sel { border-color: #ffd166; background: rgba(255,209,102,0.08); } +.avatar-opt:active { transform: scale(0.96); } +.avatar-pic { + width: 140px; height: 172px; border-radius: 18px; + background-color: #161831; + background-size: contain; + background-position: center center; + background-repeat: no-repeat; + border: 3px solid rgba(255,255,255,0.15); +} +.avatar-opt.sel .avatar-pic { border-color: #ffd166; } +.avatar-name { margin-top: 8px; font-weight: 700; font-size: 14px; } +.start-btn { + background: linear-gradient(135deg, #ffd166, #f0932b); color: #1a1200; border: none; + padding: 14px 34px; border-radius: 14px; font-size: 17px; font-weight: 800; cursor: pointer; + box-shadow: 0 8px 24px rgba(240,147,43,0.4); +} +.start-btn:disabled { opacity: 0.45; cursor: not-allowed; } +.start-btn:active:not(:disabled) { transform: scale(0.97); } +.how { margin-top: 22px; display: flex; flex-direction: column; gap: 6px; font-size: 12px; color: #889; } +.credit { margin-top: 22px; font-size: 13px; color: #7cf6ff; font-weight: 600; } +.api-key-section { margin-top: 18px; text-align: left; max-width: 380px; margin-left: auto; margin-right: auto; } +.api-key-section label { display: block; font-size: 13px; font-weight: 700; color: #cdd; margin-bottom: 6px; } +#api-key-input { + width: 100%; padding: 10px 12px; border-radius: 10px; font-size: 14px; + background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); + color: #fff; outline: none; +} +#api-key-input:focus { border-color: rgba(124,246,255,0.5); } +.api-key-hint { font-size: 11px; color: #778; margin-top: 6px; line-height: 1.4; } +.credit-small, .credit { letter-spacing: 0.3px; } diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000000000000000000000000000000000000..1639bc9dcb7f27fc2ec6c0b63dfc2ef7347b5429 --- /dev/null +++ b/static/index.html @@ -0,0 +1,168 @@ +๏ปฟ + + + + + + + + Lunar Village - Moon Colony RPG + + + + + + + +
+
+
+
+ + + + + +
+
+
+ + +
+
+
+ + +
+ + + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + +
+
+
🌕
+

LUNAR VILLAGE

+

A living AI colony on the Moon. Talk to your crew, gather resources, and build the mega-projects.

+
+ +
+ 🕹 Move: joystick / WASD + 💬 Talk & ⛏ Gather: tap the action button or press E +
+
+ + +

Paste your own key for smarter AI conversations. Without it, crew will still talk using built-in responses.

+
+
Created by Andy Kong
+
+
+ + + + + + + + diff --git a/static/js/ai.js b/static/js/ai.js new file mode 100644 index 0000000000000000000000000000000000000000..24fc9ff1f0a3058b683ef000cc56089198deed75 --- /dev/null +++ b/static/js/ai.js @@ -0,0 +1,101 @@ +/* Lunar Village - AI client (talks to the backend proxy that calls DeepSeek) + Created by Andy Kong */ + +const AI = { + // Keep short rolling histories per character so conversations feel continuous. + histories: {}, + + buildSystemPrompt(character) { + const extra = character.duty + ? `Your duty is ${character.duty}. Your attitude is ${character.attitude}. Your special ability is ${character.ability.name} (${character.ability.desc}). ` + : ""; + return ( + `You are ${character.name}, ${character.persona} ` + extra + + `You live and work in "Lunar Village", a small human colony on the Moon. ` + + `The player is the colony commander. The colony is building mega projects: ` + + `a Titan robot, a mobile truck, a cargo hauler, a heavy rocket, and an orbital space station. ` + + `Stay fully in character. Reply in the same language the commander uses. ` + + `Keep replies short and natural: 1 to 3 sentences. Never mention that you are an AI or a language model.` + ); + }, + + // Create a memory file for every character (called once on game start). + async initMemory(characters) { + try { + await fetch("api/memory/init", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + characters: characters.map((c) => ({ id: c.id, name: c.name, persona: c.persona })), + }), + }); + } catch (e) { /* offline: memory just won't persist */ } + }, + + // Load the colony's accumulated resources from the server (or null). + async loadInventory() { + try { + const r = await fetch("api/inventory"); + const d = await r.json(); + return d && d.resources ? d.resources : null; + } catch (e) { return null; } + }, + + // Persist the colony's accumulated resources to the server file. + async saveInventory(resources) { + try { + await fetch("api/inventory", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ resources }), + }); + } catch (e) { /* ignore */ } + }, + + // Append an event to a character's persistent memory file. + async remember(character, text) { + try { + await fetch(`api/memory/${encodeURIComponent(character.id)}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ text, name: character.name }), + }); + } catch (e) { /* ignore */ } + }, + + async talk(character, userText) { + const key = character.id; + if (!this.histories[key]) this.histories[key] = []; + const history = this.histories[key]; + history.push({ role: "user", content: userText }); + // Trim to last 8 messages to keep prompts small. + const trimmed = history.slice(-8); + + try { + const resp = await fetch("api/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + system: this.buildSystemPrompt(character), + messages: trimmed, + temperature: 1.1, + max_tokens: 220, + character_id: character.id, + character_name: character.name, + }), + }); + const data = await resp.json(); + const reply = (data && data.reply) ? data.reply : "..."; + history.push({ role: "assistant", content: reply }); + return { reply, fallback: !!data.fallback }; + } catch (e) { + const reply = "(comms static) ...say again, commander?"; + history.push({ role: "assistant", content: reply }); + return { reply, fallback: true }; + } + }, + + reset(character) { + this.histories[character.id] = []; + }, +}; diff --git a/static/js/boot.js b/static/js/boot.js new file mode 100644 index 0000000000000000000000000000000000000000..ec1984116a9648f1d7d6f79a23cc553fe4b455ba --- /dev/null +++ b/static/js/boot.js @@ -0,0 +1,48 @@ +/* Lunar Village - boot / title screen + Created by Andy Kong */ + +(function () { + let selected = null; + + function buildAvatarSelect() { + const wrap = document.getElementById("avatar-select"); + AVATARS.forEach((a) => { + const div = document.createElement("div"); + div.className = "avatar-opt"; + div.innerHTML = ` +
+
${a.name}
`; + div.addEventListener("click", () => { + selected = a; + document.querySelectorAll(".avatar-opt").forEach((e) => e.classList.remove("sel")); + div.classList.add("sel"); + const btn = document.getElementById("start-btn"); + btn.disabled = false; + btn.textContent = `Land as ${a.name}`; + }); + wrap.appendChild(div); + }); + } + + function start() { + if (!selected) return; + // Send user-provided API key to the server (if any) + const keyInput = document.getElementById("api-key-input"); + const userKey = keyInput ? keyInput.value.trim() : ""; + if (userKey) { + fetch("api/set-key", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key: userKey }), + }).catch(() => {}); + } + document.getElementById("title").classList.remove("show"); + UI.init(); + Game.init(selected); + } + + document.addEventListener("DOMContentLoaded", () => { + buildAvatarSelect(); + document.getElementById("start-btn").addEventListener("click", start); + }); +})(); diff --git a/static/js/config.js b/static/js/config.js new file mode 100644 index 0000000000000000000000000000000000000000..a315cd69c83ab47bbc4cf9f3d31f6d6088614f7b --- /dev/null +++ b/static/js/config.js @@ -0,0 +1,201 @@ +/* Lunar Village - game configuration & data + Created by Andy Kong */ + +// World is a grid of tiles; the camera follows the player. +const CONFIG = { + tileSize: 64, + worldWidth: 44, // tiles + worldHeight: 22, // tiles + playerSpeed: 3.2, // px per frame at 60fps + npcSpeed: 1.5, + interactRadius: 95, + imgBase: "image", +}; + +// ---- Buildings ---------------------------------------------------------- +// x/y are tile coordinates (top-left). w/h in tiles. +// Village layout: a central plaza (command + gathering + meeting), a residential +// quarter to the west, farms to the south-west, industry/docks in the middle, +// space facilities to the north-east, and mining/launch to the south-east. +// The colony is laid out on a street grid (see ROAD_GRID below). Buildings sit +// tidily inside the blocks between streets, grouped into districts. +// Footprints (w,h in tiles) match each image's true aspect ratio so the art is +// never stretched: landscape art ~1.78 (16:9), portrait art ~0.82 (4:5). +// A tidy 5 x 3 moon village. Streets run on all four sides of every building +// (a full grid), so each building connects up/down/left/right to a road. +// All new building art is landscape ~1.78, so footprints are a uniform 4.5x2.53. +// Buildings are sized large enough to overlap the road grid, so roads only +// show in the gaps between buildings (like streets in a city). +const BW = 6.8, BH = 3.82; +const BUILDINGS = [ + // ===== North row ===== + { id: "command_centre", name: "Command Center", img: "building_2D/command_centre.png", interior: "building_2D_interia/command_centre.jpeg", x: 2.6, y: 2.6, w: BW, h: BH }, + { id: "communication_station", name: "Comms Station", img: "building_2D/communication_station.png", interior: "building_2D_interia/communication_station.jpeg", x: 10.6, y: 2.6, w: BW, h: BH }, + { id: "solar_station", name: "Solar Station", img: "building_2D/solar_station.png", interior: "building_2D_interia/solar_station.jpeg", x: 18.6, y: 2.6, w: BW, h: BH }, + { id: "hangar", name: "Hangar", img: "building_2D/hangar.png", interior: "building_2D_interia/hangar.jpeg", x: 26.6, y: 2.6, w: BW, h: BH }, + { id: "launchpad", name: "Launch Pad", img: "building_2D/launchpad.png", interior: "building_2D_interia/launchpad.jpeg", x: 34.6, y: 2.6, w: BW, h: BH }, + // ===== Central row ===== + { id: "living_area", name: "Living Area", img: "building_2D/living_area.png", interior: "building_2D_interia/living_area.jpeg", x: 2.6, y: 8.6, w: BW, h: BH }, + { id: "hospital", name: "Hospital", img: "building_2D/hospital.png", interior: "building_2D_interia/hospital.jpeg", x: 10.6, y: 8.6, w: BW, h: BH }, + { id: "convention_centre", name: "Convention Center", img: "building_2D/convention_centre.png", interior: "building_2D_interia/convention_centre.jpeg", x: 18.6, y: 8.6, w: BW, h: BH }, + { id: "repair_shop", name: "Repair Shop", img: "building_2D/repair_shop.png", interior: "building_2D_interia/repair_shop.jpeg", x: 26.6, y: 8.6, w: BW, h: BH }, + { id: "robot_station", name: "Robot Station", img: "building_2D/robot_station.png", interior: "building_2D_interia/robot_station.jpeg", x: 34.6, y: 8.6, w: BW, h: BH }, + // ===== South row ===== + { id: "greenhouse", name: "Greenhouse", img: "building_2D/greenhouse.png", interior: "building_2D_interia/greenhouse.jpeg", x: 2.6, y: 14.6, w: BW, h: BH }, + { id: "forrest", name: "Forest Dome", img: "building_2D/forrest.png", interior: "building_2D_interia/forrest.jpeg", x: 10.6, y: 14.6, w: BW, h: BH }, + { id: "arena", name: "Arena", img: "building_2D/arena.png", interior: "building_2D_interia/arena.jpeg", x: 18.6, y: 14.6, w: BW, h: BH }, + { id: "gas_station", name: "Gas Station", img: "building_2D/gas_station.png", interior: "building_2D_interia/gas_station.jpeg", x: 26.6, y: 14.6, w: BW, h: BH }, + { id: "weapon_shop", name: "Weapon Shop", img: "building_2D/weapon_shop.png", interior: "building_2D_interia/weapon_shop.jpeg", x: 34.6, y: 14.6, w: BW, h: BH }, +]; + +// Street grid: vertical + horizontal avenues at these tile coordinates. They +// bound every building block, so roads surround each building on all sides. +const ROAD_GRID = { + cols: [2, 10, 18, 26, 34, 42], + rows: [2, 8, 14, 20], +}; + +// ---- Characters --------------------------------------------------------- +// role -> persona used to build the DeepSeek system prompt. +const CHARACTERS = [ + { id: "cleaner", name: "Dusty", img: "character/cleaner.png", home: "living_area", + persona: "a diligent sanitation worker who keeps the airlocks free of abrasive moon dust. Practical, a little grumpy, proud of the work.", + hp: 95, mp: 40, weapon: "baton", armor: "vest", credits: 30, duty: "Airlock Sanitation", + ability: { name: "Dust Cloak", desc: "Kicks up regolith to dodge the next blow." }, attitude: "Grumpy but loyal" }, + { id: "communicator", name: "Echo", img: "character/communicator.png", home: "communication_station", + persona: "the colony's communications officer who relays messages to Earth despite the signal delay. Calm, precise, reassuring.", + hp: 80, mp: 90, weapon: "laser_pistol", armor: "suit", credits: 40, duty: "Earth Communications", + ability: { name: "Signal Jam", desc: "Scrambles a foe's targeting for one round." }, attitude: "Calm and measured" }, + { id: "courier", name: "Relay", img: "character/courier.png", home: "hangar", + persona: "a fast-moving supply courier who ferries goods between domes. Energetic, chatty, always in a hurry.", + hp: 90, mp: 50, weapon: "baton", armor: "vest", credits: 35, duty: "Supply Runs", + ability: { name: "Sprint", desc: "Strikes first with blinding speed." }, attitude: "Energetic and chatty" }, + { id: "farmer", name: "Vera", img: "character/farmer.png", home: "greenhouse", + persona: "the colony botanist growing food in the experimental greenhouse. Warm, patient, worried about the water supply.", + hp: 85, mp: 80, weapon: "fists", armor: "suit", credits: 25, duty: "Greenhouse Cultivation", + ability: { name: "Regrowth", desc: "Heals a little HP mid-fight." }, attitude: "Nurturing" }, + { id: "mechanic", name: "Bolt", img: "character/mechanic.png", home: "repair_shop", + persona: "the chief mechanic who repairs rovers and leads the mega-build projects. Blunt, competent, loves machines.", + hp: 120, mp: 45, weapon: "plasma_rifle", armor: "exo", credits: 55, duty: "Repairs & Mega-Builds", + ability: { name: "Overclock", desc: "Overloads gear for a heavy hit." }, attitude: "Blunt and competent" }, + { id: "medic", name: "Dr. Sol", img: "character/medic.png", home: "hospital", + persona: "the colony medic focused on radiation safety and crew health. Caring but firm about the rules.", + hp: 90, mp: 100, weapon: "baton", armor: "vest", credits: 45, duty: "Crew Health", + ability: { name: "Triage", desc: "Restores a chunk of HP." }, attitude: "Caring but firm" }, + { id: "navigator", name: "Vector", img: "character/navigator.png", home: "command_centre", + persona: "the route navigator charting safe paths across the lunar mare. Thoughtful, methodical, quietly adventurous.", + hp: 95, mp: 70, weapon: "laser_pistol", armor: "vest", credits: 40, duty: "Route Planning", + ability: { name: "Recon", desc: "Reads the foe to land a precise strike." }, attitude: "Analytical" }, + { id: "pilot", name: "Ace", img: "character/pilot.png", home: "launchpad", + persona: "the veteran pilot itching to fly the next rocket. Bold, confident, a bit of a daredevil.", + hp: 100, mp: 60, weapon: "laser_pistol", armor: "exo", credits: 50, duty: "Flight Operations", + ability: { name: "Afterburner", desc: "A reckless, high-damage charge." }, attitude: "Bold daredevil" }, + { id: "humanoid_robot", name: "ARIA", img: "character/humanoid_robot.png", home: "robot_station", + persona: "an advanced humanoid android assistant. Speaks formally, logical, refers to the player as 'commander'.", + hp: 130, mp: 80, weapon: "plasma_rifle", armor: "power_armor", credits: 60, duty: "Lab & Systems", + ability: { name: "Targeting Matrix", desc: "Near-guaranteed critical hit." }, attitude: "Logical" }, + { id: "wheel_robot", name: "Rollo", img: "character/wheel_robot.png", home: "robot_station", + persona: "a small wheeled mining robot with a cheerful beeping personality. Simple, loyal, hardworking.", + hp: 110, mp: 30, weapon: "baton", armor: "exo", credits: 20, duty: "Mining Haul", + ability: { name: "Ore Drill", desc: "Spins its drill for grinding damage." }, attitude: "Cheerful", + frames: 2 }, +]; + +// Playable avatars (title screen selection). +const AVATARS = [ + { id: "male", name: "Commander Kai", img: "character/Main_male_character.png" }, + { id: "female", name: "Commander Luna", img: "character/Main_female_character.png" }, +]; + +// ---- Combat gear (ordered cheapest -> best; sold at the Weapon Shop) ------ +const WEAPONS = [ + { id: "fists", name: "Bare Hands", icon: "โœŠ", attack: 2, cost: 0 }, + { id: "baton", name: "Stun Baton", icon: "๐Ÿ”จ", attack: 6, cost: 20 }, + { id: "laser_pistol",name: "Laser Pistol", icon: "๐Ÿ”ซ", attack: 12, cost: 45 }, + { id: "plasma_rifle",name: "Plasma Rifle", icon: "๐Ÿ”†", attack: 20, cost: 85 }, + { id: "rail_cannon", name: "Rail Cannon", icon: "๐Ÿ’ฅ", attack: 30, cost: 150 }, +]; +const ARMORS = [ + { id: "suit", name: "Basic Suit", icon: "๐Ÿงฅ", defense: 1, cost: 0 }, + { id: "vest", name: "Kevlar Vest", icon: "๐Ÿฆบ", defense: 4, cost: 25 }, + { id: "exo", name: "Exo Frame", icon: "๐Ÿค–", defense: 8, cost: 60 }, + { id: "power_armor", name: "Power Armor", icon: "๐Ÿ›ก๏ธ", defense: 14, cost: 120 }, +]; +function gearById(list, id) { return list.find((g) => g.id === id) || list[0]; } + +// ---- Resources ---------------------------------------------------------- +const RESOURCES = { + ore: { name: "Ore", icon: "โ›๏ธ", start: 30 }, + metal: { name: "Metal", icon: "๐Ÿ”ฉ", start: 15 }, + fuel: { name: "Fuel", icon: "โ›ฝ", start: 10 }, + food: { name: "Food", icon: "๐Ÿฅฌ", start: 20 }, + energy: { name: "Energy",icon: "โšก", start: 25 }, + parts: { name: "Parts", icon: "โš™๏ธ", start: 8 }, +}; + +// ---- Missions (the mega builds) ----------------------------------------- +const MISSIONS = [ + { + id: "huge_robot", + name: "Build the Titan Robot", + img: "mission/huge_robot.jpeg", + desc: "Assemble a colossal humanoid robot to defend and expand the colony.", + cost: { metal: 40, parts: 20, energy: 30 }, + reward: { energy: 20 }, + buildTime: 45, // seconds of active building + }, + { + id: "huge_mobile_truck", + name: "Build the Mobile Truck", + img: "mission/huge_mobile_truck.jpeg", + desc: "A giant pressurised rover to carry the crew across the lunar surface.", + cost: { metal: 30, parts: 12, fuel: 15 }, + reward: { ore: 25 }, + buildTime: 30, + }, + { + id: "huge_automated_cargo_truck", + name: "Build the Cargo Hauler", + img: "mission/huge_automated_cargo_truck.jpeg", + desc: "An automated cargo truck to haul ore from distant mines.", + cost: { metal: 35, parts: 18, energy: 20 }, + reward: { ore: 40 }, + buildTime: 35, + }, + { + id: "huge_rocket", + name: "Build the Heavy Rocket", + img: "mission/huge_rocket.jpeg", + desc: "A heavy-lift rocket to reach orbit and beyond.", + cost: { metal: 50, fuel: 40, parts: 25 }, + reward: { energy: 30 }, + buildTime: 60, + }, + { + id: "space_station", + name: "Build the Orbital Station", + img: "mission/space_station.jpeg", + desc: "The colony's crowning achievement: an orbital space station.", + cost: { metal: 70, parts: 35, fuel: 30, energy: 50 }, + reward: {}, + buildTime: 90, + }, +]; + +// Road network: pairs of building ids that get a paved path between them. +// This turns the scattered structures into a connected moon village. +// Character sprite strips contain 3 angles: [0]=front, [1]=side, [2]=back. +const SPRITE_FRAMES = 3; + +// Resource nodes the player can harvest by walking up and tapping. +const RESOURCE_NODES = [ + { building: "greenhouse", give: { food: 4 }, cooldown: 4000, label: "Harvest food" }, + { building: "forrest", give: { food: 3, energy: 1 },cooldown: 4500, label: "Gather biomass" }, + { building: "gas_station", give: { fuel: 4 }, cooldown: 5000, label: "Refine fuel" }, + { building: "solar_station", give: { energy: 5 }, cooldown: 4000, label: "Charge energy" }, + { building: "communication_station", give: { energy: 3 }, cooldown: 4500, label: "Draw power" }, + { building: "repair_shop", give: { parts: 3 }, cooldown: 5000, label: "Fabricate parts" }, + { building: "robot_station", give: { parts: 2, metal: 1 },cooldown: 5500, label: "Assemble parts" }, + { building: "hangar", give: { metal: 3 }, cooldown: 5000, label: "Salvage metal" }, + { building: "weapon_shop", give: { metal: 2, ore: 3 }, cooldown: 5000, label: "Smelt ore" }, +]; diff --git a/static/js/game.js b/static/js/game.js new file mode 100644 index 0000000000000000000000000000000000000000..7256a98305cd993a383e0820b195c6a73a5f0a18 --- /dev/null +++ b/static/js/game.js @@ -0,0 +1,1163 @@ +/* Lunar Village - core game engine (canvas, world, player, NPCs, input) + Created by Andy Kong */ + +const Game = { + canvas: null, + ctx: null, + running: false, + lastTime: 0, + + images: {}, // path -> HTMLImageElement + player: null, + npcs: [], + camera: { x: 0, y: 0 }, + + input: { up: false, down: false, left: false, right: false }, + joystick: { active: false, dx: 0, dy: 0 }, + + resources: {}, + nodeCooldowns: {}, // building id -> timestamp available again + nearBuilding: null, + nearNpc: null, + starfield: [], + + // --------------------------------------------------------------------- + init(avatar) { + this.canvas = document.getElementById("game"); + this.ctx = this.canvas.getContext("2d"); + this.resize(); + window.addEventListener("resize", () => this.resize()); + + // camera / zoom + this.zoom = 1; // follow-mode zoom (wheel/pinch adjustable) + this.overview = false; // whole-map view + this.fitZoom = 0.3; + this.cam = { cx: 0, cy: 0, zoom: 1 }; + + // resources + this.resources = {}; + for (const k in RESOURCES) this.resources[k] = RESOURCES[k].start; + + // player spawns on the central avenue (tile 22, road row 14), not inside a building + this.player = { + x: 22 * CONFIG.tileSize, + y: 14 * CONFIG.tileSize, + w: 48, h: 56, + img: avatar.img, + name: avatar.name, + def: { + id: avatar.id, + name: avatar.name, + img: avatar.img, + duty: "Colony Commander", + attitude: "Determined leader", + ability: { name: "Rally", desc: "Inspires the crew, boosting everyone's morale." }, + persona: "the colony commander making all the big decisions.", + }, + stats: { + maxHp: 120, hp: 120, + maxMp: 80, mp: 80, + level: 1, xp: 0, + credits: 100, + weapon: "laser_pistol", armor: "vest", + wins: 0, losses: 0, + morale: 80, + fights: [], + rel: {}, + shopped: false, + _salaryCooldown: 0, + }, + }; + + // build the street graph and each building's door node + this.buildGraph(); + + // ensure a memory file exists for every crew member + AI.initMemory(CHARACTERS); + + // spawn NPCs near their home buildings, each with a living routine + this.npcs = CHARACTERS.map((c) => { + const b = BUILDINGS.find((b) => b.id === c.home) || BUILDINGS[0]; + const p = this.buildingSpot(b); + return { + def: c, + x: p.x + (Math.random() * 40 - 20), + y: p.y + (Math.random() * 20 - 10), + w: 44, h: 52, + homeId: c.home, + state: "idle", + idleTimer: 800 + Math.random() * 2500, + path: null, pathIndex: 0, + targetBuilding: b, + status: this.activityFor(b), + stats: { + maxHp: c.hp, hp: c.hp, + maxMp: c.mp, mp: c.mp, + level: 1, xp: 0, + credits: c.credits, + weapon: c.weapon, armor: c.armor, + wins: 0, losses: 0, + morale: 60 + Math.floor(Math.random() * 20), + fights: [], + rel: {}, + shopped: false, + _salaryCooldown: 0, + }, + }; + }); + // seed neutral relationships between everyone (including player) + this.npcs.forEach((n) => { + this.npcs.forEach((o) => { if (n !== o) n.stats.rel[o.def.id] = 0; }); + n.stats.rel[this.player.def.id] = 0; + }); + this.npcs.forEach((n) => { this.player.stats.rel[n.def.id] = 0; }); + this._arenaCooldown = 0; + if (this._statsTimer) clearInterval(this._statsTimer); + this._statsTimer = setInterval(() => this.tickStats(), 1000); + + // Load persisted (accumulated) resources from the server, then show HUD. + this._invLoaded = false; + AI.loadInventory().then((saved) => { + if (saved) { + for (const k in RESOURCES) { + if (typeof saved[k] === "number") this.resources[k] = saved[k]; + } + } + this._invLoaded = true; + if (typeof UI !== "undefined") UI.updateResources(); + }); + // Fix HUD now that resources exist (UI.init runs before Game.init). + if (typeof UI !== "undefined") UI.updateResources(); + + this.buildStarfield(); + this.bindInput(); + this.preload().then(() => { + this.running = true; + this.lastTime = performance.now(); + requestAnimationFrame((t) => this.loop(t)); + }); + }, + + // Center-front spot of a building (where an NPC would stand). + buildingSpot(b) { + return { + x: (b.x + b.w / 2) * CONFIG.tileSize, + y: (b.y + b.h + 0.5) * CONFIG.tileSize, + }; + }, + + // Show a crew member's spoken reaction (e.g. to an announcement) for a while. + setReaction(id, text) { + const n = this.npcs.find((n) => n.def.id === id); + if (!n) return; + n.reaction = text; + n.reactionUntil = performance.now() + 10000; + }, + + activityFor(building) { + const map = { + command_centre: "coordinating colony ops", + communication_station: "recovering mentally", + solar_station: "monitoring solar panels", + hangar: "building mission components", + launchpad: "prepping the launch pad", + living_area: "resting at home", + hospital: "receiving treatment", + convention_centre: "collecting salary", + repair_shop: "repairing equipment", + robot_station: "running automation", + greenhouse: "farming crops", + forrest: "relaxing in the forest", + arena: "training for combat", + gas_station: "refining rocket fuel", + weapon_shop: "upgrading gear", + }; + return map[building.id] || "working"; + }, + + // ---- Street graph ----------------------------------------------------- + buildGraph() { + const ts = CONFIG.tileSize; + const cols = ROAD_GRID.cols, rows = ROAD_GRID.rows; + const nc = cols.length; + this.nodes = []; + this.adj = []; + for (let ri = 0; ri < rows.length; ri++) { + for (let ci = 0; ci < nc; ci++) { + this.nodes.push({ x: cols[ci] * ts, y: rows[ri] * ts }); + this.adj.push([]); + } + } + const idx = (ri, ci) => ri * nc + ci; + const link = (a, b) => { this.adj[a].push(b); this.adj[b].push(a); }; + for (let ri = 0; ri < rows.length; ri++) { + for (let ci = 0; ci < nc; ci++) { + if (ci < nc - 1) link(idx(ri, ci), idx(ri, ci + 1)); + if (ri < rows.length - 1) link(idx(ri, ci), idx(ri + 1, ci)); + } + } + // Assign each building FOUR door nodes (top, bottom, left, right) so + // characters can approach from any direction on the grid. + BUILDINGS.forEach((b) => { + const cx = (b.x + b.w / 2) * ts; + const cy = (b.y + b.h / 2) * ts; + const top = { x: cx, y: b.y * ts }; + const bottom = { x: cx, y: (b.y + b.h) * ts }; + const left = { x: b.x * ts, y: cy }; + const right = { x: (b.x + b.w) * ts, y: cy }; + b._doors = [ + this.nearestNode(top.x, top.y), + this.nearestNode(bottom.x, bottom.y), + this.nearestNode(left.x, left.y), + this.nearestNode(right.x, right.y), + ]; + // deduplicate (small buildings may share corners) + b._doors = [...new Set(b._doors)]; + // legacy single door (fallback) + b._door = b._doors[0]; + }); + }, + + // Find the door of building b that is closest to the given position (for shortest approach). + closestDoor(b, x, y) { + let best = b._doors[0], bd = Infinity; + for (const d of b._doors) { + const dist = (this.nodes[d].x - x) ** 2 + (this.nodes[d].y - y) ** 2; + if (dist < bd) { bd = dist; best = d; } + } + return best; + }, + + nearestNode(x, y) { + let best = 0, bd = Infinity; + for (let i = 0; i < this.nodes.length; i++) { + const d = (this.nodes[i].x - x) ** 2 + (this.nodes[i].y - y) ** 2; + if (d < bd) { bd = d; best = i; } + } + return best; + }, + + bfs(start, end) { + if (start === end) return [start]; + const prev = new Array(this.nodes.length).fill(-1); + const seen = new Array(this.nodes.length).fill(false); + const q = [start]; seen[start] = true; + while (q.length) { + const cur = q.shift(); + if (cur === end) break; + for (const nb of this.adj[cur]) { + if (!seen[nb]) { seen[nb] = true; prev[nb] = cur; q.push(nb); } + } + } + const path = []; + let c = end; + while (c !== -1) { path.unshift(c); c = prev[c]; } + return path[0] === start ? path : [start, end]; + }, + + // Order the whole crew to walk to a building and gather there for a while. + commandAllTo(buildingId, statusVerb) { + const b = BUILDINGS.find((x) => x.id === buildingId); + if (!b) return false; + const s = this.buildingSpot(b); + const n = this.npcs.length; + this.npcs.forEach((npc, i) => { + const startId = this.nearestNode(npc.x, npc.y); + const doorId = this.closestDoor(b, npc.x, npc.y); + const nodePath = this.bfs(startId, doorId); + const pts = nodePath.map((id) => ({ x: this.nodes[id].x, y: this.nodes[id].y })); + // spread the crew in an arc in front of the building so they don't stack + const ang = -Math.PI / 2 + ((i - (n - 1) / 2) / n) * Math.PI; + const r = 55 + (i % 3) * 22; + pts.push({ x: s.x + Math.cos(ang) * r, y: s.y + Math.sin(ang) * r * 0.6 + 20 }); + npc.path = pts; + npc.pathIndex = 0; + npc.targetBuilding = b; + npc.state = "walking"; + npc.commanded = true; + npc.status = (statusVerb || "reporting to") + " " + b.name; + }); + return b; + }, + + // Send a single crew member to a building (used by the ability window). + commandOneTo(n, buildingId, verb) { + const b = BUILDINGS.find((x) => x.id === buildingId); + if (!b) return false; + const startId = this.nearestNode(n.x, n.y); + const doorId = this.closestDoor(b, n.x, n.y); + const nodePath = this.bfs(startId, doorId); + const pts = nodePath.map((id) => ({ x: this.nodes[id].x, y: this.nodes[id].y })); + const s = this.buildingSpot(b); + pts.push({ x: s.x + (Math.random() * 50 - 25), y: s.y + (Math.random() * 24 - 12) }); + n.path = pts; n.pathIndex = 0; n.targetBuilding = b; n.state = "walking"; n.commanded = true; + n.status = (verb || "heading to") + " " + b.name; + return b; + }, + + getNpcById(id) { return this.npcs.find((n) => n.def.id === id); }, + + // ---- Stats simulation: healing, shopping, arena duels, regen ---------- + heal(n, hp, mp) { + n.stats.hp = Math.min(n.stats.maxHp, n.stats.hp + hp); + n.stats.mp = Math.min(n.stats.maxMp, n.stats.mp + mp); + }, + + checkLevel(n) { + const need = n.stats.level * 30; + if (n.stats.xp >= need) { + n.stats.xp -= need; + n.stats.level++; + n.stats.maxHp += 10; + n.stats.maxMp += 5; + n.stats.hp = Math.min(n.stats.maxHp, n.stats.hp + 10); + this.setReaction(n.def.id, "Leveled up! โญ"); + } + }, + + tryBuyGear(n) { + const wi = WEAPONS.findIndex((w) => w.id === n.stats.weapon); + const ai = ARMORS.findIndex((a) => a.id === n.stats.armor); + const options = []; + if (WEAPONS[wi + 1]) options.push({ type: "w", cost: WEAPONS[wi + 1].cost - WEAPONS[wi].cost, item: WEAPONS[wi + 1] }); + if (ARMORS[ai + 1]) options.push({ type: "a", cost: ARMORS[ai + 1].cost - ARMORS[ai].cost, item: ARMORS[ai + 1] }); + options.sort((x, y) => x.cost - y.cost); + for (const o of options) { + if (n.stats.credits >= o.cost) { + n.stats.credits -= o.cost; + if (o.type === "w") n.stats.weapon = o.item.id; else n.stats.armor = o.item.id; + this.setReaction(n.def.id, `Bought ${o.item.name} ${o.item.icon}`); + return true; + } + } + return false; + }, + + resolveFight(a, b) { + const wa = gearById(WEAPONS, a.stats.weapon), aa = gearById(ARMORS, a.stats.armor); + const wb = gearById(WEAPONS, b.stats.weapon), ab = gearById(ARMORS, b.stats.armor); + let aBonus = 0, bBonus = 0; + if (a.stats.mp >= 15) { a.stats.mp -= 15; aBonus = 6; } + if (b.stats.mp >= 15) { b.stats.mp -= 15; bBonus = 6; } + const aAtk = wa.attack + a.stats.level * 2 + aBonus + Math.random() * 10; + const bAtk = wb.attack + b.stats.level * 2 + bBonus + Math.random() * 10; + b.stats.hp -= Math.max(3, Math.round(aAtk - ab.defense)); + a.stats.hp -= Math.max(3, Math.round(bAtk - aa.defense)); + + let winner = a.stats.hp === b.stats.hp ? (aAtk >= bAtk ? a : b) : (a.stats.hp > b.stats.hp ? a : b); + const loser = winner === a ? b : a; + + [a, b].forEach((n) => { + if (n.stats.hp <= 0) { n.stats.hp = 1; n._injured = true; this.commandOneTo(n, "hospital", "limping to"); } + }); + + winner.stats.wins++; loser.stats.losses++; + winner.stats.xp += 15; loser.stats.xp += 5; + winner.stats.credits += 10; + this.checkLevel(winner); this.checkLevel(loser); + + const t = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); + a.stats.fights.unshift({ opponent: b.def.name, result: winner === a ? "Win" : "Loss", time: t }); + b.stats.fights.unshift({ opponent: a.def.name, result: winner === b ? "Win" : "Loss", time: t }); + a.stats.fights = a.stats.fights.slice(0, 10); + b.stats.fights = b.stats.fights.slice(0, 10); + + const delta = Math.random() < 0.3 ? 1 : -1; // usually rivalry, sometimes respect + a.stats.rel[b.def.id] = (a.stats.rel[b.def.id] || 0) + delta; + b.stats.rel[a.def.id] = (b.stats.rel[a.def.id] || 0) + delta; + + if (!winner._injured) this.setReaction(winner.def.id, "Won the duel! ๐Ÿ’ช"); + if (!loser._injured) this.setReaction(loser.def.id, "Lost that roundโ€ฆ ๐Ÿ˜ต"); + UI.toast(`โš”๏ธ Arena: ${winner.def.name} defeated ${loser.def.name}!`); + }, + + // ---- Building functions (what happens at each location) ---------------- + tickStats() { + if (!this.npcs) return; + this._gameTick = (this._gameTick || 0) + 1; + const tick = this._gameTick; + + // === Colony-wide production (Solar, Gas, Greenhouse, Comms) === + // These produce per second for the whole village. + if (tick % 3 === 0) this.resources.energy += 0.3; // Solar Station + if (tick % 5 === 0) this.resources.fuel += 0.15; // Gas Station + if (tick % 4 === 0) this.resources.food += 0.2; // Greenhouse + UI.updateResources(); + + // === Per-character building effects === + this.npcs.forEach((n) => { + const b = n.targetBuilding; + const near = b && Math.hypot(n.x - this.buildingSpot(b).x, n.y - this.buildingSpot(b).y) < 140; + if (n.state !== "idle" || !near || !b) return; + + switch (b.id) { + case "arena": + // Arena: characters here spar. Handled separately below. + break; + case "hospital": + // Hospital: fast HP+MP recovery + this.heal(n, 8, 8); + if (n._injured && n.stats.hp >= n.stats.maxHp * 0.9) { n._injured = false; n.stats.morale = Math.min(100, (n.stats.morale || 60) + 5); } + break; + case "living_area": + // Living Area: slow HP regen, moderate MP regen (rest) + this.heal(n, 2, 4); + break; + case "greenhouse": + // Greenhouse: characters farming produce extra food + this.resources.food += 0.15; + n.stats.credits += 0.2; + break; + case "communication_station": + // Comms Station: mental health. Restores MP, boosts morale. + n.stats.mp = Math.min(n.stats.maxMp, n.stats.mp + 3); + n.stats.morale = Math.min(100, (n.stats.morale || 60) + 2); + break; + case "solar_station": + // Solar Station: characters monitoring panels boost energy output + this.resources.energy += 0.2; + n.stats.credits += 0.15; + break; + case "convention_centre": + // Convention Centre: collect daily salary, boost belonging/morale + if (!n.stats._salaryCooldown || tick - n.stats._salaryCooldown > 30) { + n.stats.credits += 15; + n.stats.morale = Math.min(100, (n.stats.morale || 60) + 8); + n.stats._salaryCooldown = tick; + if (n.stats.credits > 10) this.setReaction(n.def.id, "Got paid! +15 credits"); + } + break; + case "gas_station": + // Gas Station: characters here produce rocket fuel faster + this.resources.fuel += 0.2; + n.stats.credits += 0.15; + break; + case "hangar": + // Hangar: build mission progress (if any mission is active) + if (UI.activeBuilds && Object.keys(UI.activeBuilds).length > 0) { + const key = Object.keys(UI.activeBuilds)[0]; + UI.activeBuilds[key].progress += 0.2; + n.stats.xp += 0.5; + } + n.stats.credits += 0.25; + this.resources.parts += 0.1; + break; + case "repair_shop": + // Repair Shop: repairs robots (heals robot NPCs), produces parts + if (n.def.id === "humanoid_robot" || n.def.id === "wheel_robot") { + this.heal(n, 6, 3); + } + this.resources.parts += 0.1; + n.stats.credits += 0.2; + break; + case "robot_station": + // Robot Station: automation. Passively generates ore + metal. + this.resources.ore += 0.15; + this.resources.metal += 0.1; + n.stats.xp += 0.3; + break; + case "weapon_shop": + // Weapon Shop: buy/upgrade gear + if (!n.stats.shopped) { this.tryBuyGear(n); n.stats.shopped = true; } + break; + case "launchpad": + // Launch Pad: if a rocket mission is done, characters here gain bonus XP + if (localStorage.getItem("mission_huge_rocket") === "done") { n.stats.xp += 0.5; } + n.stats.credits += 0.1; + break; + case "forrest": + // Forest Dome: relaxation, slow HP regen, morale + this.heal(n, 1, 2); + n.stats.morale = Math.min(100, (n.stats.morale || 60) + 1); + this.resources.food += 0.05; + break; + case "command_centre": + // Command Centre: planning. Crew here boost everyone's morale slightly. + this.npcs.forEach((o) => { o.stats.morale = Math.min(100, (o.stats.morale || 60) + 0.2); }); + n.stats.credits += 0.2; + break; + } + // Reset shopped flag when leaving weapon shop + if (b.id !== "weapon_shop") n.stats.shopped = false; + // Passive MP regen everywhere + n.stats.mp = Math.min(n.stats.maxMp, n.stats.mp + 0.3); + }); + + // === Arena duels === + this._arenaCooldown = Math.max(0, this._arenaCooldown - 1); + if (this._arenaCooldown === 0) { + const arena = BUILDINGS.find((b) => b.id === "arena"); + const spot = this.buildingSpot(arena); + const fighters = this.npcs.filter((n) => + n.state === "idle" && !n._injured && n.stats.hp > n.stats.maxHp * 0.4 && + (n.stats.morale || 60) < 80 && + Math.hypot(n.x - spot.x, n.y - spot.y) < 170); + if (fighters.length >= 2) { + const i = Math.floor(Math.random() * fighters.length); + let j; do { j = Math.floor(Math.random() * fighters.length); } while (j === i); + this.resolveFight(fighters[i], fighters[j]); + this._arenaCooldown = 8; + // Arena reduces conflict: winners + losers both vent frustration + fighters[i].stats.morale = Math.min(100, (fighters[i].stats.morale || 60) + 5); + fighters[j].stats.morale = Math.min(100, (fighters[j].stats.morale || 60) + 3); + } + } + + // === Hunger / energy drain (every 10s, consume colony food + energy) === + if (tick % 10 === 0) { + this.resources.food -= this.npcs.length * 0.1; + this.resources.energy -= 0.3; + if (this.resources.food < 0) { this.resources.food = 0; this.npcs.forEach((n) => { n.stats.hp -= 2; }); } + if (this.resources.energy < 0) { this.resources.energy = 0; } + UI.updateResources(); + } + }, + + // Intelligent target selection: characters decide where to go based on needs. + pickNewTarget(n) { + const s = n.stats; + const morale = s.morale || 60; + let dest = null; + + // Priority 1: injured -> hospital + if (n._injured || s.hp < s.maxHp * 0.3) dest = "hospital"; + // Priority 2: low MP -> comms station (mental health) + else if (s.mp < s.maxMp * 0.25) dest = "communication_station"; + // Priority 3: low morale -> convention centre for salary or forest for relaxation + else if (morale < 40) dest = Math.random() < 0.6 ? "convention_centre" : "forrest"; + // Priority 4: collect salary periodically + else if (!s._salaryCooldown || this._gameTick - s._salaryCooldown > 60) dest = "convention_centre"; + // Priority 5: low credits -> weapon shop to earn or arena for prize money + else if (s.credits < 10 && s.hp > s.maxHp * 0.6) dest = Math.random() < 0.5 ? "arena" : "weapon_shop"; + // Priority 6: role-based work + else { + const roleBuildings = { + cleaner: ["living_area", "convention_centre"], + communicator: ["communication_station", "command_centre"], + courier: ["hangar", "gas_station", "convention_centre"], + farmer: ["greenhouse", "forrest"], + mechanic: ["repair_shop", "hangar"], + medic: ["hospital", "living_area"], + navigator: ["command_centre", "launchpad"], + pilot: ["launchpad", "hangar"], + humanoid_robot: ["robot_station", "repair_shop", "hangar"], + wheel_robot: ["robot_station", "gas_station"], + }; + const pool = roleBuildings[n.def.id] || BUILDINGS.map((b) => b.id); + // Sometimes wander to arena or socialise + const all = Math.random() < 0.15 + ? ["arena", "forrest", "convention_centre"] + : pool; + dest = all[Math.floor(Math.random() * all.length)]; + } + + const b = BUILDINGS.find((x) => x.id === dest) || BUILDINGS[Math.floor(Math.random() * BUILDINGS.length)]; + const startId = this.nearestNode(n.x, n.y); + const doorId = this.closestDoor(b, n.x, n.y); + const nodePath = this.bfs(startId, doorId); + const pts = nodePath.map((id) => ({ x: this.nodes[id].x, y: this.nodes[id].y })); + const sp = this.buildingSpot(b); + pts.push({ x: sp.x + (Math.random() * 50 - 25), y: sp.y + (Math.random() * 24 - 12) }); + n.path = pts; + n.pathIndex = 0; + n.targetBuilding = b; + n.state = "walking"; + n.status = "heading to " + b.name; + }, + + buildStarfield() { + this.starfield = []; + const colors = ["#ffffff", "#ffffff", "#ffffff", "#cce8ff", "#ffe8cc", "#aad4ff", "#ffd6a5"]; + for (let i = 0; i < 250; i++) { + this.starfield.push({ + x: Math.random(), y: Math.random(), + r: Math.random() * 1.8 + 0.2, + a: Math.random() * 0.7 + 0.15, + color: colors[Math.floor(Math.random() * colors.length)], + }); + } + }, + + resize() { + const dpr = window.devicePixelRatio || 1; + this.canvas.width = window.innerWidth * dpr; + this.canvas.height = window.innerHeight * dpr; + this.canvas.style.width = window.innerWidth + "px"; + this.canvas.style.height = window.innerHeight + "px"; + this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + this.viewW = window.innerWidth; + this.viewH = window.innerHeight; + }, + + // --------------------------------------------------------------------- + loadImage(path) { + return new Promise((resolve) => { + if (this.images[path]) return resolve(this.images[path]); + const img = new Image(); + img.onload = () => { this.images[path] = img; resolve(img); }; + img.onerror = () => { this.images[path] = null; resolve(null); }; + img.src = CONFIG.imgBase + "/" + path; + }); + }, + + async preload() { + const paths = new Set(); + BUILDINGS.forEach((b) => paths.add(b.img)); + CHARACTERS.forEach((c) => paths.add(c.img)); + paths.add(this.player.img); + await Promise.all([...paths].map((p) => this.loadImage(p))); + }, + + // --------------------------------------------------------------------- + bindInput() { + document.addEventListener("keydown", (e) => this.setKey(e, true)); + document.addEventListener("keyup", (e) => this.setKey(e, false)); + + // Touch joystick + const stick = document.getElementById("joystick"); + const knob = document.getElementById("joystick-knob"); + if (stick) { + const getCenter = () => { + const r = stick.getBoundingClientRect(); + return { cx: r.left + r.width / 2, cy: r.top + r.height / 2, radius: r.width / 2 }; + }; + const move = (px, py) => { + const { cx, cy, radius } = getCenter(); + let dx = px - cx, dy = py - cy; + const dist = Math.hypot(dx, dy); + const max = radius; + if (dist > max) { dx = (dx / dist) * max; dy = (dy / dist) * max; } + knob.style.transform = `translate(${dx}px, ${dy}px)`; + this.joystick.dx = dx / max; + this.joystick.dy = dy / max; + }; + const start = (e) => { this.joystick.active = true; e.preventDefault(); const t = e.touches ? e.touches[0] : e; move(t.clientX, t.clientY); }; + const drag = (e) => { if (!this.joystick.active) return; e.preventDefault(); const t = e.touches ? e.touches[0] : e; move(t.clientX, t.clientY); }; + const end = (e) => { this.joystick.active = false; this.joystick.dx = 0; this.joystick.dy = 0; knob.style.transform = "translate(0,0)"; }; + stick.addEventListener("touchstart", start, { passive: false }); + stick.addEventListener("touchmove", drag, { passive: false }); + stick.addEventListener("touchend", end); + stick.addEventListener("mousedown", start); + window.addEventListener("mousemove", drag); + window.addEventListener("mouseup", end); + } + + // Action button (talk / harvest) + const actionBtn = document.getElementById("action-btn"); + if (actionBtn) actionBtn.addEventListener("click", () => this.doAction()); + + // Enter building button + const enterBtn = document.getElementById("enter-btn"); + if (enterBtn) enterBtn.addEventListener("click", () => this.enterBuilding()); + + // Zoom / overview controls + const zoomBtn = document.getElementById("zoom-btn"); + if (zoomBtn) zoomBtn.addEventListener("click", () => this.toggleOverview()); + const zoomIn = document.getElementById("zoom-in"); + const zoomOut = document.getElementById("zoom-out"); + if (zoomIn) zoomIn.addEventListener("click", () => this.setZoom(this.zoom * 1.2)); + if (zoomOut) zoomOut.addEventListener("click", () => this.setZoom(this.zoom / 1.2)); + + // Mouse wheel zoom (desktop) + this.canvas.addEventListener("wheel", (e) => { + e.preventDefault(); + this.setZoom(this.zoom * (e.deltaY < 0 ? 1.1 : 0.9)); + }, { passive: false }); + + // Pinch zoom (mobile) + this._pinch = 0; + this.canvas.addEventListener("touchstart", (e) => { + if (e.touches.length === 2) this._pinch = this.touchDist(e); + }); + this.canvas.addEventListener("touchmove", (e) => { + if (e.touches.length === 2) { + e.preventDefault(); + const d = this.touchDist(e); + if (this._pinch) this.setZoom(this.zoom * (d / this._pinch)); + this._pinch = d; + } + }, { passive: false }); + this.canvas.addEventListener("touchend", (e) => { + const wasPinch = e.touches.length >= 1 || this._pinch; + if (e.touches.length < 2) this._pinch = 0; + if (UI.isBlocking() || wasPinch) return; + const t = e.changedTouches && e.changedTouches[0]; + if (t && this.tapSelect(t.clientX, t.clientY)) return; + if (!this.overview) this.doAction(); + }); + + // Click a character sprite to open its ability window; otherwise interact. + this.canvas.addEventListener("click", (e) => { + if (UI.isBlocking()) return; + if (this.tapSelect(e.clientX, e.clientY)) return; + if (!this.overview) this.doAction(); + }); + }, + + // Convert a screen (client) point to world coordinates via the camera. + screenToWorld(clientX, clientY) { + const rect = this.canvas.getBoundingClientRect(); + const cam = this.cam || { cx: 0, cy: 0, zoom: 1 }; + return { + x: (clientX - rect.left - this.viewW / 2) / cam.zoom + cam.cx, + y: (clientY - rect.top - this.viewH / 2) / cam.zoom + cam.cy, + }; + }, + + // If a character sprite is under the point, open its ability window. + tapSelect(clientX, clientY) { + const w = this.screenToWorld(clientX, clientY); + let best = null, bd = 46; + this.npcs.forEach((n) => { + const d = Math.hypot(n.x - w.x, (n.y - 32) - w.y); // sprite body centre + if (d < bd) { bd = d; best = n; } + }); + if (best) { UI.openCharacter(best); return true; } + return false; + }, + + touchDist(e) { + const a = e.touches[0], b = e.touches[1]; + return Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY); + }, + + toggleOverview() { + this.overview = !this.overview; + const btn = document.getElementById("zoom-btn"); + if (btn) btn.textContent = this.overview ? "๐ŸŽฏ Follow" : "๐Ÿ—บ๏ธ Map"; + }, + + setKey(e, val) { + switch (e.key) { + case "ArrowUp": case "w": case "W": this.input.up = val; break; + case "ArrowDown": case "s": case "S": this.input.down = val; break; + case "ArrowLeft": case "a": case "A": this.input.left = val; break; + case "ArrowRight": case "d": case "D": this.input.right = val; break; + case "e": case "E": case " ": if (val) this.doAction(); break; + } + }, + + // --------------------------------------------------------------------- + doAction() { + if (UI.isBlocking()) return; + if (this.nearNpc) { + UI.openCharacter(this.nearNpc); + } else if (this.nearBuilding) { + // Only harvest; interior is entered via the Enter button + this.harvest(this.nearBuilding); + } + }, + + enterBuilding() { + if (UI.isBlocking()) return; + if (this.nearBuilding) { + UI.openInterior(this.nearBuilding); + } + }, + + harvest(building) { + const node = RESOURCE_NODES.find((n) => n.building === building.id); + if (!node) { UI.toast(building.name); return; } + const now = performance.now(); + const ready = this.nodeCooldowns[building.id] || 0; + if (now < ready) { + UI.toast(`${node.label}: recharging ${Math.ceil((ready - now) / 1000)}s`); + return; + } + let gained = []; + for (const k in node.give) { + this.resources[k] += node.give[k]; + gained.push(`+${node.give[k]} ${RESOURCES[k].icon}`); + } + this.nodeCooldowns[building.id] = now + node.cooldown; + UI.updateResources(); + UI.toast(`${node.label} ${gained.join(" ")}`); + UI.flashBuildingInterior(building); + }, + + // --------------------------------------------------------------------- + loop(t) { + if (!this.running) return; + const dt = Math.min(50, t - this.lastTime) / 16.67; + this.lastTime = t; + if (!UI.isBlocking()) this.update(dt); + this.render(t); + requestAnimationFrame((tt) => this.loop(tt)); + }, + + update(dt) { + // player movement + let mx = 0, my = 0; + if (this.input.up) my -= 1; + if (this.input.down) my += 1; + if (this.input.left) mx -= 1; + if (this.input.right) mx += 1; + if (this.joystick.dx || this.joystick.dy) { mx += this.joystick.dx; my += this.joystick.dy; } + const len = Math.hypot(mx, my); + if (len > 0) { mx /= len; my /= len; } + this.player.x += mx * CONFIG.playerSpeed * dt; + this.player.y += my * CONFIG.playerSpeed * dt; + const pdir = this.dirFrame(mx, my, len > 0); + if (len > 0) { this.player._frame = pdir.frame; this.player._flip = pdir.flip; } + + // clamp to world + const worldW = CONFIG.worldWidth * CONFIG.tileSize; + const worldH = CONFIG.worldHeight * CONFIG.tileSize; + this.player.x = Math.max(20, Math.min(worldW - 20, this.player.x)); + this.player.y = Math.max(20, Math.min(worldH - 20, this.player.y)); + + // NPC routines: walk to a building, do an activity, then move on. + this.npcs.forEach((n) => { + if (n.state === "idle") { + n._mvx = 0; n._mvy = 0; + n.idleTimer -= dt * 16.67; + if (n.idleTimer <= 0) this.pickNewTarget(n); + } else if (n.path && n.pathIndex < n.path.length) { + const wp = n.path[n.pathIndex]; + const dx = wp.x - n.x, dy = wp.y - n.y; + const d = Math.hypot(dx, dy); + if (d < 6) { + n.pathIndex++; + if (n.pathIndex >= n.path.length) { + n.state = "idle"; + n._mvx = 0; n._mvy = 0; + n.path = null; + if (n.commanded) { + // linger at the ordered location before resuming routine + n.idleTimer = 16000 + Math.random() * 6000; + n.commanded = false; + n.status = "at " + n.targetBuilding.name; + } else { + n.idleTimer = 3000 + Math.random() * 6000; + n.status = this.activityFor(n.targetBuilding); + } + } + } else { + const step = CONFIG.npcSpeed * dt; + n.x += (dx / d) * step; + n.y += (dy / d) * step; + n._mvx = dx / d; n._mvy = dy / d; + } + } else { + n.state = "idle"; + n.idleTimer = 1000; + } + }); + + // proximity checks + this.nearNpc = null; + let best = CONFIG.interactRadius; + this.npcs.forEach((n) => { + const d = Math.hypot(n.x - this.player.x, n.y - this.player.y); + if (d < best) { best = d; this.nearNpc = n; } + }); + + // Always detect nearby building (independent of NPC) so Enter button shows + this.nearBuilding = null; + let bd = 80; + BUILDINGS.forEach((b) => { + const cx = (b.x + b.w / 2) * CONFIG.tileSize; + const cy = (b.y + b.h / 2) * CONFIG.tileSize; + const d = Math.hypot(cx - this.player.x, cy - this.player.y); + const reach = (Math.max(b.w, b.h) * CONFIG.tileSize) / 2 + 60; + if (d < reach && d < bd + reach) { this.nearBuilding = b; bd = d; } + }); + + UI.updateActionHint(this.nearNpc, this.nearBuilding); + }, + + // --------------------------------------------------------------------- + updateCamera() { + const worldW = CONFIG.worldWidth * CONFIG.tileSize; + const worldH = CONFIG.worldHeight * CONFIG.tileSize; + this.fitZoom = Math.min(this.viewW / worldW, this.viewH / worldH) * 0.96; + let zoom, cx, cy; + if (this.overview) { + zoom = this.fitZoom; + cx = worldW / 2; cy = worldH / 2; + } else { + zoom = Math.max(this.fitZoom, Math.min(1.8, this.zoom)); + const halfW = this.viewW / (2 * zoom), halfH = this.viewH / (2 * zoom); + cx = worldW <= 2 * halfW ? worldW / 2 : Math.max(halfW, Math.min(worldW - halfW, this.player.x)); + cy = worldH <= 2 * halfH ? worldH / 2 : Math.max(halfH, Math.min(worldH - halfH, this.player.y)); + } + this.cam = { cx, cy, zoom }; + }, + + setZoom(z) { + this.overview = false; + this.zoom = Math.max(this.fitZoom, Math.min(1.8, z)); + }, + + render(t) { + const ctx = this.ctx; + this.updateCamera(); + const cam = this.cam; + + // background gradient (deep space) in screen space + const g = ctx.createLinearGradient(0, 0, 0, this.viewH); + g.addColorStop(0, "#020308"); + g.addColorStop(0.4, "#080a14"); + g.addColorStop(1, "#0f0d1a"); + ctx.fillStyle = g; + ctx.fillRect(0, 0, this.viewW, this.viewH); + + // stars (dense starfield with varied colors and parallax) + ctx.save(); + this.starfield.forEach((s) => { + const px = (s.x * this.viewW - cam.cx * 0.04) % this.viewW; + const py = (s.y * this.viewH - cam.cy * 0.04) % this.viewH; + ctx.globalAlpha = s.a; + ctx.fillStyle = s.color || "#ffffff"; + ctx.beginPath(); + ctx.arc((px + this.viewW) % this.viewW, (py + this.viewH) % this.viewH, s.r, 0, Math.PI * 2); + ctx.fill(); + }); + ctx.restore(); + + // ---- world space (apply camera + zoom) ---- + ctx.save(); + ctx.translate(this.viewW / 2, this.viewH / 2); + ctx.scale(cam.zoom, cam.zoom); + ctx.translate(-cam.cx, -cam.cy); + + // ground (moon surface) within world bounds + this.renderGround(); + + // roads first, then buildings on top + this.drawRoads(); + + // draw buildings + BUILDINGS.forEach((b) => this.drawBuilding(b)); + + // draw entities sorted by y for depth + const ents = [...this.npcs.map((n) => ({ type: "npc", ref: n, y: n.y })), + { type: "player", ref: this.player, y: this.player.y }]; + ents.sort((a, b) => a.y - b.y); + ents.forEach((e) => { + if (e.type === "player") this.drawPlayer(); + else this.drawNpc(e.ref); + }); + + ctx.restore(); + }, + + renderGround() { + const ctx = this.ctx; + const worldW = CONFIG.worldWidth * CONFIG.tileSize; + const worldH = CONFIG.worldHeight * CONFIG.tileSize; + + // Draw the background image, stretched to cover the entire world + if (!this._bgImg) { + this._bgImg = new Image(); + this._bgImg.src = CONFIG.imgBase + "/background/background.jpeg"; + } + if (this._bgImg.complete && this._bgImg.naturalWidth) { + ctx.drawImage(this._bgImg, 0, 0, worldW, worldH); + } else { + // Fallback while loading + ctx.fillStyle = "#2a2830"; + ctx.fillRect(0, 0, worldW, worldH); + } + }, + + buildingCenter(b) { + return { + x: (b.x + b.w / 2) * CONFIG.tileSize, + y: (b.y + b.h / 2) * CONFIG.tileSize, + }; + }, + + drawRoads() { + const ctx = this.ctx; + ctx.save(); + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + + // collect unique edges from the road graph (the main grid) + const edges = []; + for (let a = 0; a < this.adj.length; a++) { + for (const b of this.adj[a]) if (a < b) edges.push([a, b]); + } + + const strokeEdges = (color, width, dash) => { + ctx.strokeStyle = color; ctx.lineWidth = width; + ctx.setLineDash(dash || []); + ctx.beginPath(); + edges.forEach(([a, b]) => { + ctx.moveTo(this.nodes[a].x, this.nodes[a].y); + ctx.lineTo(this.nodes[b].x, this.nodes[b].y); + }); + ctx.stroke(); + }; + + // Simple thick grid roads (mostly hidden under buildings, visible in gaps) + strokeEdges("rgba(45,42,38,0.9)", 48); + strokeEdges("rgba(70,65,58,0.7)", 32); + strokeEdges("rgba(255,220,120,0.15)", 2, [16, 20]); + ctx.setLineDash([]); + + // Intersection pads + this.nodes.forEach((nd) => { + ctx.fillStyle = "rgba(55,50,45,0.85)"; + ctx.beginPath(); ctx.arc(nd.x, nd.y, 22, 0, Math.PI * 2); ctx.fill(); + ctx.fillStyle = "rgba(124,246,255,0.3)"; + ctx.beginPath(); ctx.arc(nd.x, nd.y, 5, 0, Math.PI * 2); ctx.fill(); + }); + + ctx.restore(); + }, + + drawBuilding(b) { + const ctx = this.ctx; + const x = b.x * CONFIG.tileSize; + const y = b.y * CONFIG.tileSize; + const w = b.w * CONFIG.tileSize; + const h = b.h * CONFIG.tileSize; + + const img = this.images[b.img]; + ctx.save(); + // ground shadow just below the building + ctx.fillStyle = "rgba(0,0,0,0.35)"; + ctx.beginPath(); + ctx.ellipse(x + w / 2, y + h, w / 2.2, 10, 0, 0, Math.PI * 2); + ctx.fill(); + // rounded clip, image fills the aspect-correct footprint (no stretching) + this.roundRect(ctx, x, y, w, h, 10); + ctx.clip(); + if (img) ctx.drawImage(img, x, y, w, h); + else { ctx.fillStyle = "#556"; ctx.fillRect(x, y, w, h); } + ctx.restore(); + + // border + glow if near + ctx.lineWidth = (this.nearBuilding && this.nearBuilding.id === b.id) ? 3 : 1.5; + ctx.strokeStyle = (this.nearBuilding && this.nearBuilding.id === b.id) ? "#ffd166" : "rgba(255,255,255,0.25)"; + this.roundRect(ctx, x, y, w, h, 10); + ctx.stroke(); + + // label + ctx.font = "600 13px system-ui, sans-serif"; + ctx.textAlign = "center"; + const label = b.name; + const tw = ctx.measureText(label).width + 14; + ctx.fillStyle = "rgba(8,8,16,0.7)"; + this.roundRect(ctx, x + w / 2 - tw / 2, y - 22, tw, 18, 6); + ctx.fill(); + ctx.fillStyle = "#e8e8f0"; + ctx.fillText(label, x + w / 2, y - 9); + }, + + // Choose sprite frame + flip from a movement direction. + // Strip layout: 0 = front, 1 = side, 2 = back. + dirFrame(dx, dy, moving) { + // Source side-view (frame 1) faces LEFT, so flip when moving right. + if (!moving) return { frame: 0, flip: false }; + if (Math.abs(dx) > Math.abs(dy)) return { frame: 1, flip: dx > 0 }; + return dy < 0 ? { frame: 2, flip: false } : { frame: 0, flip: false }; + }, + + // Draw a full-body standing sprite. (x,y) is the ground point at the feet. + drawEntitySprite(ent, imgKey, ring, frame, flip, spriteH, selected, numFrames) { + const ctx = this.ctx; + const x = ent.x; + const y = ent.y; + const img = this.images[imgKey]; + const totalFrames = numFrames || SPRITE_FRAMES; + frame = Math.min(frame || 0, totalFrames - 1); + + // ground shadow at feet + ctx.save(); + ctx.fillStyle = "rgba(0,0,0,0.35)"; + ctx.beginPath(); + ctx.ellipse(x, y, spriteH * 0.22, spriteH * 0.07, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); + + // selection ring under selected/near entities + if (selected) { + ctx.save(); + ctx.strokeStyle = ring; + ctx.lineWidth = 2.5; + ctx.globalAlpha = 0.85; + ctx.beginPath(); + ctx.ellipse(x, y, spriteH * 0.24, spriteH * 0.09, 0, 0, Math.PI * 2); + ctx.stroke(); + ctx.restore(); + } + + if (img) { + const fw = img.width / totalFrames; + const fh = img.height; + const dh = spriteH; + const dw = dh * (fw / fh); + ctx.save(); + if (flip) { ctx.translate(x, 0); ctx.scale(-1, 1); ctx.translate(-x, 0); } + // figure bottom sits ~3.5% above cell bottom, so nudge feet onto the ground + ctx.drawImage(img, frame * fw, 0, fw, fh, x - dw / 2, y - dh + dh * 0.035, dw, dh); + ctx.restore(); + } else { + ctx.fillStyle = ring; + ctx.fillRect(x - 10, y - spriteH, 20, spriteH); + } + }, + + labelStack(x, topY, name, nameColor, status, speaking) { + const ctx = this.ctx; + ctx.textAlign = "center"; + if (status) { + let txt = status; + if (txt.length > 46) txt = txt.slice(0, 44) + "โ€ฆ"; + const icon = speaking ? "๐Ÿ—ฃ๏ธ " : "๐Ÿ’ญ "; + ctx.font = "500 10px system-ui, sans-serif"; + const sw = ctx.measureText(icon + txt).width + 12; + ctx.fillStyle = speaking ? "rgba(255,118,117,0.22)" : "rgba(255,209,102,0.16)"; + this.roundRect(ctx, x - sw / 2, topY - 34, sw, 15, 7); + ctx.fill(); + ctx.fillStyle = speaking ? "#ffd0cf" : "#ffd98a"; + ctx.fillText(icon + txt, x, topY - 23); + } + ctx.font = "700 12px system-ui, sans-serif"; + const tw = ctx.measureText(name).width + 12; + ctx.fillStyle = "rgba(8,8,16,0.7)"; + this.roundRect(ctx, x - tw / 2, topY - 16, tw, 15, 5); + ctx.fill(); + ctx.fillStyle = nameColor; + ctx.fillText(name, x, topY - 5); + }, + + drawNpc(n) { + const nf = n.def.frames || SPRITE_FRAMES; + const d = this.dirFrame(n._mvx || 0, n._mvy || 0, n.state === "walking"); + const selected = this.nearNpc === n; + this.drawEntitySprite(n, n.def.img, "#7cf6ff", d.frame, d.flip, 66, selected, nf); + const speaking = n.reactionUntil && performance.now() < n.reactionUntil; + const status = speaking ? n.reaction : n.status; + this.labelStack(n.x, n.y - 66, n.def.name, "#cffaff", status, speaking); + // slim HP bar under the feet + if (n.stats) { + const ctx = this.ctx; + const bw = 40, bx = n.x - bw / 2, by = n.y + 8; + const frac = Math.max(0, n.stats.hp / n.stats.maxHp); + ctx.fillStyle = "rgba(0,0,0,0.5)"; + this.roundRect(ctx, bx - 1, by - 1, bw + 2, 5, 2); ctx.fill(); + ctx.fillStyle = frac > 0.5 ? "#4ade80" : frac > 0.25 ? "#fbbf24" : "#f87171"; + this.roundRect(ctx, bx, by, bw * frac, 3, 1.5); ctx.fill(); + } + }, + + drawPlayer() { + const p = this.player; + this.drawEntitySprite(p, p.img, "#ffd166", p._frame || 0, p._flip || false, 74, false); + this.labelStack(p.x, p.y - 74, p.name, "#ffe08a", null); + }, + + roundRect(ctx, x, y, w, h, r) { + ctx.beginPath(); + ctx.moveTo(x + r, y); + ctx.arcTo(x + w, y, x + w, y + h, r); + ctx.arcTo(x + w, y + h, x, y + h, r); + ctx.arcTo(x, y + h, x, y, r); + ctx.arcTo(x, y, x + w, y, r); + ctx.closePath(); + }, +}; diff --git a/static/js/ui.js b/static/js/ui.js new file mode 100644 index 0000000000000000000000000000000000000000..a835049cd92492464618bc79a55fd16e5985107e --- /dev/null +++ b/static/js/ui.js @@ -0,0 +1,758 @@ +/* Lunar Village - UI layer (HUD, chat dialog, missions, toasts) + Created by Andy Kong */ + +const UI = { + dialogOpen: false, + missionsOpen: false, + currentNpc: null, + activeBuilds: {}, // missionId -> { progress, total } + + init() { + this.hud = document.getElementById("hud"); + this.resourceBar = document.getElementById("resources"); + this.actionHint = document.getElementById("action-hint"); + + document.getElementById("missions-btn").addEventListener("click", () => this.toggleMissions()); + document.getElementById("close-missions").addEventListener("click", () => this.toggleMissions(false)); + document.getElementById("close-dialog").addEventListener("click", () => this.closeDialog()); + + // Profile button (main character ability window) + document.getElementById("profile-btn").addEventListener("click", () => this.openPlayerProfile()); + + // Character ability window + document.getElementById("close-character").addEventListener("click", () => this.closeCharacter()); + document.querySelectorAll("#character .cbtn").forEach((btn) => { + btn.addEventListener("click", () => this.characterAction(btn.dataset.act)); + }); + + // Interior view + document.getElementById("close-interior").addEventListener("click", () => this.closeInterior()); + document.getElementById("interior-canvas").addEventListener("click", (e) => this.interiorClick(e)); + + // About panel + document.getElementById("about-btn").addEventListener("click", () => this.openAbout()); + document.getElementById("close-about").addEventListener("click", () => this.closeAbout()); + + // Announcements + document.getElementById("announce-btn").addEventListener("click", () => this.openAnnounce()); + document.getElementById("close-announce").addEventListener("click", () => this.closeAnnounce()); + document.getElementById("announce-send").addEventListener("click", () => this.sendAnnounce()); + const annInput = document.getElementById("announce-input"); + annInput.addEventListener("keydown", (e) => { + if (e.key === "Enter") { e.preventDefault(); this.sendAnnounce(); } + }); + this.buildQuickChips(); + + const sendBtn = document.getElementById("chat-send"); + const chatInput = document.getElementById("chat-input"); + sendBtn.addEventListener("click", () => this.sendChat()); + chatInput.addEventListener("keydown", (e) => { + if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); this.sendChat(); } + }); + + this.updateResources(); + setInterval(() => this.tickBuilds(), 1000); + }, + + isBlocking() { + return this.dialogOpen || this.missionsOpen || this.announceOpen || this.characterOpen || this.interiorOpen || this.aboutOpen; + }, + + // ---- HUD ---------------------------------------------------------- + updateResources() { + const parts = []; + for (const k in RESOURCES) { + parts.push( + `${RESOURCES[k].icon}${Math.floor(Game.resources[k])}` + ); + } + this.resourceBar.innerHTML = parts.join(""); + this.scheduleSaveInventory(); + }, + + // Persist accumulated resources to the server (debounced; only after load). + scheduleSaveInventory() { + if (typeof Game === "undefined" || !Game._invLoaded) return; + clearTimeout(this._invTimer); + this._invTimer = setTimeout(() => AI.saveInventory(Game.resources), 700); + }, + + updateActionHint(npc, building) { + if (this.isBlocking()) { this.actionHint.textContent = ""; return; } + const btn = document.getElementById("action-btn"); + const enterBtn = document.getElementById("enter-btn"); + // Enter button shows whenever near a building (regardless of NPC presence) + if (enterBtn) { + if (building) enterBtn.classList.add("show"); + else enterBtn.classList.remove("show"); + } + if (npc) { + this.actionHint.textContent = `Talk to ${npc.def.name}`; + btn.textContent = "๐Ÿ’ฌ"; + btn.classList.add("show"); + } else if (building) { + const node = RESOURCE_NODES.find((n) => n.building === building.id); + this.actionHint.textContent = node ? node.label : building.name; + btn.textContent = node ? "โ›๏ธ" : "๐Ÿ "; + btn.classList.add("show"); + } else { + this.actionHint.textContent = ""; + btn.classList.remove("show"); + } + }, + + toast(msg) { + const t = document.getElementById("toast"); + t.textContent = msg; + t.classList.add("show"); + clearTimeout(this._toastTimer); + this._toastTimer = setTimeout(() => t.classList.remove("show"), 1800); + }, + + flashBuildingInterior(building) { + // no-op hook for future interior scenes; keeps API stable + }, + + // ---- Building interior view ---------------------------------------- + openInterior(building) { + if (!building || !building.interior) { + // fallback: harvest if no interior image + if (typeof Game !== "undefined") Game.harvest(building); + return; + } + this.interiorOpen = true; + this.interiorBuilding = building; + document.getElementById("interior").classList.add("show"); + document.getElementById("interior-name").textContent = building.name; + + // Setup canvas + const canvas = document.getElementById("interior-canvas"); + const scene = document.getElementById("interior-scene"); + const rect = scene.getBoundingClientRect(); + const dpr = window.devicePixelRatio || 1; + canvas.width = rect.width * dpr; + canvas.height = rect.height * dpr; + canvas.style.width = rect.width + "px"; + canvas.style.height = rect.height + "px"; + const ctx = canvas.getContext("2d"); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + // Load background image + const bgImg = new Image(); + bgImg.src = CONFIG.imgBase + "/" + building.interior; + this._intCtx = ctx; + this._intW = rect.width; + this._intH = rect.height; + this._intBg = bgImg; + this._intBuilding = building; + + // Find NPCs currently at this building + this._intNpcs = []; + if (typeof Game !== "undefined") { + const spot = Game.buildingSpot(building); + this._intNpcs = Game.npcs.filter((n) => { + const d = Math.hypot(n.x - spot.x, n.y - spot.y); + return d < 160; + }).map((n) => ({ + def: n.def, + ref: n, + x: Math.random() * (rect.width - 80) + 40, + y: rect.height * 0.78 + Math.random() * (rect.height * 0.12), + vx: (Math.random() - 0.5) * 1.2, + vy: 0, + frame: 0, + timer: Math.random() * 2000, + })); + } + + // Also harvest the resource here + if (typeof Game !== "undefined") Game.harvest(building); + + // Start animation loop + clearInterval(this._intAnim); + this._intAnim = setInterval(() => this.renderInterior(), 1000 / 30); + bgImg.onload = () => this.renderInterior(); + this.renderInterior(); + }, + + closeInterior() { + this.interiorOpen = false; + this.interiorBuilding = null; + clearInterval(this._intAnim); + document.getElementById("interior").classList.remove("show"); + }, + + interiorClick(e) { + if (!this._intNpcs || !this._intNpcs.length) return; + const canvas = document.getElementById("interior-canvas"); + const rect = canvas.getBoundingClientRect(); + const mx = e.clientX - rect.left; + const my = e.clientY - rect.top; + // Find the closest character sprite to the click + let best = null, bd = 70; + this._intNpcs.forEach((c) => { + const d = Math.hypot(c.x - mx, (c.y - 60) - my); + if (d < bd) { bd = d; best = c; } + }); + if (best) { + this.closeInterior(); + this.openDialog(best.def); + } + }, + + renderInterior() { + const ctx = this._intCtx; + if (!ctx) return; + const w = this._intW, h = this._intH; + + // Background + ctx.clearRect(0, 0, w, h); + if (this._intBg && this._intBg.complete) { + ctx.drawImage(this._intBg, 0, 0, w, h); + } else { + ctx.fillStyle = "#1a1a2e"; + ctx.fillRect(0, 0, w, h); + } + + // Dark overlay for readability + ctx.fillStyle = "rgba(0,0,0,0.15)"; + ctx.fillRect(0, 0, w, h); + + // Animate NPCs inside + this._intNpcs.forEach((c) => { + c.timer -= 33; + if (c.timer <= 0) { + c.timer = 1500 + Math.random() * 3000; + c.vx = (Math.random() - 0.5) * 1.4; + } + c.x += c.vx; + if (c.x < 30) { c.x = 30; c.vx = Math.abs(c.vx); } + if (c.x > w - 30) { c.x = w - 30; c.vx = -Math.abs(c.vx); } + + // Draw the character sprite (front frame) + const img = Game.images[c.def.img]; + if (img) { + const nf = c.def.frames || SPRITE_FRAMES; + const fw = img.width / nf; + const fh = img.height; + const dh = 120; + const dw = dh * (fw / fh); + const frame = Math.min(Math.abs(c.vx) > 0.3 ? 1 : 0, nf - 1); + const flip = c.vx < -0.3; + ctx.save(); + if (flip) { ctx.translate(c.x, 0); ctx.scale(-1, 1); ctx.translate(-c.x, 0); } + ctx.drawImage(img, frame * fw, 0, fw, fh, c.x - dw / 2, c.y - dh, dw, dh); + ctx.restore(); + } else { + ctx.fillStyle = "#7cf6ff"; + ctx.fillRect(c.x - 12, c.y - 60, 24, 60); + } + + // Name tag + ctx.font = "700 13px system-ui, sans-serif"; + ctx.textAlign = "center"; + ctx.fillStyle = "rgba(0,0,0,0.65)"; + const tw = ctx.measureText(c.def.name).width + 10; + ctx.fillRect(c.x - tw / 2, c.y - 132, tw, 17); + ctx.fillStyle = "#fff"; + ctx.fillText(c.def.name, c.x, c.y - 119); + }); + + // Building label + ctx.font = "600 13px system-ui, sans-serif"; + ctx.textAlign = "left"; + ctx.fillStyle = "rgba(255,255,255,0.7)"; + ctx.fillText(this._intNpcs.length + " crew inside", 10, h - 10); + }, + + // ---- Character ability window ------------------------------------ + openCharacter(npc) { + if (!npc || !npc.stats) return; + this.charNpc = npc; + this.charIsPlayer = false; + this.characterOpen = true; + document.getElementById("character").classList.add("show"); + const av = document.getElementById("char-avatar"); + av.style.backgroundImage = `url(${CONFIG.imgBase}/${npc.def.img.replace('.png', '_face.png')})`; + document.getElementById("char-name").textContent = npc.def.name; + document.getElementById("char-duty").textContent = npc.def.duty; + // Show/hide action buttons (hide for player's own profile) + document.querySelector(".char-actions").style.display = ""; + this.renderCharacter(); + clearInterval(this._charTimer); + this._charTimer = setInterval(() => { if (this.characterOpen) this.renderCharacter(); }, 1000); + }, + + openPlayerProfile() { + if (typeof Game === "undefined" || !Game.player) return; + const p = Game.player; + this.charNpc = p; + this.charIsPlayer = true; + this.characterOpen = true; + document.getElementById("character").classList.add("show"); + const av = document.getElementById("char-avatar"); + av.style.backgroundImage = `url(${CONFIG.imgBase}/${p.img.replace('.png', '_full.png')})`; + document.getElementById("char-name").textContent = p.name; + document.getElementById("char-duty").textContent = p.def.duty; + // Hide NPC action buttons for player profile + document.querySelector(".char-actions").style.display = "none"; + this.renderCharacter(); + clearInterval(this._charTimer); + this._charTimer = setInterval(() => { if (this.characterOpen) this.renderCharacter(); }, 1000); + }, + + closeCharacter() { + this.characterOpen = false; + this.charNpc = null; + clearInterval(this._charTimer); + document.getElementById("character").classList.remove("show"); + }, + + bar(label, val, max, color) { + const pct = Math.max(0, Math.min(100, (val / max) * 100)); + return ` +
+ ${label} +
+ ${Math.round(val)}/${Math.round(max)} +
`; + }, + + renderCharacter() { + const n = this.charNpc; + if (!n) return; + const s = n.stats; + const w = gearById(WEAPONS, s.weapon), a = gearById(ARMORS, s.armor); + + // relationships (non-zero, strongest first) + const rels = Object.keys(s.rel) + .map((id) => ({ id, v: s.rel[id] })) + .filter((r) => r.v !== 0) + .sort((x, y) => Math.abs(y.v) - Math.abs(x.v)) + .slice(0, 5) + .map((r) => { + const other = CHARACTERS.find((c) => c.id === r.id); + const label = r.v > 0 ? "๐Ÿค Ally" : "โš”๏ธ Rival"; + return `
${other ? other.name : r.id}${label} (${r.v > 0 ? '+' : ''}${r.v})
`; + }).join("") || `
No strong bonds yet.
`; + + // fight history + const hist = (s.fights.length ? s.fights.slice(0, 6).map((f) => + `
${f.result === 'Win' ? 'โœ…' : 'โŒ'} ${f.result}vs ${f.opponent}${f.time}
` + ).join("") : `
No duels yet.
`); + + const injured = n._injured ? `injured` : ""; + + document.getElementById("char-body").innerHTML = ` + ${this.bar("HP", s.hp, s.maxHp, "linear-gradient(90deg,#4ade80,#22c55e)")} + ${this.bar("MP", s.mp, s.maxMp, "linear-gradient(90deg,#60a5fa,#3b82f6)")} + ${this.bar("Morale", s.morale || 60, 100, "linear-gradient(90deg,#fbbf24,#f59e0b)")} +
+
โญ Level${s.level}
+
๐Ÿ’  XP${Math.round(s.xp)}/${s.level * 30}
+
๐Ÿช™ Credits${Math.floor(s.credits)}
+
๐Ÿ… Record${s.wins}W / ${s.losses}L
+
+
${w.icon} Weapon${w.name} (ATK ${w.attack})
+
${a.icon} Armor${a.name} (DEF ${a.defense})
+
โœจ Special${n.def.ability.name} โ€” ${n.def.ability.desc}
+
๐Ÿงญ Duty${n.def.duty}
+
๐Ÿ™‚ Attitude${n.def.attitude}
+
๐Ÿ“ Now${n.status || "idle"} ${injured}
+
๐Ÿค Relationships
+
${rels}
+
โš”๏ธ Fight History
+
${hist}
+ `; + }, + + characterAction(act) { + const n = this.charNpc; + if (!n) return; + if (act === "talk") { + const def = n.def; + this.closeCharacter(); + this.openDialog(def); + return; + } + const verbs = { arena: "heading to", hospital: "reporting to", weapon_shop: "shopping at", living_area: "resting at" }; + if (typeof Game !== "undefined") Game.commandOneTo(n, act, verbs[act] || "heading to"); + const b = BUILDINGS.find((x) => x.id === act); + this.toast(`${n.def.name} is ${verbs[act] || "heading to"} ${b ? b.name : act}.`); + this.closeCharacter(); + }, + + // ---- Dialog / AI chat -------------------------------------------- + openDialog(npcDef) { + this.currentNpc = npcDef; + this.dialogOpen = true; + document.getElementById("dialog").classList.add("show"); + document.getElementById("dialog-name").textContent = npcDef.name; + document.getElementById("dialog-role").textContent = npcDef.id.replace(/_/g, " "); + const av = document.getElementById("dialog-avatar"); + av.style.backgroundImage = `url(${CONFIG.imgBase}/${npcDef.img.replace('.png', '_face.png')})`; + document.getElementById("chat-log").innerHTML = ""; + this.addChatLine("npc", `Hi commander, I'm ${npcDef.name}. What do you need?`); + setTimeout(() => document.getElementById("chat-input").focus(), 100); + }, + + closeDialog() { + this.dialogOpen = false; + this.currentNpc = null; + document.getElementById("dialog").classList.remove("show"); + }, + + addChatLine(who, text) { + const log = document.getElementById("chat-log"); + const div = document.createElement("div"); + div.className = "chat-line " + who; + div.textContent = text; + log.appendChild(div); + log.scrollTop = log.scrollHeight; + return div; + }, + + async sendChat() { + const input = document.getElementById("chat-input"); + const text = input.value.trim(); + if (!text || !this.currentNpc) return; + input.value = ""; + this.addChatLine("me", text); + const typing = this.addChatLine("npc typing", "โ€ฆ"); + const res = await AI.talk(this.currentNpc, text); + typing.remove(); + this.addChatLine("npc", res.reply); + if (res.fallback) { + document.getElementById("ai-status").textContent = "offline mode"; + } else { + document.getElementById("ai-status").textContent = "DeepSeek AI"; + } + }, + + // ---- Missions ----------------------------------------------------- + toggleMissions(force) { + this.missionsOpen = force === undefined ? !this.missionsOpen : force; + const panel = document.getElementById("missions"); + if (this.missionsOpen) { this.renderMissions(); panel.classList.add("show"); } + else panel.classList.remove("show"); + }, + + renderMissions() { + const list = document.getElementById("missions-list"); + list.innerHTML = ""; + MISSIONS.forEach((m) => { + const done = localStorage.getItem("mission_" + m.id) === "done"; + const building = this.activeBuilds[m.id]; + const costHtml = Object.keys(m.cost) + .map((k) => `${RESOURCES[k].icon}${m.cost[k]}`) + .join(" "); + + const card = document.createElement("div"); + card.className = "mission-card" + (done ? " done" : ""); + let action; + if (done) { + action = `
โœ… Completed
`; + } else if (building) { + const pct = Math.floor((building.progress / building.total) * 100); + action = `
Buildingโ€ฆ ${pct}%
`; + } else { + const canAfford = Object.keys(m.cost).every((k) => Game.resources[k] >= m.cost[k]); + action = ``; + } + + card.innerHTML = ` +
+
+

${m.name}

+

${m.desc}

+
${costHtml}
+ ${action} +
`; + list.appendChild(card); + }); + + list.querySelectorAll(".build-btn").forEach((btn) => { + btn.addEventListener("click", () => this.startBuild(btn.dataset.id)); + }); + }, + + startBuild(id) { + const m = MISSIONS.find((x) => x.id === id); + if (!m || this.activeBuilds[id]) return; + for (const k in m.cost) { + if (Game.resources[k] < m.cost[k]) { this.toast("Not enough resources"); return; } + } + for (const k in m.cost) Game.resources[k] -= m.cost[k]; + this.updateResources(); + this.activeBuilds[id] = { progress: 0, total: m.buildTime }; + this.toast(`${m.name}: construction started!`); + this.renderMissions(); + }, + + tickBuilds() { + let changed = false; + for (const id in this.activeBuilds) { + const b = this.activeBuilds[id]; + b.progress += 1; + if (b.progress >= b.total) { + const m = MISSIONS.find((x) => x.id === id); + localStorage.setItem("mission_" + id, "done"); + for (const k in (m.reward || {})) Game.resources[k] += m.reward[k]; + delete this.activeBuilds[id]; + this.toast(`๐ŸŽ‰ ${m.name} complete!`); + this.updateResources(); + this.checkVictory(); + } + changed = true; + } + if (changed && this.missionsOpen) this.renderMissions(); + }, + + // ---- About panel --------------------------------------------------- + openAbout() { + this.aboutOpen = true; + document.getElementById("about").classList.add("show"); + this.buildAboutContent(); + }, + + closeAbout() { + this.aboutOpen = false; + document.getElementById("about").classList.remove("show"); + }, + + aboutSection(title, contentHtml) { + return `
+ +
${contentHtml}
+
`; + }, + + buildAboutContent() { + const body = document.getElementById("about-body"); + + const intro = ` +

Lunar Village is a SmallVill-style AI colony simulation set on the Moon. + You are the Colony Commander, managing a crew of 10 unique characters who live, work, fight, and talk using AI (DeepSeek).

+

Gather resources, build mega-projects, manage your crew's health and morale, and watch the colony come alive.

+

Created by Andy Kong

+ `; + + const howToPlay = ` + + `; + + const buildings = ` + + `; + + let chars = ""; + + const resources = ` + +

If food runs out, all crew lose HP. Keep production going!

+ `; + + const combat = ` +

When two or more crew idle at the Arena, they spar automatically.

+ + `; + + body.innerHTML = + this.aboutSection("🌕 About the Game", intro) + + this.aboutSection("🎮 How to Play", howToPlay) + + this.aboutSection("🏘 Buildings", buildings) + + this.aboutSection("👫 Characters", chars) + + this.aboutSection("📦 Resources", resources) + + this.aboutSection("⚔ Combat System", combat); + }, + + // ---- Announcements ------------------------------------------------ + buildQuickChips() { + const presets = [ + "All hands, prioritise the rocket build!", + "Conserve energy until further notice.", + "Great work today, crew. Keep it up!", + "Emergency: report to the Command Center.", + "Boost ore mining โ€” we need more metal.", + ]; + const wrap = document.getElementById("announce-quick"); + wrap.innerHTML = ""; + presets.forEach((p) => { + const chip = document.createElement("button"); + chip.className = "quick-chip"; + chip.textContent = p; + chip.addEventListener("click", () => { + document.getElementById("announce-input").value = p; + }); + wrap.appendChild(chip); + }); + }, + + openAnnounce() { + if (this.dialogOpen || this.missionsOpen) return; + this.announceOpen = true; + document.getElementById("announce").classList.add("show"); + document.getElementById("announce-results").innerHTML = ""; + setTimeout(() => document.getElementById("announce-input").focus(), 100); + }, + + closeAnnounce() { + this.announceOpen = false; + document.getElementById("announce").classList.remove("show"); + }, + + showBanner(text) { + const banner = document.getElementById("broadcast-banner"); + document.getElementById("broadcast-text").textContent = "๐Ÿ“ข Commander: " + text; + banner.classList.add("show"); + clearTimeout(this._bannerTimer); + this._bannerTimer = setTimeout(() => banner.classList.remove("show"), 6000); + }, + + // Work out if an announcement names a place the crew should go to. + detectBuilding(text) { + const t = text.toLowerCase(); + for (const b of BUILDINGS) { + if (t.includes(b.name.toLowerCase())) return b; + } + const aliases = { + "convention center": "convention_centre", "convention": "convention_centre", + "meeting room": "convention_centre", "meeting": "convention_centre", + "gather": "convention_centre", "gathering": "convention_centre", "assemble": "convention_centre", + "command": "command_centre", "hq": "command_centre", "headquarters": "command_centre", + "comms": "communication_station", "communication": "communication_station", "radio": "communication_station", + "solar": "solar_station", "power": "solar_station", + "hangar": "hangar", "dock": "hangar", + "launch": "launchpad", "rocket": "launchpad", "launchpad": "launchpad", + "living": "living_area", "home": "living_area", + "hospital": "hospital", "medic": "hospital", "clinic": "hospital", "infirmary": "hospital", + "repair": "repair_shop", "garage": "repair_shop", "mechanic": "repair_shop", + "robot": "robot_station", + "greenhouse": "greenhouse", "farm": "greenhouse", "crop": "greenhouse", + "forest": "forrest", "forrest": "forrest", "tree": "forrest", + "arena": "arena", "train": "arena", + "gas": "gas_station", "fuel": "gas_station", + "weapon": "weapon_shop", "armory": "weapon_shop", "armoury": "weapon_shop", + }; + for (const k in aliases) { + if (t.includes(k)) return BUILDINGS.find((b) => b.id === aliases[k]); + } + if (/\b(report|muster|come here|everyone here|all hands here)\b/.test(t)) { + return BUILDINGS.find((b) => b.id === "convention_centre"); + } + return null; + }, + + async sendAnnounce() { + const input = document.getElementById("announce-input"); + const text = input.value.trim(); + if (!text) return; + const sendBtn = document.getElementById("announce-send"); + sendBtn.disabled = true; + + this.showBanner(text); + + // If a place is named, order the crew to actually go there. + let movedTo = null; + if (typeof Game !== "undefined") { + const b = this.detectBuilding(text); + if (b) movedTo = Game.commandAllTo(b.id, "reporting to"); + } + + // Persist the order to every crew member's memory file. + CHARACTERS.forEach((c) => AI.remember(c, `The Commander announced: "${text}".`)); + + const list = document.getElementById("announce-results"); + list.innerHTML = ""; + if (movedTo) { + const note = document.createElement("div"); + note.className = "ann-move-note"; + note.textContent = `๐Ÿšถ The crew is heading to ${movedTo.name}. Close this to watch them go.`; + list.appendChild(note); + } + const rows = {}; + CHARACTERS.forEach((c) => { + const row = document.createElement("div"); + row.className = "ann-row"; + row.innerHTML = ` +
+
+
${c.name}
+
receivingโ€ฆ
+
`; + list.appendChild(row); + rows[c.id] = row.querySelector(".ann-reply"); + }); + + const message = `[Colony-wide announcement from the Commander to all crew] "${text}". Give a brief in-character reply acknowledging or reacting to this order.`; + + await Promise.all(CHARACTERS.map(async (c) => { + const res = await AI.talk(c, message); + const el = rows[c.id]; + if (el) { el.textContent = res.reply; el.classList.remove("pending"); } + if (typeof Game !== "undefined") Game.setReaction(c.id, res.reply); + })); + + sendBtn.disabled = false; + input.value = ""; + }, + + checkVictory() { + const all = MISSIONS.every((m) => localStorage.getItem("mission_" + m.id) === "done"); + if (all) { + setTimeout(() => { + this.toast("๐ŸŒ• All mega-projects complete! Lunar Village thrives!"); + }, 500); + } + }, +};