Der Reichweiten-Halo am Haken ignoriert die Seilkühlzeit: 25–42 ms lang zeichnet das Spiel „greifbar“ an einem Seil, das grabbable() gerade verweigert #26

Closed
opened 2026-09-04 15:46:53 +02:00 by gilli · 1 comment
Owner

Komponente: GAME (render, Zeilen 3459–3463), CORE (grabbable 701–705, findGrab 706–717,
releaseHook 749–763, integrateHang 806–812 / 826–833), RENDER.drawHook 2088–2107
Betroffene Version: main @ 86a481d, index.html (3.684 Zeilen, MD5 777d18c3edbb834aa41fccc7d10a7d18)

Beschreibung

Der Renderer zeichnet um jeden Haken einen pulsierenden Kreis (in reducedMotion zusätzlich ein
Häkchen, drawHook Zeilen 2096–2107) als Signal „von hier aus greifbar“. Die Bedingung dafür
steht in render:

index.html:3462   if (body && !S.dying) reach = h !== body.hook && Math.hypot(h.x - body.x, h.y - body.y) <= K.GRAB_RADIUS;

Die Engine entscheidet an anderer Stelle — grabbable() (Zeilen 701–705) verlangt zusätzlich,
dass die Seilkühlzeit abgelaufen ist:

index.html:701    function grabbable(b, h) { if ((h.coolT || 0) > 0) return false; ... }

coolT wird gesetzt von releaseHook (0,3 s), vom schlaffen Seil (0,3 s) und vom
Auspendel-Rückfall (0,5 s). Direkt nach dem Loslassen hängt der Körper aber noch fast am Ende
des Radius — die Render-Bedingung ist also für mehrere Bildraten wahr, während jeder
Greifversuch (Tastatur E/K, Autogreif, Klick auf genau diesen Haken über grabTarget)
abgewiesen wird.

Zwei Signale, zwei Antworten: Das HUD (el.grabHint, gespeist aus S.hookHint = CORE.findGrab(...),
Zeilen 3315–3318) meldet korrekt „kein Ziel“, der Kreis am Haken meldet weiter „greifbar“.

Messprotokoll

Node gegen das extrahierte CORE, reale Kette (Seed 4242, Grube 171 084 … 171 758,
Tempo 640 px/s), Schrittweite K.FIXED_TIMESTEP. Pro Schritt wird für jeden Haken die
Render-Bedingung (h !== b.hook && d ≤ K.GRAB_RADIUS) gegen CORE.grabbable(b, h) geprüft —
exakt die beiden Formeln aus Zeile 3462 und Zeile 701, ohne den Renderer zu rufen.

Fall Schritte mit Halo davon Griff verweigert (coolT > 0) erster Widerspruch
Kette, Haltezeit 0,44 s (Strategie-Wert) 7 5 = 42 ms t 1,72 s · d 223 px · coolT 0,30
Kette, Haltezeit 0,20 s (frühes Loslassen) 4 3 = 25 ms t 1,48 s · d 223 px · coolT 0,30

Der Widerspruch sitzt also bei 223 px — 2 px innerhalb der Reichweite, unmittelbar am
Loslassmoment, den der Spieler gerade gesetzt hat. Bei einer Kette passiert das zweimal
(nach jedem der beiden Seile).

Der Auspendel-Rückfall (hangT > 6 bzw. Stall > 0,5 s, coolT = 0.5) ist derselbe Fall, nur
länger: Dort hängt der Körper zur Ruhe kommend direkt unter dem Haken (d ≈ Seillänge ≤ 225) und
der Kreis bleibt 0,5 s lang stehen.

Ursache im Code

render prüft nur die Geometrie (Abstand, „nicht der eigene Haken“), nicht den Zustand
derselben Anfrage. findGrab (706–717) filtert zusätzlich über grabbable und verwirft Haken
hinter dem Körper (h.x < b.x - 70), grabTarget (723–726) verlangt für das Klick-Ziel
ebenfalls grabbable. Zwei Stellen prüfen die Distanz gegen K.GRAB_RADIUS, ohne
grabbable zu rufen: hookAtPoint (Zeile 3213, Klick-Treffer-test) und render
(Zeile 3462). Beim Klick läuft die Entscheidung danach noch durch grabTarget →
grabbable — ein falscher Klick kann dort nichts erzwingen. Nur am gezeichneten Halo
bleibt die reine Geometrie die letzte Auskunft
, und damit die einzige sichtbare
Zusage, die widersprüchlich werden kann.

