Pabloler21 commited on
Commit
5154bf5
·
1 Parent(s): c30144e

feat(game): letterboxed scene shell + recovered drawer + tone wiring

Browse files
Files changed (4) hide show
  1. app.py +99 -72
  2. render.py +26 -3
  3. styles.css +300 -0
  4. tests/test_render.py +27 -1
app.py CHANGED
@@ -21,7 +21,7 @@ from character import (FRAGMENT_TONES, INTRO_CARDS, INTRO_SEQUENCES,
21
  from engine import run_turn, run_turn_stream
22
  from finale import finale_steps, finale_steps_bad, finale_steps_good
23
  from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal
24
- from render import render_entity, render_treasure
25
  from sting import chime_wav_bytes, menu_loop_wav_bytes, type_tick_wav_bytes
26
  from voice import speak
27
 
@@ -74,7 +74,8 @@ def _pristine_state():
74
 
75
  def _reset():
76
  """Wipe the board for a fresh game without a page reload. Order matches
77
- [msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel]."""
 
78
  fresh = _pristine_state()
79
  return (
80
  gr.update(value="", interactive=True,
@@ -86,6 +87,7 @@ def _reset():
86
  render_treasure([]),
87
  render_entity(20),
88
  _voice_html(None),
 
89
  )
90
 
91
 
@@ -399,9 +401,15 @@ _HEAD_JS = """
399
  }, 130);
400
  }
401
 
402
- // mute button + restart + mode buttons, wired once rendered
403
  function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; }
404
  function wire() {
 
 
 
 
 
 
405
  var btn = document.querySelector('.voice-btn');
406
  var rb = document.querySelector('.restart-btn');
407
  if (btn && !btn.dataset.hollowWired) {
@@ -530,7 +538,8 @@ def _enter_game(mode: str, tone_seed: int = 0):
530
  _render_bond(state["affinity"], get_tier(state["affinity"])),
531
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
532
  wounds=state.get("wounds", [])),
533
- render_entity(state["affinity"]),
 
534
  )
535
 
536
 
@@ -639,11 +648,13 @@ def _play_finale(state: dict, history: list):
639
  bond = _render_bond(state["affinity"], tier)
640
  treasure_html = render_treasure(state["treasure"], struck=struck,
641
  claimed=set(claimed))
642
- entity = render_entity(state["affinity"], "build", seq=state["turn"])
 
643
  locked = gr.update(interactive=False)
644
  input_locked = gr.update(interactive=False)
645
  mutated = False
646
  convulsed = False
 
647
 
648
  for step in finale_steps(claimed):
649
  role = "assistant" if step["speaker"] == "hollow" else "user"
@@ -657,7 +668,7 @@ def _play_finale(state: dict, history: list):
657
  if step["stage"] == "frenzy":
658
  convulsed = True
659
  entity = render_entity(state["affinity"], "convulse_loop",
660
- seq=state["turn"])
661
 
662
  if step["stage"] == "turn" and not mutated:
663
  mutated = True
@@ -665,18 +676,21 @@ def _play_finale(state: dict, history: list):
665
  treasure_html = render_treasure(state["treasure"], mine=True)
666
  # the `end` face lands and the heartbeat flatlines, exactly now
667
  # (seamless — the convulsion already settled on this face)
668
- entity = render_entity(state["affinity"], "end_settle", seq=state["turn"])
 
669
  input_locked = gr.update(interactive=False, placeholder="stay.")
670
 
671
  speak_it = step["speaker"] == "hollow"
672
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
673
- yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity, voice)
 
674
  if pause > 0:
675
  time.sleep(pause)
676
 
677
  yield (
678
  gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
679
- locked, chat_hist, state, bond, treasure_html, entity, _voice_html(None),
 
680
  )
681
 
682
 
@@ -690,11 +704,13 @@ def _play_finale_bad(state: dict, history: list):
690
  revealed = 0
691
  treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]),
692
  wounds=wounds, revealed=0)
693
- entity = render_entity(state["affinity"], "build", seq=state["turn"])
 
694
  locked = gr.update(interactive=False)
695
  input_locked = gr.update(interactive=False)
696
  mutated = False
697
  frenzied = False
 
698
 
699
  for step in finale_steps_bad(wounds):
700
  role = "assistant" if step["speaker"] == "hollow" else "user"
@@ -715,23 +731,27 @@ def _play_finale_bad(state: dict, history: list):
715
  yours=True)
716
  input_locked = gr.update(interactive=False, placeholder="get out.")
717
 
718
- # the child stays a blurred smudge through the whole recital; the rage
719
- # face is revealed only AFTER the convulsion, with the threat
720
  if step["stage"] == "frenzy":
721
  frenzied = True
722
- entity = render_entity(state["affinity"], "frenzy", seq=state["turn"])
 
723
  elif frenzied:
724
- entity = render_entity(state["affinity"], "rage", seq=state["turn"])
 
725
 
726
  speak_it = step["speaker"] == "hollow"
727
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
728
- yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity, voice)
 
729
  if pause > 0:
730
  time.sleep(pause)
731
 
732
  yield (
733
  gr.update(interactive=False, placeholder="see you soon."),
734
- locked, chat_hist, state, bond, treasure_html, entity, _voice_html(None),
 
735
  )
736
 
737
 
@@ -744,12 +764,14 @@ def _play_finale_good(state: dict, history: list):
744
  bond = _render_bond(state["affinity"], tier)
745
  treasure_html = render_treasure(state["treasure"],
746
  claimed=set(state["claimed"]))
747
- entity = render_entity(state["affinity"], "build", seq=state["turn"])
 
748
  locked = gr.update(interactive=False)
749
  input_locked = gr.update(interactive=False)
750
  mutated = False
751
  convulsed = False
752
  settled = False
 
753
 
754
  for step in finale_steps_good(OWN_FRAGMENTS):
755
  chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
@@ -765,30 +787,32 @@ def _play_finale_good(state: dict, history: list):
765
  if step["stage"] == "frenzy":
766
  convulsed = True
767
  entity = render_entity(state["affinity"], "convulse_good",
768
- seq=state["turn"])
769
  elif convulsed and step["stage"] != "loop":
770
  # the smile lands + the relief sigh plays, exactly now (seamless —
771
  # the convulsion already settled on this face); sigh only once
772
  if not settled:
773
  settled = True
774
  entity = render_entity(state["affinity"], "peace_settle",
775
- seq=state["turn"])
776
  else:
777
  entity = render_entity(state["affinity"], "peace",
778
- seq=state["turn"])
779
 
780
  if step["stage"] == "loop":
781
  entity = render_entity(state["affinity"], "peace_dissolve",
782
- seq=state["turn"])
783
 
784
  voice, pause = _voice_and_pause(step["text"], True, step["pause"])
785
- yield (input_locked, locked, chat_hist, state, bond, treasure_html, entity, voice)
 
786
  if pause > 0:
787
  time.sleep(pause)
788
 
789
  yield (
790
  gr.update(interactive=False, placeholder="it's quiet now."),
791
- locked, chat_hist, state, bond, treasure_html, entity, _voice_html(None),
 
792
  )
793
 
794
 
@@ -797,10 +821,10 @@ def chat(user_msg: str, state: dict, history: list):
797
  # logic is unchanged (match on the sentinel class, robust to re-serializing)
798
  if history and "hollow-typing" in str(history[-1].get("content", "")):
799
  history = history[:-1]
 
 
 
800
  if not user_msg.strip() or state.get("ended"):
801
- ending = state.get("ending")
802
- if state.get("ended") and ending is None:
803
- ending = "loop" # sessions ended before `ending` existed
804
  tier = get_tier(state["affinity"])
805
  wounds = state.get("wounds", [])
806
  entity_mode = {"good": "peace", "bad": "rage", "loop": "end"}.get(
@@ -816,8 +840,10 @@ def chat(user_msg: str, state: dict, history: list):
816
  else set(state["claimed"]),
817
  wounds=wounds, revealed=len(wounds),
818
  yours=(ending == "bad")),
819
- render_entity(state["affinity"], entity_mode, seq=state["turn"]),
 
820
  _voice_html(None),
 
821
  )
822
  return
823
 
@@ -832,8 +858,10 @@ def chat(user_msg: str, state: dict, history: list):
832
  _render_bond(state["affinity"], tier),
833
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
834
  wounds=state.get("wounds", [])),
835
- render_entity(state["affinity"], "idle", seq=state["turn"]),
 
836
  _voice_html(None),
 
837
  )
838
  return
839
 
@@ -899,8 +927,10 @@ def chat(user_msg: str, state: dict, history: list):
899
  _render_bond(state["affinity"], tier_now),
900
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
901
  wounds=state.get("wounds", [])),
902
- render_entity(state["affinity"], "idle", seq=state["turn"]),
 
903
  voice,
 
904
  )
905
  except Exception as e:
906
  print(f"[chat] run_turn_stream failed: {e!r}")
@@ -939,6 +969,7 @@ def chat(user_msg: str, state: dict, history: list):
939
  _plea = _memory_plea_level(state, bool(do_recall and recall_memory))
940
  _ph = ("a memory: a person, a place, a moment you lived..."
941
  if _plea >= 2 else "tell it something you remember...")
 
942
  yield (
943
  gr.update(value="", interactive=True, placeholder=_ph),
944
  gr.update(interactive=True),
@@ -946,9 +977,12 @@ def chat(user_msg: str, state: dict, history: list):
946
  state,
947
  _render_bond(state["affinity"], tier),
948
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
949
- wounds=state.get("wounds", [])),
950
- render_entity(state["affinity"], mode, seq=state["turn"]),
 
 
951
  voice,
 
952
  )
953
 
954
 
@@ -996,47 +1030,38 @@ with gr.Blocks(title="Hollow") as demo:
996
  elem_classes="intro-enter-btn")
997
 
998
  with gr.Column(visible=False, elem_id="game-view") as game_view:
999
- with gr.Row():
1000
- gr.Markdown(
1001
- "## Hollow\n*something is here, at the edge of the wood.<br>"
1002
- "it remembers what you give it — and how you give it.*"
1003
- )
1004
  restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
1005
  scale=0)
1006
  voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
1007
 
1008
- with gr.Row():
1009
- with gr.Column(scale=1, min_width=220):
1010
- entity_panel = gr.HTML(value=render_entity(20))
1011
-
1012
- with gr.Column(scale=2):
1013
- chatbot = gr.Chatbot(
1014
- value=[{"role": "assistant", "content": OPENING_LINE}],
1015
- label="",
1016
- show_label=False,
1017
- height=500,
1018
- buttons=[],
1019
- autoscroll=True,
1020
- )
1021
-
1022
- bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
1023
-
1024
- with gr.Row():
1025
- msg_input = gr.Textbox(
1026
- placeholder="tell it something you remember...",
1027
- show_label=False,
1028
- scale=9,
1029
- autofocus=True,
1030
- )
1031
- send_btn = gr.Button("", scale=1)
1032
-
1033
- with gr.Column(scale=1):
1034
- treasure_panel = gr.HTML(value=render_treasure([]))
1035
- # carries Hollow's voice <audio>; kept MOUNTED (not visible=False,
1036
- # which can unmount it so the audio never plays) and collapsed to
1037
- # zero size via CSS so it has no visual footprint
1038
- voice_panel = gr.HTML(value="",
1039
- elem_classes="voice-channel")
1040
 
1041
  # show_progress="hidden" kills Gradio's per-component "processing" overlay;
1042
  # we show our own "..." pending bubble instead (set in _start_turn)
@@ -1048,7 +1073,8 @@ with gr.Blocks(title="Hollow") as demo:
1048
  ).then(
1049
  fn=chat,
1050
  inputs=[msg_input, state, chatbot],
1051
- outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel],
 
1052
  show_progress="hidden",
1053
  )
1054
 
@@ -1060,7 +1086,8 @@ with gr.Blocks(title="Hollow") as demo:
1060
  ).then(
1061
  fn=chat,
1062
  inputs=[msg_input, state, chatbot],
1063
- outputs=[msg_input, send_btn, chatbot, state, bond_panel, treasure_panel, entity_panel, voice_panel],
 
1064
  show_progress="hidden",
1065
  )
1066
 
@@ -1068,7 +1095,7 @@ with gr.Blocks(title="Hollow") as demo:
1068
  fn=_reset,
1069
  inputs=None,
1070
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1071
- treasure_panel, entity_panel, voice_panel],
1072
  show_progress="hidden",
1073
  )
1074
 
@@ -1082,7 +1109,7 @@ with gr.Blocks(title="Hollow") as demo:
1082
  show_progress="hidden")
1083
 
1084
  _enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
1085
- treasure_panel, entity_panel]
1086
  _intro_outputs = [menu_view, intro_view, pending_mode]
1087
  tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
1088
  show_progress="hidden")
 
21
  from engine import run_turn, run_turn_stream
22
  from finale import finale_steps, finale_steps_bad, finale_steps_good
23
  from memory import apply_update, get_tier, decide_ending, pick_aware_memory, should_recall, style_signal
24
+ from render import render_entity, render_treasure, render_recovered, _tone_class
25
  from sting import chime_wav_bytes, menu_loop_wav_bytes, type_tick_wav_bytes
26
  from voice import speak
27
 
 
74
 
75
  def _reset():
76
  """Wipe the board for a fresh game without a page reload. Order matches
77
+ [msg_input, send_btn, chatbot, state, bond_panel, treasure_panel,
78
+ entity_panel, voice_panel, recovered_panel]."""
79
  fresh = _pristine_state()
80
  return (
81
  gr.update(value="", interactive=True,
 
87
  render_treasure([]),
88
  render_entity(20),
89
  _voice_html(None),
90
+ render_recovered(0),
91
  )
92
 
93
 
 
401
  }, 130);
402
  }
403
 
404
+ // mute button + restart + mode buttons, wired once rendered
405
  function icon() { return st.muted ? '\\uD83D\\uDD07' : '\\uD83D\\uDD0A'; }
406
  function wire() {
407
+ // corner drawers: header chip toggles expand
408
+ document.querySelectorAll('.game-drawer .drawer-head').forEach(function (h) {
409
+ if (h.dataset.hollowWired) return;
410
+ h.dataset.hollowWired = '1';
411
+ h.addEventListener('click', function () { h.closest('.game-drawer').classList.toggle('open'); });
412
+ });
413
  var btn = document.querySelector('.voice-btn');
414
  var rb = document.querySelector('.restart-btn');
415
  if (btn && !btn.dataset.hollowWired) {
 
538
  _render_bond(state["affinity"], get_tier(state["affinity"])),
539
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
540
  wounds=state.get("wounds", [])),
541
+ render_entity(state["affinity"], tone=state["tone"]),
542
+ render_recovered(state.get("fragments_told", 0)),
543
  )
544
 
545
 
 
648
  bond = _render_bond(state["affinity"], tier)
649
  treasure_html = render_treasure(state["treasure"], struck=struck,
650
  claimed=set(claimed))
651
+ entity = render_entity(state["affinity"], "build", seq=state["turn"],
652
+ tone=state.get("tone", 0))
653
  locked = gr.update(interactive=False)
654
  input_locked = gr.update(interactive=False)
655
  mutated = False
656
  convulsed = False
657
+ recovered = render_recovered(state.get("fragments_told", 0))
658
 
659
  for step in finale_steps(claimed):
660
  role = "assistant" if step["speaker"] == "hollow" else "user"
 
668
  if step["stage"] == "frenzy":
669
  convulsed = True
670
  entity = render_entity(state["affinity"], "convulse_loop",
671
+ seq=state["turn"], tone=state.get("tone", 0))
672
 
673
  if step["stage"] == "turn" and not mutated:
674
  mutated = True
 
676
  treasure_html = render_treasure(state["treasure"], mine=True)
677
  # the `end` face lands and the heartbeat flatlines, exactly now
678
  # (seamless — the convulsion already settled on this face)
679
+ entity = render_entity(state["affinity"], "end_settle",
680
+ seq=state["turn"], tone=state.get("tone", 0))
681
  input_locked = gr.update(interactive=False, placeholder="stay.")
682
 
683
  speak_it = step["speaker"] == "hollow"
684
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
685
+ yield (input_locked, locked, chat_hist, state, bond, treasure_html,
686
+ entity, voice, recovered)
687
  if pause > 0:
688
  time.sleep(pause)
689
 
690
  yield (
691
  gr.update(interactive=False, placeholder=_DEAD_PLACEHOLDER),
692
+ locked, chat_hist, state, bond, treasure_html, entity,
693
+ _voice_html(None), recovered,
694
  )
695
 
696
 
 
704
  revealed = 0
705
  treasure_html = render_treasure(state["treasure"], claimed=set(state["claimed"]),
706
  wounds=wounds, revealed=0)
707
+ entity = render_entity(state["affinity"], "build", seq=state["turn"],
708
+ tone=state.get("tone", 0))
709
  locked = gr.update(interactive=False)
710
  input_locked = gr.update(interactive=False)
711
  mutated = False
712
  frenzied = False
713
+ recovered = render_recovered(state.get("fragments_told", 0))
714
 
715
  for step in finale_steps_bad(wounds):
716
  role = "assistant" if step["speaker"] == "hollow" else "user"
 
731
  yours=True)
732
  input_locked = gr.update(interactive=False, placeholder="get out.")
733
 
734
+ # the child stays a smudge through the whole recital; the rage face is
735
+ # revealed only AFTER the convulsion, with the threat
736
  if step["stage"] == "frenzy":
737
  frenzied = True
738
+ entity = render_entity(state["affinity"], "frenzy",
739
+ seq=state["turn"], tone=state.get("tone", 0))
740
  elif frenzied:
741
+ entity = render_entity(state["affinity"], "rage",
742
+ seq=state["turn"], tone=state.get("tone", 0))
743
 
744
  speak_it = step["speaker"] == "hollow"
745
  voice, pause = _voice_and_pause(step["text"], speak_it, step["pause"])
746
+ yield (input_locked, locked, chat_hist, state, bond, treasure_html,
747
+ entity, voice, recovered)
748
  if pause > 0:
749
  time.sleep(pause)
750
 
751
  yield (
752
  gr.update(interactive=False, placeholder="see you soon."),
753
+ locked, chat_hist, state, bond, treasure_html, entity,
754
+ _voice_html(None), recovered,
755
  )
756
 
757
 
 
764
  bond = _render_bond(state["affinity"], tier)
765
  treasure_html = render_treasure(state["treasure"],
766
  claimed=set(state["claimed"]))
767
+ entity = render_entity(state["affinity"], "build", seq=state["turn"],
768
+ tone=state.get("tone", 0))
769
  locked = gr.update(interactive=False)
770
  input_locked = gr.update(interactive=False)
771
  mutated = False
772
  convulsed = False
773
  settled = False
774
+ recovered = render_recovered(state.get("fragments_told", 0))
775
 
776
  for step in finale_steps_good(OWN_FRAGMENTS):
777
  chat_hist = chat_hist + [{"role": "assistant", "content": step["text"]}]
 
787
  if step["stage"] == "frenzy":
788
  convulsed = True
789
  entity = render_entity(state["affinity"], "convulse_good",
790
+ seq=state["turn"], tone=state.get("tone", 0))
791
  elif convulsed and step["stage"] != "loop":
792
  # the smile lands + the relief sigh plays, exactly now (seamless —
793
  # the convulsion already settled on this face); sigh only once
794
  if not settled:
795
  settled = True
796
  entity = render_entity(state["affinity"], "peace_settle",
797
+ seq=state["turn"], tone=state.get("tone", 0))
798
  else:
799
  entity = render_entity(state["affinity"], "peace",
800
+ seq=state["turn"], tone=state.get("tone", 0))
801
 
802
  if step["stage"] == "loop":
803
  entity = render_entity(state["affinity"], "peace_dissolve",
804
+ seq=state["turn"], tone=state.get("tone", 0))
805
 
806
  voice, pause = _voice_and_pause(step["text"], True, step["pause"])
807
+ yield (input_locked, locked, chat_hist, state, bond, treasure_html,
808
+ entity, voice, recovered)
809
  if pause > 0:
810
  time.sleep(pause)
811
 
812
  yield (
813
  gr.update(interactive=False, placeholder="it's quiet now."),
814
+ locked, chat_hist, state, bond, treasure_html, entity,
815
+ _voice_html(None), recovered,
816
  )
817
 
818
 
 
821
  # logic is unchanged (match on the sentinel class, robust to re-serializing)
822
  if history and "hollow-typing" in str(history[-1].get("content", "")):
823
  history = history[:-1]
824
+ ending = state.get("ending")
825
+ if state.get("ended") and ending is None:
826
+ ending = "loop" # sessions ended before `ending` existed
827
  if not user_msg.strip() or state.get("ended"):
 
 
 
828
  tier = get_tier(state["affinity"])
829
  wounds = state.get("wounds", [])
830
  entity_mode = {"good": "peace", "bad": "rage", "loop": "end"}.get(
 
840
  else set(state["claimed"]),
841
  wounds=wounds, revealed=len(wounds),
842
  yours=(ending == "bad")),
843
+ render_entity(state["affinity"], entity_mode, seq=state["turn"],
844
+ tone=state.get("tone", 0)),
845
  _voice_html(None),
846
+ render_recovered(state.get("fragments_told", 0)),
847
  )
848
  return
849
 
 
858
  _render_bond(state["affinity"], tier),
859
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
860
  wounds=state.get("wounds", [])),
861
+ render_entity(state["affinity"], "idle", seq=state["turn"],
862
+ tone=state.get("tone", 0)),
863
  _voice_html(None),
864
+ render_recovered(state.get("fragments_told", 0)),
865
  )
866
  return
867
 
 
927
  _render_bond(state["affinity"], tier_now),
928
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
929
  wounds=state.get("wounds", [])),
930
+ render_entity(state["affinity"], "idle", seq=state["turn"],
931
+ tone=state.get("tone", 0)),
932
  voice,
933
+ render_recovered(state.get("fragments_told", 0)),
934
  )
935
  except Exception as e:
936
  print(f"[chat] run_turn_stream failed: {e!r}")
 
969
  _plea = _memory_plea_level(state, bool(do_recall and recall_memory))
970
  _ph = ("a memory: a person, a place, a moment you lived..."
971
  if _plea >= 2 else "tell it something you remember...")
972
+ just_claimed = recall_memory if (do_recall and recall_memory) else None
973
  yield (
974
  gr.update(value="", interactive=True, placeholder=_ph),
975
  gr.update(interactive=True),
 
977
  state,
978
  _render_bond(state["affinity"], tier),
979
  render_treasure(state["treasure"], claimed=set(state["claimed"]),
980
+ wounds=state.get("wounds", []),
981
+ just_claimed=just_claimed),
982
+ render_entity(state["affinity"], mode, seq=state["turn"],
983
+ tone=state.get("tone", 0)),
984
  voice,
985
+ render_recovered(state.get("fragments_told", 0)),
986
  )
987
 
988
 
 
1030
  elem_classes="intro-enter-btn")
1031
 
1032
  with gr.Column(visible=False, elem_id="game-view") as game_view:
1033
+ gr.HTML('<div id="game-bg"></div><div id="game-vig"></div>'
1034
+ '<div id="game-frame-edge"></div>')
1035
+ with gr.Row(elem_id="game-topbar"):
1036
+ gr.HTML('<div id="game-title">Hollow</div>')
 
1037
  restart_btn = gr.Button("↺ begin again", elem_classes="restart-btn",
1038
  scale=0)
1039
  voice_btn = gr.Button("🔊", elem_classes="voice-btn", scale=0)
1040
 
1041
+ # left drawer — your memories
1042
+ with gr.Column(elem_id="drawer-left", elem_classes="game-drawer"):
1043
+ treasure_panel = gr.HTML(value=render_treasure([]))
1044
+ # right drawer — its own recovered memories
1045
+ with gr.Column(elem_id="drawer-right", elem_classes="game-drawer"):
1046
+ recovered_panel = gr.HTML(value=render_recovered(0))
1047
+
1048
+ # center: the child silhouette in the fog
1049
+ entity_panel = gr.HTML(value=render_entity(20), elem_id="game-entity")
1050
+
1051
+ # bottom: the conversation as subtitle/dialogue + input
1052
+ with gr.Column(elem_id="game-dialogue"):
1053
+ chatbot = gr.Chatbot(
1054
+ value=[{"role": "assistant", "content": OPENING_LINE}],
1055
+ label="", show_label=False, height=210, buttons=[],
1056
+ autoscroll=True,
1057
+ )
1058
+ bond_panel = gr.HTML(value=_render_bond(20, "Hollow"))
1059
+ with gr.Row(elem_id="game-inputrow"):
1060
+ msg_input = gr.Textbox(
1061
+ placeholder="tell it something you remember...",
1062
+ show_label=False, scale=9, autofocus=True)
1063
+ send_btn = gr.Button("→", scale=1)
1064
+ voice_panel = gr.HTML(value="", elem_classes="voice-channel")
 
 
 
 
 
 
 
 
1065
 
1066
  # show_progress="hidden" kills Gradio's per-component "processing" overlay;
1067
  # we show our own "..." pending bubble instead (set in _start_turn)
 
1073
  ).then(
1074
  fn=chat,
1075
  inputs=[msg_input, state, chatbot],
1076
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1077
+ treasure_panel, entity_panel, voice_panel, recovered_panel],
1078
  show_progress="hidden",
1079
  )
1080
 
 
1086
  ).then(
1087
  fn=chat,
1088
  inputs=[msg_input, state, chatbot],
1089
+ outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1090
+ treasure_panel, entity_panel, voice_panel, recovered_panel],
1091
  show_progress="hidden",
1092
  )
1093
 
 
1095
  fn=_reset,
1096
  inputs=None,
1097
  outputs=[msg_input, send_btn, chatbot, state, bond_panel,
1098
+ treasure_panel, entity_panel, voice_panel, recovered_panel],
1099
  show_progress="hidden",
1100
  )
1101
 
 
1109
  show_progress="hidden")
1110
 
1111
  _enter_outputs = [intro_view, game_view, state, chatbot, bond_panel,
1112
+ treasure_panel, entity_panel, recovered_panel]
1113
  _intro_outputs = [menu_view, intro_view, pending_mode]
1114
  tester_btn.click(lambda: _show_intro("tester"), None, _intro_outputs,
1115
  show_progress="hidden")
render.py CHANGED
@@ -2,6 +2,7 @@ import base64
2
  import html
3
  from pathlib import Path
4
 
 
5
  from sting import (flatline_wav_bytes, heartbeat_wav_bytes,
6
  sigh_wav_bytes, sting_wav_bytes)
7
 
@@ -305,15 +306,31 @@ def _wound_entries(wounds: list[str], revealed: int) -> list[str]:
305
  return parts
306
 
307
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
308
  def render_treasure(treasure: list[str], struck: set[str] | None = None,
309
  mine: bool = False, claimed: set[str] | None = None,
310
  wounds: list[str] | None = None, revealed: int = 0,
311
- yours: bool = False) -> str:
312
  struck = struck or set()
313
  claimed = claimed or set()
314
  wounds = wounds or []
315
  title = "✦ Your Words" if yours else "✦ Treasure"
316
- header = f'<div style="{_HEADER}">{title}</div>'
317
  if mine:
318
  body = f'<div style="{_MINE}">...mine now.</div>'
319
  elif yours:
@@ -328,13 +345,19 @@ def render_treasure(treasure: list[str], struck: set[str] | None = None,
328
  for i, m in enumerate(treasure):
329
  # older memories sink into the dark; newest stays fully lit
330
  age_op = max(0.45, round(1 - 0.07 * (n - 1 - i), 2))
 
 
 
 
 
331
  style = f"{_ITEM}opacity:{age_op};"
332
  if m in claimed:
333
  style += _CLAIMED
334
  if m in struck:
335
  style += _STRUCK
 
336
  parts.append(
337
- f'<div style="{style}">{html.escape(m, quote=True)}</div>'
338
  )
339
  parts.extend(_wound_entries(wounds, revealed))
340
  body = "".join(parts)
 
2
  import html
3
  from pathlib import Path
4
 
5
+ from character import OWN_FRAGMENTS
6
  from sting import (flatline_wav_bytes, heartbeat_wav_bytes,
7
  sigh_wav_bytes, sting_wav_bytes)
8
 
 
306
  return parts
307
 
308
 
309
+ def render_recovered(fragments_told: int) -> str:
310
+ """The child's own recovered memories — the right-hand drawer."""
311
+ n = max(0, min(int(fragments_told), len(OWN_FRAGMENTS)))
312
+ head = f'<div class="drawer-head" style="{_HEADER}">✦ What it remembers</div>'
313
+ if n == 0:
314
+ body = f'<div style="{_EMPTY}">it remembers nothing of itself.</div>'
315
+ else:
316
+ rows = "".join(
317
+ f'<div class="recovered-item" style="{_ITEM}{_CLAIMED}font-style:italic;">'
318
+ f'{html.escape(OWN_FRAGMENTS[i], quote=True)}</div>'
319
+ for i in range(n)
320
+ )
321
+ body = rows
322
+ return f'<div class="recovered-panel" style="{_PANEL}">{head}{body}</div>'
323
+
324
+
325
  def render_treasure(treasure: list[str], struck: set[str] | None = None,
326
  mine: bool = False, claimed: set[str] | None = None,
327
  wounds: list[str] | None = None, revealed: int = 0,
328
+ yours: bool = False, just_claimed: str | None = None) -> str:
329
  struck = struck or set()
330
  claimed = claimed or set()
331
  wounds = wounds or []
332
  title = "✦ Your Words" if yours else "✦ Treasure"
333
+ header = f'<div class="drawer-head" style="{_HEADER}">{title}</div>'
334
  if mine:
335
  body = f'<div style="{_MINE}">...mine now.</div>'
336
  elif yours:
 
345
  for i, m in enumerate(treasure):
346
  # older memories sink into the dark; newest stays fully lit
347
  age_op = max(0.45, round(1 - 0.07 * (n - 1 - i), 2))
348
+ classes = ["treasure-item"]
349
+ if m == just_claimed:
350
+ classes.append("claiming")
351
+ if m in claimed:
352
+ classes.append("claimed")
353
  style = f"{_ITEM}opacity:{age_op};"
354
  if m in claimed:
355
  style += _CLAIMED
356
  if m in struck:
357
  style += _STRUCK
358
+ cls = " ".join(classes)
359
  parts.append(
360
+ f'<div class="{cls}" style="{style}">{html.escape(m, quote=True)}</div>'
361
  )
362
  parts.extend(_wound_entries(wounds, revealed))
363
  body = "".join(parts)
styles.css CHANGED
@@ -1319,3 +1319,303 @@ footer { display: none !important; }
1319
  background: rgba(30,24,42,0.82) !important;
1320
  border-color: rgba(190,178,210,0.6) !important;
1321
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1319
  background: rgba(30,24,42,0.82) !important;
1320
  border-color: rgba(190,178,210,0.6) !important;
1321
  }
1322
+
1323
+ /* ── 26. Game scene — letterboxed foggy wood (Direction 3b) ── */
1324
+ #game-view {
1325
+ position: fixed;
1326
+ inset: 0;
1327
+ overflow: hidden;
1328
+ background: #04040a;
1329
+ z-index: 50;
1330
+ }
1331
+ #game-view.hide, #game-view.hidden { display: none !important; }
1332
+
1333
+ /* blurred ambient forest backdrop, same treatment as intro/menu */
1334
+ #game-bg {
1335
+ position: fixed;
1336
+ inset: -6%;
1337
+ background-image: url('assets/background.webp');
1338
+ background-size: cover;
1339
+ background-position: center 38%;
1340
+ filter: grayscale(1) blur(44px) brightness(0.30);
1341
+ transform: scale(1.08);
1342
+ z-index: 0;
1343
+ }
1344
+
1345
+ /* radial vignette — the scene breathes through this */
1346
+ #game-vig {
1347
+ position: fixed;
1348
+ inset: 0;
1349
+ pointer-events: none;
1350
+ z-index: 1;
1351
+ background:
1352
+ radial-gradient(ellipse at 50% 36%, transparent 34%, rgba(4,3,8,0.78) 100%),
1353
+ linear-gradient(to bottom, rgba(6,4,12,0.62) 0%, transparent 13%);
1354
+ }
1355
+
1356
+ /* a subtle inner frame edge so the scene feels contained like the intro */
1357
+ #game-frame-edge {
1358
+ position: fixed;
1359
+ left: 50%;
1360
+ top: 50%;
1361
+ transform: translate(-50%, -50%);
1362
+ width: min(1280px, 95vw);
1363
+ aspect-ratio: 16 / 10;
1364
+ max-height: 82vh;
1365
+ pointer-events: none;
1366
+ z-index: 2;
1367
+ box-shadow: inset 0 0 90px rgba(0,0,0,0.55);
1368
+ }
1369
+
1370
+ /* top bar: title + controls */
1371
+ #game-topbar {
1372
+ position: fixed;
1373
+ top: 0;
1374
+ left: 0;
1375
+ right: 0;
1376
+ z-index: 10;
1377
+ display: flex;
1378
+ align-items: center;
1379
+ justify-content: space-between;
1380
+ padding: 14px 22px;
1381
+ pointer-events: none;
1382
+ }
1383
+ #game-topbar > * { pointer-events: auto; }
1384
+
1385
+ #game-title {
1386
+ font-family: 'Special Elite', monospace;
1387
+ font-size: 1.05rem;
1388
+ letter-spacing: 0.22em;
1389
+ text-transform: uppercase;
1390
+ color: #c4bcd8;
1391
+ text-shadow: 0 1px 6px rgba(0,0,0,0.9), 0 0 12px rgba(120,90,160,0.35);
1392
+ }
1393
+ #game-title .named {
1394
+ font-style: italic;
1395
+ text-transform: none;
1396
+ letter-spacing: 0.08em;
1397
+ color: #e2dcf0;
1398
+ }
1399
+
1400
+ /* corner drawers */
1401
+ .game-drawer {
1402
+ position: fixed;
1403
+ top: 64px;
1404
+ z-index: 8;
1405
+ width: min(260px, 22vw);
1406
+ min-width: 180px;
1407
+ max-height: calc(100vh - 90px);
1408
+ transition: max-height 0.45s ease, opacity 0.35s ease;
1409
+ overflow: hidden;
1410
+ opacity: 0.75;
1411
+ }
1412
+ .game-drawer:hover { opacity: 1; }
1413
+ .game-drawer.open { max-height: calc(100vh - 90px); }
1414
+ .game-drawer:not(.open) {
1415
+ max-height: 42px;
1416
+ cursor: pointer;
1417
+ }
1418
+ #drawer-left { left: 18px; }
1419
+ #drawer-right { right: 18px; }
1420
+
1421
+ .drawer-head {
1422
+ cursor: pointer;
1423
+ user-select: none;
1424
+ padding-bottom: 6px !important;
1425
+ border-bottom: 1px solid #1e1628;
1426
+ margin-bottom: 8px;
1427
+ }
1428
+ .drawer-head::after {
1429
+ content: ' ›';
1430
+ opacity: 0.55;
1431
+ margin-left: 6px;
1432
+ display: inline-block;
1433
+ transition: transform 0.3s ease;
1434
+ }
1435
+ .game-drawer.open .drawer-head::after {
1436
+ transform: rotate(90deg);
1437
+ }
1438
+
1439
+ /* the child silhouette zone — center, bottom-weighted */
1440
+ #game-entity {
1441
+ position: fixed;
1442
+ left: 50%;
1443
+ bottom: 0;
1444
+ transform: translateX(-50%);
1445
+ z-index: 4;
1446
+ width: min(360px, 26vw);
1447
+ min-width: 220px;
1448
+ height: min(520px, 58vh);
1449
+ pointer-events: none;
1450
+ display: flex;
1451
+ align-items: flex-end;
1452
+ justify-content: center;
1453
+ }
1454
+
1455
+ .entity-scene {
1456
+ width: 100%;
1457
+ height: 100%;
1458
+ position: relative;
1459
+ display: flex;
1460
+ align-items: flex-end;
1461
+ justify-content: center;
1462
+ }
1463
+
1464
+ .entity-silhouette {
1465
+ position: relative;
1466
+ width: 100%;
1467
+ height: 100%;
1468
+ display: flex;
1469
+ align-items: flex-end;
1470
+ justify-content: center;
1471
+ }
1472
+
1473
+ .entity-silhouette img {
1474
+ position: absolute;
1475
+ bottom: 0;
1476
+ width: 100% !important;
1477
+ height: auto !important;
1478
+ max-height: 100%;
1479
+ object-fit: contain !important;
1480
+ object-position: bottom center !important;
1481
+ }
1482
+
1483
+ .entity-glow {
1484
+ position: absolute;
1485
+ inset: 10% 0 0 0;
1486
+ background: radial-gradient(ellipse 50% 60% at 50% 80%,
1487
+ rgba(140,120,170,0.18) 0%, transparent 70%);
1488
+ pointer-events: none;
1489
+ opacity: 0.6;
1490
+ mix-blend-mode: screen;
1491
+ }
1492
+
1493
+ /* bottom subtitle / dialogue panel */
1494
+ #game-dialogue {
1495
+ position: fixed;
1496
+ left: 50%;
1497
+ bottom: 22px;
1498
+ transform: translateX(-50%);
1499
+ z-index: 9;
1500
+ width: min(820px, 80vw);
1501
+ display: flex;
1502
+ flex-direction: column;
1503
+ gap: 8px;
1504
+ }
1505
+
1506
+ #game-dialogue .chatbot {
1507
+ height: 210px !important;
1508
+ background: rgba(10,8,16,0.55) !important;
1509
+ border: 10px solid transparent !important;
1510
+ border-image: var(--stone-frame) 30 !important;
1511
+ box-shadow: var(--stone-bevel),
1512
+ 0 -20px 60px rgba(0,0,0,0.55) !important;
1513
+ backdrop-filter: blur(2px);
1514
+ -webkit-backdrop-filter: blur(2px);
1515
+ padding: 10px 14px !important;
1516
+ }
1517
+
1518
+ #game-dialogue .bot,
1519
+ #game-dialogue .bot .prose,
1520
+ #game-dialogue .bot .message,
1521
+ #game-dialogue .bot p {
1522
+ font-style: italic !important;
1523
+ font-size: 1.14rem !important;
1524
+ color: #d8d2e6 !important;
1525
+ text-shadow: 0 0 16px rgba(120,70,160,0.22);
1526
+ }
1527
+
1528
+ #game-dialogue .user,
1529
+ #game-dialogue .user .prose,
1530
+ #game-dialogue .user .message,
1531
+ #game-dialogue .user p {
1532
+ font-style: normal !important;
1533
+ font-size: 1.02rem !important;
1534
+ color: #e6e0f0 !important;
1535
+ }
1536
+
1537
+ #game-dialogue .user {
1538
+ background: rgba(18,14,28,0.72) !important;
1539
+ }
1540
+
1541
+ #game-inputrow {
1542
+ display: flex;
1543
+ gap: 8px;
1544
+ align-items: flex-end;
1545
+ }
1546
+
1547
+ #game-inputrow textarea,
1548
+ #game-inputrow input[type="text"] {
1549
+ background: rgba(8,6,14,0.55) !important;
1550
+ border-bottom: 1px solid #3a2a50 !important;
1551
+ padding: 8px 10px !important;
1552
+ }
1553
+
1554
+ /* tone classes — the world blooms or rots */
1555
+ .tone-warm #game-bg { filter: grayscale(1) blur(44px) brightness(0.38); }
1556
+ .tone-warm #game-vig {
1557
+ background:
1558
+ radial-gradient(ellipse at 50% 36%, transparent 38%, rgba(4,3,8,0.62) 100%),
1559
+ linear-gradient(to bottom, rgba(6,4,12,0.45) 0%, transparent 13%);
1560
+ }
1561
+ .tone-warm .entity-img { filter: brightness(1.05) !important; }
1562
+
1563
+ .tone-neutral #game-bg { filter: grayscale(1) blur(44px) brightness(0.30); }
1564
+
1565
+ .tone-wounded #game-bg { filter: grayscale(1) blur(46px) brightness(0.22); }
1566
+ .tone-wounded #game-vig {
1567
+ background:
1568
+ radial-gradient(ellipse at 50% 36%, transparent 30%, rgba(4,3,8,0.86) 100%),
1569
+ linear-gradient(to bottom, rgba(6,4,12,0.75) 0%, transparent 13%);
1570
+ }
1571
+ .tone-wounded .entity-img { opacity: 0.78 !important; }
1572
+
1573
+ .tone-hostile #game-bg {
1574
+ filter: grayscale(1) blur(46px) brightness(0.20);
1575
+ }
1576
+ .tone-hostile #game-vig {
1577
+ background:
1578
+ radial-gradient(ellipse at 50% 36%, transparent 26%, rgba(30,4,8,0.82) 100%),
1579
+ linear-gradient(to bottom, rgba(40,6,12,0.55) 0%, transparent 20%);
1580
+ }
1581
+ .tone-hostile .entity-img {
1582
+ filter: sepia(0.25) saturate(1.4) brightness(0.85) !important;
1583
+ }
1584
+
1585
+ /* recall migration — the claimed memory drifts toward the child */
1586
+ .treasure-item { transition: opacity 0.6s ease; }
1587
+ .treasure-item.claiming {
1588
+ animation: claim-migrate 1.4s ease-out forwards;
1589
+ }
1590
+
1591
+ @keyframes claim-migrate {
1592
+ 0% { transform: translateX(0); opacity: 1; }
1593
+ 45% { transform: translateX(60%); opacity: 0.6; }
1594
+ 100% { transform: translateX(120%); opacity: 0.25; }
1595
+ }
1596
+
1597
+ /* keep existing entity animations working inside the new scene */
1598
+ .entity-flash-wrap,
1599
+ .entity-frenzy-wrap,
1600
+ .entity-convulse-soft {
1601
+ position: absolute;
1602
+ inset: 0;
1603
+ pointer-events: none;
1604
+ }
1605
+
1606
+ /* ensure the global body stains intensify under hostile tone */
1607
+ .tone-hostile ~ .gradio-container::after,
1608
+ .tone-hostile + .gradio-container::after {
1609
+ opacity: 1.35;
1610
+ }
1611
+
1612
+ @media (max-width: 900px) {
1613
+ .game-drawer { width: 150px; min-width: 140px; }
1614
+ #game-entity { width: 42vw; min-width: 160px; }
1615
+ #game-dialogue { width: 92vw; }
1616
+ }
1617
+
1618
+ @media (prefers-reduced-motion: reduce) {
1619
+ .entity-sway { animation: none !important; }
1620
+ .treasure-item.claiming { animation: none !important; opacity: 0.4; }
1621
+ }
tests/test_render.py CHANGED
@@ -1,4 +1,4 @@
1
- from render import render_entity, render_treasure
2
 
