diff --git a/.gitignore b/.gitignore index ffe7c5a..0d6cdfe 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .idea/ __pycache__/ *.pyc +build/ +dist/ +*.spec diff --git a/web/Dockerfile b/web/Dockerfile new file mode 100644 index 0000000..890ae28 --- /dev/null +++ b/web/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim AS base +WORKDIR /app + +# Install server dependencies +COPY web/requirements.txt /tmp/ +RUN pip install --no-cache-dir -r /tmp/requirements.txt + +# ---------- +FROM base AS builder +COPY web/static/generate_icons.py /tmp/ +RUN python /tmp/generate_icons.py && mv icon-*.png /tmp/ + +# ---------- +FROM base AS runner + +# Game source +COPY main.py player.py scene.py function_list.py class_list.py race_list.py enum_list.py spell_list.py ./ + +# Web server +COPY web/server.py ./web/server.py +COPY web/static/index.html web/static/manifest.json web/static/sw.js ./web/static/ +COPY --from=builder /tmp/icon-192.png /tmp/icon-512.png ./web/static/ + +EXPOSE 8080 +CMD ["uvicorn", "web.server:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..b455540 --- /dev/null +++ b/web/README.md @@ -0,0 +1,46 @@ +# web/ — Kanjin Web Server & PWA + +## Quick start (Docker) + +From the project root: + +```bash +docker build -t kanjin -f web/Dockerfile . +docker run -p 8080:8080 kanjin +``` + +Open http://localhost:8080 in any browser. On Chrome/Edge you can **"Install as app"** via the address bar icon — removes the URL bar and adds a home-screen icon. + +## Windows standalone .exe + +From the project root, with PyInstaller installed: + +```bash +pip install pyinstaller +pyinstaller --onefile --name Kanjin main.py +``` + +Deliver `dist/Kanjin.exe` — no Python or dependencies needed on the player's machine. + +## Development + +```bash +cd web +python -m pip install -r requirements.txt +python generate_icons.py +python server.py # starts uvicorn on :8080 with hot-reload +``` + +Requires Linux or WSL (uses `pty`). The server spawns `main.py` from the parent directory in a pseudo-terminal and streams it to the browser over WebSocket. + +## File layout + +| Path | Purpose | +|---|---| +| `server.py` | FastAPI app: serves index.html, mounts /static, handles /ws WebSocket | +| `static/index.html` | xterm.js in-browser terminal connected over WebSocket | +| `static/manifest.json` | PWA manifest — enables Chrome "Install as app" | +| `static/sw.js` | Service worker — precaches the shell for offline launch | +| `static/generate_icons.py` | Generates placeholder PNG icons (run once) | +| `requirements.txt` | Python deps (fastapi, uvicorn) | +| `Dockerfile` | Multi-stage Docker build | diff --git a/web/requirements.txt b/web/requirements.txt new file mode 100644 index 0000000..5b0ff56 --- /dev/null +++ b/web/requirements.txt @@ -0,0 +1,2 @@ +fastapi>=0.115.0 +uvicorn[standard]>=0.32.0 diff --git a/web/server.py b/web/server.py new file mode 100644 index 0000000..9691afe --- /dev/null +++ b/web/server.py @@ -0,0 +1,89 @@ +import asyncio +import os +import signal +import sys +from pathlib import Path + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +try: + import pty + HAS_PTY = True +except ImportError: + HAS_PTY = False + +import uvicorn +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles + +app = FastAPI(title="Kanjin") + +STATIC_DIR = Path(__file__).resolve().parent / "static" +app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static") + + +@app.get("/") +async def index(): + html = (STATIC_DIR / "index.html").read_text(encoding="utf-8") + return HTMLResponse(html) + + +@app.websocket("/ws") +async def websocket_endpoint(ws: WebSocket): + if not HAS_PTY: + await ws.accept() + await ws.send_text("Server must run on Linux (or WSL) with PTY support.\n") + await ws.close() + return + + await ws.accept() + + master_fd, slave_fd = pty.openpty() + loop = asyncio.get_event_loop() + + proc = await asyncio.create_subprocess_exec( + sys.executable, str(PROJECT_ROOT / "main.py"), + stdin=slave_fd, + stdout=slave_fd, + stderr=slave_fd, + cwd=str(PROJECT_ROOT), + close_fds=True, + preexec_fn=os.setsid, + ) + os.close(slave_fd) + + async def read_pty(): + try: + while True: + data = await loop.run_in_executor(None, os.read, master_fd, 4096) + if not data: + break + await ws.send_text(data.decode(errors="replace")) + except (ConnectionError, WebSocketDisconnect): + pass + finally: + try: + os.close(master_fd) + except OSError: + pass + if proc.returncode is None: + try: + os.killpg(os.getpgid(proc.pid), signal.SIGTERM) + await asyncio.wait_for(proc.wait(), timeout=5) + except (ProcessLookupError, asyncio.TimeoutError): + proc.kill() + await proc.wait() + + async def write_pty(): + try: + async for message in ws.iter_text(): + os.write(master_fd, message.encode()) + except (ConnectionError, WebSocketDisconnect): + pass + + await asyncio.gather(read_pty(), write_pty()) + + +if __name__ == "__main__": + uvicorn.run("server:app", host="0.0.0.0", port=8080, reload=True) diff --git a/web/static/generate_icons.py b/web/static/generate_icons.py new file mode 100644 index 0000000..cf3bee6 --- /dev/null +++ b/web/static/generate_icons.py @@ -0,0 +1,30 @@ +"""Generate solid-colour placeholder PNG icons for the PWA manifest.""" +import struct +import zlib +from pathlib import Path + + +def _chunk(chunk_type, data): + c = chunk_type + data + crc = struct.pack(">I", zlib.crc32(c) & 0xFFFFFFFF) + return struct.pack(">I", len(data)) + c + crc + + +def solid_png(width, height, r, g, b): + header = b"\x89PNG\r\n\x1a\n" + ihdr = _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + raw = bytearray() + for _ in range(height): + raw.append(0) + raw.extend(bytes([r, g, b]) * width) + idat = _chunk(b"IDAT", zlib.compress(bytes(raw))) + iend = _chunk(b"IEND", b"") + return header + ihdr + idat + iend + + +if __name__ == "__main__": + dst = Path(__file__).parent + for size in (192, 512): + path = dst / f"icon-{size}.png" + path.write_bytes(solid_png(size, size, 0x1A, 0x1A, 0x2E)) + print(f"Created {path.name} ({size}x{size})") diff --git a/web/static/icon-192.png b/web/static/icon-192.png new file mode 100644 index 0000000..0842a71 Binary files /dev/null and b/web/static/icon-192.png differ diff --git a/web/static/icon-512.png b/web/static/icon-512.png new file mode 100644 index 0000000..1abad87 Binary files /dev/null and b/web/static/icon-512.png differ diff --git a/web/static/index.html b/web/static/index.html new file mode 100644 index 0000000..8c91090 --- /dev/null +++ b/web/static/index.html @@ -0,0 +1,53 @@ + + +
+ + + + + + + +