Skip to content

Commit f9941f4

Browse files
authored
Merge pull request #15 from dhcold/dhc/confident-knuth
Share hash, ramp/corner fixes, prop rotation, UI tooltips
2 parents 19a380f + 1afe5a4 commit f9941f4

8 files changed

Lines changed: 588 additions & 79 deletions

File tree

main.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
#!/usr/bin/env python3
22
"""Turnt-o-mapper -- launch entry point."""
33

4+
import argparse
45
import sys
6+
7+
from PyQt6.QtCore import QTimer
58
from PyQt6.QtWidgets import QApplication
69
from turnt_o_mapper.app import App, DARK_QSS
710

8-
if __name__ == "__main__":
11+
12+
def main():
13+
parser = argparse.ArgumentParser(
14+
description="Turnt-o-mapper: Quake3 .map generator for Turnt and Diabotical .rbe converter")
15+
parser.add_argument(
16+
"--hash", type=str, default=None,
17+
help="Apply a tom1_... config hash on startup")
18+
parser.add_argument(
19+
"--auto-generate", action="store_true",
20+
help="Automatically generate after applying --hash")
21+
args = parser.parse_args()
22+
923
app = QApplication(sys.argv)
1024
app.setStyleSheet(DARK_QSS)
1125
window = App()
26+
27+
if args.hash:
28+
window._apply_hash(args.hash)
29+
if args.auto_generate:
30+
QTimer.singleShot(100, window._on_generate)
31+
1232
window.show()
1333
sys.exit(app.exec())
34+
35+
36+
if __name__ == "__main__":
37+
main()

turnt_o_mapper/app.py

Lines changed: 251 additions & 29 deletions
Large diffs are not rendered by default.

turnt_o_mapper/brushes.py

Lines changed: 13 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
from .constants import (
1515
WALL_T, DOOR_H, HIDDEN_TEX,
16-
SLOPE_RATIO, MAX_RAMP_ANGLE, MIN_SLOPE_RATIO,
16+
SLOPE_RATIO, MIN_RAMP_ANGLE, MAX_RAMP_ANGLE, MIN_SLOPE_RATIO,
1717
RAMP_TEX, OUTLINE_W,
1818
)
1919
from .layout import _clip_footprint, _clip_intervals
@@ -463,19 +463,16 @@ def wy(ylo, yhi, xlo, xhi, zlo, zhi, lbl):
463463

464464