3
 
4
  def test_entity_renders_silhouette_scene():
@@ -8,6 +8,25 @@ def test_entity_renders_silhouette_scene():
8
  assert 'entity-portal' not in html # the gothic arch is gone
9
 
10
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
  class TestRenderTreasure:
12
  def test_empty_shows_placeholder(self):
13
  out = render_treasure([])
@@ -161,6 +180,13 @@ class TestRenderTreasureUX:
161
  out = render_treasure(["mine still"])
162
  assert "border-left-color:#3a2a50" not in out
163
 
 
 
 
 
 
 
 
164
 
165
  class TestTreasureWounds:
166
  def test_redacted_entry_per_wound(self):
 
1
+ from render import render_entity, render_treasure, render_recovered
2
 
3
 
4
  def test_entity_renders_silhouette_scene():
 
8
  assert 'entity-portal' not in html # the gothic arch is gone
9
 
10
 
11
+ class TestRenderRecovered:
12
+ def test_empty_shows_nothing(self):
13
+ out = render_recovered(0)
14
+ assert "remembers nothing of itself" in out.lower()
15
+ assert "drawer-head" in out
16
+
17
+ def test_reveals_fragments_in_order(self):
18
+ from character import OWN_FRAGMENTS
19
+ out = render_recovered(2)
20
+ assert OWN_FRAGMENTS[0] in out
21
+ assert OWN_FRAGMENTS[1] in out
22
+ assert OWN_FRAGMENTS[2] not in out
23
+
24
+ def test_capped_at_total_fragments(self):
25
+ from character import OWN_FRAGMENTS
26
+ out = render_recovered(99)
27
+ assert out.count('class="recovered-item') == len(OWN_FRAGMENTS)
28
+
29
+
30
  class TestRenderTreasure:
31
  def test_empty_shows_placeholder(self):
32
  out = render_treasure([])
 
180
  out = render_treasure(["mine still"])
181
  assert "border-left-color:#3a2a50" not in out
182
 
183
+ def test_just_claimed_item_gets_claiming_class(self):
184
+ out = render_treasure(["mine still", "taken one"],
185
+ claimed={"taken one"},
186
+ just_claimed="taken one")
187
+ assert "claiming" in out
188
+ assert "class=\"treasure-item claiming" in out
189
+
190
 
191
  class TestTreasureWounds:
192
  def test_redacted_entry_per_wound(self):