# -*- coding: utf-8 -*- """ Visual simulation of the 3D Jukebox cabinet in the Casino Hall. Loads assets/animations/hall/jackbox-v1.bin directly (3 frames: Front, Side, Back). Renders pixel-perfect 640x480 views matching MASM32 DrawCityVertical3D with alpha transparency. """ import math import struct import re from pathlib import Path import numpy as np from PIL import Image REPO_ROOT = Path(__file__).resolve().parents[2] DOCS_DIR = REPO_ROOT / 'docs' / 'previews_minigames' DOCS_DIR.mkdir(parents=True, exist_ok=True) FP_1 = 65536 SCREEN_W = 640 SCREEN_H = 480 HALF_W = 320 HALF_H = 240 HALL_STANDING_EYE = FP_1 * 21 // 20 # 1. Load map with open(REPO_ROOT / 'casino3d_data.inc', 'r', encoding='latin1') as f: text = f.read() lines = text.splitlines() map_lines = [] capturing = False for l in lines: if 'casinoMap db' in l: capturing = True if capturing: map_lines.append(l) if len(map_lines) == 32: break casinoMap = [] for l in map_lines: vals = [int(x.strip()) for x in re.findall(r'\d+', l)] casinoMap.extend(vals) # 2. Load HD textures tex_raw = open(REPO_ROOT / 'assets/textures/casino3d-hd.bin', 'rb').read() slot_size = 128 * 128 * 4 def get_tex_pixel(slot, tx, ty): idx = slot * slot_size + (ty * 128 + tx) * 4 if idx + 4 > len(tex_raw): return (50, 50, 50, 255) b, g, r, a = tex_raw[idx:idx+4] return (r, g, b, a) # 3. Load Jackbox Art pack (ANM1) jackbox_raw = open(REPO_ROOT / 'assets/animations/hall/jackbox-v1.bin', 'rb').read() magic, jw, jh, jframes, jlen = struct.unpack('<4s4I', jackbox_raw[:20]) assert magic == b'ANM1' and (jw, jh, jframes) == (256, 512, 3), f'Invalid pack: {magic}, {jw}x{jh}, {jframes}' jpixels = jackbox_raw[20:] def get_jackbox_pixel(frame, tx, ty): idx = frame * (jw * jh * 4) + (ty * jw + tx) * 4 b, g, r, a = jpixels[idx:idx+4] return (r, g, b, a) tableCos = [round(math.cos(i * 2 * math.pi / 512) * FP_1) for i in range(512)] tableSin = [round(math.sin(i * 2 * math.pi / 512) * FP_1) for i in range(512)] def render_scene(cam_x, cam_y, cam_angle, jb_x, jb_y, out_path): playerX = int(cam_x * FP_1) playerY = int(cam_y * FP_1) playerAngle = cam_angle % 512 playerDirX = tableCos[playerAngle] playerDirY = tableSin[playerAngle] playerPlaneX = -66 * playerDirY // 100 playerPlaneY = 66 * playerDirX // 100 screen_pixels = [(20, 20, 25)] * (SCREEN_W * SCREEN_H) zBuffer = [0x7FFFFFFF] * SCREEN_W depthBuffer = [0x7FFFFFFF] * (SCREEN_W * SCREEN_H) # Floor & Ceiling for y in range(HALF_H, SCREEN_H): pDist = (HALF_H * FP_1) // (y - HALF_H + 1) shade = max(35, min(255, 255 - (pDist >> 13))) cShade = max(20, shade - 25) fStepX = (playerPlaneX * 2 * pDist // FP_1) // SCREEN_W fStepY = (playerPlaneY * 2 * pDist // FP_1) // SCREEN_W fX = playerX + ((playerDirX - playerPlaneX) * pDist // FP_1) fY = playerY + ((playerDirY - playerPlaneY) * pDist // FP_1) for x in range(SCREEN_W): tx = (fX >> 9) & 127 ty = (fY >> 9) & 127 r, g, b, _ = get_tex_pixel(0, tx, ty) screen_pixels[y * SCREEN_W + x] = ((r * shade) >> 8, (g * shade) >> 8, (b * shade) >> 8) cx = (fX >> 10) & 127 cy = (fY >> 10) & 127 cr, cg, cb, _ = get_tex_pixel(5, cx, cy) screen_pixels[(SCREEN_H - 1 - y) * SCREEN_W + x] = ((cr * cShade) >> 8, (cg * cShade) >> 8, (cb * cShade) >> 8) fX += fStepX fY += fStepY cityRayX = [0] * SCREEN_W cityRayY = [0] * SCREEN_W for col in range(SCREEN_W): camX = (2 * col - SCREEN_W) * FP_1 // SCREEN_W cityRayX[col] = playerDirX + (playerPlaneX * camX // FP_1) cityRayY[col] = playerDirY + (playerPlaneY * camX // FP_1) # Walls for col in range(SCREEN_W): rayDirX = cityRayX[col] rayDirY = cityRayY[col] mapX = playerX >> 16 mapY = playerY >> 16 deltaDistX = (1 << 32) // abs(rayDirX) if rayDirX != 0 else 0x7FFFFFFF deltaDistY = (1 << 32) // abs(rayDirY) if rayDirY != 0 else 0x7FFFFFFF if rayDirX < 0: stepX = -1 sideDistX = ((playerX - (mapX << 16)) * deltaDistX) >> 16 else: stepX = 1 sideDistX = (((mapX + 1 << 16) - playerX) * deltaDistX) >> 16 if rayDirY < 0: stepY = -1 sideDistY = ((playerY - (mapY << 16)) * deltaDistY) >> 16 else: stepY = 1 sideDistY = (((mapY + 1 << 16) - playerY) * deltaDistY) >> 16 side = 0 hit = 0 steps = 0 while hit == 0 and steps < 64: steps += 1 if sideDistX < sideDistY: sideDistX += deltaDistX mapX += stepX side = 0 else: sideDistY += deltaDistY mapY += stepY side = 1 if 0 <= mapX < 32 and 0 <= mapY < 32: tile = casinoMap[mapY * 32 + mapX] if tile != 0: hit = tile else: hit = 1 perpWallDist = sideDistX - deltaDistX if side == 0 else sideDistY - deltaDistY perpWallDist = max(2048, perpWallDist) zBuffer[col] = perpWallDist lineH = (SCREEN_H * FP_1) // perpWallDist drawStart = max(0, HALF_H - lineH // 2) drawEnd = min(SCREEN_H - 1, HALF_H + lineH // 2) wallHit = playerY + ((perpWallDist * rayDirY) >> 16) if side == 0 else playerX + ((perpWallDist * rayDirX) >> 16) texX = ((wallHit & 0xFFFF) * 128) >> 16 if (side == 0 and rayDirX > 0) or (side == 1 and rayDirY < 0): texX = 127 - texX shade = max(40, min(255, 255 - (perpWallDist >> 12))) if side == 1: shade = (shade * 180) >> 8 texId = hit if hit != 0 else 1 for y in range(drawStart, drawEnd): texY = (((y * 2 - SCREEN_H + lineH) * 128) // (lineH * 2)) & 127 r, g, b, _ = get_tex_pixel(texId, texX, texY) screen_pixels[y * SCREEN_W + col] = ((r * shade) >> 8, (g * shade) >> 8, (b * shade) >> 8) depthBuffer[y * SCREEN_W + col] = perpWallDist def draw_vertical(axis, coord, loBound, hiBound, zLow, zHigh, frame_idx): for col in range(SCREEN_W): ray_dir = cityRayX[col] if axis == 0 else cityRayY[col] if abs(ray_dir) < 256: continue orig = playerX if axis == 0 else playerY num = (coord - orig) depth = (num << 16) // ray_dir if depth <= 4096 or depth >= zBuffer[col]: continue along = playerY + ((depth * cityRayY[col]) >> 16) if axis == 0 else playerX + ((depth * cityRayX[col]) >> 16) if along < loBound or along >= hiBound: continue srcX = (along - loBound) * jw // (hiBound - loBound) if srcX < 0 or srcX >= jw: continue top = (HALL_STANDING_EYE - zHigh) * SCREEN_H // depth + HALF_H bottom = (HALL_STANDING_EYE - zLow) * SCREEN_H // depth + HALF_H span = bottom - top if span <= 0: continue stepY = (jh << 16) // span r_start = max(0, top) r_end = min(SCREEN_H, bottom) curY = (r_start - top) * stepY shade = max(40, min(255, 255 - (depth >> 12))) for row in range(r_start, r_end): srcY = (curY >> 16) curY += stepY if srcY < 0 or srcY >= jh: continue r, g, b, a = get_jackbox_pixel(frame_idx, srcX, srcY) # Alpha testing for materials >= 104 (cmp eax, 80000000h; jb city_vertical_next_pixel) if a < 128: continue if depth < depthBuffer[row * SCREEN_W + col]: depthBuffer[row * SCREEN_W + col] = depth screen_pixels[row * SCREEN_W + col] = ((r * shade) >> 8, (g * shade) >> 8, (b * shade) >> 8) # Footprint: width 0.75m, depth 0.55m, height 1.85m wx = int(jb_x * FP_1) wy = int(jb_y * FP_1) x0 = wx - FP_1 * 15 // 40 x1 = wx + FP_1 * 15 // 40 y0 = wy - FP_1 * 11 // 40 y1 = wy + FP_1 * 11 // 40 zHigh = FP_1 * 37 // 20 # 1. Rear Face (Y = y0) - Frame 2 draw_vertical(1, y0, x0, x1, 0, zHigh, 2) # 2. Left Side (X = x0) - Frame 1 draw_vertical(0, x0, y0, y1, 0, zHigh, 1) # 3. Right Side (X = x1) - Frame 1 draw_vertical(0, x1, y0, y1, 0, zHigh, 1) # 4. Front Face (Y = y1) - Frame 0 draw_vertical(1, y1, x0, x1, 0, zHigh, 0) img = Image.new('RGB', (SCREEN_W, SCREEN_H)) img.putdata(screen_pixels) img.save(str(out_path)) print(f'Rendered: {out_path.name}') if __name__ == '__main__': jb_x, jb_y = 6.0, 20.0 # 1. Front View (from South looking North directly at Front) render_scene(6.0, 22.2, 384, jb_x, jb_y, DOCS_DIR / 'jukebox_3d_front.png') # 2. 45-degree angle View (from South-East looking North-West) render_scene(7.2, 21.8, 416, jb_x, jb_y, DOCS_DIR / 'jukebox_3d_angle.png') # 3. Side View (from East looking West) render_scene(8.0, 20.0, 128, jb_x, jb_y, DOCS_DIR / 'jukebox_3d_side.png') # 4. Close-up Front View (standing right at the jukebox) render_scene(6.0, 21.1, 384, jb_x, jb_y, DOCS_DIR / 'jukebox_3d_close.png') print('All Jukebox 3D preview frames rendered to docs/previews_minigames/!')