465465
def _adaptive_ramp_len(dz: int, max_available: int) -> int:
466-
"""Choose ramp horizontal length with a progressive random angle.
466+
"""Choose ramp horizontal length — as gentle as possible.
467467
468-
Picks a random target angle between 15 deg and 25 deg, computes the
469-
corresponding ramp length, then clamps to the available space.
470-
**Never returns more than max_available** — the caller must reduce
471-
dz if the resulting angle would exceed 30 deg.
468+
Computes the ideal length for the shallowest angle (MIN_RAMP_ANGLE,
469+
10°). If that doesn't fit, uses all available space — the ramp gets
470+
steeper but never exceeds 30° (layout caps dz accordingly).
472471
"""
473472
if max_available <= 0:
474473
return max(abs(dz), 1)
475-
# Random target between 15° and 25° — variety in ramp feel
476-
target_angle = random.uniform(15, 25)
477-
target_len = int(dz / math.tan(math.radians(target_angle)))
478-
return min(target_len, max_available)
474+
ideal_len = int(dz / math.tan(math.radians(MIN_RAMP_ANGLE)))
475+
return min(ideal_len, max_available)
479476

480477

481478
def compute_bridge_footprint(ax, ay, az, bx, by, bz,
@@ -539,14 +536,12 @@ def corridor_brushes(ax, ay, az, bx, by, bz,
539536
hi_far = rb_far if ax <= bx else ra_far
540537

541538
def _clamp_dz(actual_len, cur_dz, cur_lo, cur_hi):
542-
"""Reduce dz so the ramp angle never exceeds 30 deg."""
543-
if actual_len <= 0:
544-
return cur_lo, cur_hi
545-
max_dz = int(actual_len / MIN_SLOPE_RATIO)
546-
if cur_hi - cur_lo > max_dz:
547-
mid = (cur_lo + cur_hi) // 2
548-
cur_lo = mid - max_dz // 2
549-
cur_hi = mid + max_dz // 2
539+
"""Keep ramp endpoints pinned to room floors.
540+
541+
Layout already caps dz so the angle stays reasonable.
542+
A steeper-than-ideal ramp is always better than one that
543+
floats in the air and cannot be reached via crouchslide.
544+
"""
550545
return cur_lo, cur_hi
551546

552547
if axis == 'x':

turnt_o_mapper/constants.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
ALL_TEXTURES: Dict[str, int] = {
1919
"NULL":2,"common/caulk":2,"common/lavacaulk":4,"common/nodraw":2,
2020
"common/nodrawnonsolid":2,"common/slick":5,"common/slimecaulk":4,
21-
"common/watercaulk":3,"common/weapclip":2,"common/playerclip":2,
21+
"common/watercaulk":3,"common/weapclip":2,
2222
"turnt/temp_blue":8,"turnt/temp_dark":0,"turnt/temp_green":7,
2323
"turnt/temp_light":1,"turnt/temp_orange":9,"turnt/temp_purple":10,
2424
"turnt/temp_red":6,"turnt/temp_yellow":11,
@@ -68,10 +68,11 @@
6868
# ══════════════════════════════════════════════════════════════════════════════
6969
# RAMP PHYSICS CONSTANTS
7070
# ══════════════════════════════════════════════════════════════════════════════
71-
SLOPE_RATIO = 3.73 # horizontal / vertical ~= 15deg -- shallowest target
71+
MIN_RAMP_ANGLE = 10 # degrees -- shallowest (ideal) ramp angle
7272
MAX_RAMP_ANGLE = 30 # degrees -- steepest allowed ramp
73-
# Minimum slope ratio that keeps angle <= MAX_RAMP_ANGLE: 1/tan(30deg) ~= 1.732
74-
MIN_SLOPE_RATIO = 1.0 / math.tan(math.radians(MAX_RAMP_ANGLE))
73+
# Slope ratios: horizontal / vertical
74+
SLOPE_RATIO = 1.0 / math.tan(math.radians(MIN_RAMP_ANGLE)) # ~5.67 for 10°
75+
MIN_SLOPE_RATIO = 1.0 / math.tan(math.radians(MAX_RAMP_ANGLE)) # ~1.73 for 30°
7576

7677
# ══════════════════════════════════════════════════════════════════════════════
7778
# THEME PALETTES

turnt_o_mapper/dbt_import.py

Lines changed: 111 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,96 @@
1010
"""
1111

1212
import math
13+
import struct
1314
import sys
1415
import time
1516
from typing import Dict, List, Optional
1617

