fix(audio,ui): eliminate gunshot sound bugs, implement rich custom 2.5D graphics for minigames, add visual test suite

This commit is contained in:
Игорь Федосеев 2026-09-30 20:49:52 +03:00
parent 50127c5a21
commit b255493506
10 changed files with 3127 additions and 483 deletions

Binary file not shown.

File diff suppressed because it is too large Load diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

BIN
poker.exe

Binary file not shown.

View file

@ -43,6 +43,7 @@ Offline build tools that process raw images, generate lookup tables, and compose
| `build_all_3d.py` | Python | Offline assembly file generator that synthesizes the complete `casino3d.inc` raycaster engine in CP1251. |
| `generate_unified_casino3d.py` | Python | Alternative unified generator for `casino3d.inc` with full sprite definitions and game interaction hooks. |
| `generate_casino3d.py`<br>`build_engine.py`<br>`make_casino3d.py` | Python | Lightweight orchestration scripts for triggering `casino3d.inc` regeneration in CP1251 encoding. |
| `generate_rich_minigames.py` | Python | Patches `casino3d.inc` with rich, custom 2.5D graphics and fixed audio for Coin, Craps, and Skulls. |
| `patch_poker_asm.py` | Python | Helper utility to patch `poker.asm` with 3D engine hooks, player save anchors, and mutex management. |
---
@ -53,7 +54,9 @@ Verification scripts, simulation harnesses, and runtime helpers:
| Tool | Language | Purpose & Usage |
|------|----------|-----------------|
| `test_minigames_render.py` | Python (Pillow) | Visual simulation test suite that rasterizes 2.5D minigame UI frames and saves PNG previews to `docs/previews_minigames/`. Usage: `python tools/tests/test_minigames_render.py`. |
| `simulate_render.py` | Python (Pillow) | Software CPU simulation of the 3D raycaster renderer. Reads `casino3d_data.inc` and texture binary, performs DDA raycasting, depth-sorting of 28 casino sprites, and saves the rendered view to `docs/render_view.png`. Usage: `python tools/tests/simulate_render.py`. |
| `release_smoke_check.ps1` | PowerShell | Automated smoke test for release readiness: verifies file presence, launcher SHA-256 hashes, CP1251 source markers, and absence of deprecated assets. Usage: `powershell -File tools/tests/release_smoke_check.ps1`. |
| `build_graphics.ps1` | PowerShell | Builds the Windows subsystem executable `build/graphics/poker.exe` using MASM32/MSVC toolchain. Usage: `powershell -File tools/tests/build_graphics.ps1`. |
| `achievement_popup.bat` | Batch (CP1251) | Standalone console achievement notification popup called by `poker.exe`. Reads achievement text from `runtime/achievement.txt` and renders a decorative banner with rarity colors (COMMON, RARE, EPIC, LEGENDARY). |

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,379 @@
# -*- coding: utf-8 -*-
import math
from pathlib import Path
from PIL import Image
REPO_ROOT = Path(__file__).resolve().parents[2]
OUTPUT_DIR = REPO_ROOT / 'docs' / 'previews_minigames'
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
# Parse font8x8.inc
font_path = REPO_ROOT / 'font8x8.inc'
font_bytes = bytearray(256 * 8)
with open(font_path, 'r', encoding='latin1') as f:
text = f.read()
import re
matches = re.findall(r'0([0-9A-Fa-f]{2})h', text)
for i, m in enumerate(matches[:256*8]):
font_bytes[i] = int(m, 16)
SCREEN_W = 640
SCREEN_H = 480
class RasterBuffer:
def __init__(self):
self.pixels = [(0, 0, 0)] * (SCREEN_W * SCREEN_H)
def to_rgb(self, c):
# 0x00BBGGRR format
r = c & 0xFF
g = (c >> 8) & 0xFF
b = (c >> 16) & 0xFF
return (r, g, b)
def draw_box(self, x, y, w, h, color):
px = x * 2
py = y * 2
pw = w * 2
ph = h * 2
rgb = self.to_rgb(color)
for row in range(py, min(py + ph, SCREEN_H)):
if row < 0:
continue
base = row * SCREEN_W
for col in range(px, min(px + pw, SCREEN_W)):
if col < 0:
continue
self.pixels[base + col] = rgb
def draw_box_border(self, x, y, w, h, fill_color, border_color, b_width):
self.draw_box(x, y, w, h, border_color)
self.draw_box(x + b_width, y + b_width, w - b_width * 2, h - b_width * 2, fill_color)
def draw_pixel(self, x, y, color):
self.draw_box(x, y, 1, 1, color)
def draw_circle(self, cx, cy, radius, color):
r_sq = radius * radius
for dy in range(-radius, radius + 1):
for dx in range(-radius, radius + 1):
if dx * dx + dy * dy <= r_sq:
self.draw_pixel(cx + dx, cy + dy, color)
def draw_char(self, x, y, ch, color):
if isinstance(ch, str):
try:
code = ch.encode('cp1251')[0]
except:
code = ord(ch) & 0xFF
else:
code = ch & 0xFF
glyph_offset = code * 8
for row in range(8):
row_bits = font_bytes[glyph_offset + row]
for col in range(8):
if row_bits & (0x80 >> col):
self.draw_pixel(x + col, y + row, color)
def draw_string(self, x, y, s, color):
cur_x = x
for ch in s:
self.draw_char(cur_x, y, ch, color)
cur_x += 8
def draw_keycap(self, x, y, text, is_active=False):
w = len(text) * 8 + 8
h = 13
if is_active:
border_c = 0x0000FFFF
fill_c = 0x001B4B22
txt_c = 0x0000FFFF
else:
border_c = 0x00606870
fill_c = 0x001A1C22
txt_c = 0x00D0D8E0
self.draw_box(x, y + 2, w, h, 0x0008080C) # shadow
self.draw_box_border(x, y, w, h, fill_c, border_c, 1)
self.draw_string(x + 4, y + 3, text, txt_c)
return w + 4
def draw_chip_stack(self, x, y, count=3):
# Draw stack of 3D casino clay chips
for i in range(count):
cy = y - i * 4
self.draw_box(x + 2, cy + 5, 20, 2, 0x00041006) # shadow
self.draw_box_border(x, cy, 24, 6, 0x001515B8, 0x00FFFFFF, 1)
# white stripe inlays
self.draw_box(x + 4, cy + 1, 3, 4, 0x00FFFFFF)
self.draw_box(x + 17, cy + 1, 3, 4, 0x00FFFFFF)
def draw_coin_ellipse(self, cx, cy, rx, ry, outer_color, inner_color, relief=0):
if rx <= 0 or ry <= 0:
return
rx_sq = rx * rx
ry_sq = ry * ry
rx_ry_sq = rx_sq * ry_sq
thresh_sq = (rx_ry_sq * 3) // 4
for dy in range(-ry, ry + 1):
cur_y = cy + dy
for dx in range(-rx, rx + 1):
cur_x = cx + dx
val = dx * dx * ry_sq + dy * dy * rx_sq
if val <= rx_ry_sq:
if val >= thresh_sq:
self.draw_pixel(cur_x, cur_y, outer_color)
else:
self.draw_pixel(cur_x, cur_y, inner_color)
if rx >= 14 and relief == 1:
gold_dark = 0x00007A9A
# Imperial Eagle
self.draw_box(cx - 3, cy - 14, 7, 2, gold_dark)
self.draw_box(cx - 4, cy - 12, 9, 2, gold_dark)
self.draw_box(cx - 2, cy - 9, 5, 12, gold_dark)
self.draw_box(cx - 1, cy - 8, 3, 10, 0x0000D7FF)
self.draw_box(cx - 10, cy - 8, 7, 3, gold_dark)
self.draw_box(cx - 13, cy - 5, 10, 3, gold_dark)
self.draw_box(cx - 11, cy - 2, 7, 3, gold_dark)
self.draw_box(cx + 4, cy - 8, 7, 3, gold_dark)
self.draw_box(cx + 4, cy - 5, 10, 3, gold_dark)
self.draw_box(cx + 5, cy - 2, 7, 3, gold_dark)
self.draw_box(cx - 4, cy + 4, 9, 3, gold_dark)
self.draw_box(cx - 3, cy + 7, 7, 3, gold_dark)
elif rx >= 14 and relief == 2:
gold_dark = 0x00007A9A
# Roman numeral 'I'
self.draw_box(cx - 5, cy - 10, 11, 3, gold_dark)
self.draw_box(cx - 2, cy - 7, 5, 14, gold_dark)
self.draw_box(cx - 1, cy - 6, 3, 12, 0x0000D7FF)
self.draw_box(cx - 6, cy + 7, 13, 3, gold_dark)
for w_dy in [-10, -5, 0, 5, 9]:
self.draw_box(cx - 12, cy + w_dy, 2, 2, gold_dark)
self.draw_box(cx + 11, cy + w_dy, 2, 2, gold_dark)
def draw_craps_die(self, x, y, size, val):
self.draw_box(x + 3, y + size, size, 3, 0x00031408)
self.draw_box(x + size, y + 3, 3, size, 0x00031408)
body_color = 0x00F0F4FA
bevel_color = 0x00C0C6D0
border_color = 0x00353540
self.draw_box_border(x, y, size, size, body_color, border_color, 1)
self.draw_box(x + 1, y + 1, size - 2, 1, 0x00FFFFFF)
self.draw_box(x + 1, y + 1, 1, size - 2, 0x00FFFFFF)
self.draw_box(x + 1, y + size - 2, size - 2, 1, bevel_color)
self.draw_box(x + size - 2, y + 1, 1, size - 2, bevel_color)
pip_c = 0x001515C8
pip_r = 3
if val in [1, 3, 5]:
self.draw_circle(x + size // 2, y + size // 2, pip_r, pip_c)
self.draw_pixel(x + size // 2 - 1, y + size // 2 - 1, 0x00FFFFFF)
if val >= 2:
self.draw_circle(x + 9, y + 9, pip_r, pip_c)
self.draw_circle(x + size - 10, y + size - 10, pip_r, pip_c)
if val >= 4:
self.draw_circle(x + size - 10, y + 9, pip_r, pip_c)
self.draw_circle(x + 9, y + size - 10, pip_r, pip_c)
if val == 6:
self.draw_circle(x + 9, y + size // 2, pip_r, pip_c)
self.draw_circle(x + size - 10, y + size // 2, pip_r, pip_c)
def draw_gothic_cup(self, x, y, cup_id, is_lifted=False, has_ball=False):
lift_y = y - 36 if is_lifted else y
if not is_lifted:
self.draw_box(x + 6, y + 42, 36, 4, 0x000A0410)
else:
self.draw_box(x + 10, y + 42, 28, 2, 0x0012061A)
if is_lifted and has_ball:
self.draw_glowing_orb(x + 24, y + 26)
self.draw_box_border(x + 6, lift_y + 36, 36, 6, 0x00221A30, 0x0000B0D0, 1)
self.draw_box(x + 18, lift_y + 28, 12, 8, 0x00332545)
self.draw_box(x + 22, lift_y + 28, 4, 8, 0x0055406E)
self.draw_box_border(x + 8, lift_y + 8, 32, 20, 0x002D1E40, 0x0000D7FF, 1)
self.draw_box(x + 14, lift_y + 10, 6, 16, 0x00503570)
self.draw_box(x + 17, lift_y + 10, 3, 16, 0x007850A0)
self.draw_box_border(x + 4, lift_y + 2, 40, 7, 0x00150A20, 0x0000FFFF, 1)
self.draw_box(x + 12, lift_y + 4, 16, 2, 0x0000FFFF)
self.draw_box(x + 21, lift_y + 12, 6, 5, 0x0000D7FF)
self.draw_box(x + 22, lift_y + 17, 4, 3, 0x0000D7FF)
self.draw_pixel(x + 22, lift_y + 14, 0x00150A20)
self.draw_pixel(x + 24, lift_y + 14, 0x00150A20)
self.draw_string(x + 21, lift_y + 24, str(cup_id), 0x0000FFFF)
def draw_glowing_orb(self, cx, cy):
self.draw_circle(cx, cy, 14, 0x00081E2E)
self.draw_circle(cx, cy, 11, 0x00103A58)
self.draw_circle(cx, cy, 8, 0x001A588A)
self.draw_circle(cx, cy, 6, 0x0000B0E0)
self.draw_circle(cx, cy, 4, 0x0000E0FF)
self.draw_circle(cx - 1, cy - 1, 2, 0x00A0FFFF)
self.draw_pixel(cx - 2, cy - 2, 0x00FFFFFF)
def save(self, filename):
img = Image.new('RGB', (SCREEN_W, SCREEN_H))
img.putdata(self.pixels)
out_path = OUTPUT_DIR / filename
img.save(out_path)
print(f"Saved {out_path}")
# ==============================================================================
# 1. RENDER COIN GAME
# ==============================================================================
def render_coin(filename='coin_preview.png'):
b = RasterBuffer()
b.draw_box(0, 0, 320, 240, 0x00060914) # Mahogany cabinet
b.draw_box_border(6, 6, 308, 228, 0x000F1522, 0x001B2E48, 3)
for cx in [12, 306]:
for cy in [12, 226]:
b.draw_box(cx, cy, 3, 3, 0x0000D7FF)
b.draw_pixel(cx + 1, cy + 1, 0x00FFFFFF)
b.draw_box_border(20, 12, 280, 26, 0x000A101C, 0x0000D7FF, 2)
b.draw_string(44, 21, '*** З О Л О Т А Я М О Н Е Т А ***', 0x0000FFFF)
for bx in range(24, 296, 12):
b.draw_pixel(bx, 13, 0x0000FFFF)
b.draw_pixel(bx, 36, 0x0000D7FF)
# Status meters
b.draw_box_border(20, 44, 110, 18, 0x000A1A0E, 0x001A4B22, 1)
b.draw_string(24, 49, 'БАНК: $12500', 0x0050FF50)
b.draw_box_border(190, 44, 110, 18, 0x001A100A, 0x003A2814, 1)
b.draw_string(194, 49, 'СТАВКА: $50 [W/S]', 0x0000D7FF)
# Center Dish
b.draw_circle(160, 110, 46, 0x000090C0)
b.draw_circle(160, 110, 44, 0x0000D7FF)
b.draw_circle(160, 110, 41, 0x00180824)
# Coin at rest (Heads)
b.draw_coin_ellipse(160, 110, 26, 26, 0x0000A0D0, 0x0000D7FF, relief=1)
# Left: [1] ОРЕЛ (Active)
b.draw_box_border(20, 72, 76, 52, 0x001C3822, 0x0000FFFF, 2)
b.draw_string(28, 78, '[1] ОРЁЛ', 0x0000FFFF)
b.draw_coin_ellipse(58, 106, 12, 12, 0x0000A0D0, 0x0000D7FF, relief=1)
# Right: [2] РЕШКА
b.draw_box_border(224, 72, 76, 52, 0x00121620, 0x00404855, 1)
b.draw_string(232, 78, '[2] РЕШКА', 0x0090A0B0)
b.draw_coin_ellipse(262, 106, 12, 12, 0x000080A0, 0x0000B0D0, relief=2)
# Streak
b.draw_box_border(44, 154, 232, 20, 0x000E141E, 0x0000D7FF, 1)
b.draw_string(52, 160, 'СЕРИЯ: [X] [X] [X] [ ] [ ] x4!', 0x0000FFFF)
# Keycaps
b.draw_box(16, 194, 288, 1, 0x001E2E40)
b.draw_keycap(22, 202, '1:ОРЁЛ', is_active=True)
b.draw_keycap(88, 202, '2:РЕШКА')
b.draw_keycap(160, 202, 'ПРОБЕЛ:БРОСОК', is_active=True)
b.draw_keycap(274, 202, 'ESC')
b.save(filename)
# ==============================================================================
# 2. RENDER DICE GAME
# ==============================================================================
def render_dice(filename='dice_preview.png'):
b = RasterBuffer()
# Emerald felt
b.draw_box(0, 0, 320, 240, 0x00082E12)
# Oak wood border & leather bumper
b.draw_box_border(4, 4, 312, 232, 0x00093816, 0x00122244, 3)
b.draw_box_border(8, 8, 304, 224, 0x000A3F19, 0x0005140A, 2)
# Pyramidal backboard rubber diamond texture along top
for bx in range(12, 308, 12):
b.draw_box(bx + 4, 11, 4, 4, 0x00041006)
b.draw_pixel(bx + 5, 12, 0x000F4820)
# Header
b.draw_string(48, 20, '==== G R A N D C R A P S T A B L E ====', 0x0000D7FF)
b.draw_string(72, 32, '[ PASS LINE ] [ ANY CRAPS ]', 0x0000B0A0)
# Status
b.draw_box_border(16, 46, 100, 16, 0x0006240E, 0x000E5022, 1)
b.draw_string(20, 50, 'БАНК: $12500', 0x0050FF50)
b.draw_box_border(204, 46, 100, 16, 0x0006240E, 0x000E5022, 1)
b.draw_string(208, 50, 'СТАВКА: $50 [W/S]', 0x0000D7FF)
# Betting Zones
# Left: [1] ЧЕТ (Active with chip stack!)
b.draw_box_border(16, 68, 88, 54, 0x000B441D, 0x0000FFFF, 2)
b.draw_string(24, 74, '[1] ЧЁТ', 0x0000FFFF)
b.draw_string(20, 86, '2 4 6 8 10 12', 0x0000D7FF)
b.draw_chip_stack(48, 110, count=4)
# Right: [2] НЕЧЕТ
b.draw_box_border(216, 68, 88, 54, 0x00073014, 0x001A602A, 1)
b.draw_string(224, 74, '[2] НЕЧЕТ', 0x0080B090)
b.draw_string(222, 86, '3 5 7 9 11', 0x00508060)
# Center Dice Field
b.draw_box_border(112, 68, 96, 54, 0x00072E13, 0x00145826, 1)
# Draw Two Precision Dice
b.draw_craps_die(120, 76, 36, 4)
b.draw_craps_die(164, 76, 36, 6)
# Score Plaque
b.draw_box_border(36, 130, 248, 30, 0x00051D0C, 0x0000D7FF, 1)
b.draw_string(44, 135, 'СУММА: 4 + 6 = 10 (ЧЁТ)', 0x0000FFFF)
b.draw_string(68, 147, 'ВЫИГРЫШ: +$100! (x2)', 0x0050FF50)
# Keycaps
b.draw_box(16, 188, 288, 1, 0x00104820)
b.draw_keycap(20, 198, '1:ЧЁТ', is_active=True)
b.draw_keycap(82, 198, '2:НЕЧЕТ')
b.draw_keycap(160, 198, 'ПРОБЕЛ:БРОСОК', is_active=True)
b.draw_keycap(274, 198, 'ESC')
b.save(filename)
# ==============================================================================
# 3. RENDER SKULLS GAME
# ==============================================================================
def render_skulls(filename='skulls_preview.png'):
b = RasterBuffer()
# Dark gothic table with purple velvet
b.draw_box(0, 0, 320, 240, 0x000D0612)
b.draw_box_border(4, 4, 312, 232, 0x00160B20, 0x00321845, 3)
b.draw_box_border(8, 8, 304, 224, 0x00220F30, 0x0000D7FF, 1)
# Header
b.draw_box_border(24, 12, 272, 24, 0x0012081A, 0x0000D7FF, 2)
b.draw_string(48, 20, '+ + + Н А П Ё Р С Т К И С К В Е Р Н Ы + + +', 0x0000FFFF)
# Status
b.draw_box_border(20, 42, 110, 16, 0x000D0616, 0x00401858, 1)
b.draw_string(24, 46, 'БАНК: $12500', 0x0050FF50)
b.draw_box_border(190, 42, 110, 16, 0x000D0616, 0x00401858, 1)
b.draw_string(194, 46, 'СТАВКА: $50 [W/S]', 0x0000D7FF)
# Table Runner
b.draw_box_border(16, 66, 288, 114, 0x001A0B25, 0x00452060, 1)
# Narrative banner
b.draw_string(40, 74, 'СЛЕДИ ЗА ЗОЛОТЫМ ОКОМ СУДЬБЫ...', 0x0000FFFF)
# Draw 3 Gothic Cups (Cup 2 is lifted revealing glowing orb!)
b.draw_gothic_cup(48, 100, 1, is_lifted=False)
b.draw_gothic_cup(136, 100, 2, is_lifted=True, has_ball=True)
b.draw_gothic_cup(224, 100, 3, is_lifted=False)
# Keycaps
b.draw_box(16, 190, 288, 1, 0x003A1850)
b.draw_keycap(18, 200, '1:КУБОК')
b.draw_keycap(82, 200, '2:КУБОК', is_active=True)
b.draw_keycap(146, 200, '3:КУБОК')
b.draw_keycap(212, 200, 'ПРОБЕЛ', is_active=True)
b.draw_keycap(274, 200, 'ESC')
b.save(filename)
if __name__ == '__main__':
render_coin()
render_dice()
render_skulls()
print("All 3 minigames previews rendered successfully!")