Merge branch 'gameplay-crossing-0.1.0': designated street crossing + review map fixes

This commit is contained in:
zwitschi committed 2026-11-07 16:54:09 +01:00
commit 1dbe737b85
41 files changed
+390 -110

No files matched your search

+21 -3
View File
@@ -15,6 +15,8 @@
# Every actor is (re)created by label, so rerunning the script rebuilds the map
# deterministically instead of stacking duplicates.
import os
import unreal
MAP = "/Game/Hollywood/DemoMap"
@@ -22,6 +24,16 @@ RESULT = "/tmp/gigline/demo-map.txt"
lines = []
# EditorLevelLibrary.load_level/new_level are deprecated no-ops in -nullrhi
# commandlets (they return without switching worlds, so staged actors land in
# whatever world is current and get saved over the wrong map). The
# LevelEditorSubsystem actually switches.
LES = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
def switch_level(path):
return LES.load_level(path)
def log(msg):
lines.append(str(msg))
unreal.log(str(msg))
@@ -51,11 +63,11 @@ def clear(prefixes=()):
def build():
# --- level ------------------------------------------------------------
if unreal.EditorAssetLibrary.does_asset_exist(MAP):
if not unreal.EditorLevelLibrary.load_level(MAP):
if not switch_level(MAP):
log(f"could not load {MAP}")
return
else:
if not unreal.EditorLevelLibrary.new_level(MAP):
if not switch_level(MAP):
log(f"could not create {MAP}")
return
@@ -83,7 +95,7 @@ def build():
# --- site meshes, if they have been imported --------------------------
imported = 0
for name in ("Terrain_DEM", "Road_Hollywood", "Sidewalk_South", "Sidewalk_North",
"Kerb_South", "Kerb_North"):
"Kerb_South", "Kerb_North", "Crossing_Markings"):
mesh = unreal.EditorAssetLibrary.load_asset(f"/Game/Hollywood/Site/{name}")
if mesh is None:
continue
@@ -103,6 +115,12 @@ def build():
unreal.Rotator(pitch=0.0, yaw=0.0, roll=0.0))
log("staged: Demo_PlayerStart on the north sidewalk facing south")
# A lockable LFS .umap checks out read-only, which makes save_map silently
# return False — chmod u+w first or the staged actors never persist.
umap = os.path.join(unreal.Paths.project_dir(), MAP.replace("/Game/", "Content/") + ".umap")
if os.path.exists(umap) and not os.access(umap, os.W_OK):
os.chmod(umap, 0o644)
log(f"map writable: chmod u+w {umap}")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), MAP)
log(f"save_map {MAP}: {saved}")
+64 -27
View File
@@ -21,10 +21,34 @@ MAP_PATH = "/Game/Test/ReviewMap"
lines = []
# EditorLevelLibrary.load_level/new_level are deprecated no-ops in -nullrhi
# commandlets (they return without switching worlds, so staged actors land in
# whatever world is current and get saved over the wrong map). The
# LevelEditorSubsystem actually switches.
LES = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
def switch_level(path):
return LES.load_level(path)
def log(msg):
lines.append(str(msg))
def ensure_writable(path):
# A packed .umap left read-only (git sets 644->444 on checkout, or the
# editor re-flags it) makes save_map silently return False with no error.
# chmod u+w it so the staged camera list and actors actually persist.
p = os.path.join(unreal.Paths.project_dir(), path.replace("/Game/", "Content/") + ".umap")
try:
mode = os.stat(p).st_mode & 0o777
if mode & 0o200 == 0:
os.chmod(p, 0o644)
log(f"map writable: chmod u+w {p} (was {oct(mode)})")
except FileNotFoundError:
pass
def spawn(cls, loc, label=None, scale=None, rot=None):
a = unreal.EditorLevelLibrary.spawn_actor_from_class(cls, loc)
if a is None:
@@ -149,6 +173,18 @@ try:
else:
log("MISSING: /Script/GigLine.GigLineCharacter")
# --- NPC prefab -----------------------------------------------------------
# The reusable NPC person: a collision capsule + visible MPFB body, standing
# feet on Z 0, placed clear of the pawn so the prefab reads on its own.
npc = None
npc_class = unreal.load_class(None, "/Script/GigLine.GigLineNPCPerson")
if npc_class:
npc = spawn(npc_class, unreal.Vector(-4500, 300, 0), "NPC_Person")
if npc:
log("staged: NPC_Person (AGigLineNPCPerson) at Z 0")
else:
log("MISSING: /Script/GigLine.GigLineNPCPerson")
# --- camera list ---------------------------------------------------------
# Every camera is a (name, location, target) triple; the look-at rotation is
# computed from the pair so a camera cannot drift out of aim when one number
@@ -162,6 +198,9 @@ try:
("scale-cubes", unreal.Vector(0, -900, 200), unreal.Vector(0, 600, 80)),
# the pawn the game spawns, with the grounded cube beside it
("player-placement", unreal.Vector(320, -520, 140), unreal.Vector(0, 0, 80)),
# the NPC prefab: capsule + visible body, feet on Z 0, at double scale
# distance because it stands off to the west of the main staging
("npc-prefab", unreal.Vector(-4200, 600, 160), unreal.Vector(-4500, 300, 90)),
# low, close on feet and cube base: ground contact must be obvious
("ground-contact", unreal.Vector(180, -260, 35), unreal.Vector(0, 40, 20)),
# shadow comparison, low angle so both shadows read
@@ -182,13 +221,33 @@ try:
# oblique from the north-west, reading the colonnade in depth
("hero-facade-oblique", unreal.Vector(hero_x - 2200, -3400, 620), unreal.Vector(hero_x + 300, hero_y, 700)),
]
# --- review shot cameras + save -------------------------------------------
# Order matters: the review scene is staged in the default (unsaved) world.
# Save it as ReviewMap BEFORE touching DemoMap — switching back to a map
# that only exists in memory fails silently, and the final save would then
# write the demo world over ReviewMap (this exact corruption happened once).
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if shots:
shots.set_editor_property("shot_locations", [c[1] for c in cams])
shots.set_editor_property(
"shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
shots.set_editor_property("shot_names", [c[0] for c in cams])
shots.set_editor_property("quit_when_done", True)
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
ensure_writable(MAP_PATH)
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), MAP_PATH)
log(f"save_map {MAP_PATH}: {saved}")
# --- demo map views: what the player actually sees ----------------------
demo = unreal.EditorAssetLibrary.load_asset("/Game/Hollywood/DemoMap")
if demo and unreal.EditorAssetLibrary.does_asset_exist("/Game/Hollywood/DemoMap"):
# The demo map has terrain, street, sidewalks and the facade; the review
# floor does not. Load it, stage the shot cameras, save, then restore the
# review map so the rest of the run is unaffected.
unreal.EditorLevelLibrary.load_level("/Game/Hollywood/DemoMap")
# floor does not. Load it, stage the shot cameras, save. No switch back:
# the review map is already saved, and the run ends here.
switch_level("/Game/Hollywood/DemoMap")
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if actor.get_actor_label() == "ShotSequencer":
unreal.EditorLevelLibrary.destroy_actor(actor)
@@ -200,7 +259,7 @@ try:
# the colonnade's underside.
("demo-spawn-view", unreal.Vector(-10722.5, -3500, 165), unreal.Vector(-10722.5, -1450, 700)),
("demo-street-level", unreal.Vector(-11000, -400, 165), unreal.Vector(-10722.5, -1450, 500)),
("demo-crossing", unreal.Vector(-13600, -1650, 165), unreal.Vector(-12722.5, -1450, 500)),
("demo-crossing", unreal.Vector(-23750, -1800, 480), unreal.Vector(-24291, -1400, 300)),
# the two north-side background buildings (TCL, Tussaud's) seen from
# the south sidewalk, across Hollywood Blvd: their frontage and mass.
("demo-north-buildings", unreal.Vector(-22800, -1000, 400), unreal.Vector(-22800, 3000, 300)),
@@ -214,36 +273,14 @@ try:
# quit after the demo shots so a dedicated DemoMap capture run
# terminates cleanly (render_screenshots.sh runs both maps).
seq.set_editor_property("quit_when_done", True)
ensure_writable("/Game/Hollywood/DemoMap")
saved = unreal.EditorLoadingAndSavingUtils.save_map(
unreal.EditorLevelLibrary.get_editor_world(), "/Game/Hollywood/DemoMap")
log(f"demo map staged: {len(demo_shots)} cameras, save {saved}")
unreal.EditorLevelLibrary.load_level("/Game/Test/ReviewMap")
for actor in unreal.EditorLevelLibrary.get_all_level_actors():
if actor.get_actor_label() == "ShotSequencer":
unreal.EditorLevelLibrary.destroy_actor(actor)
else:
log("MISSING: /Game/Hollywood/DemoMap (run Tools/ue_build_demo_map.py)")
shots = spawn(unreal.GigLineShotActor, unreal.Vector(0, 0, 300), "ShotSequencer")
if shots:
shots.set_editor_property("shot_locations", [c[1] for c in cams])
shots.set_editor_property(
"shot_rotations",
[unreal.MathLibrary.find_look_at_rotation(c[1], c[2]) for c in cams])
shots.set_editor_property("shot_names", [c[0] for c in cams])
shots.set_editor_property("quit_when_done", True)
log(f"staged: ShotSequencer with {len(cams)} cameras: {', '.join(c[0] for c in cams)}")
except Exception as exc: # noqa
log(f"staging failed: {exc!r}")
# --- save --------------------------------------------------------------------
try:
world = unreal.EditorLevelLibrary.get_editor_world()
saved = unreal.EditorLoadingAndSavingUtils.save_map(world, MAP_PATH)
log(f"save_map {MAP_PATH}: {saved}")
except Exception as exc: # noqa
log(f"save_map failed: {exc!r}")
with open(RESULT, "w") as f:
f.write("\n".join(lines) + "\n")
+18 -4
View File
@@ -27,7 +27,10 @@ def log(msg):
unreal.log(str(msg))
def import_fbx(path, name, collision):
def import_fbx(path, name, collision="complex"):
# 5.8 note: there is no FBX import 'no collision' switch exposed to Python;
# a default import simply generates no collision primitives, so collision="none"
# means: skip the trace-flag step below and leave the body setup empty.
task = unreal.AssetImportTask()
task.filename = path
task.destination_path = DEST
@@ -58,10 +61,16 @@ def import_fbx(path, name, collision):
# something to stand on. Terrain and street get complex-as-simple: a simple
# hull would ramp straight over the kerbs.
#
# UE 5.8 exposes this only through StaticMeshEditorSubsystem (the mesh itself
# UE 5.8 exposes this only through the body setup's trace flag (the mesh itself
# has no trace-flag property, and the subsystem has no setter in Python), so
# the import path sets the body setup's trace flag through the mesh's
# collision options at import, then verifies it by reading it back.
# the import path sets the body setup's trace flag, then verifies it by reading
# it back.
if collision == "none":
box = mesh.get_bounding_box()
mn, mx = box.min, box.max
log(f"{name:18} import ok size=({mx.x - mn.x:.0f}, {mx.y - mn.y:.0f}, {mx.z - mn.z:.0f}) cm "
f"min_z={mn.z:.1f} collision=none")
return
trace = (unreal.CollisionTraceFlag.CTF_USE_COMPLEX_AS_SIMPLE
if collision == "complex" else unreal.CollisionTraceFlag.CTF_USE_SIMPLE_AND_COMPLEX)
try:
@@ -83,6 +92,11 @@ def main():
return
for path in files:
stem = os.path.splitext(os.path.basename(path))[0]
# Crossing markings are paint on the road surface — no collision at all,
# so the player never stands on a 1 cm lip at the band edges.
if stem == "Crossing_Markings":
import_fbx(path, stem, "none")
continue
collision = "complex" if stem in ("Terrain_DEM", "Road_Hollywood",
"Sidewalk_South", "Sidewalk_North") else "simple"
import_fbx(path, stem, collision)