18+
19+
# ── Color → texture mapping ──────────────────────────────────────────────────
20+
# Maps hex RGB colours found on invisible props to the nearest turnt texture.
21+
22+
_COLOR_PALETTE = [
23+
# (R, G, B, texture_name)
24+
(0, 0, 0, "turnt/temp_dark"),
25+
(255, 255, 255, "turnt/temp_light"),
26+
(255, 0, 0, "turnt/temp_red"),
27+
(0, 128, 0, "turnt/temp_green"),
28+
(0, 0, 255, "turnt/temp_blue"),
29+
(255, 165, 0, "turnt/temp_orange"),
30+
(128, 0, 128, "turnt/temp_purple"),
31+
(255, 255, 0, "turnt/temp_yellow"),
32+
(0, 255, 255, "turnt/turnt_cyan"),
33+
(255, 0, 255, "turnt/turnt_magenta"),
34+
(0, 255, 128, "turnt/turnt_mint"),
35+
(128, 255, 0, "turnt/turnt_lime"),
36+
(255, 215, 0, "turnt/turnt_gold"),
37+
(0, 128, 128, "turnt/turnt_teal"),
38+
(128, 0, 255, "turnt/turnt_violet"),
39+
(255, 128, 128, "turnt/turnt_coral"),
40+
(135, 206, 235, "turnt/turnt_sky"),
41+
]
42+
43+
# Material name → texture fallback
44+
_MATERIAL_MAP: Dict[str, str] = {
45+
"concrete": "turnt/turnt_concrete",
46+
"asphalt": "turnt/turnt_asphalt",
47+
"tech": "turnt/turnt_tech",
48+
"slick": "common/slick",
49+
"boost": "turnt/turnt_boost",
50+
"speed": "turnt/turnt_speed",
51+
"hazard": "turnt/turnt_hazard",
52+
"checkpoint": "turnt/turnt_checkpoint",
53+
"platform": "turnt/turnt_platform",
54+
}
55+
56+
57+
def _parse_hex_color(s: str) -> Optional[tuple]:
58+
"""Parse hex color string (AARRGGBB, RRGGBB, #RRGGBB) → (R, G, B) or None."""
59+
s = s.strip().lstrip("#")
60+
if not s:
61+
return None
62+
try:
63+
if len(s) == 8: # AARRGGBB
64+
r, g, b = int(s[2:4], 16), int(s[4:6], 16), int(s[6:8], 16)
65+
elif len(s) == 6: # RRGGBB
66+
r, g, b = int(s[0:2], 16), int(s[2:4], 16), int(s[4:6], 16)
67+
else:
68+
return None
69+
return (r, g, b)
70+
except ValueError:
71+
return None
72+
73+
74+
def _nearest_texture(r: int, g: int, b: int) -> str:
75+
"""Find the turnt texture with the closest RGB distance."""
76+
best_dist = float("inf")
77+
best_tex = "turnt/temp_dark"
78+
for pr, pg, pb, tex in _COLOR_PALETTE:
79+
d = (r - pr) ** 2 + (g - pg) ** 2 + (b - pb) ** 2
80+
if d < best_dist:
81+
best_dist = d
82+
best_tex = tex
83+
return best_tex
84+
85+
86+
def _resolve_prop_texture(props: dict, fallback: str) -> str:
87+
"""Pick a texture for an invisible prop based on color/material properties."""
88+
# Material takes priority (exact name match)
89+
mat = props.get("material", "").lower()
90+
if mat:
91+
for key, tex in _MATERIAL_MAP.items():
92+
if key in mat:
93+
return tex
94+
95+
# Color fallback — parse hex and find nearest palette entry
96+
color_str = props.get("color", "")
97+
rgb = _parse_hex_color(color_str)
98+
if rgb:
99+
return _nearest_texture(*rgb)
100+
101+
return fallback
102+
17103
# Allow running this file directly (e.g. for quick testing)
18104
try:
19105
from .constants import ALL_TEXTURES, NODRAW_TEX
@@ -440,17 +526,17 @@ def rbe_entities_to_map(entities, sx, sy, sz, opaque_tex="turnt/turnt_concrete")
440526
clip_v = props.get("clip", "fullclip").lower()
441527

442528
if no_show:
443-
prop_tex = "common/clip"
529+
prop_tex = _resolve_prop_texture(props, "common/nodraw")
444530
elif slide_v == "1" or slide_v == "2":
445531
prop_tex = "common/slick"
446532
elif clip_v == "noclip":
447533
prop_tex = NODRAW_TEX
448534
elif clip_v == "playerclip":
449-
prop_tex = "common/clip"
535+
prop_tex = _resolve_prop_texture(props, "common/nodraw")
450536
elif clip_v == "weapon":
451537
prop_tex = "common/weapclip"
452538
else:
453-
prop_tex = opaque_tex # fullclip + visible → solid
539+
prop_tex = _resolve_prop_texture(props, opaque_tex)
454540

