diff --git a/NOTES.md b/NOTES.md index 91a3bfd..4909058 100644 --- a/NOTES.md +++ b/NOTES.md @@ -1681,3 +1681,33 @@ exakt uebernommen, Idle 2.8 fps. **Offen:** globale Sprite-Basis-Offsets pro BOB entschluesseln (dann sind ALLE Animationszuordnungen inkl. Smacks-Sprung exakt abzulesen); Karten- 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. diff --git a/game/intro.py b/game/intro.py index 3b36896..60b4d1b 100644 --- a/game/intro.py +++ b/game/intro.py @@ -957,7 +957,10 @@ def main(): else: from . import menu as menu_mod from . import level as level_mod + from . import worldmap as worldmap_mod m = menu_mod.MenuAssets(assets) + map_state = {} + pending_map = 'W1L1' def play_title_music(): path = audio_paths.get(('TITEL', None)) @@ -976,10 +979,17 @@ def main(): r = menu_mod.run_menu(clock, m) state = {'start': 'map', 'title': 'title'}.get(r, 'quit') elif state == 'map': - r = menu_mod.run_worldmap(clock, m) - state = {'level': 'level', 'menu': 'menu'}.get(r, 'quit') + r = worldmap_mod.run_worldmap(clock, assets, m, state=map_state) + 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': - 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': play_title_music() # Karte/Menue laufen mit Titelmusik state = 'map' diff --git a/game/level.py b/game/level.py index 5a96152..4e26795 100644 --- a/game/level.py +++ b/game/level.py @@ -154,9 +154,7 @@ class Level: def __init__(self, assets, map_name): container = assets.container self.name = map_name - self.info = LEVEL_INFO.get(map_name, dict(spawn=(2, 2), time=599, - chars=['TONY'], water_y=None, - door_px=None)) + self.info = LEVEL_INFO.get(map_name) world = map_name[:2] self.width, self.height, self.cells = parse_map(container.entries[map_name + '.MAP']) palette = get_world_palette(container.entries, world) @@ -181,6 +179,24 @@ class Level: self.surface = self.surface.convert() 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, # 2. Runde): statt einer 45-Grad-Annahme wird pro Slope-Tile (ctype # 16) und Spalte der oberste Nicht-Himmel-Pixel der TILE-GRAFIK als diff --git a/game/worldmap.py b/game/worldmap.py new file mode 100644 index 0000000..d8864cb --- /dev/null +++ b/game/worldmap.py @@ -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(' 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()