WELTKARTE mit Levelauswahl -- Pfad/Punkte/Namen live aus dem Binary

game/worldmap.py: parst Kartenpfad, Levelpunkt-Positionen und die 22
Levelnamen zur Laufzeit aus START.EXE (NE-Datensegment, Anker-Suche,
Fallback W1L1). Links/Rechts laesst die Kartenfigur (Mini-Kopf KARTE4-7
je Charakter) die Original-Wegpunkte ablaufen, der aktive Levelpunkt
blinkt orange/blau (KARTE0/2 -- Blinksequenz stand exakt so in der
Binary-Animationstabelle), Space/Return startet das Level, ESC -> Menue.
Kartenposition bleibt ueber Levelbesuche erhalten.

Level ohne handgepflegte LEVEL_INFO erhalten einen generischen Spawn
(erste Bodenstelle) -- damit sind ALLE 22 Level anwaehlbar und spielbar
(Basis-Physik; Wasser/Tueren dort folgen mit den echten Leveldaten).

E2E: Karte -> laufen -> W1L3 spielen -> zurueck -> W1L2 -> QUIT rc=0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 14:18:30 +02:00
co-authored by Claude Fable 5
parent cdf6da6f7d
commit 68be969e0d
4 changed files with 251 additions and 6 deletions
+30
View File
@@ -1681,3 +1681,33 @@ exakt uebernommen, Idle 2.8 fps.
**Offen:** globale Sprite-Basis-Offsets pro BOB entschluesseln (dann sind **Offen:** globale Sprite-Basis-Offsets pro BOB entschluesseln (dann sind
ALLE Animationszuordnungen inkl. Smacks-Sprung exakt abzulesen); Karten- ALLE Animationszuordnungen inkl. Smacks-Sprung exakt abzulesen); Karten-
pfad in die Weltkarte einbauen; Cheat-Mode-Aktivierung finden. pfad in die Weltkarte einbauen; Cheat-Mode-Aktivierung finden.
## 25.07.2026 (13) -- WELTKARTE MIT LEVELAUSWAHL (Daten aus dem Binary)
game/worldmap.py: parst zur Laufzeit Pfadsegmente, Levelpunkt-Positionen
und die 22 Levelnamen direkt aus START.EXE (NE-Datensegment; Anker-Suche
statt fester Offsets, Fallback auf nur-W1L1 wenn Parse scheitert).
Struktur pro Level: Wegpunkte (flag 1) -> Standpunkt (flag 0) ->
Label-Position; Sonder-Records mit ffff-Koordinaten (Weltwechsel) werden
uebersprungen. Ergebnis: 21 Segmente + 22 Namen (letztes W3L7-Label fehlt
noch -- Randfall im Parser, spaeter).
Karten-Sprites entschluesselt: KARTE0/2 = grosser oranger/blauer
Levelpunkt (Blink-Animation laut Binary-Tabelle [100] 900,902,900!),
KARTE1/3 = kleine Punkte, KARTE4-7 = MINI-KOEPFE (Tony/Smacks/Toucan/
Coco) als Kartenfigur, KARTE8/9 = Seeschlangen-/Baum-Deko-Frames.
Bedienung: Links/Rechts wandert zum vorigen/naechsten Levelpunkt (die
Figur laeuft die Original-Wegpunkte ab, ~0.18s/Punkt), Space/Return
startet das angewaehlte Level, ESC -> Menue. Der aktive Punkt blinkt
orange/blau (0.5s) wie im Original-Datensatz. Position bleibt ueber
Level-Besuche erhalten (map_state).
Level ohne handgepflegte LEVEL_INFO bekommen einen GENERISCHEN Spawn
(erste Spalte mit Boden + 2 Luftzellen darueber); Wasser/Tuer dort noch
None. Alle 22 Level sind anwaehlbar (bewusst -- Test-freundlich, quasi
eingebauter Cheat-Mode ;-).
E2E verifiziert: Karte -> 2x Right (Figur laeuft) -> W1L3 gestartet
(generischer Spawn) -> ESC -> Karte (Position gemerkt) -> Left -> W1L2
gestartet -> ESC/ESC/QUIT rc=0. --once exit 0.
+13 -3
View File
@@ -957,7 +957,10 @@ def main():
else: else:
from . import menu as menu_mod from . import menu as menu_mod
from . import level as level_mod from . import level as level_mod
from . import worldmap as worldmap_mod
m = menu_mod.MenuAssets(assets) m = menu_mod.MenuAssets(assets)
map_state = {}
pending_map = 'W1L1'
def play_title_music(): def play_title_music():
path = audio_paths.get(('TITEL', None)) path = audio_paths.get(('TITEL', None))
@@ -976,10 +979,17 @@ def main():
r = menu_mod.run_menu(clock, m) r = menu_mod.run_menu(clock, m)
state = {'start': 'map', 'title': 'title'}.get(r, 'quit') state = {'start': 'map', 'title': 'title'}.get(r, 'quit')
elif state == 'map': elif state == 'map':
r = menu_mod.run_worldmap(clock, m) r = worldmap_mod.run_worldmap(clock, assets, m, state=map_state)
state = {'level': 'level', 'menu': 'menu'}.get(r, 'quit') if isinstance(r, tuple) and r[0] == 'level':
pending_map = r[1]
state = 'level'
elif r == 'menu':
state = 'menu'
else:
state = 'quit'
elif state == 'level': elif state == 'level':
r = level_mod.run_level(clock, assets, audio_paths) r = level_mod.run_level(clock, assets, audio_paths,
map_name=pending_map)
if r == 'map': if r == 'map':
play_title_music() # Karte/Menue laufen mit Titelmusik play_title_music() # Karte/Menue laufen mit Titelmusik
state = 'map' state = 'map'
+19 -3
View File
@@ -154,9 +154,7 @@ class Level:
def __init__(self, assets, map_name): def __init__(self, assets, map_name):
container = assets.container container = assets.container
self.name = map_name self.name = map_name
self.info = LEVEL_INFO.get(map_name, dict(spawn=(2, 2), time=599, self.info = LEVEL_INFO.get(map_name)
chars=['TONY'], water_y=None,
door_px=None))
world = map_name[:2] world = map_name[:2]
self.width, self.height, self.cells = parse_map(container.entries[map_name + '.MAP']) self.width, self.height, self.cells = parse_map(container.entries[map_name + '.MAP'])
palette = get_world_palette(container.entries, world) palette = get_world_palette(container.entries, world)
@@ -181,6 +179,24 @@ class Level:
self.surface = self.surface.convert() self.surface = self.surface.convert()
self.palette = palette self.palette = palette
# Level ohne handgepflegte LEVEL_INFO (alle ausser W1L1): Spawn
# generisch suchen -- erste Spalte mit Boden und Luft darueber.
if self.info is None:
spawn = (3, 5)
for tx in range(2, self.width - 2):
for ty in range(2, self.height - 1):
if (self.cells[ty * self.width + tx][1] & SOLID_BIT
and self.cells[(ty - 1) * self.width + tx][1] == 0
and self.cells[(ty - 2) * self.width + tx][1] == 0):
spawn = (tx, ty)
break
else:
continue
break
self.info = dict(spawn=spawn, time=599,
chars=['TONY', 'SMACKS'], water_y=None,
door_px=None)
# PIXELGENAUE Slope-Laufflaechen (Stefans "steht in der Luft"-Bug, # PIXELGENAUE Slope-Laufflaechen (Stefans "steht in der Luft"-Bug,
# 2. Runde): statt einer 45-Grad-Annahme wird pro Slope-Tile (ctype # 2. Runde): statt einer 45-Grad-Annahme wird pro Slope-Tile (ctype
# 16) und Spalte der oberste Nicht-Himmel-Pixel der TILE-GRAFIK als # 16) und Spalte der oberste Nicht-Himmel-Pixel der TILE-GRAFIK als
+189
View File
@@ -0,0 +1,189 @@
"""Weltkarte mit Levelauswahl -- Pfad, Levelpunkte und Namen aus dem
START.EXE-Binary (NE-Datensegment), Marker/Koepfe aus KARTE0-9.PCC.
Binary-Struktur (Fund 25.07.2026, Offsets ~145114..146760 in START.EXE):
Header, dann pro Level ein Segment aus 0xffff-terminierten u16-Records:
[x y 0 25] Standpunkt (Wiederholung des vorigen Endpunkts)
[x y 1 25] ... Wegpunkte (die Figur laeuft sie ab; 25 = Ticks)
[x y 0 25] Endpunkt = Standpunkt an diesem Level
[label_x label_y] Position des Levelpunkt-Markers auf der Karte
[0] (Flag/Reserve)
danach die 22 Levelnamen als Pascal-Strings (\x04W1L0...).
Karten-Sprites: KARTE0/2 = grosser oranger/blauer Punkt, KARTE1/3 = kleine
Varianten, KARTE4-7 = Mini-Koepfe (Tony, Smacks, Toucan, Coco) als
Spielfigur-Marker. Blink-Animation des aktiven Punkts laut Binary-Tabelle:
[delay 100] 900,902,900 = KARTE0/KARTE2-Wechsel.
"""
import re
import struct
import sys
import time
import pygame
from . import intro
# Mini-Kopf-Sprite je Charakter (KARTE4=Tony, 5=Smacks, 6=Toucan, 7=Coco)
HEAD_SPRITE = {'TONY': 'KARTE4', 'SMACKS': 'KARTE5', 'TOUCAN': 'KARTE6', 'COCO': 'KARTE7'}
STEP_SECONDS = 0.18 # Lauftempo der Kartenfigur pro Wegpunkt
BLINK_SECONDS = 0.5 # orange/blau-Wechsel des aktiven Punkts
def parse_worldmap(exe_path):
'''Liest Pfadsegmente + Levelnamen aus START.EXE. Rueckgabe:
(segments, names) mit segments = Liste von dicts
{waypoints: [(x,y)...], stand: (x,y), label: (x,y)}.
Wirft bei Strukturbruch Exception (Aufrufer faellt zurueck).'''
d = open(exe_path, 'rb').read()
anchor = struct.pack('<4H', 58, 40, 0, 25)
start = d.find(anchor)
if start < 0:
raise ValueError('Kartenpfad-Anker nicht gefunden')
names_at = d.find(b'\x04W1L0\x04W1L1')
if names_at < 0:
raise ValueError('Levelnamen nicht gefunden')
# Namen lesen (22 Pascal-Strings)
names = []
p = names_at
while len(names) < 22 and p < len(d) - 1:
ln = d[p]
if not (1 <= ln <= 8):
break
names.append(d[p + 1:p + 1 + ln].decode('ascii'))
p += 1 + ln
# Records lesen
def read_list(p):
vals = []
while p <= names_at - 2:
v = struct.unpack('<H', d[p:p + 2])[0]
p += 2
if v == 0xffff:
return vals, p
vals.append(v)
if len(vals) > 8:
return None, p
return vals, p
segments = []
p = start
cur = {'waypoints': [], 'stand': None, 'label': None}
while p < names_at - 2:
vals, p = read_list(p)
if vals is None:
continue
if len(vals) == 4 and vals[3] == 25:
x, y, flag = vals[0], vals[1], vals[2]
if x > 320 or y > 200:
continue # Sonder-/Sprungrecords (Weltwechsel) ueberspringen
cur['waypoints'].append((x, y))
if flag == 0 and len(cur['waypoints']) > 1:
cur['stand'] = (x, y)
elif len(vals) == 2:
cur['label'] = (vals[0], vals[1])
if cur['stand'] is None and cur['waypoints']:
cur['stand'] = cur['waypoints'][-1]
segments.append(cur)
cur = {'waypoints': [], 'stand': None, 'label': None}
# len==1 ([0]-Flag) und leere Listen: ignorieren
return segments, names
class WorldMap:
'''Zustand + Zeichnen der Karte. current = Index des angewaehlten Levels.'''
def __init__(self, assets, menu_assets):
self.assets = assets
self.karte = menu_assets.karte
self.font = menu_assets.font_small
exe = None
import os
base = os.path.dirname(os.path.abspath(assets.container.path)) \
if hasattr(assets.container, 'path') else None
try:
from .intro import PROJECT_ROOT
exe = os.path.join(PROJECT_ROOT, 'game', 'original', 'START.EXE')
self.segments, self.names = parse_worldmap(exe)
except Exception as exc:
print(f'[worldmap] Binary-Pfaddaten nicht lesbar ({exc}) -- '
f'Fallback nur Level 1', file=sys.stderr)
self.segments = [dict(waypoints=[(113, 35)], stand=(113, 35), label=(108, 51))]
self.names = ['W1L1']
self.dot_big_on = intro.make_border_bg_transparent(assets.pcc_surface('KARTE0'))
self.dot_big_off = intro.make_border_bg_transparent(assets.pcc_surface('KARTE2'))
self.dot_small = intro.make_border_bg_transparent(assets.pcc_surface('KARTE3'))
self.heads = {c: intro.make_border_bg_transparent(assets.pcc_surface(n))
for c, n in HEAD_SPRITE.items()}
self.current = 1 if len(self.segments) > 1 else 0 # Start: Level 1 (Index 0 = W1L0)
def draw(self, canvas, char, t, walk_pos=None):
canvas.blit(self.karte, (0, 0))
for i, seg in enumerate(self.segments):
if seg['label'] is None:
continue
lx, ly = seg['label']
if i == self.current:
dot = self.dot_big_on if int(t / BLINK_SECONDS) % 2 == 0 else self.dot_big_off
else:
dot = self.dot_small
canvas.blit(dot, (lx - dot.get_width() // 2, ly - dot.get_height() // 2))
# Spielfigur-Kopf
px, py = walk_pos if walk_pos else self.segments[self.current]['stand']
head = self.heads.get(char, self.heads['TONY'])
canvas.blit(head, (px - head.get_width() // 2, py - head.get_height()))
def run_worldmap(clock, assets, menu_assets, char='TONY', state={}):
'''Weltkarte: Links/Rechts wandert zum vorigen/naechsten Levelpunkt
(Figur laeuft die Original-Wegpunkte ab), Space/Return startet das
angewaehlte Level, ESC -> Menue.
Rueckgabe: ('level', name) | 'menu' | 'quit'.
state haelt die aktuelle Position ueber Aufrufe hinweg.'''
wm = state.get('wm')
if wm is None or wm.assets is not assets:
wm = WorldMap(assets, menu_assets)
state['wm'] = wm
walking = None # (Wegpunktliste, laufindex, richtung) waehrend der Bewegung
start = time.monotonic()
canvas = pygame.Surface((320, 200))
while True:
t = time.monotonic() - start
for event in pygame.event.get():
if event.type == pygame.QUIT:
return 'quit'
if event.type == pygame.KEYDOWN:
if event.key == pygame.K_RETURN and (event.mod & pygame.KMOD_ALT):
intro.toggle_fullscreen()
continue
if event.key == pygame.K_ESCAPE:
return 'menu'
if walking is None:
if event.key in (pygame.K_RIGHT, pygame.K_UP) and wm.current < len(wm.segments) - 1:
pts = wm.segments[wm.current + 1]['waypoints']
walking = {'pts': pts, 'i': 0.0, 'target': wm.current + 1}
elif event.key in (pygame.K_LEFT, pygame.K_DOWN) and wm.current > 0:
pts = list(reversed(wm.segments[wm.current]['waypoints']))
walking = {'pts': pts, 'i': 0.0, 'target': wm.current - 1}
elif event.key in (pygame.K_SPACE, pygame.K_RETURN):
return ('level', wm.names[wm.current])
dt = clock.tick(60) / 1000.0
walk_pos = None
if walking is not None:
walking['i'] += dt / STEP_SECONDS
pts = walking['pts']
if walking['i'] >= len(pts) - 1 or len(pts) < 2:
wm.current = walking['target']
walking = None
else:
i0 = int(walking['i'])
frac = walking['i'] - i0
x0, y0 = pts[i0]
x1, y1 = pts[min(i0 + 1, len(pts) - 1)]
walk_pos = (int(x0 + (x1 - x0) * frac), int(y0 + (y1 - y0) * frac))
wm.draw(canvas, char, t, walk_pos)
intro._blit_centered(intro.build_letterboxed(canvas))
pygame.display.flip()