botapest 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- botapest/__init__.py +0 -0
- botapest/cli.py +49 -0
- botapest/hooks.py +62 -0
- botapest/seed.py +68 -0
- botapest/server.py +112 -0
- botapest/static/city-live.js +34 -0
- botapest/static/city-render.js +318 -0
- botapest/static/city-scape.js +106 -0
- botapest/static/city.html +47 -0
- botapest/static/city.js +52 -0
- botapest/static/hotel.js +185 -0
- botapest/static/index.html +109 -0
- botapest/static/render.js +175 -0
- botapest/zone.py +55 -0
- botapest-0.2.0.dist-info/METADATA +70 -0
- botapest-0.2.0.dist-info/RECORD +19 -0
- botapest-0.2.0.dist-info/WHEEL +4 -0
- botapest-0.2.0.dist-info/entry_points.txt +2 -0
- botapest-0.2.0.dist-info/licenses/LICENSE +21 -0
botapest/__init__.py
ADDED
|
File without changes
|
botapest/cli.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
"""botapest — run from any git repo to watch agents build its city."""
|
|
2
|
+
import argparse
|
|
3
|
+
import subprocess
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import uvicorn
|
|
7
|
+
|
|
8
|
+
from . import hooks, server
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def free_port(port: int) -> None:
|
|
12
|
+
holders = lambda: subprocess.run(["lsof", "-ti", f":{port}"],
|
|
13
|
+
capture_output=True, text=True).stdout.split()
|
|
14
|
+
pids = holders()
|
|
15
|
+
if not pids:
|
|
16
|
+
return
|
|
17
|
+
print(f"freeing port {port} (pid {', '.join(pids)})")
|
|
18
|
+
for sig in ("-TERM", "-KILL"):
|
|
19
|
+
subprocess.run(["kill", sig, *pids], capture_output=True)
|
|
20
|
+
for _ in range(20):
|
|
21
|
+
if not holders():
|
|
22
|
+
return
|
|
23
|
+
time.sleep(.1)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def main() -> None:
|
|
27
|
+
parser = argparse.ArgumentParser(
|
|
28
|
+
prog="botapest",
|
|
29
|
+
description="Habbo-style visualization of Claude Code agents building your repo as a city.")
|
|
30
|
+
parser.add_argument("command", nargs="?", default="serve", choices=["serve", "attach", "detach"],
|
|
31
|
+
help="serve the city (default), or attach/detach Claude Code hooks")
|
|
32
|
+
parser.add_argument("--repo", default=".", help="git repo to map as the city (default: cwd)")
|
|
33
|
+
parser.add_argument("--zone", help="zoning manifest (default: <repo>/.botapest.json, else auto-zoned)")
|
|
34
|
+
parser.add_argument("--port", type=int, default=4242)
|
|
35
|
+
args = parser.parse_args()
|
|
36
|
+
|
|
37
|
+
if args.command == "attach":
|
|
38
|
+
hooks.attach(args.port)
|
|
39
|
+
elif args.command == "detach":
|
|
40
|
+
hooks.detach()
|
|
41
|
+
else:
|
|
42
|
+
free_port(args.port)
|
|
43
|
+
server.configure(args.repo, args.zone)
|
|
44
|
+
print(f"Botapest City on http://localhost:{args.port} (repo: {args.repo})")
|
|
45
|
+
uvicorn.run(server.app, port=args.port, log_level="warning")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
if __name__ == "__main__":
|
|
49
|
+
main()
|
botapest/hooks.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Attach/detach the city to Claude Code via ~/.claude/settings.json hooks.
|
|
2
|
+
|
|
3
|
+
Hooks fire-and-forget to localhost with a 1s timeout, so Claude Code is
|
|
4
|
+
never blocked when the city isn't running.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
SETTINGS = Path.home() / ".claude" / "settings.json"
|
|
10
|
+
BACKUP = SETTINGS.with_suffix(".json.botapest.bak")
|
|
11
|
+
EVENTS = [
|
|
12
|
+
"SessionStart",
|
|
13
|
+
"UserPromptSubmit",
|
|
14
|
+
"Notification",
|
|
15
|
+
"PreToolUse",
|
|
16
|
+
"PostToolUse",
|
|
17
|
+
"Stop",
|
|
18
|
+
"SubagentStop",
|
|
19
|
+
"SessionEnd",
|
|
20
|
+
]
|
|
21
|
+
def command(port: int) -> str:
|
|
22
|
+
return (
|
|
23
|
+
f"curl -sf -m 1 -X POST http://localhost:{port}/hook "
|
|
24
|
+
"-H 'Content-Type: application/json' --data-binary @- "
|
|
25
|
+
">/dev/null 2>&1 || true"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def has_hotel_hook(entries: list) -> bool:
|
|
30
|
+
return any(
|
|
31
|
+
"/hook" in hook.get("command", "") and "curl" in hook.get("command", "")
|
|
32
|
+
for entry in entries
|
|
33
|
+
for hook in entry.get("hooks", [])
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def attach(port: int = 4242) -> None:
|
|
38
|
+
settings = json.loads(SETTINGS.read_text()) if SETTINGS.exists() else {}
|
|
39
|
+
BACKUP.write_text(json.dumps(settings, indent=2))
|
|
40
|
+
hooks = settings.setdefault("hooks", {})
|
|
41
|
+
added = 0
|
|
42
|
+
for event in EVENTS:
|
|
43
|
+
entries = hooks.setdefault(event, [])
|
|
44
|
+
if not has_hotel_hook(entries):
|
|
45
|
+
entries.append({"matcher": "", "hooks": [{"type": "command", "command": command(port)}]})
|
|
46
|
+
added += 1
|
|
47
|
+
SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
|
|
48
|
+
print(f"attached {added} events ({SETTINGS}, backup at {BACKUP.name})")
|
|
49
|
+
print("new Claude Code sessions will now report to the city")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def detach() -> None:
|
|
53
|
+
if not SETTINGS.exists():
|
|
54
|
+
return
|
|
55
|
+
settings = json.loads(SETTINGS.read_text())
|
|
56
|
+
hooks = settings.get("hooks", {})
|
|
57
|
+
for event in list(hooks):
|
|
58
|
+
hooks[event] = [e for e in hooks[event] if not has_hotel_hook([e])]
|
|
59
|
+
if not hooks[event]:
|
|
60
|
+
del hooks[event]
|
|
61
|
+
SETTINGS.write_text(json.dumps(settings, indent=2) + "\n")
|
|
62
|
+
print("detached — botapest hooks removed")
|
botapest/seed.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Build a city snapshot from a git repo + zoning manifest.
|
|
2
|
+
|
|
3
|
+
Per building: loc (mass), commits + age (attention), centrality (how many
|
|
4
|
+
other components it has co-committed with). A component may set "group": N
|
|
5
|
+
to aggregate files into one building per N-segment path prefix.
|
|
6
|
+
"""
|
|
7
|
+
import subprocess
|
|
8
|
+
import time
|
|
9
|
+
from fnmatch import fnmatch
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def git(repo: str, *args: str) -> str:
|
|
14
|
+
return subprocess.run(["git", "-C", repo, *args], capture_output=True, text=True).stdout
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def component_of(path: str, components: list) -> str | None:
|
|
18
|
+
for c in components:
|
|
19
|
+
if any(fnmatch(path, g) for g in c["globs"]):
|
|
20
|
+
return c["id"]
|
|
21
|
+
return None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def count_lines(path: Path) -> int:
|
|
25
|
+
try:
|
|
26
|
+
return sum(1 for _ in open(path, errors="ignore"))
|
|
27
|
+
except OSError:
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def seed(repo: str, zone: dict) -> dict:
|
|
32
|
+
comp = {f: component_of(f, zone["components"]) for f in git(repo, "ls-files").splitlines()}
|
|
33
|
+
files = [f for f, c in comp.items() if c]
|
|
34
|
+
|
|
35
|
+
commits = dict.fromkeys(files, 0)
|
|
36
|
+
last = dict.fromkeys(files, 0)
|
|
37
|
+
cocomp = {f: set() for f in files}
|
|
38
|
+
timestamp = 0
|
|
39
|
+
touched: list[str] = []
|
|
40
|
+
for line in git(repo, "log", "--name-only", "--pretty=%ct").splitlines() + [""]:
|
|
41
|
+
if line.isdigit() or not line: # commit boundary: flush previous
|
|
42
|
+
comps = {comp[f] for f in touched}
|
|
43
|
+
for f in touched:
|
|
44
|
+
commits[f] += 1
|
|
45
|
+
last[f] = max(last[f], timestamp)
|
|
46
|
+
if len(touched) <= 15: # mega-commits aren't coupling signal
|
|
47
|
+
cocomp[f] |= comps - {comp[f]}
|
|
48
|
+
touched = []
|
|
49
|
+
if line.isdigit():
|
|
50
|
+
timestamp = int(line)
|
|
51
|
+
elif line in commits:
|
|
52
|
+
touched.append(line)
|
|
53
|
+
|
|
54
|
+
group = {c["id"]: c.get("group") for c in zone["components"]}
|
|
55
|
+
buildings: dict[str, dict] = {}
|
|
56
|
+
now = time.time()
|
|
57
|
+
for f in sorted(files):
|
|
58
|
+
depth = group[comp[f]]
|
|
59
|
+
key = "/".join(f.split("/")[:depth]) if depth else f
|
|
60
|
+
b = buildings.setdefault(key, {"path": key, "component": comp[f], "loc": 0,
|
|
61
|
+
"commits": 0, "centrality": 0, "age_days": 9999, "files": 0})
|
|
62
|
+
b["loc"] += count_lines(Path(repo, f))
|
|
63
|
+
b["commits"] += commits[f]
|
|
64
|
+
b["centrality"] = max(b["centrality"], len(cocomp[f]))
|
|
65
|
+
if last[f]:
|
|
66
|
+
b["age_days"] = min(b["age_days"], round((now - last[f]) / 86400))
|
|
67
|
+
b["files"] += 1
|
|
68
|
+
return {"zone": zone, "buildings": list(buildings.values())}
|
botapest/server.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Botapest City — event relay + city seeding.
|
|
2
|
+
|
|
3
|
+
Receives Claude Code hook payloads on POST /hook, normalizes them, and
|
|
4
|
+
broadcasts to browsers over SSE at GET /events. GET /city-data.json
|
|
5
|
+
seeds the configured repo's city on demand (cached per git HEAD).
|
|
6
|
+
"""
|
|
7
|
+
import asyncio
|
|
8
|
+
import json
|
|
9
|
+
import os
|
|
10
|
+
from collections import deque
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from fastapi import FastAPI, Request
|
|
14
|
+
from fastapi.responses import StreamingResponse
|
|
15
|
+
from fastapi.staticfiles import StaticFiles
|
|
16
|
+
|
|
17
|
+
from .seed import git, seed
|
|
18
|
+
from .zone import load_zone
|
|
19
|
+
|
|
20
|
+
app = FastAPI()
|
|
21
|
+
subscribers: set[asyncio.Queue] = set()
|
|
22
|
+
history: deque = deque(maxlen=100)
|
|
23
|
+
city = {"repo": ".", "zone_path": None, "head": None, "data": None}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def configure(repo: str, zone_path: str | None) -> None:
|
|
27
|
+
city.update(repo=repo, zone_path=zone_path, head=None, data=None)
|
|
28
|
+
|
|
29
|
+
MAX_DETAIL = 80
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize(raw: dict) -> dict:
|
|
33
|
+
event = {
|
|
34
|
+
"event": raw.get("hook_event_name", "unknown"),
|
|
35
|
+
"session": (raw.get("session_id") or "")[:8],
|
|
36
|
+
}
|
|
37
|
+
if raw.get("agent_id"): # event fired inside a subagent
|
|
38
|
+
event["agent_id"] = raw["agent_id"][:8]
|
|
39
|
+
event["agent_type"] = raw.get("agent_type", "agent")
|
|
40
|
+
tool = raw.get("tool_name")
|
|
41
|
+
if tool:
|
|
42
|
+
event["tool"] = tool
|
|
43
|
+
tool_input = raw.get("tool_input") or {}
|
|
44
|
+
detail = (
|
|
45
|
+
tool_input.get("file_path")
|
|
46
|
+
or tool_input.get("command")
|
|
47
|
+
or tool_input.get("pattern")
|
|
48
|
+
or tool_input.get("query")
|
|
49
|
+
or tool_input.get("url")
|
|
50
|
+
or tool_input.get("description")
|
|
51
|
+
or ""
|
|
52
|
+
)
|
|
53
|
+
if detail.startswith("/"):
|
|
54
|
+
detail = os.path.basename(detail)
|
|
55
|
+
event["detail"] = str(detail)[:MAX_DETAIL]
|
|
56
|
+
file_path, cwd = tool_input.get("file_path") or "", raw.get("cwd") or ""
|
|
57
|
+
if cwd and file_path.startswith(cwd + "/"):
|
|
58
|
+
event["path"] = file_path[len(cwd) + 1:]
|
|
59
|
+
if tool == "Bash" and "git commit" in str(tool_input.get("command") or ""):
|
|
60
|
+
event["commit"] = True
|
|
61
|
+
if tool in ("Task", "Agent"):
|
|
62
|
+
event["agent_type"] = tool_input.get("subagent_type", "agent")
|
|
63
|
+
event["agent_name"] = str(tool_input.get("description", "agent"))[:40]
|
|
64
|
+
if event["event"] == "UserPromptSubmit":
|
|
65
|
+
event["detail"] = str(raw.get("prompt") or "")[:MAX_DETAIL]
|
|
66
|
+
if event["event"] == "Notification":
|
|
67
|
+
event["detail"] = str(raw.get("message") or "")[:MAX_DETAIL]
|
|
68
|
+
return event
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@app.post("/hook")
|
|
72
|
+
async def hook(request: Request) -> dict:
|
|
73
|
+
raw = await request.json()
|
|
74
|
+
event = normalize(raw)
|
|
75
|
+
history.append(event)
|
|
76
|
+
for queue in subscribers:
|
|
77
|
+
queue.put_nowait(event)
|
|
78
|
+
return {"ok": True}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@app.get("/city-data.json")
|
|
82
|
+
def city_data() -> dict:
|
|
83
|
+
head = git(city["repo"], "rev-parse", "HEAD").strip()
|
|
84
|
+
if city["head"] != head:
|
|
85
|
+
city["data"] = seed(city["repo"], load_zone(city["repo"], city["zone_path"]))
|
|
86
|
+
city["head"] = head
|
|
87
|
+
return city["data"]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@app.get("/events")
|
|
91
|
+
async def events() -> StreamingResponse:
|
|
92
|
+
queue: asyncio.Queue = asyncio.Queue()
|
|
93
|
+
for event in history: # replay so late joiners see state
|
|
94
|
+
queue.put_nowait(event)
|
|
95
|
+
subscribers.add(queue)
|
|
96
|
+
|
|
97
|
+
async def stream():
|
|
98
|
+
try:
|
|
99
|
+
yield "retry: 2000\n\n"
|
|
100
|
+
while True:
|
|
101
|
+
try:
|
|
102
|
+
event = await asyncio.wait_for(queue.get(), timeout=15)
|
|
103
|
+
yield f"data: {json.dumps(event)}\n\n"
|
|
104
|
+
except asyncio.TimeoutError:
|
|
105
|
+
yield ": ping\n\n"
|
|
106
|
+
finally:
|
|
107
|
+
subscribers.discard(queue)
|
|
108
|
+
|
|
109
|
+
return StreamingResponse(stream(), media_type="text/event-stream")
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
app.mount("/", StaticFiles(directory=Path(__file__).parent / "static", html=True), name="static")
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// City backdrop on the main page: fixed view, grows from live events via cityHandle().
|
|
2
|
+
const cityCanvas = document.getElementById('citybg');
|
|
3
|
+
const cityCtx = cityCanvas.getContext('2d');
|
|
4
|
+
const cityCam = { ox: 0, oy: 0, s: 1 };
|
|
5
|
+
let cityState = null;
|
|
6
|
+
|
|
7
|
+
fetch('city-data.json').then(r => r.json()).then(data => {
|
|
8
|
+
cityState = City.layout(data);
|
|
9
|
+
City.fit(cityCam, cityCanvas, cityState, 115, 30, 1.18);
|
|
10
|
+
requestAnimationFrame(function frame(t) {
|
|
11
|
+
cityCtx.clearRect(0, 0, cityCanvas.width, cityCanvas.height);
|
|
12
|
+
City.draw(cityCtx, cityCam, cityState, t);
|
|
13
|
+
requestAnimationFrame(frame);
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function cityHandle(e) {
|
|
18
|
+
if (cityState) City.applyEvent(cityState, e);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
cityCanvas.addEventListener('mousemove', m => {
|
|
22
|
+
const r = cityCanvas.getBoundingClientRect();
|
|
23
|
+
const hit = cityState && City.pick(cityState,
|
|
24
|
+
(m.clientX - r.left) * (cityCanvas.width / r.width),
|
|
25
|
+
(m.clientY - r.top) * (cityCanvas.height / r.height));
|
|
26
|
+
const tip = document.getElementById('tooltip');
|
|
27
|
+
if (hit) {
|
|
28
|
+
tip.textContent = `${hit.path} · ${hit.floors} fl · ${hit.commits} commits`
|
|
29
|
+
+ `${hit.scaffold ? ' · under construction' : ''}`;
|
|
30
|
+
tip.style.left = `${m.clientX + 14}px`;
|
|
31
|
+
tip.style.top = `${m.clientY + 14}px`;
|
|
32
|
+
tip.style.display = 'block';
|
|
33
|
+
} else tip.style.display = 'none';
|
|
34
|
+
});
|
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
// Botapest City lib — strip-packed layout from zoning + git seed, iso drawing, live growth.
|
|
2
|
+
// Pages talk to the `City` object; CityScape (loaded after) draws ground/water/props.
|
|
3
|
+
const City = (() => {
|
|
4
|
+
const HW = 26, HH = 13, FLOOR = 14;
|
|
5
|
+
const proj = (cam, x, y) => ({ sx: cam.ox + (x - y) * HW * cam.s, sy: cam.oy + (x + y) * HH * cam.s });
|
|
6
|
+
const lift = (p, h) => ({ sx: p.sx, sy: p.sy - h });
|
|
7
|
+
const hash = s => [...s].reduce((a, c) => (a * 31 + c.charCodeAt(0)) >>> 0, 7);
|
|
8
|
+
const depth = (a, b) => (a.x + a.y) - (b.x + b.y);
|
|
9
|
+
|
|
10
|
+
function shade(hex, f) {
|
|
11
|
+
const n = parseInt(hex.slice(1), 16);
|
|
12
|
+
const c = v => Math.round(Math.min(255, v * f));
|
|
13
|
+
return `rgb(${c(n >> 16)},${c((n >> 8) & 255)},${c(n & 255)})`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function mix(hexA, hexB, k) {
|
|
17
|
+
const a = parseInt(hexA.slice(1), 16), b = parseInt(hexB.slice(1), 16);
|
|
18
|
+
const ch = sh => Math.round(((a >> sh) & 255) * (1 - k) + ((b >> sh) & 255) * k);
|
|
19
|
+
return `rgb(${ch(16)},${ch(8)},${ch(0)})`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function quad(ctx, a, b, c, d, fill) {
|
|
23
|
+
ctx.fillStyle = fill;
|
|
24
|
+
ctx.beginPath();
|
|
25
|
+
ctx.moveTo(a.sx, a.sy); ctx.lineTo(b.sx, b.sy); ctx.lineTo(c.sx, c.sy); ctx.lineTo(d.sx, d.sy);
|
|
26
|
+
ctx.closePath();
|
|
27
|
+
ctx.fill();
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---- layout: pack districts into full-width layer strips, lots back-to-front ----
|
|
31
|
+
function layout(data) {
|
|
32
|
+
const state = { zone: data.zone, blocks: [], buildings: [], props: [],
|
|
33
|
+
byPath: new Map(), items: [], clouds: [], cityHall: null };
|
|
34
|
+
const byComp = {};
|
|
35
|
+
for (const b of data.buildings) (byComp[b.component] ??= []).push(b);
|
|
36
|
+
|
|
37
|
+
let W = Math.min(80, Math.max(24, Math.round(1.9 * Math.sqrt(data.buildings.length + 30))));
|
|
38
|
+
const strips = [];
|
|
39
|
+
for (const layer of data.zone.layers) {
|
|
40
|
+
const comps = data.zone.components.filter(c => c.layer === layer);
|
|
41
|
+
if (!comps.length) continue;
|
|
42
|
+
const lists = comps.map(c => byComp[c.id] || []);
|
|
43
|
+
const need = comps.map((c, i) => c.kind === 'civic' ? 12 : Math.max(1, lists[i].length));
|
|
44
|
+
const total = need.reduce((a, b) => a + b, 0);
|
|
45
|
+
const streets = comps.length - 1;
|
|
46
|
+
const rows = Math.max(2, Math.ceil(total / (W - streets - 2)));
|
|
47
|
+
const widths = need.map(n => Math.max(Math.ceil(n / rows),
|
|
48
|
+
Math.min(Math.ceil(n / rows) + 1, Math.round((W - streets) * n / total))));
|
|
49
|
+
strips.push({ comps, lists, rows, widths, used: widths.reduce((a, b) => a + b, 0) + streets });
|
|
50
|
+
}
|
|
51
|
+
W = Math.max(W, ...strips.map(s => s.used));
|
|
52
|
+
|
|
53
|
+
let y = 0;
|
|
54
|
+
for (const strip of strips) {
|
|
55
|
+
let x = Math.floor((W - strip.used) / 2);
|
|
56
|
+
strip.comps.forEach((comp, i) => {
|
|
57
|
+
const block = { comp, list: strip.lists[i], x0: x, y0: y,
|
|
58
|
+
cols: strip.widths[i], rows: strip.rows, lots: [], next: 0,
|
|
59
|
+
pave: [mix('#57455a', comp.color, .22), mix('#4d3d50', comp.color, .22)] };
|
|
60
|
+
for (let r = 0; r < block.rows; r++)
|
|
61
|
+
for (let c = 0; c < block.cols; c++)
|
|
62
|
+
block.lots.push({ x: x + c + .5, y: y + r + .5 });
|
|
63
|
+
state.blocks.push(block);
|
|
64
|
+
x += strip.widths[i] + 1;
|
|
65
|
+
});
|
|
66
|
+
y += strip.rows + 1; // avenue after each strip
|
|
67
|
+
}
|
|
68
|
+
state.W = W;
|
|
69
|
+
state.H = y;
|
|
70
|
+
buildGround(state);
|
|
71
|
+
for (const block of state.blocks) fillBlock(state, block);
|
|
72
|
+
state.items = [...state.buildings, ...state.props].sort(depth);
|
|
73
|
+
state.clouds = data.zone.clouds.map((c, i) => ({ name: c.name, tether: c.tether, band: 60 + (i % 2) * 52 }));
|
|
74
|
+
return state;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// codes: 0 street, 1 avenue, 2 green, 3 plaza, 10+i district pavement
|
|
78
|
+
function buildGround(state) {
|
|
79
|
+
const g = Array.from({ length: state.H }, () => new Array(state.W).fill(2));
|
|
80
|
+
state.blocks.forEach((block, i) => {
|
|
81
|
+
for (let r = 0; r < block.rows; r++) {
|
|
82
|
+
for (let c = 0; c < block.cols; c++)
|
|
83
|
+
g[block.y0 + r][block.x0 + c] = block.comp.kind === 'civic' ? 3 : 10 + i;
|
|
84
|
+
if (block.x0 + block.cols < state.W) g[block.y0 + r][block.x0 + block.cols] = 0;
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
for (const block of state.blocks) // avenue row under each strip
|
|
88
|
+
for (let x = 0; x < state.W; x++) g[block.y0 + block.rows][x] = 1;
|
|
89
|
+
g.forEach((row, y) => row.forEach((code, x) => {
|
|
90
|
+
const h = hash(`g${x},${y}`);
|
|
91
|
+
if (code === 2 && h % 3 === 0) state.props.push({ kind: 'tree', x: x + .5, y: y + .5, seed: h });
|
|
92
|
+
if (code === 1 && x % 6 === 3 && h % 2) state.props.push({ kind: 'lamp', x: x + .5, y: y + .6, seed: h });
|
|
93
|
+
}));
|
|
94
|
+
state.ground = g;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function fillBlock(state, block) {
|
|
98
|
+
const under = block.comp.layer === 'under';
|
|
99
|
+
for (const b of block.list)
|
|
100
|
+
b.floors = under ? 1 : 1 + b.centrality + (b.commits >= 12 ? 1 : 0);
|
|
101
|
+
block.list.sort((a, b) => b.floors - a.floors); // towers at the strip's back
|
|
102
|
+
block.list.forEach((b, i) => place(state, block, b, i));
|
|
103
|
+
block.next = block.list.length;
|
|
104
|
+
for (let i = block.next; i < block.lots.length; i++) { // leftover lots get dressing
|
|
105
|
+
const h = hash(block.comp.id + i);
|
|
106
|
+
const kind = under ? (h % 3 ? 'car' : 'crates')
|
|
107
|
+
: h % 4 === 0 ? 'tree' : h % 4 === 1 ? 'car' : null;
|
|
108
|
+
if (kind) state.props.push({ kind, ...block.lots[i], seed: h, block, lot: i });
|
|
109
|
+
}
|
|
110
|
+
if (block.comp.kind === 'civic')
|
|
111
|
+
state.cityHall = { x: block.x0 + block.cols / 2, y: block.y0 + block.rows / 2 };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function place(state, block, b, i) {
|
|
115
|
+
const under = block.comp.layer === 'under';
|
|
116
|
+
const lot = block.lots[i];
|
|
117
|
+
b.x = lot.x;
|
|
118
|
+
b.y = lot.y;
|
|
119
|
+
b.color = block.comp.color;
|
|
120
|
+
b.heightScale = under ? .35 : 1;
|
|
121
|
+
b.foot = b.floors === 1 && !under ? .96 // minnows join into terraces
|
|
122
|
+
: .55 + .38 * Math.min(1, Math.log10(b.loc + 1) / 4);
|
|
123
|
+
b.lit ??= Math.max(0, 1 - b.age_days / 240);
|
|
124
|
+
state.buildings.push(b);
|
|
125
|
+
state.byPath.set(b.path, b);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const matches = (path, g) =>
|
|
129
|
+
g.startsWith('*') ? path.endsWith(g.slice(1)) : g.includes('*') ? path.startsWith(g.split('*')[0]) : path === g;
|
|
130
|
+
|
|
131
|
+
function addBuilding(state, path) {
|
|
132
|
+
const comp = state.zone.components.find(c => c.globs.some(g => matches(path, g)));
|
|
133
|
+
const block = state.blocks.find(bl => bl.comp === comp);
|
|
134
|
+
if (!block || block.next >= block.lots.length) return null;
|
|
135
|
+
const b = { path, component: comp.id, loc: 30, commits: 0, centrality: 0, age_days: 0,
|
|
136
|
+
files: 1, floors: 1, lit: 1, born: performance.now() };
|
|
137
|
+
const i = block.next++;
|
|
138
|
+
const prop = state.props.find(p => p.block === block && p.lot === i);
|
|
139
|
+
if (prop) prop.hidden = true;
|
|
140
|
+
place(state, block, b, i);
|
|
141
|
+
state.items.push(b);
|
|
142
|
+
state.items.sort(depth);
|
|
143
|
+
return b;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function applyEvent(state, e) {
|
|
147
|
+
if (e.commit) {
|
|
148
|
+
for (const b of state.buildings)
|
|
149
|
+
if (b.scaffold) { b.scaffold = false; b.floors = Math.min(b.floors + 1, 14); b.flash = performance.now(); }
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
if (!e.path) return;
|
|
153
|
+
let b = state.byPath.get(e.path);
|
|
154
|
+
if (!b && ['Edit', 'Write', 'NotebookEdit'].includes(e.tool)) b = addBuilding(state, e.path);
|
|
155
|
+
if (!b) return;
|
|
156
|
+
b.lit = 1;
|
|
157
|
+
if (['Edit', 'Write', 'NotebookEdit'].includes(e.tool)) b.scaffold = true;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function fit(cam, canvas, state, reserve = 210, bias = 55, overscan = 1) {
|
|
161
|
+
cam.s = overscan * Math.min((canvas.width - 40) / ((state.W + state.H) * HW),
|
|
162
|
+
(canvas.height - reserve) / ((state.W + state.H) * HH));
|
|
163
|
+
cam.ox = 0; cam.oy = 0;
|
|
164
|
+
const center = proj(cam, state.W / 2, state.H / 2);
|
|
165
|
+
cam.ox = canvas.width / 2 - center.sx;
|
|
166
|
+
cam.oy = canvas.height / 2 + bias - center.sy;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// ---- buildings ----
|
|
170
|
+
function drawBuilding(ctx, cam, b, t) {
|
|
171
|
+
const pop = b.born ? Math.min(1, (t - b.born) / 600) : 1;
|
|
172
|
+
const f = b.foot * pop / 2;
|
|
173
|
+
const base = [proj(cam, b.x - f, b.y - f), proj(cam, b.x + f, b.y - f),
|
|
174
|
+
proj(cam, b.x + f, b.y + f), proj(cam, b.x - f, b.y + f)];
|
|
175
|
+
const h = b.floors * FLOOR * b.heightScale * pop * cam.s;
|
|
176
|
+
const top = base.map(p => lift(p, h));
|
|
177
|
+
quad(ctx, base[3], base[2], top[2], top[3], shade(b.color, .55));
|
|
178
|
+
quad(ctx, base[2], base[1], top[1], top[2], shade(b.color, .75));
|
|
179
|
+
quad(ctx, top[0], top[1], top[2], top[3], shade(b.color, 1.05));
|
|
180
|
+
if (h > 14 * cam.s) drawWindows(ctx, cam, base, h, b, t);
|
|
181
|
+
if (pop === 1) roofProps(ctx, cam, b, top, t);
|
|
182
|
+
if (b.scaffold) drawScaffold(ctx, cam, base, h);
|
|
183
|
+
if (b.flash && t - b.flash < 1500) drawFlash(ctx, cam, top, (t - b.flash) / 1500);
|
|
184
|
+
b.screen = { sx: (base[1].sx + base[3].sx) / 2, top: top[2].sy - 4,
|
|
185
|
+
x0: base[3].sx, x1: base[1].sx, y0: top[2].sy, y1: base[2].sy };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function drawWindows(ctx, cam, base, h, b, t) {
|
|
189
|
+
const seed = hash(b.path);
|
|
190
|
+
const glow = Math.max(b.lit, (seed % 10 < 3 ? .35 : 0) + Math.sin(t / 900 + seed) * .04);
|
|
191
|
+
for (let k = 0; k < b.floors; k++) {
|
|
192
|
+
const y = -((k + .55) / b.floors) * h;
|
|
193
|
+
for (const [a, c, off] of [[base[3], base[2], 0], [base[2], base[1], b.floors]]) {
|
|
194
|
+
const lit = glow > 0 && (seed >> (k + off)) % 3 !== 0;
|
|
195
|
+
ctx.fillStyle = lit ? `rgba(255,214,120,${Math.min(1, .25 + glow)})` : 'rgba(20,12,24,.55)';
|
|
196
|
+
for (const u of [.3, .65]) {
|
|
197
|
+
const x = a.sx + (c.sx - a.sx) * u, yy = a.sy + (c.sy - a.sy) * u + y;
|
|
198
|
+
ctx.fillRect(x - 2 * cam.s, yy, 4 * cam.s, 5 * cam.s);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function roofProps(ctx, cam, b, top, t) {
|
|
205
|
+
const s = cam.s, seed = hash(b.path);
|
|
206
|
+
const cx = (top[1].sx + top[3].sx) / 2, cy = (top[0].sy + top[2].sy) / 2;
|
|
207
|
+
if (b.floors >= 6 && seed % 2) {
|
|
208
|
+
ctx.strokeStyle = '#1a0a16';
|
|
209
|
+
ctx.lineWidth = Math.max(1, 1.2 * s);
|
|
210
|
+
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx, cy - 13 * s); ctx.stroke();
|
|
211
|
+
ctx.fillStyle = `rgba(255,90,90,${.45 + .4 * Math.sin(t / 420 + seed)})`;
|
|
212
|
+
ctx.fillRect(cx - 1.5 * s, cy - 16 * s, 3 * s, 3 * s);
|
|
213
|
+
} else if (b.floors >= 3 && seed % 3 === 0) {
|
|
214
|
+
ctx.fillStyle = '#4a3a4e';
|
|
215
|
+
ctx.fillRect(cx - 4 * s, cy - 8 * s, 8 * s, 8 * s);
|
|
216
|
+
ctx.fillStyle = '#5d4a61';
|
|
217
|
+
ctx.beginPath(); ctx.ellipse(cx, cy - 8 * s, 4 * s, 2 * s, 0, 0, Math.PI * 2); ctx.fill();
|
|
218
|
+
} else if (b.floors >= 2 && seed % 5 === 0) {
|
|
219
|
+
ctx.fillStyle = '#8a8f98';
|
|
220
|
+
ctx.fillRect(cx - 3 * s, cy - 4 * s, 6 * s, 4 * s);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function drawScaffold(ctx, cam, base, h) {
|
|
225
|
+
ctx.strokeStyle = '#c98f4a';
|
|
226
|
+
ctx.lineWidth = Math.max(1, 1.5 * cam.s);
|
|
227
|
+
const up = h + 7 * cam.s;
|
|
228
|
+
ctx.beginPath();
|
|
229
|
+
for (const p of base) { ctx.moveTo(p.sx, p.sy); ctx.lineTo(p.sx, p.sy - up); }
|
|
230
|
+
const tops = base.map(p => lift(p, up));
|
|
231
|
+
ctx.moveTo(tops[0].sx, tops[0].sy);
|
|
232
|
+
for (const p of [...tops.slice(1), tops[0]]) ctx.lineTo(p.sx, p.sy);
|
|
233
|
+
ctx.stroke();
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function drawFlash(ctx, cam, top, k) {
|
|
237
|
+
const cx = (top[1].sx + top[3].sx) / 2, cy = (top[0].sy + top[2].sy) / 2;
|
|
238
|
+
ctx.globalAlpha = 1 - k;
|
|
239
|
+
ctx.strokeStyle = '#ffd678';
|
|
240
|
+
ctx.lineWidth = 2;
|
|
241
|
+
ctx.beginPath();
|
|
242
|
+
ctx.ellipse(cx, cy, (12 + 36 * k) * cam.s, (6 + 18 * k) * cam.s, 0, 0, Math.PI * 2);
|
|
243
|
+
ctx.stroke();
|
|
244
|
+
ctx.globalAlpha = 1;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function drawCityHall(ctx, cam, x, y) {
|
|
248
|
+
const c = proj(cam, x, y), s = cam.s;
|
|
249
|
+
const w = 64 * s, h = 54 * s, base = c.sy + 14 * s;
|
|
250
|
+
ctx.fillStyle = '#b08c3e';
|
|
251
|
+
ctx.fillRect(c.sx - w / 2 - 8 * s, base - 6 * s, w + 16 * s, 8 * s);
|
|
252
|
+
ctx.fillStyle = '#d4a953';
|
|
253
|
+
ctx.fillRect(c.sx - w / 2, base - h, w, h - 6 * s);
|
|
254
|
+
ctx.fillStyle = '#f9efe3';
|
|
255
|
+
for (let i = 0; i < 5; i++)
|
|
256
|
+
ctx.fillRect(c.sx - w / 2 + (4 + i * 13) * s, base - h + 14 * s, 5 * s, h - 24 * s);
|
|
257
|
+
ctx.fillStyle = '#b08c3e';
|
|
258
|
+
ctx.beginPath();
|
|
259
|
+
ctx.moveTo(c.sx - w / 2 - 6 * s, base - h + 14 * s);
|
|
260
|
+
ctx.lineTo(c.sx, base - h - 16 * s);
|
|
261
|
+
ctx.lineTo(c.sx + w / 2 + 6 * s, base - h + 14 * s);
|
|
262
|
+
ctx.closePath(); ctx.fill();
|
|
263
|
+
ctx.strokeStyle = '#5a2c4d'; ctx.lineWidth = 1; ctx.stroke();
|
|
264
|
+
ctx.fillStyle = '#c0395b';
|
|
265
|
+
ctx.fillRect(c.sx - 1.5 * s, base - h - 34 * s, 3 * s, 20 * s);
|
|
266
|
+
ctx.fillRect(c.sx + 1.5 * s, base - h - 34 * s, 12 * s, 7 * s);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function drawCloud(ctx, cam, cloud, t) {
|
|
270
|
+
const bob = Math.sin(t / 2400 + hash(cloud.name)) * 6 * cam.s;
|
|
271
|
+
const x = cloud.sx + bob, sy = cloud.sy, s = cam.s;
|
|
272
|
+
ctx.strokeStyle = 'rgba(249,239,227,.35)';
|
|
273
|
+
ctx.setLineDash([3, 5]);
|
|
274
|
+
ctx.beginPath(); ctx.moveTo(x, sy + 14 * s); ctx.lineTo(cloud.ax, cloud.ay); ctx.stroke();
|
|
275
|
+
ctx.setLineDash([]);
|
|
276
|
+
ctx.fillStyle = '#f9efe3';
|
|
277
|
+
for (const [dx, dy, w, h] of [[-30, -8, 60, 16], [-18, -18, 36, 14], [-6, 2, 42, 12]])
|
|
278
|
+
ctx.fillRect(x + dx * s, sy + dy * s, w * s, h * s);
|
|
279
|
+
ctx.font = `bold ${Math.max(8, 9 * s)}px Silkscreen, monospace`;
|
|
280
|
+
ctx.textAlign = 'center';
|
|
281
|
+
ctx.fillText(cloud.name, x, sy - 24 * s);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function draw(ctx, cam, state, t) {
|
|
285
|
+
CityScape.drawHorizon(ctx, cam, state, t);
|
|
286
|
+
CityScape.drawWater(ctx, cam, state, t);
|
|
287
|
+
CityScape.drawGround(ctx, cam, state, t);
|
|
288
|
+
for (const it of state.items) {
|
|
289
|
+
if (it.kind) { if (!it.hidden) CityScape.drawProp(ctx, cam, it, t); }
|
|
290
|
+
else drawBuilding(ctx, cam, it, t);
|
|
291
|
+
}
|
|
292
|
+
if (state.cityHall) drawCityHall(ctx, cam, state.cityHall.x, state.cityHall.y);
|
|
293
|
+
ctx.fillStyle = 'rgba(243,207,217,.6)';
|
|
294
|
+
for (const block of state.blocks) {
|
|
295
|
+
const p = proj(cam, block.x0 + block.cols / 2, block.y0 + block.rows + .55);
|
|
296
|
+
ctx.font = `${Math.max(7, 8 * cam.s)}px Silkscreen, monospace`;
|
|
297
|
+
ctx.textAlign = 'center';
|
|
298
|
+
ctx.fillText(block.comp.name.toUpperCase(), p.sx, p.sy);
|
|
299
|
+
}
|
|
300
|
+
for (const c of state.clouds) {
|
|
301
|
+
const block = state.blocks.find(bl => bl.comp.id === c.tether);
|
|
302
|
+
const a = proj(cam, block.x0 + block.cols / 2, block.y0 + block.rows / 2);
|
|
303
|
+
c.sx = a.sx; c.ax = a.sx; c.ay = a.sy;
|
|
304
|
+
c.sy = Math.min(c.band * Math.max(.8, cam.s), a.sy - 120 * cam.s);
|
|
305
|
+
drawCloud(ctx, cam, c, t);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function pick(state, mx, my) {
|
|
310
|
+
let hit = null;
|
|
311
|
+
for (const b of state.buildings)
|
|
312
|
+
if (b.screen && mx > b.screen.x0 && mx < b.screen.x1 && my > b.screen.y0 && my < b.screen.y1)
|
|
313
|
+
hit = b;
|
|
314
|
+
return hit;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
return { layout, fit, draw, applyEvent, pick, proj, hash, shade, mix };
|
|
318
|
+
})();
|