455541
# ── Size: base differs per shape ─────────────────────────
456542
# All coords in DBT world-units (1 block = 40 X/Z, 20 Y).
@@ -480,18 +566,24 @@ def rbe_entities_to_map(entities, sx, sy, sz, opaque_tex="turnt/turnt_concrete")
480566
zrot = e.get("zrot", 0.0)
481567

482568
def _rot3d(lx, ly, lz):
483-
"""Rotate local offset (lx, ly, lz) by xrot/yrot/zrot."""
484-
# Yaw (around Q-Z axis = DBT Y-up)
485-
cy_, sy_ = math.cos(yrot), math.sin(yrot)
486-
x1 = cy_ * lx + sy_ * ly
487-
y1 = -sy_ * lx + cy_ * ly
488-
z1 = lz
489-
# Pitch (around Q-X axis = DBT X)
569+
"""Rotate local offset (lx, ly, lz) by xrot/yrot/zrot.
570+
571+
The Y↔Z axis swap (DBT→Q3) has determinant -1, so all
572+
rotation angles are effectively negated. Using CW
573+
matrices with the original angles achieves this.
574+
Order: Pitch → Yaw → Roll.
575+
"""
576+
# Pitch — around Q-X, angle = -xrot
490577
cx_, sx_ = math.cos(xrot), math.sin(xrot)
491-
x2 = x1
492-
y2 = cx_ * y1 - sx_ * z1
493-
z2 = sx_ * y1 + cx_ * z1
494-
# Roll (around Q-Y axis = DBT Z)
578+
x1 = lx
579+
y1 = cx_ * ly + sx_ * lz
580+
z1 = -sx_ * ly + cx_ * lz
581+
# Yaw — around Q-Z, angle = -yrot
582+
cy_, sy_ = math.cos(yrot), math.sin(yrot)
583+
x2 = cy_ * x1 + sy_ * y1
584+
y2 = -sy_ * x1 + cy_ * y1
585+
z2 = z1
586+
# Roll — around Q-Y, angle = -zrot
495587
cz_, sz_ = math.cos(zrot), math.sin(zrot)
496588
x3 = cz_ * x2 - sz_ * z2
497589
y3 = y2
@@ -571,14 +663,15 @@ def _rotated_box(ox, oy, oz, hx, hy, hz, tex, label):
571663

