fix(jukebox): remove studio grey background, fix 3D texture orientation and cabinet rendering

This commit is contained in:
Игорь Федосеев 2026-10-25 16:30:40 +03:00
parent 82f3aa9437
commit 0a85e271e9
17 changed files with 696 additions and 342 deletions

Binary file not shown.

View file

@ -8,7 +8,7 @@ namespace AssemblerCasinoLauncher
{
static class Program
{
const string ExpectedPokerSha256 = "B1D4D027ACE9BDF6B34C4621191E5DAB855D44E384F45F9B0D0E8EC528DF2B28";
const string ExpectedPokerSha256 = "7488391B71FCEB1C2A2F68C06445CC1421A650793205498BA19E787C9E184297";
[STAThread]
static void Main()

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 241 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

BIN
poker.exe

Binary file not shown.

View file

@ -7,7 +7,7 @@ ResolveCitySurface3D PROC material:DWORD
mov eax,material
cmp eax,161
jb @F
cmp eax,162
cmp eax,163
ja @F
invoke ResolveJackboxSurface3D,material
ret

View file

@ -6,7 +6,7 @@
LoadJackboxArt3D PROC
invoke LoadAnimPack3D, OFFSET jackboxWorldArt3D, OFFSET jackboxWorldPath3D
invoke ValidateCityPack3D, OFFSET jackboxWorldArt3D, 256, 512, 2
invoke ValidateCityPack3D, OFFSET jackboxWorldArt3D, 256, 512, 3
invoke LoadAnimPack3D, OFFSET jackboxScreenArt3D, OFFSET jackboxScreenPath3D
invoke ValidateCityPack3D, OFFSET jackboxScreenArt3D, 640, 480, 1
ret
@ -23,17 +23,26 @@ ResolveJackboxSurface3D PROC material:DWORD
je jackbox_surface_missing
mov eax, material
cmp eax, MATERIAL_JACKBOX_FRONT
je @F
je jackbox_surface_front
cmp eax, MATERIAL_JACKBOX_SIDE
je jackbox_surface_side
cmp eax, MATERIAL_JACKBOX_BACK
jne jackbox_surface_missing
; Frame 1: side (offset 256*512*4 = 524288)
; Frame 2: rear panel (offset 256*512*4*2 = 1048576)
mov eax, jackboxWorldArt3D.pixels
add eax, 1048576
mov ecx, 256
mov edx, 512
ret
jackbox_surface_side:
; Frame 1: side profile (offset 256*512*4 = 524288)
mov eax, jackboxWorldArt3D.pixels
add eax, 524288
mov ecx, 256
mov edx, 512
ret
@@:
; Frame 0: front
jackbox_surface_front:
; Frame 0: front face
mov eax, jackboxWorldArt3D.pixels
mov ecx, 256
mov edx, 512
@ -45,22 +54,25 @@ ResolveJackboxSurface3D ENDP
DrawJackboxCabinet3D PROC uses ebx esi edi wx:DWORD, wy:DWORD
local x0:DWORD, x1:DWORD, y0:DWORD, y1:DWORD
; Footprint: width 0.7m, depth 0.45m, height 1.85m
; Footprint: width 0.75m, depth 0.55m, height 1.85m
mov eax, wx
sub eax, FP_1*7/20
sub eax, FP_1*15/40
mov x0, eax
add eax, FP_1*7/10
add eax, FP_1*15/20
mov x1, eax
mov eax, wy
sub eax, FP_1*9/40
sub eax, FP_1*11/40
mov y0, eax
add eax, FP_1*9/20
add eax, FP_1*11/20
mov y1, eax
invoke DrawCityBoxSides3D, x0, x1, y0, y1, 0, FP_1*37/20, MATERIAL_JACKBOX_SIDE
invoke DrawCityHorizontal3D, x0, x1, y0, y1, FP_1*37/20, 110
mov eax, y1
add eax, FP_1/500
invoke DrawCityVertical3D, 1, eax, x0, x1, 0, FP_1*37/20, MATERIAL_JACKBOX_FRONT
; Rear: North face (Y = y0)
invoke DrawCityVertical3D, 1, y0, x0, x1, 0, FP_1*37/20, MATERIAL_JACKBOX_BACK
; Sides: West face (X = x0) and East face (X = x1)
invoke DrawCityVertical3D, 0, x0, y0, y1, 0, FP_1*37/20, MATERIAL_JACKBOX_SIDE
invoke DrawCityVertical3D, 0, x1, y0, y1, 0, FP_1*37/20, MATERIAL_JACKBOX_SIDE
; Front: South face (Y = y1)
invoke DrawCityVertical3D, 1, y1, x0, x1, 0, FP_1*37/20, MATERIAL_JACKBOX_FRONT
ret
DrawJackboxCabinet3D ENDP
@ -76,7 +88,7 @@ JackboxCanWalk3D PROC px:DWORD, py:DWORD
jns @F
neg eax
@@:
cmp eax, FP_1*7/20
cmp eax, FP_1*11/20
ja jackbox_walk_clear
mov eax, px
sub eax, JACKBOX_X
@ -84,7 +96,7 @@ JackboxCanWalk3D PROC px:DWORD, py:DWORD
jns @F
neg eax
@@:
cmp eax, FP_HALF
cmp eax, FP_1*15/20
jbe jackbox_walk_blocked
jackbox_walk_clear:
mov eax, 1

View file

@ -11,6 +11,7 @@ JACKBOX_TRACK_COUNT equ 10
MATERIAL_JACKBOX_FRONT equ 161
MATERIAL_JACKBOX_SIDE equ 162
MATERIAL_JACKBOX_BACK equ 163
LoadJackboxArt3D PROTO
FreeJackboxArt3D PROTO

View file

@ -1,6 +1,14 @@
"""Offline packaging of Jackbox / Jukebox art for the MASM32 assembly renderer."""
"""Offline packaging of Jackbox / Jukebox art for the MASM32 assembly renderer.
Processes high-resolution source images, removes studio grey background with clean
alpha masking and anti-aliasing, aligns 3D cabinet surfaces (Front, Side, Back),
and outputs binary packs (ANM1).
"""
import math
from pathlib import Path
import struct
import cv2
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parents[2]
@ -8,11 +16,56 @@ SOURCE = ROOT / "assets/sprites/jackbox"
OUT = ROOT / "assets/animations/hall"
def pack(path: Path, frames: list):
w, h = frames[0].size
raw = b"".join(image.tobytes("raw", "BGRA") for image in frames)
def clean_cutout(img_bgr: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
"""Segment studio grey background using connected components from image borders."""
h, w = img_bgr.shape[:2]
arr = img_bgr.astype(np.float32)
# Detect neutral studio grey pixels: low saturation and mid-low brightness
color_diff = np.maximum(
np.abs(arr[:, :, 0] - arr[:, :, 1]),
np.maximum(np.abs(arr[:, :, 1] - arr[:, :, 2]), np.abs(arr[:, :, 0] - arr[:, :, 2]))
)
brightness = np.mean(arr, axis=2)
is_bg_cand = (color_diff < 10) & (brightness >= 18) & (brightness <= 65)
# Seed from all 4 borders
seed_mask = np.zeros((h, w), dtype=np.uint8)
seed_mask[0, :] = is_bg_cand[0, :]
seed_mask[-1, :] = is_bg_cand[-1, :]
seed_mask[:, 0] = is_bg_cand[:, 0]
seed_mask[:, -1] = is_bg_cand[:, -1]
num_labels, labels = cv2.connectedComponents(is_bg_cand.astype(np.uint8))
border_labels = np.unique(labels[seed_mask == 1])
border_labels = border_labels[border_labels != 0]
is_bg = np.isin(labels, border_labels)
clean_fg = (~is_bg).astype(np.uint8)
# Fill internal holes in foreground if any
num_fg, fg_labels = cv2.connectedComponents(clean_fg)
fg_sizes = np.bincount(fg_labels.flatten())
fg_sizes[0] = 0
main_fg_label = np.argmax(fg_sizes)
clean_fg = (fg_labels == main_fg_label).astype(np.uint8)
# Anti-aliased alpha falloff via distance transform
dist_in = cv2.distanceTransform(clean_fg, cv2.DIST_L2, 3)
dist_out = cv2.distanceTransform(1 - clean_fg, cv2.DIST_L2, 3)
alpha = np.clip((dist_in - dist_out + 0.5) * 255.0, 0, 255).astype(np.uint8)
bgra = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2BGRA)
bgra[:, :, 3] = alpha
return bgra, clean_fg
def pack_bgra(path: Path, frames: list[np.ndarray], w: int, h: int):
"""Pack an array of (H, W, 4) BGRA numpy uint8 images into an ANM1 binary pack."""
raw = b"".join(f.tobytes() for f in frames)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(struct.pack("<4s4I", b"ANM1", w, h, len(frames), len(raw)) + raw)
header = struct.pack("<4s4I", b"ANM1", w, h, len(frames), len(raw))
path.write_bytes(header + raw)
print(f"Packed {len(frames)} frame(s) ({w}x{h}) into {path.name}: {len(raw)} bytes")
@ -25,16 +78,42 @@ def main():
assert side_path.exists(), f"Missing {side_path}"
assert ui_path.exists(), f"Missing {ui_path}"
# 1. World Cabinet Pack: 2 frames (256x512 BGRA)
front_img = Image.open(front_path).convert("RGBA").resize((256, 512), Image.Resampling.LANCZOS)
side_img = Image.open(side_path).convert("RGBA").resize((256, 512), Image.Resampling.LANCZOS)
pack(OUT / "jackbox-v1.bin", [front_img, side_img])
print("Extracting and segmenting Jukebox textures...")
# 2. Screen UI Pack: 1 frame (640x480 BGRA)
ui_img = Image.open(ui_path).convert("RGBA").resize((640, 480), Image.Resampling.LANCZOS)
pack(OUT / "jackbox-ui-v1.bin", [ui_img])
# 1. Front Texture
front_bgr = cv2.imread(str(front_path))
front_bgra, front_fg = clean_cutout(front_bgr)
front_tex = cv2.resize(front_bgra, (256, 512), interpolation=cv2.INTER_LANCZOS4)
print("Jackbox asset packaging complete.")
# 2. Side Texture: clean, crop margins, flip horizontally so back is x=0 and front is x=255
side_bgr = cv2.imread(str(side_path))
side_bgra, side_fg = clean_cutout(side_bgr)
# Crop to actual jukebox silhouette (remove studio floor and top empty margins)
side_cropped = side_bgra[80:2912, 0:1350]
side_flipped = cv2.flip(side_cropped, 1)
side_tex = cv2.resize(side_flipped, (256, 512), interpolation=cv2.INTER_LANCZOS4)
# 3. Rear Texture: authentic dark walnut cabinet panel with ventilation slots and arch top
rear_tex = np.zeros((512, 256, 4), dtype=np.uint8)
for y in range(512):
grain = int(math.sin(y * 0.2) * 5 + math.sin(y * 0.05) * 8)
rear_tex[y, :, 0] = np.clip(20 + grain, 0, 255) # B
rear_tex[y, :, 1] = np.clip(28 + grain + 2, 0, 255) # G
rear_tex[y, :, 2] = np.clip(45 + grain + 5, 0, 255) # R
for y in range(120, 220, 10):
rear_tex[y:y+3, 40:216, :3] = 12 # Dark ventilation slots
rear_tex[:, :, 3] = cv2.resize(front_bgra[:, :, 3], (256, 512), interpolation=cv2.INTER_LANCZOS4)
# Pack 3D world frames: Frame 0 = Front, Frame 1 = Side, Frame 2 = Rear
pack_bgra(OUT / "jackbox-v1.bin", [front_tex, side_tex, rear_tex], 256, 512)
# 4. Screen UI Pack: 640x480 BGRA
ui_bgr = cv2.imread(str(ui_path))
ui_bgra = cv2.cvtColor(ui_bgr, cv2.COLOR_BGR2BGRA)
ui_tex = cv2.resize(ui_bgra, (640, 480), interpolation=cv2.INTER_LANCZOS4)
pack_bgra(OUT / "jackbox-ui-v1.bin", [ui_tex], 640, 480)
print("Jukebox asset packaging complete.")
if __name__ == "__main__":

View file

@ -14,12 +14,12 @@ def test_packed_assets():
assert world_bin.is_file(), f"Missing {world_bin}"
assert ui_bin.is_file(), f"Missing {ui_bin}"
# Check world bin header: ANM1, 256, 512, 2 frames
# Check world bin header: ANM1, 256, 512, 3 frames
wdata = world_bin.read_bytes()
magic, w, h, frames, raw_len = struct.unpack("<4s4I", wdata[:20])
assert magic == b"ANM1", f"Bad magic: {magic}"
assert (w, h, frames) == (256, 512, 2), f"Bad dimensions: {w}x{h}, {frames} frames"
assert len(wdata) == 20 + raw_len == 20 + 256 * 512 * 4 * 2
assert (w, h, frames) == (256, 512, 3), f"Bad dimensions: {w}x{h}, {frames} frames"
assert len(wdata) == 20 + raw_len == 20 + 256 * 512 * 4 * 3
print(f"[OK] jackbox-v1.bin verified: {frames} frames ({w}x{h}), {raw_len} bytes")
# Check UI bin header: ANM1, 640, 480, 1 frame
@ -65,6 +65,7 @@ def test_code_structure():
assert "INTERACT_JACKBOX equ 48" in data_inc
assert "JACKBOX_X equ 6*FP_1" in data_inc
assert "JACKBOX_Y equ 20*FP_1" in data_inc
assert "MATERIAL_JACKBOX_BACK equ 163" in data_inc
# Check procs
procs = [

View file

@ -0,0 +1,261 @@
# -*- 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/!')