OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-tps commits / 2ed4651

#120 L1: Shift+Ctrl+M + a letter adds a modifier (§12.5)
builder.add_modifier gets Shift+Ctrl+M. ActionPopup.open_choices rows may name a key (third element): shown as the row's accelerator, and that key unmodified picks it. Add modifier rows: M mirror, A array, G grid, R radial, C curve, B chamfer, D dice. gate_mod_keys (5); keymap_gate, q_menu, collection_focus green. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2ed4651f3ba92d6333aeb0d2b3c2090967d0c357
by plankatron <fleeky@gmail.com> · 2026-10-18T23:14:02.000Z

Changed files (5) +122 -2

autoloads/keymap.gd +1 -1
……@@ -395,7 +395,7 @@ const DEFAULTS: Dictionary = {
395395 "builder.add_cable": {"label": "Cable (place points on surfaces; [ ] slack hangs each span)"},
396396 "builder.edit_curve": {"label": "Edit curve (the aimed point: move, Tab, X delete, I insert)"},
397397 "builder.assign_generator": {"label": "Generator… (stairs, tunnel … on this shape; it stays the passage)"},
398 "builder.add_modifier": {"label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
398 "builder.add_modifier": {"key": KEY_M, "ctrl": true, "shift": true, "label": "Add modifier… (mirror, array, grid, radial, curve, chamfer, dice on this shape)"},
399399 # Menu-only, as in Blender (the outliner's / X menu's Delete Hierarchy): X keeps children.
400400 "builder.delete_hierarchy": {"label": "Delete hierarchy (the objects and everything under them)"},
401401 "builder.extrude": {"key": KEY_E, "label": "Extrude the aimed face"},
scenes/game/action_popup.gd +14 -0
……@@ -61,16 +61,25 @@ func open_sections(sections: Array, at: Vector2) -> void:
6161## CHOICES rather than actions: rows = [[text, Callable], ...] under one title — a list
6262## the keymap cannot know in advance (M's "move to which collection"). Picking runs the
6363## Callable once the popup has closed, like pick().
64## A row may name a KEY as its third element (#120 L1 §12.5: Shift+Ctrl+M, then a letter
65## adds that modifier): shown as the row's accelerator, and that key, unmodified, picks it.
66var _choice_keys: Dictionary = {} # keycode -> choice id
67
68
6469func open_choices(title: String, rows: Array, at: Vector2) -> void:
6570 _menu.clear()
6671 _actions.clear()
6772 _calls.clear()
73 _choice_keys.clear()
6874 _menu.add_separator(title)
6975 for r: Array in rows:
7076 _actions.append("")
7177 var id := _actions.size() - 1
7278 _calls[id] = r[1]
7379 _menu.add_item(String(r[0]), id)
80 if r.size() > 2 and int(r[2]) != 0:
81 _choice_keys[int(r[2])] = id
82 _menu.set_item_accelerator(_menu.item_count - 1, int(r[2]) as Key)
7483 Keymap.push_context("menu")
7584 _menu.popup(Rect2i(Vector2i(at), Vector2i.ZERO))
7685
……@@ -196,6 +205,11 @@ func _on_window_input(ev: InputEvent) -> void:
196205 return
197206 var k := ev as InputEventKey
198207 var code := int(k.physical_keycode if k.physical_keycode != 0 else k.keycode)
208 # A choice's own letter (open_choices' third element), unmodified.
209 if _choice_keys.has(code) and not (k.shift_pressed or k.ctrl_pressed or k.alt_pressed):
210 _menu.set_input_as_handled()
211 choose(int(_choice_keys[code]))
212 return
199213 for a: String in _actions:
200214 var b: Dictionary = Keymap.binding_of(a)
201215 if b.has("mouse") or b.is_empty():
scenes/maps/map_dc_level.gd +5 -1
……@@ -4344,7 +4344,7 @@ func _builder_key(event: InputEvent) -> void:
43444344 elif _q_menu != null:
43454345 var rows: Array = []
43464346 for k: String in MOD_DEFAULTS:
4347 rows.append([k.capitalize(), func() -> void: add_modifier(k)])
4347 rows.append([k.capitalize(), func() -> void: add_modifier(k), MOD_KEYS.get(k, 0)])
43484348 _q_menu.open_choices("Add modifier", rows, get_viewport().get_mouse_position())
43494349 return
43504350 if Keymap.tapped(event, "builder.assign_generator"):
……@@ -5613,6 +5613,10 @@ const MOD_DEFAULTS := {
56135613 "curve": {"count": 0},
56145614 "chamfer": {"width": 0.1, "segments": 1, "angle": 30.0},
56155615}
5616## The letter that picks each kind in Add modifier (Shift+Ctrl+M, then the letter; §12.5).
5617## C is curve, so chamfer takes B (BoxCutter's bevel key).
5618const MOD_KEYS := {"mirror": KEY_M, "array": KEY_A, "grid": KEY_G, "radial": KEY_R,
5619 "curve": KEY_C, "chamfer": KEY_B, "dice": KEY_D}
56165620
56175621
56185622func add_modifier(kind: String, shape: String = "", params: Dictionary = {}) -> String:
scenes/test/gate_mod_keys.gd +96 -0
……@@ -0,0 +1,96 @@
1extends Node
2## GATE — MODIFIER KEYS (#120 L1 §12.5; the menu + hotkey rule).
3## K1 Shift+Ctrl+M on a shape opens Add modifier
4## K2 ... its rows show their letters (M A G R C B D)
5## K3 G picks Grid: the shape gains a grid modifier, the menu closes
6## K4 Shift+Ctrl+M then D adds a Dice
7##
8## RUN AS A SCENE: CI=true "$HYPER_TPS_GODOT" --headless res://scenes/test/gate_mod_keys.tscn
9
10var fails := 0
11var checks := 0
12var map: Node3D
13var persist: Node
14
15
16func _ok(what: String, cond: bool, note: Variant = "") -> void:
17 checks += 1
18 if not cond:
19 fails += 1
20 print(" %-64s %s %s" % [what, "OK" if cond else "**FAIL**", str(note)])
21
22
23func _ready() -> void:
24 get_tree().root.size = Vector2i(1920, 1080)
25 var gs: Node = get_tree().root.get_node_or_null("/root/GameState")
26 gs.route("world:level:*", 1, "peer")
27 var keet: Node = get_tree().root.get_node_or_null("/root/KeetService")
28 if keet != null:
29 keet.current_member_id = "gate-author"
30 get_tree().root.get_node("/root/GameMode").set_mode("build")
31 map = (load("res://scenes/maps/map_dev_test.tscn") as PackedScene).instantiate()
32 add_child(map)
33 for i in 6:
34 await get_tree().process_frame
35 await _run()
36 get_tree().root.get_node("/root/GameMode").set_mode("play")
37 if checks < 5:
38 print(" **FAIL** only %d checks ran -- setup died before the assertions" % checks)
39 fails += 1
40 print("\nGATE mod_keys: %s (%d checks, %d failed)" % ["PASS" if fails == 0 else "FAIL", checks, fails])
41 get_tree().quit(0 if fails == 0 else 1)
42
43
44func _key(code: Key, shift := false, ctrl := false) -> InputEventKey:
45 var e := InputEventKey.new()
46 e.keycode = code
47 e.physical_keycode = code
48 e.shift_pressed = shift
49 e.ctrl_pressed = ctrl
50 e.pressed = true
51 return e
52
53
54func _kinds(shape: String) -> Array:
55 var out: Array = []
56 for m: Dictionary in (map.modifier_stack(shape) as Array):
57 out.append(String(m["kind"]))
58 return out
59
60
61func _run() -> void:
62 persist = map.get("_persist")
63 var s: String = persist.record("shape.create", {"type": "box", "gv": 1, "params": {},
64 "frame": SceneObjects.xform_to(Transform3D(Basis.IDENTITY, Vector3(140, 0, 140))),
65 "size": [1, 1, 1], "material_id": 2, "level_id": 0})
66 persist.refold()
67 map.select_shape(s)
68 await get_tree().process_frame
69 var q: Node = map.get("_q_menu")
70 var menu: PopupMenu = q.get("_menu")
71
72 # ---- K1 / K2 ----
73 get_viewport().push_input(_key(KEY_M, true, true))
74 await get_tree().process_frame
75 _ok("K1 Shift+Ctrl+M opens Add modifier", q.is_open())
76 var shown: Array = []
77 for i in menu.item_count:
78 if menu.get_item_accelerator(i) != 0:
79 shown.append(OS.get_keycode_string(menu.get_item_accelerator(i)))
80 shown.sort()
81 _ok("K2 its rows show their letters", shown == ["A", "B", "C", "D", "G", "M", "R"], shown)
82
83 # ---- K3 ----
84 q.call("_on_window_input", _key(KEY_G))
85 for i in 3:
86 await get_tree().process_frame
87 _ok("K3 G adds a Grid", _kinds(s).has("grid"), _kinds(s))
88 _ok("K3 ... and the menu closed", not q.is_open())
89
90 # ---- K4 ----
91 get_viewport().push_input(_key(KEY_M, true, true))
92 await get_tree().process_frame
93 q.call("_on_window_input", _key(KEY_D))
94 for i in 3:
95 await get_tree().process_frame
96 _ok("K4 Shift+Ctrl+M, D adds a Dice", _kinds(s).has("dice"), _kinds(s))
scenes/test/gate_mod_keys.tscn +6 -0
……@@ -0,0 +1,6 @@
1[gd_scene load_steps=2 format=3]
2
3[ext_resource type="Script" path="res://scenes/test/gate_mod_keys.gd" id="1"]
4
5[node name="ModKeysGate" type="Node"]
6script = ExtResource("1")