Spaces:
Running
Running
Update app.py
Browse files
app.py
CHANGED
|
@@ -1,35 +1,30 @@
|
|
| 1 |
import math
|
| 2 |
import random
|
|
|
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
import gradio as gr
|
| 5 |
|
| 6 |
# ============================================================
|
| 7 |
# RFT Predator Space — First-Person Observer View (Pseudo-3D)
|
| 8 |
-
#
|
| 9 |
-
#
|
| 10 |
-
#
|
|
|
|
|
|
|
| 11 |
# ============================================================
|
| 12 |
|
| 13 |
-
# -----------------------------
|
| 14 |
-
# World config (grid)
|
| 15 |
-
# -----------------------------
|
| 16 |
-
GRID_W, GRID_H = 23, 23
|
| 17 |
-
OBSTACLE_P = 0.13
|
| 18 |
-
|
| 19 |
# -----------------------------
|
| 20 |
# View config (render)
|
| 21 |
# -----------------------------
|
| 22 |
-
VIEW_W, VIEW_H = 560, 360
|
| 23 |
-
RAY_W = 280
|
| 24 |
FOV_DEG = 78
|
| 25 |
MAX_DEPTH = 18
|
| 26 |
|
| 27 |
-
# Movement
|
| 28 |
-
TURN_DEG = 20
|
| 29 |
MOVE_STEP = 1
|
| 30 |
AUTO_TICK_HZ = 8
|
| 31 |
|
| 32 |
-
# Colors
|
| 33 |
SKY = np.array([14, 16, 26], dtype=np.uint8)
|
| 34 |
FLOOR_NEAR = np.array([20, 22, 34], dtype=np.uint8)
|
| 35 |
FLOOR_FAR = np.array([10, 11, 18], dtype=np.uint8)
|
|
@@ -37,76 +32,388 @@ FLOOR_FAR = np.array([10, 11, 18], dtype=np.uint8)
|
|
| 37 |
WALL_BASE = np.array([210, 210, 225], dtype=np.uint8)
|
| 38 |
WALL_SIDE = np.array([150, 150, 170], dtype=np.uint8)
|
| 39 |
|
| 40 |
-
|
| 41 |
RETICLE = np.array([120, 190, 255], dtype=np.uint8)
|
| 42 |
|
| 43 |
# 0=E,1=S,2=W,3=N
|
| 44 |
DIRS = [(1,0),(0,1),(-1,0),(0,-1)]
|
| 45 |
ORI_DEG = [0, 90, 180, 270]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
def clamp(x, lo, hi):
|
| 48 |
return lo if x < lo else hi if x > hi else x
|
| 49 |
|
| 50 |
-
def wrap_angle_deg(a):
|
| 51 |
-
a = a % 360.0
|
| 52 |
-
if a < 0: a += 360.0
|
| 53 |
-
return a
|
| 54 |
-
|
| 55 |
def angle_diff_rad(a, b):
|
| 56 |
-
|
| 57 |
-
d = (a - b + math.pi) % (2*math.pi) - math.pi
|
| 58 |
-
return d
|
| 59 |
|
| 60 |
def seeded_rng(seed: int):
|
| 61 |
return random.Random(int(seed) & 0xFFFFFFFF)
|
| 62 |
|
| 63 |
-
def
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
grid[0, :] = 1
|
| 69 |
-
grid[
|
| 70 |
grid[:, 0] = 1
|
| 71 |
-
grid[:,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
grid[y, x] = 1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
st = {
|
| 95 |
"seed": int(seed),
|
| 96 |
"grid": grid,
|
|
|
|
| 97 |
"pred": pred,
|
| 98 |
"prey": prey,
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
"step": 0,
|
| 101 |
"caught": False,
|
| 102 |
"auto_chase": False,
|
| 103 |
"auto_run": False,
|
| 104 |
-
|
|
|
|
|
|
|
|
|
|
| 105 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
return st
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
def los_clear(grid, a, b):
|
| 109 |
-
# Grid LOS using DDA in continuous space (cell centers)
|
| 110 |
ax, ay = a[0] + 0.5, a[1] + 0.5
|
| 111 |
bx, by = b[0] + 0.5, b[1] + 0.5
|
| 112 |
dx, dy = bx - ax, by - ay
|
|
@@ -117,23 +424,20 @@ def los_clear(grid, a, b):
|
|
| 117 |
dy /= dist
|
| 118 |
|
| 119 |
x, y = ax, ay
|
| 120 |
-
steps = int(dist * 20)
|
|
|
|
| 121 |
for _ in range(steps):
|
| 122 |
x += dx * (dist / steps)
|
| 123 |
y += dy * (dist / steps)
|
| 124 |
cx, cy = int(x), int(y)
|
| 125 |
-
cx = clamp(cx, 0,
|
| 126 |
-
cy = clamp(cy, 0,
|
| 127 |
if grid[cy, cx] == 1:
|
| 128 |
return False
|
| 129 |
return True
|
| 130 |
|
| 131 |
def dda_raycast(grid, px, py, ray_dx, ray_dy, max_depth=MAX_DEPTH):
|
| 132 |
-
|
| 133 |
-
DDA raycast in grid.
|
| 134 |
-
Returns: (hit_dist, hit_side, hit_cell_x, hit_cell_y)
|
| 135 |
-
hit_side: 0 if hit vertical wall, 1 if hit horizontal wall (used for shading)
|
| 136 |
-
"""
|
| 137 |
map_x = int(px)
|
| 138 |
map_y = int(py)
|
| 139 |
|
|
@@ -156,7 +460,7 @@ def dda_raycast(grid, px, py, ray_dx, ray_dy, max_depth=MAX_DEPTH):
|
|
| 156 |
|
| 157 |
hit = False
|
| 158 |
side = 0
|
| 159 |
-
for _ in range(max_depth *
|
| 160 |
if side_dist_x < side_dist_y:
|
| 161 |
side_dist_x += delta_dist_x
|
| 162 |
map_x += step_x
|
|
@@ -166,7 +470,7 @@ def dda_raycast(grid, px, py, ray_dx, ray_dy, max_depth=MAX_DEPTH):
|
|
| 166 |
map_y += step_y
|
| 167 |
side = 1
|
| 168 |
|
| 169 |
-
if map_x < 0 or map_x >=
|
| 170 |
break
|
| 171 |
if grid[map_y, map_x] == 1:
|
| 172 |
hit = True
|
|
@@ -175,7 +479,6 @@ def dda_raycast(grid, px, py, ray_dx, ray_dy, max_depth=MAX_DEPTH):
|
|
| 175 |
if not hit:
|
| 176 |
return max_depth, 0, map_x, map_y
|
| 177 |
|
| 178 |
-
# perpendicular distance (avoid fisheye by using projection)
|
| 179 |
if side == 0:
|
| 180 |
denom = ray_dx if abs(ray_dx) > 1e-9 else 1e-9
|
| 181 |
perp = (map_x - px + (1 - step_x) / 2) / denom
|
|
@@ -187,29 +490,71 @@ def dda_raycast(grid, px, py, ray_dx, ray_dy, max_depth=MAX_DEPTH):
|
|
| 187 |
perp = clamp(perp, 0.0005, max_depth)
|
| 188 |
return perp, side, map_x, map_y
|
| 189 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
def render_first_person(st):
|
| 191 |
grid = st["grid"]
|
| 192 |
-
(cx, cy) = st["pred"]
|
| 193 |
-
ori = st["ori"]
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
px = cx + 0.5
|
| 196 |
py = cy + 0.5
|
| 197 |
|
| 198 |
fov = math.radians(FOV_DEG)
|
| 199 |
-
base = math.radians(ORI_DEG[
|
| 200 |
|
| 201 |
img = np.zeros((VIEW_H, VIEW_W, 3), dtype=np.uint8)
|
| 202 |
-
|
| 203 |
-
# sky
|
| 204 |
img[:VIEW_H//2, :, :] = SKY
|
| 205 |
|
| 206 |
-
# floor gradient
|
| 207 |
for y in range(VIEW_H//2, VIEW_H):
|
| 208 |
t = (y - VIEW_H//2) / max(1, (VIEW_H//2 - 1))
|
| 209 |
col = (FLOOR_NEAR * (1 - t) + FLOOR_FAR * t).astype(np.uint8)
|
| 210 |
img[y, :, :] = col
|
| 211 |
|
| 212 |
-
# Raycast at lower resolution then upscale to VIEW_W
|
| 213 |
wall_dists = np.full(RAY_W, MAX_DEPTH, dtype=np.float32)
|
| 214 |
|
| 215 |
for x in range(RAY_W):
|
|
@@ -219,163 +564,224 @@ def render_first_person(st):
|
|
| 219 |
ray_dy = math.sin(ang)
|
| 220 |
|
| 221 |
dist, side, hitx, hity = dda_raycast(grid, px, py, ray_dx, ray_dy, MAX_DEPTH)
|
| 222 |
-
# remove fisheye: project on camera direction
|
| 223 |
dist *= math.cos(ang - base)
|
| 224 |
dist = clamp(dist, 0.001, MAX_DEPTH)
|
| 225 |
wall_dists[x] = dist
|
| 226 |
|
| 227 |
-
# wall slice height
|
| 228 |
slice_h = int((VIEW_H * 0.92) / dist)
|
| 229 |
slice_h = clamp(slice_h, 1, VIEW_H)
|
| 230 |
top = (VIEW_H - slice_h) // 2
|
| 231 |
bot = top + slice_h
|
| 232 |
|
| 233 |
-
# shading: distance + side shading
|
| 234 |
shade = 1.0 / (1.0 + dist * 0.12)
|
| 235 |
shade = clamp(shade, 0.12, 1.0)
|
| 236 |
-
|
| 237 |
base_col = WALL_SIDE if side == 1 else WALL_BASE
|
| 238 |
-
|
| 239 |
-
# cheap "texture" pattern by hit cell coords
|
| 240 |
checker = ((hitx + hity) & 1)
|
| 241 |
tex = 0.90 if checker == 0 else 1.05
|
| 242 |
-
|
| 243 |
col = np.clip(base_col.astype(np.float32) * shade * tex, 0, 255).astype(np.uint8)
|
| 244 |
|
| 245 |
-
# draw vertical stripe into upscaled coordinates later
|
| 246 |
-
# map x from RAY_W -> VIEW_W
|
| 247 |
x0 = int(x * VIEW_W / RAY_W)
|
| 248 |
x1 = int((x + 1) * VIEW_W / RAY_W)
|
| 249 |
if x1 <= x0:
|
| 250 |
x1 = x0 + 1
|
| 251 |
img[top:bot, x0:x1, :] = col
|
| 252 |
|
| 253 |
-
#
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
# Convert view columns -> ray columns
|
| 288 |
-
for vxcol in range(x0, x1):
|
| 289 |
-
rx = int(vxcol * RAY_W / VIEW_W)
|
| 290 |
-
rx = clamp(rx, 0, RAY_W - 1)
|
| 291 |
-
if prey_dist < wall_dists[rx]:
|
| 292 |
-
img[y0:y1, vxcol:vxcol+1, :] = PREY_COLOR
|
| 293 |
-
|
| 294 |
-
# Reticle (crosshair)
|
| 295 |
cxh, cyh = VIEW_W // 2, VIEW_H // 2
|
| 296 |
img[cyh-1:cyh+2, cxh-12:cxh+13, :] = RETICLE
|
| 297 |
img[cyh-12:cyh+13, cxh-1:cxh+2, :] = RETICLE
|
| 298 |
|
| 299 |
-
# HUD strip
|
| 300 |
hud_h = 26
|
| 301 |
img[:hud_h, :, :] = np.clip(img[:hud_h, :, :].astype(np.int16) + 20, 0, 255).astype(np.uint8)
|
| 302 |
|
| 303 |
-
#
|
| 304 |
-
#
|
|
|
|
|
|
|
| 305 |
def dot(x, y, c):
|
| 306 |
img[y:y+6, x:x+6, :] = c
|
| 307 |
|
| 308 |
dot(8, 10, np.array([90, 255, 140], np.uint8) if st["auto_chase"] else np.array([60, 60, 70], np.uint8))
|
| 309 |
dot(20, 10, np.array([120, 190, 255], np.uint8) if st["auto_run"] else np.array([60, 60, 70], np.uint8))
|
| 310 |
-
dot(32, 10, np.array([255, 140, 90], np.uint8) if
|
|
|
|
|
|
|
|
|
|
|
|
|
| 311 |
|
| 312 |
return img
|
| 313 |
|
| 314 |
def render_minimap(st, scale=14):
|
| 315 |
grid = st["grid"]
|
| 316 |
-
|
| 317 |
-
img = np.zeros((
|
| 318 |
-
|
| 319 |
-
# base
|
| 320 |
img[:, :, :] = np.array([18, 20, 32], dtype=np.uint8)
|
| 321 |
|
| 322 |
-
# walls
|
| 323 |
wall = np.array([220, 220, 235], dtype=np.uint8)
|
| 324 |
-
for y in range(
|
| 325 |
-
for x in range(
|
| 326 |
if grid[y, x] == 1:
|
| 327 |
img[y*scale:(y+1)*scale, x*scale:(x+1)*scale, :] = wall
|
| 328 |
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
prey = st["prey"]
|
| 332 |
-
px, py = pred
|
| 333 |
-
qx, qy = prey
|
| 334 |
|
| 335 |
-
|
| 336 |
-
|
| 337 |
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
|
|
|
| 344 |
img[hy*scale:(hy+1)*scale, hx*scale:(hx+1)*scale, :] = np.array([80, 255, 160], np.uint8)
|
| 345 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 346 |
return img
|
| 347 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 348 |
def status(st):
|
| 349 |
-
|
|
|
|
| 350 |
tail = st["log"][-10:]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
return (
|
| 352 |
-
f"Step: {st['step']} |
|
| 353 |
-
f"
|
|
|
|
| 354 |
+ "\n".join(tail)
|
| 355 |
)
|
| 356 |
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 365 |
else:
|
| 366 |
-
st["
|
|
|
|
| 367 |
|
| 368 |
-
def
|
| 369 |
if st["caught"]:
|
| 370 |
return
|
| 371 |
-
|
|
|
|
|
|
|
|
|
|
| 372 |
|
| 373 |
-
def
|
| 374 |
if st["caught"]:
|
| 375 |
return
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
-
def
|
|
|
|
| 379 |
if st["caught"]:
|
| 380 |
return
|
| 381 |
rng = seeded_rng(st["seed"] + 1337 + st["step"] * 19)
|
|
@@ -389,28 +795,52 @@ def prey_step(st):
|
|
| 389 |
if st["grid"][ny, nx] == 1:
|
| 390 |
continue
|
| 391 |
dist = (nx-ax)**2 + (ny-ay)**2
|
| 392 |
-
scored.append((dist + rng.random()*0.1, (nx, ny)))
|
| 393 |
|
| 394 |
if scored:
|
| 395 |
scored.sort(reverse=True)
|
| 396 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 397 |
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 402 |
|
| 403 |
-
def
|
|
|
|
| 404 |
if st["caught"]:
|
| 405 |
return
|
| 406 |
-
# If prey visible + in FOV, turn toward it; else drift forward avoiding walls.
|
| 407 |
grid = st["grid"]
|
| 408 |
px = st["pred"][0] + 0.5
|
| 409 |
py = st["pred"][1] + 0.5
|
| 410 |
base = math.radians(ORI_DEG[st["ori"]])
|
| 411 |
fov = math.radians(FOV_DEG)
|
| 412 |
-
|
| 413 |
prey = st["prey"]
|
|
|
|
| 414 |
if los_clear(grid, st["pred"], prey):
|
| 415 |
vx = (prey[0] + 0.5) - px
|
| 416 |
vy = (prey[1] + 0.5) - py
|
|
@@ -418,99 +848,196 @@ def auto_chase_policy(st):
|
|
| 418 |
rel = angle_diff_rad(ang, base)
|
| 419 |
if abs(rel) <= fov * 0.5:
|
| 420 |
if rel < -0.10:
|
| 421 |
-
|
| 422 |
elif rel > 0.10:
|
| 423 |
-
|
| 424 |
else:
|
| 425 |
-
|
| 426 |
return
|
| 427 |
|
| 428 |
-
#
|
| 429 |
-
|
| 430 |
-
dx, dy = DIRS[st["ori"]]
|
| 431 |
-
if st["grid"][y+dy, x+dx] == 1:
|
| 432 |
-
if random.random() < 0.5:
|
| 433 |
-
turn_left(st); st["log"].append("AutoChase: avoid left.")
|
| 434 |
-
else:
|
| 435 |
-
turn_right(st); st["log"].append("AutoChase: avoid right.")
|
| 436 |
-
else:
|
| 437 |
-
move_forward(st); st["log"].append("AutoChase: forward roam.")
|
| 438 |
|
| 439 |
def tick(st):
|
| 440 |
if st["caught"]:
|
| 441 |
return
|
|
|
|
| 442 |
st["step"] += 1
|
| 443 |
|
| 444 |
-
|
| 445 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 446 |
|
| 447 |
-
|
| 448 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
|
| 450 |
if st["step"] >= 600:
|
| 451 |
st["caught"] = True
|
| 452 |
st["log"].append("Max steps reached (freeze).")
|
| 453 |
|
| 454 |
# -----------------------------
|
| 455 |
-
# Gradio
|
| 456 |
# -----------------------------
|
| 457 |
-
def
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
tick(st)
|
| 464 |
-
return st, render_first_person(st), render_minimap(st), status(st)
|
| 465 |
|
| 466 |
-
def
|
| 467 |
-
|
|
|
|
|
|
|
| 468 |
tick(st)
|
| 469 |
-
return st, render_first_person(st), render_minimap(st), status(st)
|
| 470 |
|
| 471 |
def ui_forward(st):
|
| 472 |
-
|
| 473 |
-
|
|
|
|
|
|
|
| 474 |
tick(st)
|
| 475 |
-
return st, render_first_person(st), render_minimap(st), status(st)
|
| 476 |
|
| 477 |
def ui_toggle_chase(st):
|
| 478 |
st["auto_chase"] = not st["auto_chase"]
|
| 479 |
st["log"].append(f"AutoChase set to {st['auto_chase']}.")
|
| 480 |
-
|
|
|
|
| 481 |
|
| 482 |
def ui_toggle_run(st):
|
| 483 |
st["auto_run"] = not st["auto_run"]
|
| 484 |
st["log"].append(f"AutoRun set to {st['auto_run']}.")
|
| 485 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
|
| 487 |
def ui_tick(st):
|
| 488 |
tick(st)
|
| 489 |
-
return st, render_first_person(st), render_minimap(st), status(st)
|
| 490 |
|
| 491 |
def ui_timer(st):
|
| 492 |
-
# Timer-driven tick when AutoRun is enabled
|
| 493 |
if st["auto_run"] and not st["caught"]:
|
| 494 |
tick(st)
|
| 495 |
-
return st, render_first_person(st), render_minimap(st), status(st)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
# -----------------------------
|
| 498 |
# App
|
| 499 |
# -----------------------------
|
| 500 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
gr.Markdown(
|
| 502 |
"## Experience reality through an RFT observer agent’s perspective\n"
|
| 503 |
-
"
|
| 504 |
-
"**
|
|
|
|
| 505 |
)
|
| 506 |
|
| 507 |
-
st = gr.State(
|
| 508 |
|
| 509 |
with gr.Row():
|
| 510 |
seed = gr.Number(label="Seed", value=1, precision=0)
|
|
|
|
| 511 |
btn_reset = gr.Button("Reset")
|
| 512 |
-
|
| 513 |
-
btn_run = gr.Button("Toggle AutoRun")
|
| 514 |
btn_tick = gr.Button("Tick")
|
| 515 |
|
| 516 |
with gr.Row():
|
|
@@ -518,25 +1045,77 @@ with gr.Blocks(title="RFT Predator Space — First Person Observer") as demo:
|
|
| 518 |
btn_fwd = gr.Button("Forward")
|
| 519 |
btn_right = gr.Button("Turn Right")
|
| 520 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 521 |
with gr.Row():
|
| 522 |
view = gr.Image(label="First-person observer view", type="numpy")
|
| 523 |
mini = gr.Image(label="Minimap (debug)", type="numpy")
|
| 524 |
|
| 525 |
-
|
|
|
|
|
|
|
| 526 |
|
| 527 |
-
|
| 528 |
-
outputs=[st, view, mini, info])
|
| 529 |
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 537 |
|
| 538 |
-
# Timer auto-run (if supported by your Gradio build)
|
| 539 |
if hasattr(gr, "Timer"):
|
| 540 |
-
gr.Timer(1.0 / AUTO_TICK_HZ).tick(ui_timer, inputs=[st], outputs=[st, view, mini, info])
|
| 541 |
|
| 542 |
demo.launch()
|
|
|
|
| 1 |
import math
|
| 2 |
import random
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
import numpy as np
|
| 6 |
import gradio as gr
|
| 7 |
|
| 8 |
# ============================================================
|
| 9 |
# RFT Predator Space — First-Person Observer View (Pseudo-3D)
|
| 10 |
+
# + Unlockable maps
|
| 11 |
+
# + Save/Load (slot + export/import) + slot dropdown auto-lists ./saves/
|
| 12 |
+
# + Hybrid mode: AutoRun ON + AutoChase OFF => predator WANDERS autonomously (while prey flees if not player-controlled)
|
| 13 |
+
# + Toggle control POV/inputs between Predator vs Prey (symmetric observers)
|
| 14 |
+
# + Optional coherence overlay (subtle; off by default)
|
| 15 |
# ============================================================
|
| 16 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
# -----------------------------
|
| 18 |
# View config (render)
|
| 19 |
# -----------------------------
|
| 20 |
+
VIEW_W, VIEW_H = 560, 360
|
| 21 |
+
RAY_W = 280
|
| 22 |
FOV_DEG = 78
|
| 23 |
MAX_DEPTH = 18
|
| 24 |
|
|
|
|
|
|
|
| 25 |
MOVE_STEP = 1
|
| 26 |
AUTO_TICK_HZ = 8
|
| 27 |
|
|
|
|
| 28 |
SKY = np.array([14, 16, 26], dtype=np.uint8)
|
| 29 |
FLOOR_NEAR = np.array([20, 22, 34], dtype=np.uint8)
|
| 30 |
FLOOR_FAR = np.array([10, 11, 18], dtype=np.uint8)
|
|
|
|
| 32 |
WALL_BASE = np.array([210, 210, 225], dtype=np.uint8)
|
| 33 |
WALL_SIDE = np.array([150, 150, 170], dtype=np.uint8)
|
| 34 |
|
| 35 |
+
AGENT_OTHER_COLOR = np.array([255, 140, 90], dtype=np.uint8) # billboard for the "other" observer
|
| 36 |
RETICLE = np.array([120, 190, 255], dtype=np.uint8)
|
| 37 |
|
| 38 |
# 0=E,1=S,2=W,3=N
|
| 39 |
DIRS = [(1,0),(0,1),(-1,0),(0,-1)]
|
| 40 |
ORI_DEG = [0, 90, 180, 270]
|
| 41 |
+
DIR_TO_ORI = {(1,0):0, (0,1):1, (-1,0):2, (0,-1):3}
|
| 42 |
+
|
| 43 |
+
# -----------------------------
|
| 44 |
+
# Progression / unlocks
|
| 45 |
+
# -----------------------------
|
| 46 |
+
MAP_UNLOCKS = [
|
| 47 |
+
("Training Bay", 0),
|
| 48 |
+
("Arena+", 1),
|
| 49 |
+
("Corridor Maze", 3),
|
| 50 |
+
("Rooms", 6),
|
| 51 |
+
("Labyrinth", 10),
|
| 52 |
+
("Dense Field", 15),
|
| 53 |
+
]
|
| 54 |
|
| 55 |
+
# -----------------------------
|
| 56 |
+
# Saves
|
| 57 |
+
# -----------------------------
|
| 58 |
+
SAVE_DIR = "saves"
|
| 59 |
+
os.makedirs(SAVE_DIR, exist_ok=True)
|
| 60 |
+
|
| 61 |
+
def _slot_path(slot: str) -> str:
|
| 62 |
+
slot = (slot or "slot1").strip().replace(" ", "_")
|
| 63 |
+
if not slot:
|
| 64 |
+
slot = "slot1"
|
| 65 |
+
if not slot.lower().endswith(".json"):
|
| 66 |
+
slot += ".json"
|
| 67 |
+
return os.path.join(SAVE_DIR, slot)
|
| 68 |
+
|
| 69 |
+
def list_save_slots():
|
| 70 |
+
try:
|
| 71 |
+
files = []
|
| 72 |
+
for fn in os.listdir(SAVE_DIR):
|
| 73 |
+
if fn.lower().endswith(".json"):
|
| 74 |
+
files.append(fn)
|
| 75 |
+
files.sort()
|
| 76 |
+
return files
|
| 77 |
+
except Exception:
|
| 78 |
+
return []
|
| 79 |
+
|
| 80 |
+
# -----------------------------
|
| 81 |
+
# Utility
|
| 82 |
+
# -----------------------------
|
| 83 |
def clamp(x, lo, hi):
|
| 84 |
return lo if x < lo else hi if x > hi else x
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
def angle_diff_rad(a, b):
|
| 87 |
+
return (a - b + math.pi) % (2*math.pi) - math.pi
|
|
|
|
|
|
|
| 88 |
|
| 89 |
def seeded_rng(seed: int):
|
| 90 |
return random.Random(int(seed) & 0xFFFFFFFF)
|
| 91 |
|
| 92 |
+
def neighbors4(x, y):
|
| 93 |
+
return [(x+1,y),(x-1,y),(x,y+1),(x,y-1)]
|
| 94 |
+
|
| 95 |
+
def bfs_reachable(grid, start):
|
| 96 |
+
H, W = grid.shape
|
| 97 |
+
sx, sy = start
|
| 98 |
+
if grid[sy, sx] == 1:
|
| 99 |
+
return set()
|
| 100 |
+
q = [(sx, sy)]
|
| 101 |
+
seen = set([(sx, sy)])
|
| 102 |
+
while q:
|
| 103 |
+
x, y = q.pop(0)
|
| 104 |
+
for nx, ny in neighbors4(x, y):
|
| 105 |
+
if 0 <= nx < W and 0 <= ny < H and (nx, ny) not in seen and grid[ny, nx] == 0:
|
| 106 |
+
seen.add((nx, ny))
|
| 107 |
+
q.append((nx, ny))
|
| 108 |
+
return seen
|
| 109 |
+
|
| 110 |
+
def pick_spawn_pair(grid, rng, min_dist=8):
|
| 111 |
+
H, W = grid.shape
|
| 112 |
+
empties = [(x, y) for y in range(1, H-1) for x in range(1, W-1) if grid[y, x] == 0]
|
| 113 |
+
rng.shuffle(empties)
|
| 114 |
+
for pred in empties[:800]:
|
| 115 |
+
reach = bfs_reachable(grid, pred)
|
| 116 |
+
if len(reach) < 30:
|
| 117 |
+
continue
|
| 118 |
+
candidates = [p for p in reach if (p[0]-pred[0])**2 + (p[1]-pred[1])**2 >= min_dist*min_dist]
|
| 119 |
+
if candidates:
|
| 120 |
+
prey = rng.choice(candidates)
|
| 121 |
+
return pred, prey
|
| 122 |
+
pred = empties[0] if empties else (1, 1)
|
| 123 |
+
prey = empties[-1] if len(empties) > 1 else (2, 2)
|
| 124 |
+
return pred, prey
|
| 125 |
+
|
| 126 |
+
def add_border_walls(grid):
|
| 127 |
+
H, W = grid.shape
|
| 128 |
grid[0, :] = 1
|
| 129 |
+
grid[H-1, :] = 1
|
| 130 |
grid[:, 0] = 1
|
| 131 |
+
grid[:, W-1] = 1
|
| 132 |
+
return grid
|
| 133 |
+
|
| 134 |
+
def compute_unlocks(catches: int):
|
| 135 |
+
unlocked = set()
|
| 136 |
+
for name, need in MAP_UNLOCKS:
|
| 137 |
+
if catches >= need:
|
| 138 |
+
unlocked.add(name)
|
| 139 |
+
return unlocked
|
| 140 |
+
|
| 141 |
+
# -----------------------------
|
| 142 |
+
# Map generators (deterministic by seed)
|
| 143 |
+
# -----------------------------
|
| 144 |
+
def map_training(seed, w=23, h=23):
|
| 145 |
+
rng = seeded_rng(seed)
|
| 146 |
+
grid = np.zeros((h, w), dtype=np.int8)
|
| 147 |
+
add_border_walls(grid)
|
| 148 |
+
for y in range(2, h-2):
|
| 149 |
+
for x in range(2, w-2):
|
| 150 |
+
if rng.random() < 0.08:
|
| 151 |
+
grid[y, x] = 1
|
| 152 |
+
return grid
|
| 153 |
|
| 154 |
+
def map_arena_plus(seed, w=23, h=23):
|
| 155 |
+
rng = seeded_rng(seed)
|
| 156 |
+
grid = np.zeros((h, w), dtype=np.int8)
|
| 157 |
+
add_border_walls(grid)
|
| 158 |
+
cx, cy = w//2, h//2
|
| 159 |
+
for y in range(1, h-1):
|
| 160 |
+
for x in range(1, w-1):
|
| 161 |
+
r2 = (x-cx)**2 + (y-cy)**2
|
| 162 |
+
if 36 <= r2 <= 44 and rng.random() < 0.85:
|
| 163 |
grid[y, x] = 1
|
| 164 |
+
for _ in range(8):
|
| 165 |
+
x = rng.randint(3, w-4)
|
| 166 |
+
y = rng.randint(3, h-4)
|
| 167 |
+
grid[y, x] = 1
|
| 168 |
+
return grid
|
| 169 |
|
| 170 |
+
def map_corridor_maze(seed, w=23, h=23):
|
| 171 |
+
rng = seeded_rng(seed)
|
| 172 |
+
grid = np.ones((h, w), dtype=np.int8)
|
| 173 |
+
add_border_walls(grid)
|
| 174 |
+
for y in range(1, h-1):
|
| 175 |
+
for x in range(1, w-1):
|
| 176 |
+
if x % 2 == 1 and y % 2 == 1:
|
| 177 |
+
grid[y, x] = 0
|
| 178 |
+
|
| 179 |
+
start = (1, 1)
|
| 180 |
+
stack = [start]
|
| 181 |
+
visited = set([start])
|
| 182 |
+
|
| 183 |
+
def carve_between(a, b):
|
| 184 |
+
ax, ay = a; bx, by = b
|
| 185 |
+
mx, my = (ax+bx)//2, (ay+by)//2
|
| 186 |
+
grid[my, mx] = 0
|
| 187 |
+
|
| 188 |
+
while stack:
|
| 189 |
+
x, y = stack[-1]
|
| 190 |
+
dirs = [(2,0),(-2,0),(0,2),(0,-2)]
|
| 191 |
+
rng.shuffle(dirs)
|
| 192 |
+
moved = False
|
| 193 |
+
for dx, dy in dirs:
|
| 194 |
+
nx, ny = x+dx, y+dy
|
| 195 |
+
if 1 <= nx < w-1 and 1 <= ny < h-1 and (nx, ny) not in visited:
|
| 196 |
+
visited.add((nx, ny))
|
| 197 |
+
carve_between((x, y), (nx, ny))
|
| 198 |
+
stack.append((nx, ny))
|
| 199 |
+
moved = True
|
| 200 |
+
break
|
| 201 |
+
if not moved:
|
| 202 |
+
stack.pop()
|
| 203 |
+
|
| 204 |
+
grid[1,1] = 0
|
| 205 |
+
grid[1,2] = 0
|
| 206 |
+
grid[2,1] = 0
|
| 207 |
+
return grid
|
| 208 |
+
|
| 209 |
+
def map_rooms(seed, w=25, h=25):
|
| 210 |
+
rng = seeded_rng(seed)
|
| 211 |
+
grid = np.ones((h, w), dtype=np.int8)
|
| 212 |
+
add_border_walls(grid)
|
| 213 |
+
|
| 214 |
+
rooms = []
|
| 215 |
+
for _ in range(10):
|
| 216 |
+
rw = rng.randint(4, 7)
|
| 217 |
+
rh = rng.randint(4, 7)
|
| 218 |
+
rx = rng.randint(1, w-rw-2)
|
| 219 |
+
ry = rng.randint(1, h-rh-2)
|
| 220 |
+
grid[ry:ry+rh, rx:rx+rw] = 0
|
| 221 |
+
rooms.append((rx, ry, rw, rh))
|
| 222 |
+
|
| 223 |
+
for i in range(len(rooms)-1):
|
| 224 |
+
x1 = rooms[i][0] + rooms[i][2]//2
|
| 225 |
+
y1 = rooms[i][1] + rooms[i][3]//2
|
| 226 |
+
x2 = rooms[i+1][0] + rooms[i+1][2]//2
|
| 227 |
+
y2 = rooms[i+1][1] + rooms[i+1][3]//2
|
| 228 |
+
if rng.random() < 0.5:
|
| 229 |
+
grid[y1, min(x1,x2):max(x1,x2)+1] = 0
|
| 230 |
+
grid[min(y1,y2):max(y1,y2)+1, x2] = 0
|
| 231 |
+
else:
|
| 232 |
+
grid[min(y1,y2):max(y1,y2)+1, x1] = 0
|
| 233 |
+
grid[y2, min(x1,x2):max(x1,x2)+1] = 0
|
| 234 |
+
return grid
|
| 235 |
|
| 236 |
+
def map_labyrinth(seed, w=31, h=23):
|
| 237 |
+
rng = seeded_rng(seed)
|
| 238 |
+
grid = np.zeros((h, w), dtype=np.int8)
|
| 239 |
+
add_border_walls(grid)
|
| 240 |
+
for y in range(1, h-1):
|
| 241 |
+
for x in range(1, w-1):
|
| 242 |
+
if (x % 2 == 0 and rng.random() < 0.85) or (y % 3 == 0 and rng.random() < 0.55):
|
| 243 |
+
grid[y, x] = 1
|
| 244 |
+
for x in range(1, w-1):
|
| 245 |
+
grid[h//2, x] = 0
|
| 246 |
+
for x in range(3, w-3, 6):
|
| 247 |
+
for y in range(2, h-2):
|
| 248 |
+
if rng.random() < 0.75:
|
| 249 |
+
grid[y, x] = 0
|
| 250 |
+
return grid
|
| 251 |
+
|
| 252 |
+
def map_dense_field(seed, w=23, h=23):
|
| 253 |
+
rng = seeded_rng(seed)
|
| 254 |
+
grid = np.zeros((h, w), dtype=np.int8)
|
| 255 |
+
add_border_walls(grid)
|
| 256 |
+
for y in range(1, h-1):
|
| 257 |
+
for x in range(1, w-1):
|
| 258 |
+
if rng.random() < 0.22:
|
| 259 |
+
grid[y, x] = 1
|
| 260 |
+
for _ in range(6):
|
| 261 |
+
cx = rng.randint(3, w-4)
|
| 262 |
+
cy = rng.randint(3, h-4)
|
| 263 |
+
for yy in range(cy-2, cy+3):
|
| 264 |
+
for xx in range(cx-2, cx+3):
|
| 265 |
+
if 1 <= xx < w-1 and 1 <= yy < h-1:
|
| 266 |
+
grid[yy, xx] = 0
|
| 267 |
+
return grid
|
| 268 |
+
|
| 269 |
+
MAP_BUILDERS = {
|
| 270 |
+
"Training Bay": map_training,
|
| 271 |
+
"Arena+": map_arena_plus,
|
| 272 |
+
"Corridor Maze": map_corridor_maze,
|
| 273 |
+
"Rooms": map_rooms,
|
| 274 |
+
"Labyrinth": map_labyrinth,
|
| 275 |
+
"Dense Field": map_dense_field,
|
| 276 |
+
}
|
| 277 |
|
| 278 |
+
# -----------------------------
|
| 279 |
+
# State construction
|
| 280 |
+
# -----------------------------
|
| 281 |
+
def build_state(seed, map_name, progress=None, override=None):
|
| 282 |
+
rng = seeded_rng(seed)
|
| 283 |
+
grid = MAP_BUILDERS[map_name](seed)
|
| 284 |
+
pred, prey = pick_spawn_pair(grid, rng, min_dist=8)
|
| 285 |
+
pred_ori = rng.randint(0, 3)
|
| 286 |
+
prey_ori = (pred_ori + 2) % 4
|
| 287 |
+
|
| 288 |
+
if progress is None:
|
| 289 |
+
progress = {"catches": 0, "unlocked": compute_unlocks(0)}
|
| 290 |
|
| 291 |
st = {
|
| 292 |
"seed": int(seed),
|
| 293 |
"grid": grid,
|
| 294 |
+
|
| 295 |
"pred": pred,
|
| 296 |
"prey": prey,
|
| 297 |
+
|
| 298 |
+
"ori": pred_ori,
|
| 299 |
+
"prey_ori": prey_ori,
|
| 300 |
+
|
| 301 |
+
"control": "pred", # "pred" or "prey" (view + manual inputs)
|
| 302 |
+
"overlay": False, # coherence overlay
|
| 303 |
+
"disturbance": 0.0, # subtle coherence metric (EWMA)
|
| 304 |
+
"last_impulse": 0.0, # updated by actions
|
| 305 |
+
|
| 306 |
"step": 0,
|
| 307 |
"caught": False,
|
| 308 |
"auto_chase": False,
|
| 309 |
"auto_run": False,
|
| 310 |
+
|
| 311 |
+
"log": [f"Reset into map: {map_name}"],
|
| 312 |
+
"map_name": map_name,
|
| 313 |
+
"progress": progress,
|
| 314 |
}
|
| 315 |
+
|
| 316 |
+
if override:
|
| 317 |
+
for k, v in override.items():
|
| 318 |
+
if k == "grid":
|
| 319 |
+
continue
|
| 320 |
+
st[k] = v
|
| 321 |
+
|
| 322 |
return st
|
| 323 |
|
| 324 |
+
# -----------------------------
|
| 325 |
+
# Save / Load helpers
|
| 326 |
+
# -----------------------------
|
| 327 |
+
def serialize_state(st):
|
| 328 |
+
catches = int(st["progress"]["catches"])
|
| 329 |
+
payload = {
|
| 330 |
+
"version": 2,
|
| 331 |
+
"seed": int(st["seed"]),
|
| 332 |
+
"map_name": str(st["map_name"]),
|
| 333 |
+
"step": int(st["step"]),
|
| 334 |
+
|
| 335 |
+
"pred": [int(st["pred"][0]), int(st["pred"][1])],
|
| 336 |
+
"prey": [int(st["prey"][0]), int(st["prey"][1])],
|
| 337 |
+
|
| 338 |
+
"ori": int(st["ori"]),
|
| 339 |
+
"prey_ori": int(st.get("prey_ori", 0)),
|
| 340 |
+
|
| 341 |
+
"control": str(st.get("control", "pred")),
|
| 342 |
+
"overlay": bool(st.get("overlay", False)),
|
| 343 |
+
"disturbance": float(st.get("disturbance", 0.0)),
|
| 344 |
+
|
| 345 |
+
"caught": bool(st["caught"]),
|
| 346 |
+
"auto_chase": bool(st["auto_chase"]),
|
| 347 |
+
"auto_run": bool(st["auto_run"]),
|
| 348 |
+
|
| 349 |
+
"catches": catches,
|
| 350 |
+
"log_tail": st["log"][-20:],
|
| 351 |
+
}
|
| 352 |
+
return payload
|
| 353 |
+
|
| 354 |
+
def deserialize_state(payload):
|
| 355 |
+
seed = int(payload.get("seed", 1))
|
| 356 |
+
map_name = str(payload.get("map_name", "Training Bay"))
|
| 357 |
+
if map_name not in MAP_BUILDERS:
|
| 358 |
+
map_name = "Training Bay"
|
| 359 |
+
|
| 360 |
+
catches = int(payload.get("catches", 0))
|
| 361 |
+
progress = {"catches": catches, "unlocked": compute_unlocks(catches)}
|
| 362 |
+
|
| 363 |
+
override = {
|
| 364 |
+
"step": int(payload.get("step", 0)),
|
| 365 |
+
"pred": tuple(payload.get("pred", [1, 1])),
|
| 366 |
+
"prey": tuple(payload.get("prey", [2, 2])),
|
| 367 |
+
|
| 368 |
+
"ori": int(payload.get("ori", 0)) % 4,
|
| 369 |
+
"prey_ori": int(payload.get("prey_ori", 0)) % 4,
|
| 370 |
+
|
| 371 |
+
"control": str(payload.get("control", "pred")) if str(payload.get("control", "pred")) in ("pred", "prey") else "pred",
|
| 372 |
+
"overlay": bool(payload.get("overlay", False)),
|
| 373 |
+
"disturbance": float(payload.get("disturbance", 0.0)),
|
| 374 |
+
"last_impulse": 0.0,
|
| 375 |
+
|
| 376 |
+
"caught": bool(payload.get("caught", False)),
|
| 377 |
+
"auto_chase": bool(payload.get("auto_chase", False)),
|
| 378 |
+
"auto_run": bool(payload.get("auto_run", False)),
|
| 379 |
+
|
| 380 |
+
"log": (payload.get("log_tail", []) or [])[:],
|
| 381 |
+
}
|
| 382 |
+
|
| 383 |
+
st = build_state(seed, map_name, progress=progress, override=override)
|
| 384 |
+
|
| 385 |
+
# validate positions (must be on empty cells)
|
| 386 |
+
grid = st["grid"]
|
| 387 |
+
H, W = grid.shape
|
| 388 |
+
px, py = st["pred"]
|
| 389 |
+
qx, qy = st["prey"]
|
| 390 |
+
ok = (
|
| 391 |
+
0 <= px < W and 0 <= py < H and 0 <= qx < W and 0 <= qy < H
|
| 392 |
+
and grid[py, px] == 0 and grid[qy, qx] == 0
|
| 393 |
+
)
|
| 394 |
+
if not ok:
|
| 395 |
+
rng = seeded_rng(seed + 777)
|
| 396 |
+
st["pred"], st["prey"] = pick_spawn_pair(grid, rng, min_dist=8)
|
| 397 |
+
st["log"].append("Loaded save had invalid positions for this map; respawned safely.")
|
| 398 |
+
|
| 399 |
+
st["log"].append("Loaded save.")
|
| 400 |
+
return st
|
| 401 |
+
|
| 402 |
+
def save_to_path(st, path):
|
| 403 |
+
payload = serialize_state(st)
|
| 404 |
+
with open(path, "w", encoding="utf-8") as f:
|
| 405 |
+
json.dump(payload, f, indent=2)
|
| 406 |
+
st["log"].append(f"Saved to: {path}")
|
| 407 |
+
|
| 408 |
+
def load_from_path(path):
|
| 409 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 410 |
+
payload = json.load(f)
|
| 411 |
+
return deserialize_state(payload)
|
| 412 |
+
|
| 413 |
+
# -----------------------------
|
| 414 |
+
# Perception + rendering
|
| 415 |
+
# -----------------------------
|
| 416 |
def los_clear(grid, a, b):
|
|
|
|
| 417 |
ax, ay = a[0] + 0.5, a[1] + 0.5
|
| 418 |
bx, by = b[0] + 0.5, b[1] + 0.5
|
| 419 |
dx, dy = bx - ax, by - ay
|
|
|
|
| 424 |
dy /= dist
|
| 425 |
|
| 426 |
x, y = ax, ay
|
| 427 |
+
steps = int(dist * 20)
|
| 428 |
+
H, W = grid.shape
|
| 429 |
for _ in range(steps):
|
| 430 |
x += dx * (dist / steps)
|
| 431 |
y += dy * (dist / steps)
|
| 432 |
cx, cy = int(x), int(y)
|
| 433 |
+
cx = clamp(cx, 0, W-1)
|
| 434 |
+
cy = clamp(cy, 0, H-1)
|
| 435 |
if grid[cy, cx] == 1:
|
| 436 |
return False
|
| 437 |
return True
|
| 438 |
|
| 439 |
def dda_raycast(grid, px, py, ray_dx, ray_dy, max_depth=MAX_DEPTH):
|
| 440 |
+
H, W = grid.shape
|
|
|
|
|
|
|
|
|
|
|
|
|
| 441 |
map_x = int(px)
|
| 442 |
map_y = int(py)
|
| 443 |
|
|
|
|
| 460 |
|
| 461 |
hit = False
|
| 462 |
side = 0
|
| 463 |
+
for _ in range(max_depth * 10):
|
| 464 |
if side_dist_x < side_dist_y:
|
| 465 |
side_dist_x += delta_dist_x
|
| 466 |
map_x += step_x
|
|
|
|
| 470 |
map_y += step_y
|
| 471 |
side = 1
|
| 472 |
|
| 473 |
+
if map_x < 0 or map_x >= W or map_y < 0 or map_y >= H:
|
| 474 |
break
|
| 475 |
if grid[map_y, map_x] == 1:
|
| 476 |
hit = True
|
|
|
|
| 479 |
if not hit:
|
| 480 |
return max_depth, 0, map_x, map_y
|
| 481 |
|
|
|
|
| 482 |
if side == 0:
|
| 483 |
denom = ray_dx if abs(ray_dx) > 1e-9 else 1e-9
|
| 484 |
perp = (map_x - px + (1 - step_x) / 2) / denom
|
|
|
|
| 490 |
perp = clamp(perp, 0.0005, max_depth)
|
| 491 |
return perp, side, map_x, map_y
|
| 492 |
|
| 493 |
+
def _apply_coherence_overlay(img, disturbance: float):
|
| 494 |
+
# Very subtle: faint torque lines + edge tint. Disturbance expected ~[0..~3]
|
| 495 |
+
d = float(disturbance)
|
| 496 |
+
if d <= 0.001:
|
| 497 |
+
return img
|
| 498 |
+
|
| 499 |
+
alpha = clamp(d * 0.06, 0.0, 0.22) # keep subtle
|
| 500 |
+
h, w, _ = img.shape
|
| 501 |
+
cx, cy = w // 2, h // 2
|
| 502 |
+
|
| 503 |
+
# edge tint
|
| 504 |
+
edge = int(min(w, h) * 0.08)
|
| 505 |
+
if edge >= 2:
|
| 506 |
+
tint = np.array([22, 8, 18], dtype=np.float32) # slight magenta heat
|
| 507 |
+
# top
|
| 508 |
+
img[:edge, :, :] = np.clip(img[:edge, :, :].astype(np.float32) + tint * alpha, 0, 255).astype(np.uint8)
|
| 509 |
+
# bottom
|
| 510 |
+
img[h-edge:, :, :] = np.clip(img[h-edge:, :, :].astype(np.float32) + tint * alpha, 0, 255).astype(np.uint8)
|
| 511 |
+
# left
|
| 512 |
+
img[:, :edge, :] = np.clip(img[:, :edge, :].astype(np.float32) + tint * alpha, 0, 255).astype(np.uint8)
|
| 513 |
+
# right
|
| 514 |
+
img[:, w-edge:, :] = np.clip(img[:, w-edge:, :].astype(np.float32) + tint * alpha, 0, 255).astype(np.uint8)
|
| 515 |
+
|
| 516 |
+
# torque lines near center
|
| 517 |
+
line_col = np.array([180, 70, 160], dtype=np.float32) # muted
|
| 518 |
+
for i in range(-40, 41):
|
| 519 |
+
x = cx + i
|
| 520 |
+
y = cy + int(i * 0.35)
|
| 521 |
+
if 0 <= x < w and 0 <= y < h:
|
| 522 |
+
img[y:y+1, x:x+1, :] = np.clip(img[y:y+1, x:x+1, :].astype(np.float32) * (1-alpha) + line_col * alpha, 0, 255).astype(np.uint8)
|
| 523 |
+
|
| 524 |
+
y2 = cy - int(i * 0.35)
|
| 525 |
+
if 0 <= x < w and 0 <= y2 < h:
|
| 526 |
+
img[y2:y2+1, x:x+1, :] = np.clip(img[y2:y2+1, x:x+1, :].astype(np.float32) * (1-alpha) + line_col * alpha, 0, 255).astype(np.uint8)
|
| 527 |
+
|
| 528 |
+
return img
|
| 529 |
+
|
| 530 |
def render_first_person(st):
|
| 531 |
grid = st["grid"]
|
|
|
|
|
|
|
| 532 |
|
| 533 |
+
# viewer is whichever agent is currently controlled
|
| 534 |
+
if st["control"] == "prey":
|
| 535 |
+
view_cell = st["prey"]
|
| 536 |
+
view_ori = st["prey_ori"]
|
| 537 |
+
other_cell = st["pred"]
|
| 538 |
+
else:
|
| 539 |
+
view_cell = st["pred"]
|
| 540 |
+
view_ori = st["ori"]
|
| 541 |
+
other_cell = st["prey"]
|
| 542 |
+
|
| 543 |
+
(cx, cy) = view_cell
|
| 544 |
px = cx + 0.5
|
| 545 |
py = cy + 0.5
|
| 546 |
|
| 547 |
fov = math.radians(FOV_DEG)
|
| 548 |
+
base = math.radians(ORI_DEG[view_ori])
|
| 549 |
|
| 550 |
img = np.zeros((VIEW_H, VIEW_W, 3), dtype=np.uint8)
|
|
|
|
|
|
|
| 551 |
img[:VIEW_H//2, :, :] = SKY
|
| 552 |
|
|
|
|
| 553 |
for y in range(VIEW_H//2, VIEW_H):
|
| 554 |
t = (y - VIEW_H//2) / max(1, (VIEW_H//2 - 1))
|
| 555 |
col = (FLOOR_NEAR * (1 - t) + FLOOR_FAR * t).astype(np.uint8)
|
| 556 |
img[y, :, :] = col
|
| 557 |
|
|
|
|
| 558 |
wall_dists = np.full(RAY_W, MAX_DEPTH, dtype=np.float32)
|
| 559 |
|
| 560 |
for x in range(RAY_W):
|
|
|
|
| 564 |
ray_dy = math.sin(ang)
|
| 565 |
|
| 566 |
dist, side, hitx, hity = dda_raycast(grid, px, py, ray_dx, ray_dy, MAX_DEPTH)
|
|
|
|
| 567 |
dist *= math.cos(ang - base)
|
| 568 |
dist = clamp(dist, 0.001, MAX_DEPTH)
|
| 569 |
wall_dists[x] = dist
|
| 570 |
|
|
|
|
| 571 |
slice_h = int((VIEW_H * 0.92) / dist)
|
| 572 |
slice_h = clamp(slice_h, 1, VIEW_H)
|
| 573 |
top = (VIEW_H - slice_h) // 2
|
| 574 |
bot = top + slice_h
|
| 575 |
|
|
|
|
| 576 |
shade = 1.0 / (1.0 + dist * 0.12)
|
| 577 |
shade = clamp(shade, 0.12, 1.0)
|
|
|
|
| 578 |
base_col = WALL_SIDE if side == 1 else WALL_BASE
|
|
|
|
|
|
|
| 579 |
checker = ((hitx + hity) & 1)
|
| 580 |
tex = 0.90 if checker == 0 else 1.05
|
|
|
|
| 581 |
col = np.clip(base_col.astype(np.float32) * shade * tex, 0, 255).astype(np.uint8)
|
| 582 |
|
|
|
|
|
|
|
| 583 |
x0 = int(x * VIEW_W / RAY_W)
|
| 584 |
x1 = int((x + 1) * VIEW_W / RAY_W)
|
| 585 |
if x1 <= x0:
|
| 586 |
x1 = x0 + 1
|
| 587 |
img[top:bot, x0:x1, :] = col
|
| 588 |
|
| 589 |
+
# Other-agent billboard (LOS + per-column occlusion)
|
| 590 |
+
other_vis = False
|
| 591 |
+
if not st["caught"] and los_clear(grid, view_cell, other_cell):
|
| 592 |
+
vx = (other_cell[0] + 0.5) - px
|
| 593 |
+
vy = (other_cell[1] + 0.5) - py
|
| 594 |
+
other_dist = math.hypot(vx, vy)
|
| 595 |
+
other_ang = math.atan2(vy, vx)
|
| 596 |
+
rel = angle_diff_rad(other_ang, base)
|
| 597 |
+
if abs(rel) <= fov * 0.5 and other_dist < MAX_DEPTH:
|
| 598 |
+
other_vis = True
|
| 599 |
+
u = (rel / fov) + 0.5
|
| 600 |
+
sx_ray = int(u * (RAY_W - 1))
|
| 601 |
+
sx_ray = clamp(sx_ray, 0, RAY_W - 1)
|
| 602 |
+
|
| 603 |
+
sprite_h = int((VIEW_H * 0.75) / max(0.2, other_dist))
|
| 604 |
+
sprite_w = int(sprite_h * 0.45)
|
| 605 |
+
sprite_h = clamp(sprite_h, 8, VIEW_H)
|
| 606 |
+
sprite_w = clamp(sprite_w, 6, VIEW_W)
|
| 607 |
+
|
| 608 |
+
sx = int(sx_ray * VIEW_W / RAY_W)
|
| 609 |
+
sy = VIEW_H // 2
|
| 610 |
+
|
| 611 |
+
x0 = clamp(sx - sprite_w // 2, 0, VIEW_W - 1)
|
| 612 |
+
x1 = clamp(sx + sprite_w // 2, 0, VIEW_W - 1)
|
| 613 |
+
y0 = clamp(sy - sprite_h // 2, 0, VIEW_H - 1)
|
| 614 |
+
y1 = clamp(sy + sprite_h // 2, 0, VIEW_H - 1)
|
| 615 |
+
|
| 616 |
+
for vxcol in range(x0, x1):
|
| 617 |
+
rx = int(vxcol * RAY_W / VIEW_W)
|
| 618 |
+
rx = clamp(rx, 0, RAY_W - 1)
|
| 619 |
+
if other_dist < wall_dists[rx]:
|
| 620 |
+
img[y0:y1, vxcol:vxcol+1, :] = AGENT_OTHER_COLOR
|
| 621 |
+
|
| 622 |
+
# Reticle
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
cxh, cyh = VIEW_W // 2, VIEW_H // 2
|
| 624 |
img[cyh-1:cyh+2, cxh-12:cxh+13, :] = RETICLE
|
| 625 |
img[cyh-12:cyh+13, cxh-1:cxh+2, :] = RETICLE
|
| 626 |
|
| 627 |
+
# HUD strip
|
| 628 |
hud_h = 26
|
| 629 |
img[:hud_h, :, :] = np.clip(img[:hud_h, :, :].astype(np.int16) + 20, 0, 255).astype(np.uint8)
|
| 630 |
|
| 631 |
+
# Indicator dots:
|
| 632 |
+
# - AutoChase
|
| 633 |
+
# - AutoRun
|
| 634 |
+
# - OtherVisible
|
| 635 |
def dot(x, y, c):
|
| 636 |
img[y:y+6, x:x+6, :] = c
|
| 637 |
|
| 638 |
dot(8, 10, np.array([90, 255, 140], np.uint8) if st["auto_chase"] else np.array([60, 60, 70], np.uint8))
|
| 639 |
dot(20, 10, np.array([120, 190, 255], np.uint8) if st["auto_run"] else np.array([60, 60, 70], np.uint8))
|
| 640 |
+
dot(32, 10, np.array([255, 140, 90], np.uint8) if other_vis else np.array([60, 60, 70], np.uint8))
|
| 641 |
+
|
| 642 |
+
# Optional coherence overlay
|
| 643 |
+
if st.get("overlay", False):
|
| 644 |
+
img = _apply_coherence_overlay(img, st.get("disturbance", 0.0))
|
| 645 |
|
| 646 |
return img
|
| 647 |
|
| 648 |
def render_minimap(st, scale=14):
|
| 649 |
grid = st["grid"]
|
| 650 |
+
H, W = grid.shape
|
| 651 |
+
img = np.zeros((H*scale, W*scale, 3), dtype=np.uint8)
|
|
|
|
|
|
|
| 652 |
img[:, :, :] = np.array([18, 20, 32], dtype=np.uint8)
|
| 653 |
|
|
|
|
| 654 |
wall = np.array([220, 220, 235], dtype=np.uint8)
|
| 655 |
+
for y in range(H):
|
| 656 |
+
for x in range(W):
|
| 657 |
if grid[y, x] == 1:
|
| 658 |
img[y*scale:(y+1)*scale, x*scale:(x+1)*scale, :] = wall
|
| 659 |
|
| 660 |
+
px, py = st["pred"]
|
| 661 |
+
qx, qy = st["prey"]
|
|
|
|
|
|
|
|
|
|
| 662 |
|
| 663 |
+
pred_col = np.array([120, 190, 255], np.uint8)
|
| 664 |
+
prey_col = np.array([255, 140, 90], np.uint8)
|
| 665 |
|
| 666 |
+
img[py*scale:(py+1)*scale, px*scale:(px+1)*scale, :] = pred_col
|
| 667 |
+
img[qy*scale:(qy+1)*scale, qx*scale:(qx+1)*scale, :] = prey_col
|
| 668 |
+
|
| 669 |
+
# headings
|
| 670 |
+
dx, dy = DIRS[st["ori"]]
|
| 671 |
+
hx, hy = px + dx, py + dy
|
| 672 |
+
if 0 <= hx < W and 0 <= hy < H:
|
| 673 |
img[hy*scale:(hy+1)*scale, hx*scale:(hx+1)*scale, :] = np.array([80, 255, 160], np.uint8)
|
| 674 |
|
| 675 |
+
dx2, dy2 = DIRS[st["prey_ori"]]
|
| 676 |
+
hx2, hy2 = qx + dx2, qy + dy2
|
| 677 |
+
if 0 <= hx2 < W and 0 <= hy2 < H:
|
| 678 |
+
img[hy2*scale:(hy2+1)*scale, hx2*scale:(hx2+1)*scale, :] = np.array([255, 220, 120], np.uint8)
|
| 679 |
+
|
| 680 |
+
# highlight controlled agent with a bright ring (simple border)
|
| 681 |
+
if st["control"] == "pred":
|
| 682 |
+
x0, y0 = px*scale, py*scale
|
| 683 |
+
else:
|
| 684 |
+
x0, y0 = qx*scale, qy*scale
|
| 685 |
+
ring = np.array([240, 240, 140], np.uint8)
|
| 686 |
+
img[y0:y0+scale, x0:x0+2, :] = ring
|
| 687 |
+
img[y0:y0+scale, x0+scale-2:x0+scale, :] = ring
|
| 688 |
+
img[y0:y0+2, x0:x0+scale, :] = ring
|
| 689 |
+
img[y0+scale-2:y0+scale, x0:x0+scale, :] = ring
|
| 690 |
+
|
| 691 |
return img
|
| 692 |
|
| 693 |
+
def unlock_summary(st):
|
| 694 |
+
catches = st["progress"]["catches"]
|
| 695 |
+
unlocked = st["progress"]["unlocked"]
|
| 696 |
+
lines = []
|
| 697 |
+
for name, need in MAP_UNLOCKS:
|
| 698 |
+
if name in unlocked:
|
| 699 |
+
lines.append(f"✅ {name} (unlocked)")
|
| 700 |
+
else:
|
| 701 |
+
lines.append(f"🔒 {name} (needs {need} catches)")
|
| 702 |
+
return "### Map progression\n" + "\n".join(lines) + f"\n\n**Total catches:** {catches}"
|
| 703 |
+
|
| 704 |
def status(st):
|
| 705 |
+
pred_ori_txt = ["E", "S", "W", "N"][st["ori"]]
|
| 706 |
+
prey_ori_txt = ["E", "S", "W", "N"][st["prey_ori"]]
|
| 707 |
tail = st["log"][-10:]
|
| 708 |
+
catches = st["progress"]["catches"]
|
| 709 |
+
current = st["map_name"]
|
| 710 |
+
|
| 711 |
+
# interpret hybrid clearly
|
| 712 |
+
mode = "Manual"
|
| 713 |
+
if st["auto_run"] and st["auto_chase"]:
|
| 714 |
+
mode = "AutoRun+AutoChase (pred chases autonomously)"
|
| 715 |
+
elif st["auto_run"] and not st["auto_chase"]:
|
| 716 |
+
mode = "Hybrid AutoRun (pred wanders autonomously)"
|
| 717 |
+
|
| 718 |
+
ctrl = "Predator" if st["control"] == "pred" else "Prey"
|
| 719 |
+
coh = st.get("disturbance", 0.0)
|
| 720 |
return (
|
| 721 |
+
f"Map: {current} | Catches: {catches} | Step: {st['step']} | Mode: {mode} | Control: {ctrl} | Overlay: {st.get('overlay', False)}\n"
|
| 722 |
+
f"Predator: {st['pred']} {pred_ori_txt} | Prey: {st['prey']} {prey_ori_txt} | "
|
| 723 |
+
f"AutoChase: {st['auto_chase']} | AutoRun: {st['auto_run']} | Caught: {st['caught']} | Coherence: {coh:.2f}\n\n"
|
| 724 |
+ "\n".join(tail)
|
| 725 |
)
|
| 726 |
|
| 727 |
+
# -----------------------------
|
| 728 |
+
# Actions (manual + autonomous)
|
| 729 |
+
# -----------------------------
|
| 730 |
+
def _add_impulse(st, x):
|
| 731 |
+
st["last_impulse"] = float(st.get("last_impulse", 0.0)) + float(x)
|
| 732 |
+
|
| 733 |
+
def _step_disturbance(st):
|
| 734 |
+
# EWMA, keeps it subtle. Decays naturally.
|
| 735 |
+
d = float(st.get("disturbance", 0.0))
|
| 736 |
+
imp = float(st.get("last_impulse", 0.0))
|
| 737 |
+
st["disturbance"] = 0.92 * d + imp
|
| 738 |
+
st["last_impulse"] = 0.0
|
| 739 |
+
|
| 740 |
+
def _agent_pos_ori(st, who):
|
| 741 |
+
if who == "prey":
|
| 742 |
+
return st["prey"], st["prey_ori"]
|
| 743 |
+
return st["pred"], st["ori"]
|
| 744 |
+
|
| 745 |
+
def _set_agent_pos_ori(st, who, pos=None, ori=None):
|
| 746 |
+
if who == "prey":
|
| 747 |
+
if pos is not None: st["prey"] = pos
|
| 748 |
+
if ori is not None: st["prey_ori"] = int(ori) % 4
|
| 749 |
else:
|
| 750 |
+
if pos is not None: st["pred"] = pos
|
| 751 |
+
if ori is not None: st["ori"] = int(ori) % 4
|
| 752 |
|
| 753 |
+
def _turn(st, who, direction): # direction = -1 left, +1 right
|
| 754 |
if st["caught"]:
|
| 755 |
return
|
| 756 |
+
pos, ori = _agent_pos_ori(st, who)
|
| 757 |
+
ori = (ori + direction) % 4
|
| 758 |
+
_set_agent_pos_ori(st, who, ori=ori)
|
| 759 |
+
_add_impulse(st, 0.9) # turning = higher disturbance
|
| 760 |
|
| 761 |
+
def _forward(st, who):
|
| 762 |
if st["caught"]:
|
| 763 |
return
|
| 764 |
+
(x, y), ori = _agent_pos_ori(st, who)
|
| 765 |
+
dx, dy = DIRS[ori]
|
| 766 |
+
nx, ny = x + dx * MOVE_STEP, y + dy * MOVE_STEP
|
| 767 |
+
if st["grid"][ny, nx] == 0:
|
| 768 |
+
_set_agent_pos_ori(st, who, pos=(nx, ny))
|
| 769 |
+
_add_impulse(st, 0.25)
|
| 770 |
+
else:
|
| 771 |
+
st["log"].append(f"{'Prey' if who=='prey' else 'Predator'} bumped wall.")
|
| 772 |
+
_add_impulse(st, 0.7)
|
| 773 |
+
|
| 774 |
+
def _check_catch_and_unlock(st):
|
| 775 |
+
if st["pred"] == st["prey"] and not st["caught"]:
|
| 776 |
+
st["caught"] = True
|
| 777 |
+
st["log"].append("CAUGHT the prey.")
|
| 778 |
+
st["progress"]["catches"] += 1
|
| 779 |
+
st["progress"]["unlocked"] = compute_unlocks(st["progress"]["catches"])
|
| 780 |
+
st["log"].append(f"Catches now {st['progress']['catches']}. Unlocks updated.")
|
| 781 |
+
_add_impulse(st, 1.2)
|
| 782 |
|
| 783 |
+
def prey_flee_step(st):
|
| 784 |
+
# prey flees predator (if prey is not player-controlled)
|
| 785 |
if st["caught"]:
|
| 786 |
return
|
| 787 |
rng = seeded_rng(st["seed"] + 1337 + st["step"] * 19)
|
|
|
|
| 795 |
if st["grid"][ny, nx] == 1:
|
| 796 |
continue
|
| 797 |
dist = (nx-ax)**2 + (ny-ay)**2
|
| 798 |
+
scored.append((dist + rng.random()*0.1, (nx, ny), (dx, dy)))
|
| 799 |
|
| 800 |
if scored:
|
| 801 |
scored.sort(reverse=True)
|
| 802 |
+
pick = scored[0] if rng.random() < 0.78 else rng.choice(scored)
|
| 803 |
+
_, (nx, ny), (dx, dy) = pick
|
| 804 |
+
st["prey"] = (nx, ny)
|
| 805 |
+
if (dx, dy) in DIR_TO_ORI and (dx, dy) != (0,0):
|
| 806 |
+
st["prey_ori"] = DIR_TO_ORI[(dx, dy)]
|
| 807 |
+
|
| 808 |
+
def predator_wander_step(st):
|
| 809 |
+
# Hybrid mode: predator wanders autonomously (if predator is not player-controlled)
|
| 810 |
+
if st["caught"]:
|
| 811 |
+
return
|
| 812 |
+
rng = seeded_rng(st["seed"] + 4242 + st["step"] * 23)
|
| 813 |
+
(x, y) = st["pred"]
|
| 814 |
+
ori = st["ori"]
|
| 815 |
+
dx, dy = DIRS[ori]
|
| 816 |
+
front_blocked = (st["grid"][y+dy, x+dx] == 1)
|
| 817 |
|
| 818 |
+
# If blocked, turn deterministically-ish. Else random preference: forward with occasional turns.
|
| 819 |
+
r = rng.random()
|
| 820 |
+
if front_blocked:
|
| 821 |
+
if r < 0.5:
|
| 822 |
+
_turn(st, "pred", -1); st["log"].append("AutoWander: avoid left.")
|
| 823 |
+
else:
|
| 824 |
+
_turn(st, "pred", +1); st["log"].append("AutoWander: avoid right.")
|
| 825 |
+
else:
|
| 826 |
+
if r < 0.72:
|
| 827 |
+
_forward(st, "pred"); st["log"].append("AutoWander: forward.")
|
| 828 |
+
elif r < 0.86:
|
| 829 |
+
_turn(st, "pred", -1); st["log"].append("AutoWander: turn left.")
|
| 830 |
+
else:
|
| 831 |
+
_turn(st, "pred", +1); st["log"].append("AutoWander: turn right.")
|
| 832 |
|
| 833 |
+
def predator_chase_step(st):
|
| 834 |
+
# AutoChase mode: predator turns/moves toward prey when in LOS+FOV; else roams with wall avoid
|
| 835 |
if st["caught"]:
|
| 836 |
return
|
|
|
|
| 837 |
grid = st["grid"]
|
| 838 |
px = st["pred"][0] + 0.5
|
| 839 |
py = st["pred"][1] + 0.5
|
| 840 |
base = math.radians(ORI_DEG[st["ori"]])
|
| 841 |
fov = math.radians(FOV_DEG)
|
|
|
|
| 842 |
prey = st["prey"]
|
| 843 |
+
|
| 844 |
if los_clear(grid, st["pred"], prey):
|
| 845 |
vx = (prey[0] + 0.5) - px
|
| 846 |
vy = (prey[1] + 0.5) - py
|
|
|
|
| 848 |
rel = angle_diff_rad(ang, base)
|
| 849 |
if abs(rel) <= fov * 0.5:
|
| 850 |
if rel < -0.10:
|
| 851 |
+
_turn(st, "pred", -1); st["log"].append("AutoChase: turn left.")
|
| 852 |
elif rel > 0.10:
|
| 853 |
+
_turn(st, "pred", +1); st["log"].append("AutoChase: turn right.")
|
| 854 |
else:
|
| 855 |
+
_forward(st, "pred"); st["log"].append("AutoChase: forward.")
|
| 856 |
return
|
| 857 |
|
| 858 |
+
# fallback: wander-ish
|
| 859 |
+
predator_wander_step(st)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 860 |
|
| 861 |
def tick(st):
|
| 862 |
if st["caught"]:
|
| 863 |
return
|
| 864 |
+
|
| 865 |
st["step"] += 1
|
| 866 |
|
| 867 |
+
# Autonomous predator step ONLY when predator is not player-controlled and AutoRun is enabled.
|
| 868 |
+
if st["auto_run"] and st["control"] != "pred":
|
| 869 |
+
if st["auto_chase"]:
|
| 870 |
+
predator_chase_step(st)
|
| 871 |
+
else:
|
| 872 |
+
predator_wander_step(st)
|
| 873 |
|
| 874 |
+
# Autonomous prey flee ONLY when prey is not player-controlled.
|
| 875 |
+
if st["control"] != "prey":
|
| 876 |
+
prey_flee_step(st)
|
| 877 |
+
|
| 878 |
+
_check_catch_and_unlock(st)
|
| 879 |
+
_step_disturbance(st)
|
| 880 |
|
| 881 |
if st["step"] >= 600:
|
| 882 |
st["caught"] = True
|
| 883 |
st["log"].append("Max steps reached (freeze).")
|
| 884 |
|
| 885 |
# -----------------------------
|
| 886 |
+
# Gradio handlers
|
| 887 |
# -----------------------------
|
| 888 |
+
def ui_refresh_slots(current_value=None):
|
| 889 |
+
choices = list_save_slots()
|
| 890 |
+
if current_value and current_value in choices:
|
| 891 |
+
value = current_value
|
| 892 |
+
else:
|
| 893 |
+
value = choices[0] if choices else "slot1.json"
|
| 894 |
+
return gr.Dropdown(choices=choices if choices else ["slot1.json"], value=value)
|
| 895 |
+
|
| 896 |
+
def ui_reset(seed, map_choice, st=None):
|
| 897 |
+
seed = int(seed)
|
| 898 |
+
progress = st["progress"] if st else {"catches": 0, "unlocked": compute_unlocks(0)}
|
| 899 |
+
if map_choice not in progress["unlocked"]:
|
| 900 |
+
map_choice = "Training Bay"
|
| 901 |
+
new_st = build_state(seed, map_choice, progress=progress)
|
| 902 |
+
# keep user prefs
|
| 903 |
+
if st:
|
| 904 |
+
new_st["control"] = st.get("control", "pred")
|
| 905 |
+
new_st["overlay"] = st.get("overlay", False)
|
| 906 |
+
return new_st, render_first_person(new_st), render_minimap(new_st), status(new_st), unlock_summary(new_st)
|
| 907 |
+
|
| 908 |
+
def ui_toggle_control(st):
|
| 909 |
+
st["control"] = "prey" if st["control"] == "pred" else "pred"
|
| 910 |
+
st["log"].append(f"Control switched to: {'Prey' if st['control']=='prey' else 'Predator'}.")
|
| 911 |
+
_add_impulse(st, 0.15)
|
| 912 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 913 |
+
|
| 914 |
+
def ui_turn_left(st):
|
| 915 |
+
who = st["control"]
|
| 916 |
+
_turn(st, who, -1)
|
| 917 |
+
st["log"].append(f"{'Prey' if who=='prey' else 'Predator'} turn left.")
|
| 918 |
tick(st)
|
| 919 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 920 |
|
| 921 |
+
def ui_turn_right(st):
|
| 922 |
+
who = st["control"]
|
| 923 |
+
_turn(st, who, +1)
|
| 924 |
+
st["log"].append(f"{'Prey' if who=='prey' else 'Predator'} turn right.")
|
| 925 |
tick(st)
|
| 926 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 927 |
|
| 928 |
def ui_forward(st):
|
| 929 |
+
who = st["control"]
|
| 930 |
+
_forward(st, who)
|
| 931 |
+
st["log"].append(f"{'Prey' if who=='prey' else 'Predator'} forward.")
|
| 932 |
+
_check_catch_and_unlock(st)
|
| 933 |
tick(st)
|
| 934 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 935 |
|
| 936 |
def ui_toggle_chase(st):
|
| 937 |
st["auto_chase"] = not st["auto_chase"]
|
| 938 |
st["log"].append(f"AutoChase set to {st['auto_chase']}.")
|
| 939 |
+
_add_impulse(st, 0.10)
|
| 940 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 941 |
|
| 942 |
def ui_toggle_run(st):
|
| 943 |
st["auto_run"] = not st["auto_run"]
|
| 944 |
st["log"].append(f"AutoRun set to {st['auto_run']}.")
|
| 945 |
+
_add_impulse(st, 0.10)
|
| 946 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 947 |
+
|
| 948 |
+
def ui_toggle_overlay(st):
|
| 949 |
+
st["overlay"] = not st.get("overlay", False)
|
| 950 |
+
st["log"].append(f"Overlay set to {st['overlay']}.")
|
| 951 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 952 |
|
| 953 |
def ui_tick(st):
|
| 954 |
tick(st)
|
| 955 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 956 |
|
| 957 |
def ui_timer(st):
|
|
|
|
| 958 |
if st["auto_run"] and not st["caught"]:
|
| 959 |
tick(st)
|
| 960 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 961 |
+
|
| 962 |
+
def ui_swap_roles(st):
|
| 963 |
+
# Optional: swap predator <-> prey positions/orientations (symmetry hammer)
|
| 964 |
+
if st["caught"]:
|
| 965 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 966 |
+
|
| 967 |
+
st["pred"], st["prey"] = st["prey"], st["pred"]
|
| 968 |
+
st["ori"], st["prey_ori"] = st["prey_ori"], st["ori"]
|
| 969 |
+
st["log"].append("Swapped roles (Predator ⇄ Prey).")
|
| 970 |
+
_add_impulse(st, 0.35)
|
| 971 |
+
_check_catch_and_unlock(st)
|
| 972 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st)
|
| 973 |
+
|
| 974 |
+
# ---- Save/load UI handlers ----
|
| 975 |
+
def ui_save_slot(st, slot_name):
|
| 976 |
+
try:
|
| 977 |
+
path = _slot_path(slot_name)
|
| 978 |
+
save_to_path(st, path)
|
| 979 |
+
export_path = path
|
| 980 |
+
except Exception as e:
|
| 981 |
+
st["log"].append(f"Save failed: {e}")
|
| 982 |
+
export_path = None
|
| 983 |
+
|
| 984 |
+
dd = ui_refresh_slots(os.path.basename(export_path) if export_path else None)
|
| 985 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st), export_path, dd
|
| 986 |
+
|
| 987 |
+
def ui_load_slot(st, selected_slot):
|
| 988 |
+
path = os.path.join(SAVE_DIR, selected_slot) if selected_slot else _slot_path("slot1")
|
| 989 |
+
try:
|
| 990 |
+
if not os.path.exists(path):
|
| 991 |
+
st["log"].append(f"No save found at {path}")
|
| 992 |
+
dd = ui_refresh_slots(selected_slot)
|
| 993 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st), None, dd
|
| 994 |
+
loaded = load_from_path(path)
|
| 995 |
+
dd = ui_refresh_slots(os.path.basename(path))
|
| 996 |
+
return loaded, render_first_person(loaded), render_minimap(loaded), status(loaded), unlock_summary(loaded), None, dd
|
| 997 |
+
except Exception as e:
|
| 998 |
+
st["log"].append(f"Load failed: {e}")
|
| 999 |
+
dd = ui_refresh_slots(selected_slot)
|
| 1000 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st), None, dd
|
| 1001 |
+
|
| 1002 |
+
def ui_import_save(st, uploaded_file):
|
| 1003 |
+
try:
|
| 1004 |
+
if uploaded_file is None:
|
| 1005 |
+
st["log"].append("Import: no file provided.")
|
| 1006 |
+
dd = ui_refresh_slots()
|
| 1007 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st), None, dd
|
| 1008 |
+
loaded = load_from_path(uploaded_file)
|
| 1009 |
+
dd = ui_refresh_slots()
|
| 1010 |
+
return loaded, render_first_person(loaded), render_minimap(loaded), status(loaded), unlock_summary(loaded), None, dd
|
| 1011 |
+
except Exception as e:
|
| 1012 |
+
st["log"].append(f"Import failed: {e}")
|
| 1013 |
+
dd = ui_refresh_slots()
|
| 1014 |
+
return st, render_first_person(st), render_minimap(st), status(st), unlock_summary(st), None, dd
|
| 1015 |
|
| 1016 |
# -----------------------------
|
| 1017 |
# App
|
| 1018 |
# -----------------------------
|
| 1019 |
+
all_map_names = [name for name, _ in MAP_UNLOCKS]
|
| 1020 |
+
initial_progress = {"catches": 0, "unlocked": compute_unlocks(0)}
|
| 1021 |
+
initial_state = build_state(seed=1, map_name="Training Bay", progress=initial_progress)
|
| 1022 |
+
|
| 1023 |
+
initial_slots = list_save_slots()
|
| 1024 |
+
initial_slot_value = initial_slots[0] if initial_slots else "slot1.json"
|
| 1025 |
+
|
| 1026 |
+
with gr.Blocks(title="RFT Predator Space — Symmetric Observers") as demo:
|
| 1027 |
gr.Markdown(
|
| 1028 |
"## Experience reality through an RFT observer agent’s perspective\n"
|
| 1029 |
+
"Two symmetric observers share the same frame.\n\n"
|
| 1030 |
+
"**Hybrid mode:** AutoRun ON + AutoChase OFF ⇒ predator wanders autonomously while prey flees (unless you control it).\n"
|
| 1031 |
+
"**Symmetry:** Toggle control to view/drive either observer."
|
| 1032 |
)
|
| 1033 |
|
| 1034 |
+
st = gr.State(initial_state)
|
| 1035 |
|
| 1036 |
with gr.Row():
|
| 1037 |
seed = gr.Number(label="Seed", value=1, precision=0)
|
| 1038 |
+
map_choice = gr.Dropdown(label="Map (locked unless unlocked)", choices=all_map_names, value="Training Bay")
|
| 1039 |
btn_reset = gr.Button("Reset")
|
| 1040 |
+
btn_control = gr.Button("Toggle Control (Pred ↔ Prey)")
|
|
|
|
| 1041 |
btn_tick = gr.Button("Tick")
|
| 1042 |
|
| 1043 |
with gr.Row():
|
|
|
|
| 1045 |
btn_fwd = gr.Button("Forward")
|
| 1046 |
btn_right = gr.Button("Turn Right")
|
| 1047 |
|
| 1048 |
+
with gr.Row():
|
| 1049 |
+
btn_chase = gr.Button("Toggle AutoChase")
|
| 1050 |
+
btn_run = gr.Button("Toggle AutoRun")
|
| 1051 |
+
btn_overlay = gr.Button("Toggle Overlay (optional)")
|
| 1052 |
+
btn_swap = gr.Button("Swap Roles (Pred ⇄ Prey)")
|
| 1053 |
+
|
| 1054 |
with gr.Row():
|
| 1055 |
view = gr.Image(label="First-person observer view", type="numpy")
|
| 1056 |
mini = gr.Image(label="Minimap (debug)", type="numpy")
|
| 1057 |
|
| 1058 |
+
with gr.Row():
|
| 1059 |
+
info = gr.Textbox(label="Run log", lines=12)
|
| 1060 |
+
unlocks = gr.Markdown(value=unlock_summary(initial_state))
|
| 1061 |
|
| 1062 |
+
gr.Markdown("### Save / Load")
|
|
|
|
| 1063 |
|
| 1064 |
+
with gr.Row():
|
| 1065 |
+
slot_pick = gr.Dropdown(label="Existing saves", choices=initial_slots if initial_slots else ["slot1.json"], value=initial_slot_value)
|
| 1066 |
+
slot_name = gr.Textbox(label="New save name (optional)", value="slot1")
|
| 1067 |
+
|
| 1068 |
+
with gr.Row():
|
| 1069 |
+
btn_refresh = gr.Button("Refresh Saves List")
|
| 1070 |
+
btn_save = gr.Button("Save (to name)")
|
| 1071 |
+
btn_load = gr.Button("Load (selected)")
|
| 1072 |
+
|
| 1073 |
+
with gr.Row():
|
| 1074 |
+
export_file = gr.File(label="Exported Save File (download this)", interactive=False)
|
| 1075 |
+
import_file = gr.File(label="Import Save File (upload)", interactive=True)
|
| 1076 |
+
btn_import = gr.Button("Import (Load Uploaded File)")
|
| 1077 |
+
|
| 1078 |
+
demo.load(
|
| 1079 |
+
lambda: (st.value, render_first_person(st.value), render_minimap(st.value), status(st.value), unlock_summary(st.value), None, ui_refresh_slots(initial_slot_value)),
|
| 1080 |
+
outputs=[st, view, mini, info, unlocks, export_file, slot_pick]
|
| 1081 |
+
)
|
| 1082 |
+
|
| 1083 |
+
btn_reset.click(ui_reset, inputs=[seed, map_choice, st], outputs=[st, view, mini, info, unlocks])
|
| 1084 |
+
|
| 1085 |
+
btn_control.click(ui_toggle_control, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1086 |
+
|
| 1087 |
+
btn_left.click(ui_turn_left, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1088 |
+
btn_right.click(ui_turn_right, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1089 |
+
btn_fwd.click(ui_forward, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1090 |
+
|
| 1091 |
+
btn_chase.click(ui_toggle_chase, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1092 |
+
btn_run.click(ui_toggle_run, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1093 |
+
btn_overlay.click(ui_toggle_overlay, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1094 |
+
btn_swap.click(ui_swap_roles, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1095 |
+
|
| 1096 |
+
btn_tick.click(ui_tick, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1097 |
+
|
| 1098 |
+
btn_refresh.click(lambda cur: ui_refresh_slots(cur), inputs=[slot_pick], outputs=[slot_pick])
|
| 1099 |
+
|
| 1100 |
+
btn_save.click(
|
| 1101 |
+
lambda st_, name_, pick_: ui_save_slot(st_, name_ if (name_ and name_.strip()) else pick_),
|
| 1102 |
+
inputs=[st, slot_name, slot_pick],
|
| 1103 |
+
outputs=[st, view, mini, info, unlocks, export_file, slot_pick]
|
| 1104 |
+
)
|
| 1105 |
+
|
| 1106 |
+
btn_load.click(
|
| 1107 |
+
ui_load_slot,
|
| 1108 |
+
inputs=[st, slot_pick],
|
| 1109 |
+
outputs=[st, view, mini, info, unlocks, export_file, slot_pick]
|
| 1110 |
+
)
|
| 1111 |
+
|
| 1112 |
+
btn_import.click(
|
| 1113 |
+
ui_import_save,
|
| 1114 |
+
inputs=[st, import_file],
|
| 1115 |
+
outputs=[st, view, mini, info, unlocks, export_file, slot_pick]
|
| 1116 |
+
)
|
| 1117 |
|
|
|
|
| 1118 |
if hasattr(gr, "Timer"):
|
| 1119 |
+
gr.Timer(1.0 / AUTO_TICK_HZ).tick(ui_timer, inputs=[st], outputs=[st, view, mini, info, unlocks])
|
| 1120 |
|
| 1121 |
demo.launch()
|