320 lines
13 KiB
Python
320 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""
|
|
Visual simulation of the solid rectangular 3D Jukebox cabinet beside billiards.
|
|
Loads assets/animations/hall/jackbox-v1.bin directly (3 frames: Front, Side, Back).
|
|
Renders pixel-perfect 640x480 views matching MASM32 DrawCityVertical3D and DrawCityHorizontal3D.
|
|
"""
|
|
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)
|
|
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, 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_vert(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)
|
|
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)
|
|
|
|
# 1. Pool table geometry: X in [26.0, 28.0], Y in [24.0, 26.0], Z in [0, 0.85m]
|
|
def draw_pool_side(axis, coord, lo, hi, z0, z1):
|
|
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 < lo or along >= hi:
|
|
continue
|
|
top = (HALL_STANDING_EYE - z1) * SCREEN_H // depth + HALF_H
|
|
bottom = (HALL_STANDING_EYE - z0) * SCREEN_H // depth + HALF_H
|
|
r_start = max(0, top)
|
|
r_end = min(SCREEN_H, bottom)
|
|
shade = max(40, min(255, 255 - (depth >> 12)))
|
|
for row in range(r_start, r_end):
|
|
if depth < depthBuffer[row * SCREEN_W + col]:
|
|
depthBuffer[row * SCREEN_W + col] = depth
|
|
screen_pixels[row * SCREEN_W + col] = ((50 * shade) >> 8, (25 * shade) >> 8, (15 * shade) >> 8)
|
|
|
|
draw_pool_side(1, int(24.0*FP_1), int(26.0*FP_1), int(28.0*FP_1), 0, int(0.85*FP_1))
|
|
draw_pool_side(1, int(26.0*FP_1), int(26.0*FP_1), int(28.0*FP_1), 0, int(0.85*FP_1))
|
|
draw_pool_side(0, int(26.0*FP_1), int(24.0*FP_1), int(26.0*FP_1), 0, int(0.85*FP_1))
|
|
draw_pool_side(0, int(28.0*FP_1), int(24.0*FP_1), int(26.0*FP_1), 0, int(0.85*FP_1))
|
|
|
|
# 2. Wall Art / Painting 116 on East Wall: X = 30.98m, Y in [25.0m .. 26.5m], Z in [1.0m .. 2.0m]
|
|
def draw_painting(axis, coord, lo, hi, z0, z1, slot):
|
|
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 < lo or along >= hi:
|
|
continue
|
|
top = (HALL_STANDING_EYE - z1) * SCREEN_H // depth + HALF_H
|
|
bottom = (HALL_STANDING_EYE - z0) * SCREEN_H // depth + HALF_H
|
|
span = bottom - top
|
|
if span <= 0:
|
|
continue
|
|
r_start = max(0, top)
|
|
r_end = min(SCREEN_H, bottom)
|
|
span_along = hi - lo
|
|
tx = int((along - lo) * 128 / span_along) % 128
|
|
shade = max(40, min(255, 255 - (depth >> 12)))
|
|
for row in range(r_start, r_end):
|
|
ty = int((row - top) * 128 / span) % 128
|
|
r, g, b, a = get_tex_pixel(slot, tx, ty)
|
|
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)
|
|
|
|
draw_painting(0, int(30.98 * FP_1), int(25.0 * FP_1), int(26.5 * FP_1), FP_1, 2 * FP_1, 116)
|
|
|
|
# 3. Solid Rectangular Jukebox Cabinet (at East wall, Y = 23.5m, clearing Painting 116!)
|
|
# Depth: X in [30.40m .. 30.95m] (back against East wall at 31.0m)
|
|
# Width: Y in [23.075m .. 23.925m] (centered at Y = 23.5m)
|
|
# Height: Z in [0 .. 1.85m]
|
|
x0 = int(30.40 * FP_1)
|
|
x1 = int(30.95 * FP_1)
|
|
y0 = int(23.075 * FP_1)
|
|
y1 = int(23.925 * FP_1)
|
|
zHigh = int(1.85 * FP_1)
|
|
|
|
# Back Face (against wall: X = x1) - Frame 2
|
|
draw_vert(0, x1, y0, y1, 0, zHigh, 2)
|
|
# North Side (Y = y0) - Frame 1
|
|
draw_vert(1, y0, x0, x1, 0, zHigh, 1)
|
|
# South Side (Y = y1) - Frame 1
|
|
draw_vert(1, y1, x0, x1, 0, zHigh, 1)
|
|
# Front Face (facing West towards billiards table: X = x0) - Frame 0
|
|
draw_vert(0, x0, y0, y1, 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__':
|
|
# 1. Front View: Standing near billiards at (26.0, 25.0) looking East (angle=0) past table at Jukebox
|
|
render_scene(26.0, 24.5, 0, DOCS_DIR / 'jukebox_3d_front.png')
|
|
# 2. Angle View: Standing at (27.5, 22.5) looking South-East (angle=480) at pool table and Jukebox
|
|
render_scene(27.5, 21.0, 128, DOCS_DIR / 'jukebox_3d_angle.png')
|
|
# 3. Close-up View: Standing at (29.2, 25.0) looking East (angle=0) right at Jukebox
|
|
render_scene(29.2, 23.5, 0, DOCS_DIR / 'jukebox_3d_close.png')
|
|
# 4. Side View: Standing at (30.0, 27.5) looking North (angle=384) at South side of Jukebox
|
|
render_scene(30.6, 25.5, 768, DOCS_DIR / 'jukebox_3d_side.png')
|
|
print('All Jukebox 3D preview frames rendered to docs/previews_minigames/!')
|