RFTSystems commited on
Commit
294e0e7
·
verified ·
1 Parent(s): f6a53e9

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +801 -222
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
- # - Raycast wall slicing (occlusion + distance shading)
9
- # - Prey billboard with LOS + per-column occlusion vs walls
10
- # - Optional top-down minimap for debugging / verification
 
 
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 # output size
23
- RAY_W = 280 # internal ray columns (upscaled to VIEW_W)
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
- PREY_COLOR = np.array([255, 140, 90], dtype=np.uint8) # billboard
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
- # minimal difference a-b in [-pi, pi]
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 make_world(seed=1):
64
- rng = seeded_rng(seed)
65
- grid = np.zeros((GRID_H, GRID_W), dtype=np.int8)
66
-
67
- # borders
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
68
  grid[0, :] = 1
69
- grid[GRID_H-1, :] = 1
70
  grid[:, 0] = 1
71
- grid[:, GRID_W-1] = 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
 
73
- # obstacles
74
- for y in range(1, GRID_H-1):
75
- for x in range(1, GRID_W-1):
76
- if rng.random() < OBSTACLE_P:
 
 
 
 
 
77
  grid[y, x] = 1
 
 
 
 
 
78
 
79
- def empty_cell():
80
- for _ in range(20000):
81
- x = rng.randint(1, GRID_W-2)
82
- y = rng.randint(1, GRID_H-2)
83
- if grid[y, x] == 0:
84
- return (x, y)
85
- return (1, 1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
 
87
- pred = empty_cell()
88
- prey = empty_cell()
89
- while prey == pred:
90
- prey = empty_cell()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
 
92
- ori = rng.randint(0, 3)
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  st = {
95
  "seed": int(seed),
96
  "grid": grid,
 
97
  "pred": pred,
98
  "prey": prey,
99
- "ori": ori,
 
 
 
 
 
 
 
 
100
  "step": 0,
101
  "caught": False,
102
  "auto_chase": False,
103
  "auto_run": False,
104
- "log": ["Reset."]
 
 
 
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) # oversample for safety
 
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, GRID_W-1)
126
- cy = clamp(cy, 0, GRID_H-1)
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 * 8):
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 >= GRID_W or map_y < 0 or map_y >= GRID_H:
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[ori])
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
- # Prey billboard: only if in FOV AND LOS AND not behind wall per-column
254
- prey = st["prey"]
255
- prey_vis = False
256
- if not st["caught"]:
257
- if los_clear(grid, st["pred"], prey):
258
- vx = (prey[0] + 0.5) - px
259
- vy = (prey[1] + 0.5) - py
260
- prey_dist = math.hypot(vx, vy)
261
- prey_ang = math.atan2(vy, vx)
262
- rel = angle_diff_rad(prey_ang, base)
263
- if abs(rel) <= fov * 0.5 and prey_dist < MAX_DEPTH:
264
- prey_vis = True
265
-
266
- # screen x in ray space
267
- u = (rel / fov) + 0.5
268
- sx_ray = int(u * (RAY_W - 1))
269
- sx_ray = clamp(sx_ray, 0, RAY_W - 1)
270
-
271
- # sprite size
272
- sprite_h = int((VIEW_H * 0.75) / max(0.2, prey_dist))
273
- sprite_w = int(sprite_h * 0.45)
274
- sprite_h = clamp(sprite_h, 8, VIEW_H)
275
- sprite_w = clamp(sprite_w, 6, VIEW_W)
276
-
277
- # convert to VIEW coords
278
- sx = int(sx_ray * VIEW_W / RAY_W)
279
- sy = VIEW_H // 2
280
-
281
- x0 = clamp(sx - sprite_w // 2, 0, VIEW_W - 1)
282
- x1 = clamp(sx + sprite_w // 2, 0, VIEW_W - 1)
283
- y0 = clamp(sy - sprite_h // 2, 0, VIEW_H - 1)
284
- y1 = clamp(sy + sprite_h // 2, 0, VIEW_H - 1)
285
-
286
- # Occlusion test: only draw columns where prey is closer than wall
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 (simple, in-image)
300
  hud_h = 26
301
  img[:hud_h, :, :] = np.clip(img[:hud_h, :, :].astype(np.int16) + 20, 0, 255).astype(np.uint8)
302
 
303
- # encode tiny indicators as pixels (keeps deps minimal: no PIL)
304
- # left corner: auto_chase / auto_run / prey_vis
 
 
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 prey_vis else np.array([60, 60, 70], np.uint8))
 
 
 
 
311
 
312
  return img
313
 
314
  def render_minimap(st, scale=14):
315
  grid = st["grid"]
316
- h, w = grid.shape
317
- img = np.zeros((h*scale, w*scale, 3), dtype=np.uint8)
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(h):
325
- for x in range(w):
326
  if grid[y, x] == 1:
327
  img[y*scale:(y+1)*scale, x*scale:(x+1)*scale, :] = wall
328
 
329
- # prey / pred
330
- pred = st["pred"]
331
- prey = st["prey"]
332
- px, py = pred
333
- qx, qy = prey
334
 
335
- img[py*scale:(py+1)*scale, px*scale:(px+1)*scale, :] = np.array([120, 190, 255], np.uint8)
336
- img[qy*scale:(qy+1)*scale, qx*scale:(qx+1)*scale, :] = np.array([255, 140, 90], np.uint8)
337
 
338
- # heading marker
339
- ori = st["ori"]
340
- dx, dy = DIRS[ori]
341
- hx = px + dx
342
- hy = py + dy
343
- if 0 <= hx < w and 0 <= hy < h:
 
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
- ori_txt = ["E", "S", "W", "N"][st["ori"]]
 
350
  tail = st["log"][-10:]
 
 
 
 
 
 
 
 
 
 
 
 
351
  return (
352
- f"Step: {st['step']} | Predator: {st['pred']} {ori_txt} | Prey: {st['prey']} | "
353
- f"AutoChase: {st['auto_chase']} | AutoRun: {st['auto_run']} | Caught: {st['caught']}\n\n"
 
354
  + "\n".join(tail)
355
  )
356
 
357
- def move_forward(st):
358
- if st["caught"]:
359
- return
360
- x, y = st["pred"]
361
- dx, dy = DIRS[st["ori"]]
362
- nx, ny = x + dx * MOVE_STEP, y + dy * MOVE_STEP
363
- if st["grid"][ny, nx] == 0:
364
- st["pred"] = (nx, ny)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
365
  else:
366
- st["log"].append("Bumped wall.")
 
367
 
368
- def turn_left(st):
369
  if st["caught"]:
370
  return
371
- st["ori"] = (st["ori"] - 1) % 4
 
 
 
372
 
373
- def turn_right(st):
374
  if st["caught"]:
375
  return
376
- st["ori"] = (st["ori"] + 1) % 4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
377
 
378
- def prey_step(st):
 
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
- st["prey"] = scored[0][1] if rng.random() < 0.78 else rng.choice(scored)[1]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
397
 
398
- def check_catch(st):
399
- if st["pred"] == st["prey"]:
400
- st["caught"] = True
401
- st["log"].append("CAUGHT the prey.")
 
 
 
 
 
 
 
 
 
 
402
 
403
- def auto_chase_policy(st):
 
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
- turn_left(st); st["log"].append("AutoChase: turn left.")
422
  elif rel > 0.10:
423
- turn_right(st); st["log"].append("AutoChase: turn right.")
424
  else:
425
- move_forward(st); st["log"].append("AutoChase: forward.")
426
  return
427
 
428
- # avoid walls: look one step ahead
429
- x, y = st["pred"]
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
- if st["auto_chase"]:
445
- auto_chase_policy(st)
 
 
 
 
446
 
447
- prey_step(st)
448
- check_catch(st)
 
 
 
 
449
 
450
  if st["step"] >= 600:
451
  st["caught"] = True
452
  st["log"].append("Max steps reached (freeze).")
453
 
454
  # -----------------------------
455
- # Gradio actions
456
  # -----------------------------
457
- def ui_reset(seed):
458
- st = make_world(int(seed))
459
- return st, render_first_person(st), render_minimap(st), status(st)
460
-
461
- def ui_left(st):
462
- turn_left(st); st["log"].append("Turn left.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  tick(st)
464
- return st, render_first_person(st), render_minimap(st), status(st)
465
 
466
- def ui_right(st):
467
- turn_right(st); st["log"].append("Turn right.")
 
 
468
  tick(st)
469
- return st, render_first_person(st), render_minimap(st), status(st)
470
 
471
  def ui_forward(st):
472
- move_forward(st); st["log"].append("Forward.")
473
- check_catch(st)
 
 
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
- return st, render_first_person(st), render_minimap(st), status(st)
 
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
- return st, render_first_person(st), render_minimap(st), status(st)
 
 
 
 
 
 
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
- with gr.Blocks(title="RFT Predator Space First Person Observer") as demo:
 
 
 
 
 
 
 
501
  gr.Markdown(
502
  "## Experience reality through an RFT observer agent’s perspective\n"
503
- "This view shows **only what the predator can perceive**: a pseudo-3D raycast viewport with occlusion and LOS-correct prey visibility.\n"
504
- "**Dots:** AutoChase / AutoRun / PreyVisible (top-left)."
 
505
  )
506
 
507
- st = gr.State(make_world(1))
508
 
509
  with gr.Row():
510
  seed = gr.Number(label="Seed", value=1, precision=0)
 
511
  btn_reset = gr.Button("Reset")
512
- btn_chase = gr.Button("Toggle AutoChase")
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
- info = gr.Textbox(label="Run log", lines=12)
 
 
526
 
527
- demo.load(lambda: (st.value, render_first_person(st.value), render_minimap(st.value), status(st.value)),
528
- outputs=[st, view, mini, info])
529
 
530
- btn_reset.click(ui_reset, inputs=[seed], outputs=[st, view, mini, info])
531
- btn_left.click(ui_left, inputs=[st], outputs=[st, view, mini, info])
532
- btn_right.click(ui_right, inputs=[st], outputs=[st, view, mini, info])
533
- btn_fwd.click(ui_forward, inputs=[st], outputs=[st, view, mini, info])
534
- btn_chase.click(ui_toggle_chase, inputs=[st], outputs=[st, view, mini, info])
535
- btn_run.click(ui_toggle_run, inputs=[st], outputs=[st, view, mini, info])
536
- btn_tick.click(ui_tick, inputs=[st], outputs=[st, view, mini, info])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()