Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ This game allows you to explore a fishing village and perform actions in it.

## Features

### Play in your browser (web interface)
FishE runs behind a single user-interface contract, so it supports multiple front-ends: the default text/console interface, a pygame window, and a browser-based **web interface**. To play in the browser, run the example web app and open the printed URL:

```bash
python3 examples/web_app.py
# then open http://127.0.0.1:8000
```

The entire game — save-file manager, fishing, shop, bank, tavern, and NPC dialogue — plays in the browser, with no extra dependencies (it uses only the Python standard library). Adding a new front-end means implementing `BaseUserInterface` and adding a `UIType` + factory branch.

### Your Goal
Build a fortune of **$10,000** in total wealth (cash on hand plus savings in the bank). Your progress toward the goal is shown in the status header, and reaching it earns a one-time victory — after which you're free to keep fishing or retire from the Home menu.

Expand Down
32 changes: 32 additions & 0 deletions examples/web_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""Example: run FishE with the browser-based web front-end.

This shows how to drive the existing game through the WebUserInterface — no game
logic changes, just a different UIType. Run it and open the printed URL:

python3 examples/web_app.py

The whole game (save-file manager, fishing, shop, bank, tavern, dialogue) then
plays in the browser.
"""

import os
import sys

# Make the game package importable when running this file directly.
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src"))

from ui.enum.uiType import UIType # noqa: E402
from fishE import FishE # noqa: E402


def main():
print("FishE web app is starting at http://127.0.0.1:8000")
print("Open that URL in your browser to play. Press Ctrl+C here to stop.")
# Building FishE starts the web server and then waits (in the save-file
# manager) for the browser to interact, so play happens entirely in-browser.
game = FishE(interfaceType=UIType.WEB)
game.play()


if __name__ == "__main__":
main()
4 changes: 2 additions & 2 deletions src/fishE.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@

# @author Daniel McCoy Stephenson
class FishE:
def __init__(self):
def __init__(self, interfaceType=INTERFACE_TYPE):
self.running = True

self.playerJsonReaderWriter = PlayerJsonReaderWriter()
Expand All @@ -43,7 +43,7 @@ def __init__(self):
self.timeService = TimeService(self.player, self.stats)
self.prompt = Prompt("What would you like to do?")
self.userInterface = UserInterfaceFactory.create_user_interface(
INTERFACE_TYPE, self.prompt, self.timeService, self.player
interfaceType, self.prompt, self.timeService, self.player
)

# Migrate old save files to new format if they exist
Expand Down
3 changes: 2 additions & 1 deletion src/ui/enum/uiType.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@
# @author Daniel McCoy Stephenson
class UIType(Enum):
CONSOLE = "console"
PYGAME = "pygame"
PYGAME = "pygame"
WEB = "web"
5 changes: 5 additions & 0 deletions src/ui/userInterfaceFactory.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,5 +29,10 @@ def create_user_interface(
from ui.pygameUserInterface import PygameUserInterface

return PygameUserInterface(currentPrompt, timeService, player)
elif ui_type == UIType.WEB:
# Imported lazily so other modes don't start the HTTP machinery.
from ui.webUserInterface import WebUserInterface

return WebUserInterface(currentPrompt, timeService, player)
else:
raise ValueError(f"Unsupported UI type: {ui_type}")
254 changes: 254 additions & 0 deletions src/ui/webUserInterface.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,254 @@
import json
import queue
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer

from ui.baseUserInterface import BaseUserInterface
from prompt.prompt import Prompt
from player.player import Player
from world.timeService import TimeService


# The single-page client. It polls /state and renders whatever screen the game
# is currently waiting on, posting the player's response to /input. Served as-is
# (no templating); it talks to the server via relative URLs.
HTML_PAGE = """<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>FishE</title>
<style>
body { font-family: monospace; background: #0b1d2a; color: #e0f0ff;
max-width: 680px; margin: 2rem auto; padding: 0 1rem; }
.header { color: #7fb0d0; border-bottom: 1px solid #2a4a5a;
padding-bottom: .5rem; margin-bottom: 1rem; }
.descriptor { margin: 1rem 0; font-size: 1.1rem; }
.prompt { color: #9fd0ff; margin: 1rem 0; }
.dialogue { white-space: pre-wrap; margin: 1rem 0; line-height: 1.5; }
button { display: block; width: 100%; text-align: left; margin: .3rem 0;
padding: .6rem; background: #163345; color: #e0f0ff;
border: 1px solid #2a4a5a; border-radius: 4px; cursor: pointer;
font-family: monospace; font-size: 1rem; }
button:hover { background: #1f4a63; }
input { width: 100%; padding: .6rem; font-family: monospace; font-size: 1rem;
background: #163345; color: #e0f0ff; border: 1px solid #2a4a5a;
border-radius: 4px; }
</style>
</head>
<body>
<h2>FishE</h2>
<div id="app">Connecting&hellip;</div>
<script>
let version = -1;
async function poll() {
try {
const response = await fetch("/state");
const state = await response.json();
if (state.version !== version) { version = state.version; render(state.screen); }
} catch (e) { /* server not ready yet */ }
setTimeout(poll, 300);
}
async function send(value) {
document.getElementById("app").innerHTML = "&hellip;";
await fetch("/input", { method: "POST", body: JSON.stringify({ value: value }) });
}
function el(tag, props, ...kids) {
const e = document.createElement(tag);
Object.assign(e, props || {});
for (const k of kids) e.append(k);
return e;
}
function render(screen) {
const app = document.getElementById("app");
app.innerHTML = "";
if (!screen || screen.type === "loading") { app.append("Waiting for the game…"); return; }
if (screen.type === "ended") { app.append("The game has ended. You can close this tab."); return; }
if (screen.header) {
const h = screen.header;
let line = `Day ${h.day} | ${h.time} | $${h.money.toFixed(2)} | Fish: ${h.fish} | Energy: ${h.energy}`;
if (h.location) line += ` | ${h.location}`;
if (h.goal) line += ` | Goal: ${h.goal}`;
app.append(el("div", { className: "header", textContent: line }));
}
if (screen.descriptor) app.append(el("div", { className: "descriptor", textContent: screen.descriptor }));
if (screen.prompt) app.append(el("div", { className: "prompt", textContent: screen.prompt }));
if (screen.type === "options") {
screen.options.forEach((opt, i) => {
const b = el("button", { textContent: `[${i + 1}] ${opt}` });
b.onclick = () => send(String(i + 1));
app.append(b);
});
} else if (screen.type === "dialogue") {
app.append(el("div", { className: "dialogue", textContent: screen.text }));
const b = el("button", { textContent: "Continue" });
b.onclick = () => send("");
app.append(b);
} else if (screen.type === "prompt") {
app.append(el("div", { className: "descriptor", textContent: screen.text }));
const inp = el("input", { type: "text" });
const submit = () => send(inp.value);
inp.onkeydown = (e) => { if (e.key === "Enter") submit(); };
const b = el("button", { textContent: "Submit" });
b.onclick = submit;
app.append(inp); app.append(b); inp.focus();
} else if (screen.type === "timed") {
app.append(el("div", { className: "descriptor", textContent: screen.message }));
const b = el("button", { textContent: "React!" });
b.onclick = () => send("");
app.append(b);
}
}
poll();
</script>
</body>
</html>
"""


def _makeRequestHandler(ui):
"""Build a request handler bound to a specific WebUserInterface instance."""

class _Handler(BaseHTTPRequestHandler):
def do_GET(self):
if self.path in ("/", "/index.html"):
self._send(200, "text/html; charset=utf-8", HTML_PAGE.encode("utf-8"))
elif self.path.startswith("/state"):
body = json.dumps(ui.get_state()).encode("utf-8")
self._send(200, "application/json", body)
else:
self._send(404, "text/plain", b"Not found")

def do_POST(self):
if self.path.startswith("/input"):
length = int(self.headers.get("Content-Length", 0))
raw = self.rfile.read(length) if length else b"{}"
try:
value = json.loads(raw or b"{}").get("value", "")
except (ValueError, TypeError):
value = ""
ui.submit_input(value)
self._send(200, "application/json", b"{}")
else:
self._send(404, "text/plain", b"Not found")

def _send(self, status, contentType, body):
self.send_response(status)
self.send_header("Content-Type", contentType)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)

def log_message(self, *args):
pass # keep the game's stdout clean

return _Handler


# @author Daniel McCoy Stephenson
class WebUserInterface(BaseUserInterface):
"""A browser-based front-end for FishE.

The synchronous game loop is unchanged: each input primitive publishes the
current screen and blocks until the browser submits a response, coordinated
through a thread-safe rendezvous. A small stdlib HTTP server (run in a daemon
thread) serves the screen state (GET /state) and the page (GET /), and
accepts the player's response (POST /input)."""

def __init__(
self,
currentPrompt: Prompt,
timeService: TimeService,
player: Player,
host="127.0.0.1",
port=8000,
start_server=True,
):
super().__init__(currentPrompt, timeService, player)
self._lock = threading.Lock()
self._screen = {"type": "loading"}
self._version = 0
self._inputQueue = queue.Queue()
self._server = None
if start_server:
self._server = ThreadingHTTPServer((host, port), _makeRequestHandler(self))
self._server.daemon_threads = True
threading.Thread(target=self._server.serve_forever, daemon=True).start()

@property
def address(self):
"""The (host, port) the server is bound to, or None if not started."""
return self._server.server_address if self._server else None

# --- web rendezvous ---------------------------------------------------
def get_state(self):
"""Snapshot of the current screen for the browser to render."""
with self._lock:
return {"version": self._version, "screen": self._screen}

def submit_input(self, value):
"""Deliver the player's browser response to the waiting game thread."""
self._inputQueue.put(value)

def _present(self, screen):
with self._lock:
self._screen = screen
self._version += 1

def _header(self):
return {
"day": self.timeService.day,
"time": self.times[self.timeService.time],
"money": float(self.player.money),
"fish": self.player.fishCount,
"energy": self.player.energy,
"location": self.currentLocationName,
"goal": self.goalProgress,
}

# --- BaseUserInterface primitives ------------------------------------
def lotsOfSpace(self):
# The browser renders a fresh screen each time; nothing to clear.
pass

def divider(self):
pass

def showOptions(self, descriptor, optionList):
self._present(
{
"type": "options",
"descriptor": descriptor,
"prompt": self.currentPrompt.text,
"options": list(optionList),
"header": self._header(),
}
)
valid = {str(i + 1) for i in range(len(optionList))}
while True:
choice = str(self._inputQueue.get())
if choice in valid:
return choice
# ignore anything that isn't a listed option and keep waiting

def showDialogue(self, text):
self._present({"type": "dialogue", "text": text})
self._inputQueue.get()
self.currentPrompt.text = "What would you like to do?"

def promptForText(self, promptText):
self._present({"type": "prompt", "text": promptText})
return str(self._inputQueue.get())

def timedKeyPress(self, message):
self._present({"type": "timed", "message": message})
startTime = time.time()
self._inputQueue.get()
return time.time() - startTime

def cleanup(self):
self._present({"type": "ended"})
if self._server is not None:
self._server.shutdown()
self._server.server_close()
self._server = None
Loading
Loading