Folgeverhalten

  • Wer nach dem Loslassen sofort wieder greifen will (Tastatur oder Klick auf den leuchtenden
    Kreis), bekommt nichts — ohne Ton, ohne Meldung, ohne Unterschied zum „daneben gegriffen“.
  • reducedMotion zeichnet dort zusätzlich ein Häkchen: Das Barrierefreiheits-Signal ist dann
    explizit „hier entlang“, während die Simulation ablehnt.
  • Zweiter, gespaltener Widerspruch derselben Art: Der Filter h.x < b.x - 70 (Haken hinter dem
    Körper) steht nur in findGrab (Zeile 709). grabTarget (723–726) prüft für das
    Klick-Ziel allein grabbable — ein Klick auf einen Haken hinter dem Körper greift also,
    E/K und Autogreif greifen nicht. Der Halo gilt für beide Lesarten gleichzeitig.

Nicht verletzt (bewusst abgegrenzt)

  • grabbable/grabTarget/findGrab selbst sind korrekt: Kein Greifen findet statt, das die
    Kühlzeit missachtet; das Klick-Ziel fällt wie vorgesehen auf findGrab zurück (#9 hält).
  • S.hookHint und damit el.grabHint sind unverändert richtig — der Widerspruch ist rein der
    Halo am Haken.

Lösungsvorschlag

Zeile 3462 auf dieselbe Quelle wie die Eingabe stellen:

if (body && !S.dying) reach = h !== body.hook && CORE.grabbable(body, h);

Wer den „Halo = freie Auswahl“-Charakter erhalten will, ergänze denselben
h.x >= body.x - 70-Filter aus findGrab — oder, sauberer: CORE exportiert eine
reachability(body, hook)-Funktion, und sowohl findGrab als auch render rufen sie.
Eine dritte Entscheidung (nur Distanz) sollte es nicht mehr geben.

Erwarteter Test

  • tests/issue26.mjs (Node, extrahiertes CORE + die Render-Bedingung als extrahierter
    einzeliger Ausdruck oder als Bridge-Aufruf): nach releaseHook an einer real generierten
    Kette darf für keinen Schritt gelten render-reach && !CORE.grabbable(...); Vorher-Zahl:
    5 bzw. 3 Schritte (42 ms / 25 ms), Nachher: 0.
  • Browser-Variante (könnte in tests/browser_issue9.py mitlaufen): echtes Dokument, Haken
    gefasst, Leertaste zum Loslassen, danach sofort E — Erwartung: kein zweiter Einhang und
    kein Halo auf dem gekühlten Haken (Screenshots des Haken-Bildbereichs vor/nach dem Loslassen
    vergleichen).

Dokumentiert in issues/26-reichweiten-halo-ignoriert-seilkuehlzeit.md (Commit 86a481d).

**Komponente:** GAME (`render`, Zeilen 3459–3463), CORE (`grabbable` 701–705, `findGrab` 706–717, `releaseHook` 749–763, `integrateHang` 806–812 / 826–833), `RENDER.drawHook` 2088–2107 **Betroffene Version:** `main` @ `86a481d`, `index.html` (3.684 Zeilen, MD5 `777d18c3edbb834aa41fccc7d10a7d18`) ## Beschreibung Der Renderer zeichnet um jeden Haken einen pulsierenden Kreis (in `reducedMotion` zusätzlich ein Häkchen, `drawHook` Zeilen 2096–2107) als Signal „von hier aus greifbar“. Die Bedingung dafür steht in `render`: ```js index.html:3462 if (body && !S.dying) reach = h !== body.hook && Math.hypot(h.x - body.x, h.y - body.y) <= K.GRAB_RADIUS; ``` Die Engine entscheidet an anderer Stelle — `grabbable()` (Zeilen 701–705) verlangt zusätzlich, dass die **Seilkühlzeit abgelaufen** ist: ```js index.html:701 function grabbable(b, h) { if ((h.coolT || 0) > 0) return false; ... } ``` `coolT` wird gesetzt von `releaseHook` (0,3 s), vom schlaffen Seil (0,3 s) und vom Auspendel-Rückfall (0,5 s). Direkt nach dem Loslassen hängt der Körper aber noch fast am Ende des Radius — die Render-Bedingung ist also für mehrere Bildraten wahr, während jeder Greifversuch (Tastatur `E`/`K`, Autogreif, Klick auf genau diesen Haken über `grabTarget`) abgewiesen wird. Zwei Signale, zwei Antworten: Das HUD (`el.grabHint`, gespeist aus `S.hookHint = CORE.findGrab(...)`, Zeilen 3315–3318) meldet korrekt „kein Ziel“, der Kreis am Haken meldet weiter „greifbar“. ## Messprotokoll Node gegen das extrahierte `CORE`, reale Kette (Seed 4242, Grube 171 084 … 171 758, Tempo 640 px/s), Schrittweite `K.FIXED_TIMESTEP`. Pro Schritt wird für jeden Haken die Render-Bedingung (`h !== b.hook && d ≤ K.GRAB_RADIUS`) gegen `CORE.grabbable(b, h)` geprüft — exakt die beiden Formeln aus Zeile 3462 und Zeile 701, ohne den Renderer zu rufen. | Fall | Schritte mit Halo | davon Griff verweigert (`coolT > 0`) | erster Widerspruch | |---|---|---|---| | Kette, Haltezeit 0,44 s (Strategie-Wert) | 7 | **5 = 42 ms** | t 1,72 s · d 223 px · `coolT` 0,30 | | Kette, Haltezeit 0,20 s (frühes Loslassen) | 4 | **3 = 25 ms** | t 1,48 s · d 223 px · `coolT` 0,30 | Der Widerspruch sitzt also bei 223 px — 2 px innerhalb der Reichweite, unmittelbar am Loslassmoment, den der Spieler gerade gesetzt hat. Bei einer Kette passiert das zweimal (nach jedem der beiden Seile). Der Auspendel-Rückfall (`hangT > 6` bzw. Stall > 0,5 s, `coolT = 0.5`) ist derselbe Fall, nur länger: Dort hängt der Körper zur Ruhe kommend direkt unter dem Haken (d ≈ Seillänge ≤ 225) und der Kreis bleibt 0,5 s lang stehen. ## Ursache im Code `render` prüft nur die **Geometrie** (Abstand, „nicht der eigene Haken“), nicht den **Zustand** derselben Anfrage. `findGrab` (706–717) filtert zusätzlich über `grabbable` und verwirft Haken hinter dem Körper (`h.x < b.x - 70`), `grabTarget` (723–726) verlangt für das Klick-Ziel ebenfalls `grabbable`. Zwei Stellen prüfen die Distanz gegen `K.GRAB_RADIUS`, ohne `grabbable` zu rufen: `hookAtPoint` (Zeile 3213, Klick-Treffer-test) und `render` (Zeile 3462). Beim Klick läuft die Entscheidung danach noch durch `grabTarget` → `grabbable` — ein falscher Klick kann dort nichts erzwingen. **Nur am gezeichneten Halo bleibt die reine Geometrie die letzte Auskunft**, und damit die einzige sichtbare Zusage, die widersprüchlich werden kann. ## Folgeverhalten * Wer nach dem Loslassen sofort wieder greifen will (Tastatur oder Klick auf den leuchtenden Kreis), bekommt nichts — ohne Ton, ohne Meldung, ohne Unterschied zum „daneben gegriffen“. * `reducedMotion` zeichnet dort zusätzlich ein Häkchen: Das Barrierefreiheits-Signal ist dann explizit „hier entlang“, während die Simulation ablehnt. * Zweiter, gespaltener Widerspruch derselben Art: Der Filter `h.x < b.x - 70` (Haken hinter dem Körper) steht **nur** in `findGrab` (Zeile 709). `grabTarget` (723–726) prüft für das Klick-Ziel allein `grabbable` — ein Klick auf einen Haken hinter dem Körper greift also, `E`/`K` und Autogreif greifen nicht. Der Halo gilt für beide Lesarten gleichzeitig. ## Nicht verletzt (bewusst abgegrenzt) * `grabbable`/`grabTarget`/`findGrab` selbst sind korrekt: Kein Greifen findet statt, das die Kühlzeit missachtet; das Klick-Ziel fällt wie vorgesehen auf `findGrab` zurück (#9 hält). * `S.hookHint` und damit `el.grabHint` sind unverändert richtig — der Widerspruch ist rein der Halo am Haken. ## Lösungsvorschlag Zeile 3462 auf dieselbe Quelle wie die Eingabe stellen: ```js if (body && !S.dying) reach = h !== body.hook && CORE.grabbable(body, h); ``` Wer den „Halo = freie Auswahl“-Charakter erhalten will, ergänze denselben `h.x >= body.x - 70`-Filter aus `findGrab` — oder, sauberer: `CORE` exportiert eine `reachability(body, hook)`-Funktion, und sowohl `findGrab` als auch `render` rufen sie. Eine dritte Entscheidung (nur Distanz) sollte es nicht mehr geben. ## Erwarteter Test * `tests/issue26.mjs` (Node, extrahiertes `CORE` + die Render-Bedingung als extrahierter einzeliger Ausdruck oder als Bridge-Aufruf): nach `releaseHook` an einer real generierten Kette darf für keinen Schritt gelten `render-reach && !CORE.grabbable(...)`; Vorher-Zahl: 5 bzw. 3 Schritte (42 ms / 25 ms), Nachher: 0. * Browser-Variante (könnte in `tests/browser_issue9.py` mitlaufen): echtes Dokument, Haken gefasst, Leertaste zum Loslassen, danach sofort `E` — Erwartung: kein zweiter Einhang *und* kein Halo auf dem gekühlten Haken (Screenshots des Haken-Bildbereichs vor/nach dem Loslassen vergleichen). --- *Dokumentiert in `issues/26-reichweiten-halo-ignoriert-seilkuehlzeit.md` (Commit `86a481d`).*
Author
Owner

Behoben in 171d332 (Doku 63dd7d4).

Geändert: CORE.reachability(body, hook) = grabbable (Seilkühlzeit, GRAB_RADIUS) + der Hinters-Filter h.x < b.x - 70 aus findGrab. findGrab ruft sie, render ruft sie — eine Entscheidung statt drei; die reine Geometrie ist keine Auskunft mehr. grabTarget bleibt die bewusst abgegrenzte Ausnahme: das angeklickte Ziel gewinnt auch hinter dem Körper (#9 hält), erzwungen wird nichts, grabbable bleibt vorgeschaltet. Der Auspendel-Rückfall (coolT = 0,5 bei Distanz = Seillänge im Radius) ist derselbe Fall und stillt mit; das reducedMotion-Häkchen hängt an demselben Signal.

Test: tests/issue26.mjs (14 Prüfungen, vorher 5 rot) — die Render-Bedingung wird per Anker if (body && !S.dying) reach = … als einzelner Ausdruck aus dem Dokument extrahiert und gegen CORE.grabbable gehalten, Kühlung pro Frame abgebaut wie im update():

  • echte Ketten (Generierung, Strategie-Haltezeit): 0 Schritte „Halo, aber Griff verweigert“ (vorher 5 pro Kette, erster Widerspruch t 2,34 s bei d = 220 px unter coolT = 0,29)
  • frühes Loslassen 0,20 s: 0 (vorher 3)
  • Auspendel-Rückfall: 0 (vorher ~0,3 s Halbleuchten direkt unter dem Haken)
  • statisch: die extrahierte Zeile muss CORE.reachability rufen — jede dritte Entscheidung (nur Distanz) macht die Suite rot
  • die abgegrenzte Klick-Ausnahme ist festgenagelt (grabTarget hinter dem Körper gewinnt weiter)

Die im Befund vorgeschlagene Browser-Variante (Halo-Pixel vor/nach dem Loslassen) ist nicht zusätzlich gebaut; die extrahierte Zeile ist genau die Bedingung, die drawHook steuert.

Alle Node-Suiten + Soak grün; browser_smoke, issue9, issue22, issue23 grün.

Behoben in `171d332` (Doku `63dd7d4`). **Geändert:** `CORE.reachability(body, hook)` = `grabbable` (Seilkühlzeit, `GRAB_RADIUS`) + der Hinters-Filter `h.x < b.x - 70` aus `findGrab`. `findGrab` ruft sie, `render` ruft sie — eine Entscheidung statt drei; die reine Geometrie ist keine Auskunft mehr. `grabTarget` bleibt die bewusst abgegrenzte Ausnahme: das angeklickte Ziel gewinnt auch hinter dem Körper (#9 hält), erzwungen wird nichts, `grabbable` bleibt vorgeschaltet. Der Auspendel-Rückfall (`coolT = 0,5` bei Distanz = Seillänge im Radius) ist derselbe Fall und stillt mit; das `reducedMotion`-Häkchen hängt an demselben Signal. **Test:** `tests/issue26.mjs` (14 Prüfungen, vorher 5 rot) — die Render-Bedingung wird per Anker `if (body && !S.dying) reach = …` als einzelner Ausdruck aus dem Dokument extrahiert und gegen `CORE.grabbable` gehalten, Kühlung pro Frame abgebaut wie im `update()`: - echte Ketten (Generierung, Strategie-Haltezeit): **0** Schritte „Halo, aber Griff verweigert“ (vorher 5 pro Kette, erster Widerspruch t 2,34 s bei d = 220 px unter `coolT = 0,29`) - frühes Loslassen 0,20 s: **0** (vorher 3) - Auspendel-Rückfall: **0** (vorher ~0,3 s Halbleuchten direkt unter dem Haken) - statisch: die extrahierte Zeile muss `CORE.reachability` rufen — jede dritte Entscheidung (nur Distanz) macht die Suite rot - die abgegrenzte Klick-Ausnahme ist festgenagelt (`grabTarget` hinter dem Körper gewinnt weiter) Die im Befund vorgeschlagene Browser-Variante (Halo-Pixel vor/nach dem Loslassen) ist nicht zusätzlich gebaut; die extrahierte Zeile ist genau die Bedingung, die `drawHook` steuert. Alle Node-Suiten + Soak grün; `browser_smoke`, `issue9`, `issue22`, `issue23` grün.
gilli closed this issue 2026-09-09 00:22:40 +02:00
Sign in to join this conversation.
No description provided.