572664
# ── cylinder ─────────────────────────────────────────────
573665
elif prop_shape == "cylinder":
574-
cyl_ang = e.get("yrot", 0.0)
575666
outer_r = max(MIN_HALF, abs(e.get("xscale", 1.0)) * sx * 4)
576-
wall_t = sx / 2.0
577-
inner_r = outer_r - wall_t
667+
wall_t = max(4.0, sx / 4.0)
668+
inner_r = max(MIN_HALF, outer_r - wall_t)
578669
fz_cyl = max(MIN_HALF, abs(e.get("yscale", 1.0)) * 100 * EFZ)
579670
z_bot = qz - fz_cyl / 2
580671
z_top = qz + fz_cyl / 2
581672
arc_step = float(sx)
673+
# Quarter circle (π/2) starting at entity yaw
674+
cyl_ang = e.get("yrot", 0.0)
582675
cyl_strs = cylinder_brushes(
583676
qx, qy, z_bot, z_top,
584677
inner_r, outer_r,
@@ -672,7 +765,7 @@ def _log(msg, level="plain"):
672765
CLIP_TEX = {
673766
2: "common/caulk",
674767
4: "common/weapclip",
675-
5: "common/clip",
768+
5: "common/nodraw",
676769
}
677770
solid_blocks = [b for b in blocks if b["type"] == 1]
678771
corner_blocks = [b for b in blocks if b["type"] == 3]

turnt_o_mapper/generation.py

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,15 @@
1010
6. Runs connectivity (BFS) and ramp-angle validation.
1111
7. Places spawn, timer, and checkpoint entities.
1212
13-
Returns ``(map_string, rooms, bridges, warnings)``.
13+
Returns ``(map_string, rooms, bridges, warnings, share_hash)``.
1414
"""
1515

1616
import math
1717
import random
1818
from typing import Dict, List
1919

2020
from .constants import DOOR_H, WALL_T, SLOPE_RATIO, MAX_RAMP_ANGLE, MIN_SLOPE_RATIO
21+
from .share import encode_cfg
2122
from .models import Room, Bridge
2223
from .layout import place_rooms, build_bridges, _snap
2324
from .brushes import (
@@ -155,10 +156,12 @@ def generate_map(cfg: dict):
155156
if fp is not None:
156157
bridge_footprints.append((*fp, br.room_a, br.room_b))
157158

159+
share_hash = encode_cfg(cfg)
160+
158161
lines = [
159162
"// Game: Quake 3",
160163
"// Format: Quake3 (Valve)",
161-
f"// Generated by Turnt-o-mapper | rooms={cfg['n_rooms']} seed={seed}",
164+
f"// Generated by Turnt-o-mapper | rooms={cfg['n_rooms']} seed={seed} | {share_hash}",
162165
"{",
163166
'"classname" "worldspawn"',
164167
'"mapversion" "220"',
@@ -458,4 +461,4 @@ def generate_map(cfg: dict):
458461
count=str(cp_n)))
459462
ei += 1
460463

461-
return "\n".join(lines), rooms, bridges, warnings
464+
return "\n".join(lines), rooms, bridges, warnings, share_hash

turnt_o_mapper/layout.py

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -224,18 +224,22 @@ def _zone_tex(_zone: int):
224224

225225
room_len, room_cross, room_h, door_hw, u_i = _room_dims_from_physics(i, cfg)
226226

227-
# Corner rooms: constrain travel dim so the room does not
228-
# protrude beyond the footprint of the previous room.
229-
# Widen cross dim (1.3x) so there is space for a crouchslide
230-
# turn. The room right after a corner also gets a wider cross.
227+
# Corner rooms: constrain dimensions so the room does not
228+
# protrude beyond the footprint of its neighbours, eliminating
229+
# misleading "tails" that look like valid routes.
230+
# • room_len (old direction) ≤ width of room X-1
231+
# • room_cross (new direction) ≤ depth of room X+1 (forward-look)
231232
if is_corner_room and i > 0:
232233
prev = rooms[i - 1]
233234
prev_cross = prev.d if prev.travel_axis == 'x' else prev.w
234235
max_d = cfg.get("max_d", 512)
235236
room_cross = _snap(min(int(room_cross * 1.3), max_d))
236-
# Ensure travel dim (old direction) never exceeds cross (turn
237-
# direction) so the "tail" always points toward the new route.
238237
room_len = _snap(min(room_len, prev_cross, room_cross))
238+
# Forward-look: cap cross to the next room's expected cross
239+
# so the corner doesn't extend beyond the post-turn corridor.
240+
if i + 1 < n:
241+
_next_len, next_cross, *_ = _room_dims_from_physics(i + 1, cfg)
242+
room_cross = _snap(min(room_cross, next_cross))
239243
corr_frac = cfg.get("corridor_width_frac", 0.67)
240244
door_hw = _snap(_clamp(int(room_cross * corr_frac / 2), 32, room_cross // 2))
241245
elif prev_was_corner_room and i > 0:
@@ -298,6 +302,11 @@ def _zone_tex(_zone: int):
298302
half = max_step // 2
299303
dz_choices = [half, max_step, -half, -max_step]
300304
dz = random.choice(dz_choices)
305+
# Early rooms: up-ramps from room 3+ (i>=2), down-ramps from room 2+ (i>=1)
306+
if dz > 0 and i < 2:
307+
dz = 0 # no up-ramps before room 3
308+
if dz < 0 and i < 1:
309+
dz = 0 # no down-ramps before room 2 (dead code: i>0 above)
301310
cz += dz
302311
cz = _snap(cz, 32)
303312
prev_ceil = rooms[i - 1].z1 + rooms[i - 1].h

0 commit comments

Comments
 (0)