Files
soulctcher-TetriNode/tetris_node.py
T
Erik W. 3d0a2da3dc feat: music settings, speed scaling, v2.2.0
- add Music tab, progressive speed toggle, and draggable volume controls

- update screenshots and README for Music + Animation

- bump version to 2.2.0 and refresh asset packaging
2026-01-26 21:50:59 -05:00

1727 lines
61 KiB
Python

import base64
import hashlib
import io
import json
import math
import os
import random
import re
import struct
from pathlib import Path
import folder_paths
import numpy as np
import torch
from PIL import Image, ImageChops, ImageDraw, ImageFilter
BOARD_WIDTH = 10
BOARD_HEIGHT = 40
VISIBLE_HEIGHT = 20
HIDDEN_ROWS = BOARD_HEIGHT - VISIBLE_HEIGHT
SPAWN_Y = HIDDEN_ROWS - 2
STATE_VERSION = 1
PREVIEW_GRID = 4
OUTPUT_SCALE = 3
EXTRA_VISIBLE_ROWS = 1 / 3
MUSIC_DIR = Path(__file__).parent / "music"
MUSIC_BLOB = MUSIC_DIR / "music.blob"
JS_MUSIC_DIR = Path(__file__).parent / "js" / "music"
MUSIC_FILES = [
"gb_a.mp3",
"gb_b.mp3",
"gb_c.mp3",
"nes_a.mp3",
"nes_b.mp3",
"nes_c.mp3",
]
MUSIC_MAGIC = b"TNMUSIC1"
def _unpack_music_blob():
if not MUSIC_BLOB.exists():
return
missing = [name for name in MUSIC_FILES if not (MUSIC_DIR / name).exists()]
try:
with MUSIC_BLOB.open("rb") as handle:
magic = handle.read(len(MUSIC_MAGIC))
if magic != MUSIC_MAGIC:
return
header_len_bytes = handle.read(4)
if len(header_len_bytes) != 4:
return
header_len = struct.unpack(">I", header_len_bytes)[0]
header_raw = handle.read(header_len)
header = json.loads(header_raw.decode("utf-8"))
MUSIC_DIR.mkdir(parents=True, exist_ok=True)
JS_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
for entry in header:
name = entry.get("name")
size = int(entry.get("size", 0))
if size <= 0:
continue
data = handle.read(size)
if name in missing:
with (MUSIC_DIR / name).open("wb") as out_file:
out_file.write(data)
js_path = JS_MUSIC_DIR / name
if not js_path.exists():
try:
js_path.write_bytes(data)
except Exception:
pass
_ensure_js_music()
except Exception:
return
def _ensure_js_music():
try:
JS_MUSIC_DIR.mkdir(parents=True, exist_ok=True)
for name in MUSIC_FILES:
target = JS_MUSIC_DIR / name
source = MUSIC_DIR / name
if target.exists() or not source.exists():
continue
try:
target.symlink_to(source)
except Exception:
try:
target.write_bytes(source.read_bytes())
except Exception:
pass
except Exception:
return
_unpack_music_blob()
SHAPES = {
"I": [
[(0, 1), (1, 1), (2, 1), (3, 1)],
[(2, 0), (2, 1), (2, 2), (2, 3)],
[(0, 2), (1, 2), (2, 2), (3, 2)],
[(1, 0), (1, 1), (1, 2), (1, 3)],
],
"J": [
[(0, 0), (0, 1), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (1, 2)],
[(0, 1), (1, 1), (2, 1), (2, 2)],
[(1, 0), (1, 1), (0, 2), (1, 2)],
],
"L": [
[(2, 0), (0, 1), (1, 1), (2, 1)],
[(1, 0), (1, 1), (1, 2), (2, 2)],
[(0, 1), (1, 1), (2, 1), (0, 2)],
[(0, 0), (1, 0), (1, 1), (1, 2)],
],
"O": [
[(1, 0), (2, 0), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (2, 1)],
[(1, 0), (2, 0), (1, 1), (2, 1)],
],
"S": [
[(1, 0), (2, 0), (0, 1), (1, 1)],
[(1, 0), (1, 1), (2, 1), (2, 2)],
[(1, 1), (2, 1), (0, 2), (1, 2)],
[(0, 0), (0, 1), (1, 1), (1, 2)],
],
"T": [
[(1, 0), (0, 1), (1, 1), (2, 1)],
[(1, 0), (1, 1), (2, 1), (1, 2)],
[(0, 1), (1, 1), (2, 1), (1, 2)],
[(1, 0), (0, 1), (1, 1), (1, 2)],
],
"Z": [
[(0, 0), (1, 0), (1, 1), (2, 1)],
[(2, 0), (1, 1), (2, 1), (1, 2)],
[(0, 1), (1, 1), (1, 2), (2, 2)],
[(1, 0), (0, 1), (1, 1), (0, 2)],
],
}
COLORS = {
"I": (85, 214, 255),
"J": (86, 105, 255),
"L": (255, 167, 71),
"O": (255, 231, 87),
"S": (122, 235, 132),
"T": (187, 128, 255),
"Z": (255, 118, 118),
"X": (50, 52, 62),
}
DEFAULT_BLOCK_STYLE = {
"border": 1,
"border_blur": 0,
"gradient": 0.35,
"gradient_angle": 45,
"fill_blur": 0,
"alpha": 1,
"clearcoat": 0,
"clearcoat_size": 0.3,
"rim_light": 0,
"roughness": 0,
"metallic": 0,
"scanlines": 0,
"shadow": 0,
"shadow_angle": 135,
"corner_radius": 0,
"bevel": 0,
"specular_size": 0,
"specular_strength": 0,
"inner_shadow": 0,
"inner_shadow_strength": 0,
"outline_opacity": 1,
"gradient_contrast": 1,
"saturation_shift": 0,
"brightness_shift": 0,
"noise": 0,
"glow": 0,
"glow_opacity": 0.5,
"pixel_snap": 0,
"texture_id": "",
"texture_opacity": 0,
"texture_scale": 1,
"texture_angle": 0,
}
PIXELATED_TEXTURE_SAMPLE_RATIO = 0.25
TEXTURE_SAMPLE_PX = 200
RANDOM_TEXTURE_IDS = {"pixelated", "wooden", "concrete", "brushed_metal", "toxic_slime"}
TEXTURE_ROTATIONS = (0, 90, 180, 270)
TEXTURE_DATA_MAP = {
"pixelated": "PIXELATED_TEXTURE_DATA",
"wooden": "WOODEN_TEXTURE_DATA",
"concrete": "CONCRETE_TEXTURE_DATA",
"brushed_metal": "BRUSHED_METAL_TEXTURE_DATA",
"toxic_slime": "TOXIC_SLIME_TEXTURE_DATA",
}
_TEXTURE_CACHE = {}
_TEXTURE_DATA_CACHE = {}
def _texture_js_path():
return os.path.join(os.path.dirname(__file__), "js", "textures.js")
def _load_texture_data():
if _TEXTURE_DATA_CACHE:
return _TEXTURE_DATA_CACHE
path = _texture_js_path()
if not os.path.exists(path):
return _TEXTURE_DATA_CACHE
with open(path, "r", encoding="utf-8") as handle:
text = handle.read()
for texture_id, const_name in TEXTURE_DATA_MAP.items():
match = re.search(
rf'export const {re.escape(const_name)} = "data:image/jpeg;base64,([^"]+)";',
text,
)
if match:
_TEXTURE_DATA_CACHE[texture_id] = match.group(1)
return _TEXTURE_DATA_CACHE
def _load_texture_image(texture_id):
if not texture_id:
return None
if texture_id in _TEXTURE_CACHE:
return _TEXTURE_CACHE[texture_id]
data_map = _load_texture_data()
payload = data_map.get(texture_id)
if not payload:
_TEXTURE_CACHE[texture_id] = None
return None
raw = base64.b64decode(payload)
img = Image.open(io.BytesIO(raw)).convert("RGBA")
_TEXTURE_CACHE[texture_id] = img
return img
def _empty_board():
return [[0 for _ in range(BOARD_WIDTH)] for _ in range(BOARD_HEIGHT)]
def _new_bag(seed, bag_count):
rng = random.Random(seed + bag_count)
bag = list(SHAPES.keys())
rng.shuffle(bag)
return bag
def _pop_shape(state):
if not state["bag"]:
state["bag"] = _new_bag(state["seed"], state["bag_count"])
state["bag_count"] += 1
return state["bag"].pop(0)
def _spawn_piece(shape):
return {"shape": shape, "rot": 0, "x": 3, "y": SPAWN_Y}
def _piece_cells(piece):
shape = SHAPES[piece["shape"]][piece["rot"] % 4]
return [(piece["x"] + dx, piece["y"] + dy) for dx, dy in shape]
def _collides(board, piece):
for x, y in _piece_cells(piece):
if x < 0 or x >= BOARD_WIDTH or y < 0 or y >= BOARD_HEIGHT:
return True
if board[y][x]:
return True
return False
def _lock_piece(board, piece):
for x, y in _piece_cells(piece):
if 0 <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH:
board[y][x] = piece["shape"]
def _clear_lines(board):
remaining = [row for row in board if any(cell == 0 for cell in row)]
cleared = BOARD_HEIGHT - len(remaining)
for _ in range(cleared):
remaining.insert(0, [0 for _ in range(BOARD_WIDTH)])
return remaining, cleared
def _prepare_background(background_image, width, height):
if background_image is None:
return None
try:
img = background_image[0].detach().cpu().numpy()
except Exception:
return None
if img.ndim != 3 or img.shape[-1] < 3:
return None
img = np.clip(img[..., :3] * 255.0, 0, 255).astype(np.uint8)
pil = Image.fromarray(img, "RGB")
src_w, src_h = pil.size
if src_w <= 0 or src_h <= 0:
return None
scale = max(width / src_w, height / src_h)
new_w = max(1, int(round(src_w * scale)))
new_h = max(1, int(round(src_h * scale)))
pil = pil.resize((new_w, new_h), Image.BICUBIC)
left = max(0, (new_w - width) // 2)
top = max(0, (new_h - height) // 2)
return pil.crop((left, top, left + width, top + height))
def _save_temp_background(background_image, prefix="TetriNode_bg"):
if background_image is None:
return []
try:
img = background_image[0].detach().cpu().numpy()
except Exception:
return []
if img.ndim != 3 or img.shape[-1] < 3:
return []
img = np.clip(img[..., :3] * 255.0, 0, 255).astype(np.uint8)
pil = Image.fromarray(img, "RGB")
width, height = pil.size
temp_dir = folder_paths.get_temp_directory()
suffix = "".join(random.choice("abcdefghijklmnopqrstupvxyz") for _ in range(5))
filename_prefix = f"{prefix}_{suffix}"
full_output_folder, filename, counter, subfolder, _ = folder_paths.get_save_image_path(
filename_prefix, temp_dir, width, height
)
file = f"{filename}_{counter:05}_.png"
os.makedirs(full_output_folder, exist_ok=True)
pil.save(os.path.join(full_output_folder, file), compress_level=1)
return [{"filename": file, "subfolder": subfolder, "type": "temp"}]
def _wrap_result(result, background_image):
ui_images = _save_temp_background(background_image)
if not ui_images:
return result
return {"ui": {"tetrinode_background": ui_images}, "result": result}
def _render_from_capture(data_url):
if not data_url:
return None
raw = data_url
if isinstance(data_url, dict) and "data" in data_url:
raw = data_url.get("data")
if not isinstance(raw, str) or not raw:
return None
try:
if raw.startswith("data:"):
_, encoded = raw.split(",", 1)
else:
encoded = raw
payload = base64.b64decode(encoded)
pil = Image.open(io.BytesIO(payload)).convert("RGB")
except Exception:
return None
arr = np.array(pil).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None, ...]
def _parse_hex_color(value):
if not isinstance(value, str):
return None
candidate = value.strip()
if candidate.startswith("#"):
candidate = candidate[1:]
if len(candidate) != 6:
return None
try:
r = int(candidate[0:2], 16)
g = int(candidate[2:4], 16)
b = int(candidate[4:6], 16)
except ValueError:
return None
return (r, g, b)
def _resolve_options(options_payload):
if not options_payload:
return {}
payload = options_payload
if isinstance(options_payload, str):
try:
payload = json.loads(options_payload)
except json.JSONDecodeError:
return {}
if not isinstance(payload, dict):
return {}
return payload
def _resolve_colors(options_payload):
colors = dict(COLORS)
payload = _resolve_options(options_payload)
mapping = {
"color_i": "I",
"color_j": "J",
"color_l": "L",
"color_o": "O",
"color_s": "S",
"color_t": "T",
"color_z": "Z",
"background_color": "X",
}
for key, shape in mapping.items():
parsed = _parse_hex_color(payload.get(key))
if parsed:
colors[shape] = parsed
return colors
def _resolve_bool(options, key, default):
if not isinstance(options, dict):
return default
value = options.get(key, default)
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"true", "1", "yes", "on"}:
return True
if normalized in {"false", "0", "no", "off"}:
return False
return default
def _parse_rgba_color(value):
if not isinstance(value, str):
return None
text = value.strip()
if not text:
return None
if text.startswith("#"):
rgb = _parse_hex_color(text)
if rgb:
return (*rgb, 31)
return None
match = re.match(
r"rgba?\s*\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)(?:\s*,\s*([0-9]*\.?[0-9]+))?\s*\)",
text,
re.IGNORECASE,
)
if not match:
return None
r = int(match.group(1))
g = int(match.group(2))
b = int(match.group(3))
a = float(match.group(4)) if match.group(4) is not None else 0.12
if not (0 <= r <= 255 and 0 <= g <= 255 and 0 <= b <= 255 and 0 <= a <= 1):
return None
return (r, g, b, int(round(a * 255)))
def _clamp(value, min_value, max_value):
return max(min_value, min(max_value, value))
def _rgb_to_hsl(r, g, b):
r /= 255.0
g /= 255.0
b /= 255.0
max_c = max(r, g, b)
min_c = min(r, g, b)
lightness = (max_c + min_c) / 2.0
if max_c == min_c:
return 0.0, 0.0, lightness
delta = max_c - min_c
s = delta / (2.0 - max_c - min_c) if lightness > 0.5 else delta / (max_c + min_c)
if max_c == r:
h = (g - b) / delta + (6.0 if g < b else 0.0)
elif max_c == g:
h = (b - r) / delta + 2.0
else:
h = (r - g) / delta + 4.0
h /= 6.0
return h * 360.0, s * 100.0, lightness * 100.0
def _hsl_to_rgb(h, s, lightness):
h = (h % 360.0) / 360.0
s /= 100.0
lightness /= 100.0
def hue_to_rgb(p, q, t):
if t < 0:
t += 1
if t > 1:
t -= 1
if t < 1 / 6:
return p + (q - p) * 6 * t
if t < 1 / 2:
return q
if t < 2 / 3:
return p + (q - p) * (2 / 3 - t) * 6
return p
if s == 0:
r = g = b = lightness
else:
q = lightness * (1 + s) if lightness < 0.5 else lightness + s - lightness * s
p = 2 * lightness - q
r = hue_to_rgb(p, q, h + 1 / 3)
g = hue_to_rgb(p, q, h)
b = hue_to_rgb(p, q, h - 1 / 3)
return int(round(r * 255)), int(round(g * 255)), int(round(b * 255))
def _adjust_color_hsl(color, saturation_shift, brightness_shift):
r, g, b = color
h, s, lightness = _rgb_to_hsl(r, g, b)
s = _clamp(s + saturation_shift * 100.0, 0.0, 100.0)
lightness = _clamp(lightness + brightness_shift * 100.0, 0.0, 100.0)
return _hsl_to_rgb(h, s, lightness)
def _adjust_color_by_factor(color, factor):
r, g, b = color
def mix(channel):
if factor >= 0:
return int(round(_clamp(channel + (255 - channel) * factor, 0, 255)))
return int(round(_clamp(channel * (1 + factor), 0, 255)))
return (mix(r), mix(g), mix(b))
def _mix_colors(color_a, color_b, t):
r1, g1, b1 = color_a
r2, g2, b2 = color_b
return (
int(round(r1 + (r2 - r1) * t)),
int(round(g1 + (g2 - g1) * t)),
int(round(b1 + (b2 - b1) * t)),
)
def _resolve_block_style(options_payload):
payload = _resolve_options(options_payload)
style = dict(DEFAULT_BLOCK_STYLE)
incoming = payload.get("block_style")
if isinstance(incoming, dict):
style.update(incoming)
return {
"border": _clamp(float(style.get("border", 0)), 0, 4),
"border_blur": _clamp(float(style.get("border_blur", 0)), 0, 6),
"gradient": _clamp(float(style.get("gradient", 0)), 0, 2),
"gradient_angle": float(style.get("gradient_angle", 0)),
"fill_blur": _clamp(float(style.get("fill_blur", style.get("blur", 0))), 0, 6),
"alpha": _clamp(float(style.get("alpha", 1)), 0, 1),
"clearcoat": _clamp(float(style.get("clearcoat", 0)), 0, 1),
"clearcoat_size": _clamp(float(style.get("clearcoat_size", 0)), 0, 1),
"rim_light": _clamp(float(style.get("rim_light", 0)), 0, 1),
"roughness": _clamp(float(style.get("roughness", 0)), 0, 1),
"metallic": _clamp(float(style.get("metallic", 0)), 0, 2),
"scanlines": _clamp(float(style.get("scanlines", 0)), 0, 1),
"shadow": _clamp(float(style.get("shadow", 0)), 0, 3),
"shadow_angle": float(style.get("shadow_angle", 0)),
"corner_radius": _clamp(float(style.get("corner_radius", 0)), 0, 10),
"bevel": _clamp(float(style.get("bevel", 0)), 0, 1),
"specular_size": _clamp(float(style.get("specular_size", 0)), 0, 1),
"specular_strength": _clamp(float(style.get("specular_strength", 0)), 0, 1),
"inner_shadow": _clamp(float(style.get("inner_shadow", 0)), 0, 8),
"inner_shadow_strength": _clamp(float(style.get("inner_shadow_strength", 0)), 0, 1),
"outline_opacity": _clamp(float(style.get("outline_opacity", 0)), 0, 1),
"gradient_contrast": _clamp(float(style.get("gradient_contrast", 0)), 0, 1),
"saturation_shift": _clamp(float(style.get("saturation_shift", 0)), -1, 1),
"brightness_shift": _clamp(float(style.get("brightness_shift", 0)), -0.3, 0.3),
"noise": _clamp(float(style.get("noise", 0)), 0, 1),
"glow": _clamp(float(style.get("glow", 0)), 0, 10),
"glow_opacity": _clamp(float(style.get("glow_opacity", 0)), 0, 1),
"pixel_snap": _clamp(float(style.get("pixel_snap", 0)), 0, 1),
"texture_id": str(style.get("texture_id", "") or ""),
"texture_opacity": _clamp(float(style.get("texture_opacity", 0)), 0, 1),
"texture_scale": _clamp(float(style.get("texture_scale", 1)), 0.1, 4),
"texture_angle": float(style.get("texture_angle", 0)),
}
def _scale_block_style(style, scale):
scaled = dict(style)
for key in ("border", "border_blur", "fill_blur", "shadow", "corner_radius", "inner_shadow", "glow"):
scaled[key] = float(style.get(key, 0)) * scale
return scaled
def _texture_transform(seed, key):
digest = hashlib.md5(f"{seed}:{key}".encode("utf-8")).digest()
rng = random.Random(digest)
u = rng.random()
v = rng.random()
rotation = rng.choice(TEXTURE_ROTATIONS)
flip_x = rng.random() < 0.5
flip_y = rng.random() < 0.5
return {
"u": u,
"v": v,
"rotation": rotation,
"flip_x": flip_x,
"flip_y": flip_y,
}
def _ghost_piece(board, piece):
ghost = dict(piece)
moved = _move(ghost, 0, 1)
while not _collides(board, moved):
ghost = moved
moved = _move(ghost, 0, 1)
return ghost
def _draw_grid(img, block_size, width, height, color, extra_px):
if not color:
return img
overlay = Image.new("RGBA", (width, height), (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
for x in range(1, BOARD_WIDTH):
xpos = x * block_size - 1
draw.line([xpos, -1, xpos, height + 1], fill=color, width=1)
for y in range(0, VISIBLE_HEIGHT):
ypos = y * block_size - 1 + extra_px
draw.line([-1, ypos, width + 1, ypos], fill=color, width=1)
return Image.alpha_composite(img, overlay)
def _draw_ghost(img, board, piece, block_size, color, extra_px):
if not color:
return img
overlay = Image.new("RGBA", img.size, (0, 0, 0, 0))
draw = ImageDraw.Draw(overlay)
ghost = _ghost_piece(board, piece)
fill = (*color, 84)
outline = (200, 200, 200, 171)
for x, y in _piece_cells(ghost):
if HIDDEN_ROWS - 1 <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH:
x0 = x * block_size
y0 = (y - HIDDEN_ROWS) * block_size + extra_px
draw.rectangle(
[x0, y0, x0 + block_size - 2, y0 + block_size - 2],
fill=fill,
)
draw.rectangle(
[x0 + 1, y0 + 1, x0 + block_size - 2, y0 + block_size - 2],
outline=outline,
)
return Image.alpha_composite(img, overlay)
def _draw_block(base, x, y, size, color, style, texture_key=None, seed=0):
block = Image.new("RGBA", (size, size), (0, 0, 0, 0))
shrink = 1 if style["pixel_snap"] >= 0.5 else 0
inner_size = size - 1 - shrink
draw_x = shrink / 2
draw_y = shrink / 2
rect_right = draw_x + inner_size - 1
rect_bottom = draw_y + inner_size - 1
corner_radius = max(0.0, style["corner_radius"])
fill_alpha = int(round(_clamp(style["alpha"], 0, 1) * 255))
metallic_strength = min(1.0, style["metallic"])
metallic_boost = max(0.0, style["metallic"] - 1.0)
base_color = _adjust_color_hsl(color, style["saturation_shift"], style["brightness_shift"])
if style["metallic"] > 0:
base_color = _adjust_color_by_factor(
base_color,
-0.2 * metallic_strength - 0.2 * metallic_boost,
)
specular_color = _mix_colors(base_color, (255, 255, 255), 1 - metallic_strength)
mask = Image.new("L", (size, size), 0)
mask_draw = ImageDraw.Draw(mask)
if corner_radius > 0:
mask_draw.rounded_rectangle(
[draw_x, draw_y, rect_right, rect_bottom],
radius=min(corner_radius, inner_size / 2),
fill=255,
)
else:
mask_draw.rectangle([draw_x, draw_y, rect_right, rect_bottom], fill=255)
fill_layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
mask_inner = mask.crop(
(int(draw_x), int(draw_y), int(draw_x) + inner_size, int(draw_y) + inner_size)
)
effective_gradient = style["gradient"] * (1 - style["roughness"] * 0.35)
if effective_gradient > 0:
angle = math.radians(style["gradient_angle"] % 360)
dx = math.cos(angle)
dy = math.sin(angle)
half = inner_size / 2
contrast = effective_gradient * max(0.2, style["gradient_contrast"])
c1 = _adjust_color_by_factor(base_color, contrast * 0.4)
c2 = _adjust_color_by_factor(base_color, -contrast * 0.4)
xs, ys = np.meshgrid(np.arange(inner_size), np.arange(inner_size))
tx = (xs - half) * dx + (ys - half) * dy
t = np.clip(tx / max(1.0, half) * 0.5 + 0.5, 0, 1)
grad = np.zeros((inner_size, inner_size, 4), dtype=np.uint8)
grad[..., 0] = (c1[0] + (c2[0] - c1[0]) * t).astype(np.uint8)
grad[..., 1] = (c1[1] + (c2[1] - c1[1]) * t).astype(np.uint8)
grad[..., 2] = (c1[2] + (c2[2] - c1[2]) * t).astype(np.uint8)
grad[..., 3] = fill_alpha
gradient_img = Image.fromarray(grad, "RGBA")
fill_layer.paste(gradient_img, (int(draw_x), int(draw_y)), mask_inner)
else:
solid = Image.new("RGBA", (size, size), (*base_color, fill_alpha))
solid.putalpha(mask)
fill_layer = solid
if style["shadow"] > 0:
rad = math.radians(style["shadow_angle"] % 360)
offset = style["shadow"] * 4
shadow_alpha = int(round(min(0.6, 0.2 + style["shadow"] * 0.6) * 255))
shadow = Image.new("RGBA", (size, size), (0, 0, 0, shadow_alpha))
shadow.putalpha(mask)
shadow = shadow.filter(ImageFilter.GaussianBlur(radius=style["shadow"] * 8))
shadow_layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
shadow_layer.paste(shadow, (int(round(math.cos(rad) * offset)), int(round(math.sin(rad) * offset))), shadow)
block = Image.alpha_composite(block, shadow_layer)
if style["fill_blur"] > 0:
fill_layer = fill_layer.filter(ImageFilter.GaussianBlur(radius=style["fill_blur"]))
block = Image.alpha_composite(block, fill_layer)
if style["bevel"] > 0:
bevel = Image.new("RGBA", (size, size), (0, 0, 0, 0))
xs, ys = np.meshgrid(np.arange(inner_size), np.arange(inner_size))
t = np.clip((xs + ys) / max(1.0, inner_size * 2), 0, 1)
bevel_arr = np.zeros((inner_size, inner_size, 4), dtype=np.uint8)
bevel_arr[..., 0] = 255
bevel_arr[..., 1] = 255
bevel_arr[..., 2] = 255
bevel_arr[..., 3] = (style["bevel"] * 0.35 * (1 - t) * 255).astype(np.uint8)
bevel_img = Image.fromarray(bevel_arr, "RGBA")
bevel.paste(bevel_img, (int(draw_x), int(draw_y)), mask_inner)
dark_arr = np.zeros((inner_size, inner_size, 4), dtype=np.uint8)
dark_arr[..., 3] = (style["bevel"] * 0.3 * t * 255).astype(np.uint8)
dark_img = Image.fromarray(dark_arr, "RGBA")
bevel.paste(dark_img, (int(draw_x), int(draw_y)), mask_inner)
block = Image.alpha_composite(block, bevel)
effective_spec_strength = style["specular_strength"] * (1 - style["roughness"])
effective_spec_size = min(1.0, style["specular_size"] + style["roughness"] * 0.35)
if effective_spec_strength > 0 and effective_spec_size > 0:
radius = max(4, inner_size * effective_spec_size)
cx = draw_x + radius * 0.6
cy = draw_y + radius * 0.6
xs, ys = np.meshgrid(np.arange(size), np.arange(size))
dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
alpha = np.clip(1 - dist / max(1.0, radius), 0, 1) * (effective_spec_strength * 0.6)
spec_arr = np.zeros((size, size, 4), dtype=np.uint8)
spec_arr[..., 0] = specular_color[0]
spec_arr[..., 1] = specular_color[1]
spec_arr[..., 2] = specular_color[2]
spec_arr[..., 3] = (alpha * 255).astype(np.uint8)
spec_img = Image.fromarray(spec_arr, "RGBA")
spec_img.putalpha(ImageChops.multiply(spec_img.split()[-1], mask))
block = Image.alpha_composite(block, spec_img)
if style["clearcoat"] > 0 and style["clearcoat_size"] > 0:
radius = max(3, inner_size * style["clearcoat_size"] * 0.6)
cx = draw_x + radius * 0.55
cy = draw_y + radius * 0.5
xs, ys = np.meshgrid(np.arange(size), np.arange(size))
dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
alpha = np.clip(1 - dist / max(1.0, radius), 0, 1) * (style["clearcoat"] * 0.7)
coat_arr = np.zeros((size, size, 4), dtype=np.uint8)
coat_arr[..., 0] = 255
coat_arr[..., 1] = 255
coat_arr[..., 2] = 255
coat_arr[..., 3] = (alpha * 255).astype(np.uint8)
coat_img = Image.fromarray(coat_arr, "RGBA")
coat_img.putalpha(ImageChops.multiply(coat_img.split()[-1], mask))
block = Image.alpha_composite(block, coat_img)
if style["rim_light"] > 0:
xs, ys = np.meshgrid(np.arange(size), np.arange(size))
cx = draw_x + inner_size / 2
cy = draw_y + inner_size / 2
dist = np.sqrt((xs - cx) ** 2 + (ys - cy) ** 2)
inner = inner_size * 0.2
outer = inner_size * 0.65
alpha = np.clip((dist - inner) / max(1.0, outer - inner), 0, 1)
rim_arr = np.zeros((size, size, 4), dtype=np.uint8)
rim_arr[..., 0] = 255
rim_arr[..., 1] = 255
rim_arr[..., 2] = 255
rim_arr[..., 3] = (alpha * style["rim_light"] * 0.45 * 255).astype(np.uint8)
rim_img = Image.fromarray(rim_arr, "RGBA")
rim_img.putalpha(ImageChops.multiply(rim_img.split()[-1], mask))
base_rgb = block.convert("RGB")
rim_rgb = rim_img.convert("RGB")
screened = ImageChops.screen(base_rgb, rim_rgb)
block = Image.merge("RGBA", (*screened.split(), block.split()[-1]))
if style["inner_shadow"] > 0 and style["inner_shadow_strength"] > 0:
inner = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(inner)
inset = style["inner_shadow"] / 2
width = max(1, int(round(style["inner_shadow"])))
alpha = int(round(style["inner_shadow_strength"] * 0.6 * 255))
inner_span = max(0, inner_size - style["inner_shadow"])
left = draw_x + inset
top = draw_y + inset
right = left + inner_span - 1
bottom = top + inner_span - 1
draw.rectangle(
[left, top, right, bottom],
outline=(0, 0, 0, alpha),
width=width,
)
inner.putalpha(ImageChops.multiply(inner.split()[-1], mask))
block = Image.alpha_composite(block, inner)
if style["scanlines"] > 0:
lines = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(lines)
step = max(2, int(inner_size / 6))
alpha = int(round(min(0.4, style["scanlines"] * 0.6) * 255))
for yy in range(int(draw_y) + step, int(draw_y + inner_size), step):
draw.line([draw_x, yy, draw_x + inner_size, yy], fill=(0, 0, 0, alpha), width=1)
lines.putalpha(ImageChops.multiply(lines.split()[-1], mask))
block = Image.alpha_composite(block, lines)
texture_id = style["texture_id"]
if texture_id and style["texture_opacity"] > 0:
texture_img = _load_texture_image(texture_id)
if texture_img:
src_w = texture_img.width
src_h = texture_img.height
if texture_id in RANDOM_TEXTURE_IDS and texture_key:
transform = _texture_transform(seed, texture_key)
if texture_id == "pixelated":
ratio = PIXELATED_TEXTURE_SAMPLE_RATIO
src_w = max(1, int(round(texture_img.width * ratio)))
src_h = max(1, int(round(texture_img.height * ratio)))
else:
src_w = max(1, min(TEXTURE_SAMPLE_PX, texture_img.width))
src_h = max(1, min(TEXTURE_SAMPLE_PX, texture_img.height))
max_x = max(0, texture_img.width - src_w)
max_y = max(0, texture_img.height - src_h)
src_x = int(math.floor(max_x * transform["u"]))
src_y = int(math.floor(max_y * transform["v"]))
crop = texture_img.crop((src_x, src_y, src_x + src_w, src_y + src_h))
if transform["rotation"]:
crop = crop.rotate(transform["rotation"], expand=True)
if transform["flip_x"]:
crop = crop.transpose(Image.FLIP_LEFT_RIGHT)
if transform["flip_y"]:
crop = crop.transpose(Image.FLIP_TOP_BOTTOM)
else:
crop = texture_img
scale_base = max(inner_size / crop.width, inner_size / crop.height)
draw_w = max(1, int(round(crop.width * scale_base * style["texture_scale"])))
draw_h = max(1, int(round(crop.height * scale_base * style["texture_scale"])))
texture_layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
resized = crop.resize((draw_w, draw_h), Image.BICUBIC)
angle = style["texture_angle"]
if angle:
resized = resized.rotate(angle, expand=True)
tx = int(round((size - resized.width) / 2))
ty = int(round((size - resized.height) / 2))
texture_layer.paste(resized, (tx, ty), resized)
if corner_radius > 0:
texture_layer.putalpha(ImageChops.multiply(texture_layer.split()[-1], mask))
base_rgb = block.convert("RGB")
tex_rgb = texture_layer.convert("RGB")
tex_alpha = texture_layer.split()[-1]
multiplied = ImageChops.multiply(base_rgb, tex_rgb)
blended = Image.blend(base_rgb, multiplied, _clamp(style["texture_opacity"], 0, 1))
composited = Image.composite(blended, base_rgb, tex_alpha)
block = Image.merge("RGBA", (*composited.split(), block.split()[-1]))
if style["glow"] > 0 and style["glow_opacity"] > 0:
glow_color = _adjust_color_by_factor(base_color, 0.25)
glow_alpha = int(round(_clamp(style["glow_opacity"], 0, 1) * 255))
glow_layer = Image.new("RGBA", (size, size), (*glow_color, glow_alpha))
glow_layer.putalpha(mask)
glow_layer = glow_layer.filter(ImageFilter.GaussianBlur(radius=style["glow"] * 6))
glow_rgb = Image.new("RGB", (size, size), (0, 0, 0))
glow_rgb.paste(glow_color, mask=glow_layer.split()[-1])
base_rgb = block.convert("RGB")
added = ImageChops.add(base_rgb, glow_rgb, scale=1.0, offset=0)
block = Image.merge("RGBA", (*added.split(), block.split()[-1]))
if style["border"] > 0 and style["outline_opacity"] > 0:
border_color = _adjust_color_by_factor(base_color, -0.4)
outline_alpha = int(round(_clamp(style["outline_opacity"], 0, 1) * 255))
border = max(1.0, float(style["border"]))
outer_mask = Image.new("L", (size, size), 0)
outer_draw = ImageDraw.Draw(outer_mask)
outer_radius = min(corner_radius, inner_size / 2)
if outer_radius > 0:
outer_draw.rounded_rectangle(
[draw_x, draw_y, rect_right, rect_bottom],
radius=outer_radius,
fill=255,
)
else:
outer_draw.rectangle(
[draw_x, draw_y, rect_right, rect_bottom],
fill=255,
)
inner_mask = Image.new("L", (size, size), 0)
inner_draw = ImageDraw.Draw(inner_mask)
inset = border / 2.0
inner_left = draw_x + border
inner_top = draw_y + border
inner_right = rect_right - border
inner_bottom = rect_bottom - border
inner_radius = max(0.0, min(corner_radius - inset, inner_size / 2))
if inner_right > inner_left and inner_bottom > inner_top:
if inner_radius > 0:
inner_draw.rounded_rectangle(
[inner_left, inner_top, inner_right, inner_bottom],
radius=inner_radius,
fill=255,
)
else:
inner_draw.rectangle(
[inner_left, inner_top, inner_right, inner_bottom],
fill=255,
)
ring = ImageChops.subtract(outer_mask, inner_mask)
border_layer = Image.new("RGBA", (size, size), (*border_color, outline_alpha))
border_layer.putalpha(ring)
if style["border_blur"] > 0:
border_layer = border_layer.filter(ImageFilter.GaussianBlur(radius=style["border_blur"]))
block = Image.alpha_composite(block, border_layer)
if style["noise"] > 0:
noise_key = f"{seed}:{texture_key or ''}:noise"
rng = random.Random(hashlib.md5(noise_key.encode("utf-8")).digest())
count = max(4, int(math.ceil(40 * style["noise"])))
noise_layer = Image.new("RGBA", (size, size), (0, 0, 0, 0))
draw = ImageDraw.Draw(noise_layer)
alpha_white = int(round(min(1, 0.3 + style["noise"] * 0.7) * 255))
alpha_black = int(round(min(1, style["noise"] * 0.5) * 255))
for _ in range(count):
nx = draw_x + rng.random() * inner_size
ny = draw_y + rng.random() * inner_size
draw.rectangle([nx, ny, nx + 1, ny + 1], fill=(255, 255, 255, alpha_white))
for _ in range(max(1, count // 2)):
nx = draw_x + rng.random() * inner_size
ny = draw_y + rng.random() * inner_size
draw.rectangle([nx, ny, nx + 1, ny + 1], fill=(0, 0, 0, alpha_black))
noise_layer.putalpha(ImageChops.multiply(noise_layer.split()[-1], mask))
block = Image.alpha_composite(block, noise_layer)
base.paste(block, (int(round(x)), int(round(y))), block)
def _render(
board,
piece,
block_size,
background_image=None,
colors=None,
ghost_enabled=False,
grid_color=None,
style=None,
seed=0,
):
width = BOARD_WIDTH * block_size
extra_px = int(round(EXTRA_VISIBLE_ROWS * block_size))
height = VISIBLE_HEIGHT * block_size + extra_px
palette = colors or COLORS
style = style or DEFAULT_BLOCK_STYLE
bg = _prepare_background(background_image, width, height)
if bg is not None:
img = bg.convert("RGBA")
else:
img = Image.new("RGBA", (width, height), (*palette["X"], 255))
img = _draw_grid(img, block_size, width, height, grid_color, extra_px)
if HIDDEN_ROWS > 0:
hidden_row = HIDDEN_ROWS - 1
for x in range(BOARD_WIDTH):
cell = board[hidden_row][x]
if cell:
color = palette[cell]
x0 = x * block_size
y0 = -block_size + extra_px
key = f"board:{x}:{hidden_row}:{cell}"
_draw_block(img, x0, y0, block_size, color, style, key, seed)
for y in range(VISIBLE_HEIGHT):
board_y = y + HIDDEN_ROWS
for x in range(BOARD_WIDTH):
cell = board[board_y][x]
if cell:
color = palette[cell]
x0 = x * block_size
y0 = y * block_size + extra_px
key = f"board:{x}:{board_y}:{cell}"
_draw_block(img, x0, y0, block_size, color, style, key, seed)
if ghost_enabled:
img = _draw_ghost(img, board, piece, block_size, palette[piece["shape"]], extra_px)
for idx, (x, y) in enumerate(_piece_cells(piece)):
if HIDDEN_ROWS - 1 <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH:
color = palette[piece["shape"]]
x0 = x * block_size
y0 = (y - HIDDEN_ROWS) * block_size + extra_px
key = f"piece:{idx}"
_draw_block(img, x0, y0, block_size, color, style, key, seed)
arr = np.array(img.convert("RGB")).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None, ...]
def _render_next_piece(shape, block_size, colors=None):
palette = colors or COLORS
grid = PREVIEW_GRID
size = grid * block_size
img = Image.new("RGB", (size, size), palette["X"])
draw = ImageDraw.Draw(img)
cells = SHAPES[shape][0]
min_x = min(x for x, _ in cells)
min_y = min(y for _, y in cells)
max_x = max(x for x, _ in cells)
max_y = max(y for _, y in cells)
shape_w = max_x - min_x + 1
shape_h = max_y - min_y + 1
offset_x = (grid - shape_w) // 2 - min_x
offset_y = (grid - shape_h) // 2 - min_y
for x, y in cells:
gx = x + offset_x
gy = y + offset_y
if 0 <= gx < grid and 0 <= gy < grid:
x0 = gx * block_size
y0 = gy * block_size
draw.rectangle([x0, y0, x0 + block_size - 2, y0 + block_size - 2], fill=palette[shape])
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None, ...]
def _get_upcoming_shapes(state, count):
if count <= 0:
return []
upcoming = [state.get("next_piece_shape")] + list(state.get("bag", []))
bag_count = state.get("bag_count", 0)
seed = state.get("seed", 0)
while len(upcoming) < count:
bag = _new_bag(seed, bag_count)
bag_count += 1
upcoming.extend(bag)
return upcoming[:count]
def _render_queue(shapes, block_size, colors=None):
palette = colors or COLORS
if not shapes:
size = PREVIEW_GRID * block_size
img = Image.new("RGB", (size, size), palette["X"])
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None, ...]
gap = block_size
width = PREVIEW_GRID * block_size
height = len(shapes) * PREVIEW_GRID * block_size + max(0, len(shapes) - 1) * gap
img = Image.new("RGB", (width, height), palette["X"])
draw = ImageDraw.Draw(img)
for idx, shape in enumerate(shapes):
if shape not in SHAPES:
continue
offset_y = idx * (PREVIEW_GRID * block_size + gap)
cells = SHAPES[shape][0]
min_x = min(x for x, _ in cells)
min_y = min(y for _, y in cells)
max_x = max(x for x, _ in cells)
max_y = max(y for _, y in cells)
shape_w = max_x - min_x + 1
shape_h = max_y - min_y + 1
offset_x = (PREVIEW_GRID - shape_w) // 2 - min_x
offset_cell_y = (PREVIEW_GRID - shape_h) // 2 - min_y
for x, y in cells:
gx = x + offset_x
gy = y + offset_cell_y
if 0 <= gx < PREVIEW_GRID and 0 <= gy < PREVIEW_GRID:
x0 = gx * block_size
y0 = offset_y + gy * block_size
draw.rectangle(
[x0, y0, x0 + block_size - 2, y0 + block_size - 2],
fill=palette[shape],
)
arr = np.array(img).astype(np.float32) / 255.0
return torch.from_numpy(arr)[None, ...]
def _default_state(seed):
state = {
"version": STATE_VERSION,
"board": _empty_board(),
"bag": [],
"bag_count": 0,
"seed": seed,
"start_level": 1,
"level_progression": "fixed",
"level": 1,
"piece": None,
"next_piece_shape": None,
"hold_piece_shape": None,
"hold_used": False,
"score": 0,
"lines_cleared_total": 0,
"tetrises": 0,
"tspins": 0,
"combo_streak": 0,
"combo_total": 0,
"goal_lines_total": 0.0,
"b2b_active": False,
"game_over": False,
"last_action": None,
"last_rotate_kick": None,
"tspin": "none",
"options": {},
}
state["piece"] = _spawn_piece(_pop_shape(state))
state["next_piece_shape"] = _pop_shape(state)
if _collides(state["board"], state["piece"]):
state["game_over"] = True
return state
def _calc_level(start_level, lines_cleared_total, progression="fixed"):
start = max(1, min(15, int(start_level)))
if progression == "variable":
remaining = int(lines_cleared_total)
level = start
while level < 15:
goal = 5 * level
if remaining < goal:
break
remaining -= goal
level += 1
return max(1, min(15, level))
return max(1, min(15, start + int(lines_cleared_total // 10)))
def _lines_to_next_level(level, lines_total, progression="fixed", start_level=1):
if level >= 15:
return 0.0
if progression == "variable":
remaining = float(lines_total)
lvl = max(1, min(15, int(start_level)))
while lvl < 15 and remaining >= 5 * lvl:
remaining -= 5 * lvl
lvl += 1
if lvl >= 15:
return 0.0
return max(0.0, float(5 * lvl - remaining))
start = max(1, min(15, int(start_level)))
lines_into_level = float(lines_total) - float((level - start) * 10)
return max(0.0, float(10 - lines_into_level))
def _score_action(level, lines_cleared, tspin_type, b2b_active):
base = 0
qualifies_b2b = False
if tspin_type == "tspin":
if lines_cleared == 0:
base = 400 * level
elif lines_cleared == 1:
base = 800 * level
qualifies_b2b = True
elif lines_cleared == 2:
base = 1200 * level
qualifies_b2b = True
elif lines_cleared == 3:
base = 1600 * level
qualifies_b2b = True
elif tspin_type == "mini":
if lines_cleared == 0:
base = 100 * level
else:
base = 200 * level
qualifies_b2b = True
else:
if lines_cleared == 1:
base = 100 * level
elif lines_cleared == 2:
base = 300 * level
elif lines_cleared == 3:
base = 500 * level
elif lines_cleared == 4:
base = 800 * level
qualifies_b2b = True
bonus = 0
next_b2b = b2b_active
if qualifies_b2b:
if b2b_active:
bonus = int(base * 0.5)
next_b2b = True
elif lines_cleared in {1, 2, 3}:
next_b2b = False
return base + bonus, next_b2b
def _awarded_goal_lines(lines_cleared, tspin_type, b2b_active):
base = 0.0
qualifies_b2b = False
if tspin_type == "tspin":
if lines_cleared == 0:
base = 4.0
elif lines_cleared == 1:
base = 8.0
qualifies_b2b = True
elif lines_cleared == 2:
base = 12.0
qualifies_b2b = True
elif lines_cleared == 3:
base = 16.0
qualifies_b2b = True
elif tspin_type == "mini":
if lines_cleared == 0:
base = 1.0
else:
base = 2.0
qualifies_b2b = True
else:
if lines_cleared == 1:
base = 1.0
elif lines_cleared == 2:
base = 3.0
elif lines_cleared == 3:
base = 5.0
elif lines_cleared == 4:
base = 8.0
qualifies_b2b = True
if qualifies_b2b and b2b_active and base > 0:
base += base * 0.5
return base
def _update_stats(state, lines_cleared):
if lines_cleared > 0:
state["combo_streak"] = state.get("combo_streak", 0) + 1
if state["combo_streak"] == 2:
state["combo_total"] = state.get("combo_total", 0) + 1
if lines_cleared == 4:
state["tetrises"] = state.get("tetrises", 0) + 1
if state.get("tspin") != "none":
state["tspins"] = state.get("tspins", 0) + 1
else:
state["combo_streak"] = 0
def _deserialize_state(state_json, seed, enforce_seed=True):
if not state_json:
return _default_state(seed)
try:
state = json.loads(state_json)
except json.JSONDecodeError:
return _default_state(seed)
if not isinstance(state, dict):
return _default_state(seed)
if state.get("version") != STATE_VERSION:
return _default_state(seed)
board = state.get("board")
piece = state.get("piece")
if not _valid_board(board) or not _valid_piece(piece):
return _default_state(seed)
if enforce_seed and state.get("seed") != seed:
return _default_state(seed)
if "bag" not in state or "bag_count" not in state:
state["bag"] = []
state["bag_count"] = 0
next_shape = state.get("next_piece_shape")
if next_shape not in SHAPES:
state["next_piece_shape"] = _pop_shape(state)
if "score" not in state:
state["score"] = 0
if "lines_cleared_total" not in state:
state["lines_cleared_total"] = 0
if "tetrises" not in state:
state["tetrises"] = 0
if "tspins" not in state:
state["tspins"] = 0
if "combo_streak" not in state:
state["combo_streak"] = 0
if "combo_total" not in state:
state["combo_total"] = 0
if "goal_lines_total" not in state:
state["goal_lines_total"] = float(state.get("lines_cleared_total", 0))
if "b2b_active" not in state:
state["b2b_active"] = False
if "game_over" not in state:
state["game_over"] = False
if "start_level" not in state:
state["start_level"] = 1
if "level_progression" not in state:
state["level_progression"] = "fixed"
if "level" not in state:
state["level"] = state.get("start_level", 1)
if "last_action" not in state:
state["last_action"] = None
if "last_rotate_kick" not in state:
state["last_rotate_kick"] = None
if "tspin" not in state:
state["tspin"] = "none"
if "options" not in state:
state["options"] = {}
if "hold_piece_shape" not in state:
state["hold_piece_shape"] = None
if state.get("hold_piece_shape") not in SHAPES:
state["hold_piece_shape"] = None
if "hold_used" not in state:
state["hold_used"] = False
return state
def _valid_board(board):
if not isinstance(board, list) or len(board) != BOARD_HEIGHT:
return False
for row in board:
if not isinstance(row, list) or len(row) != BOARD_WIDTH:
return False
for cell in row:
if cell != 0 and cell not in SHAPES:
return False
return True
def _valid_piece(piece):
if not isinstance(piece, dict):
return False
shape = piece.get("shape")
if shape not in SHAPES:
return False
for key in ("rot", "x", "y"):
if not isinstance(piece.get(key), int):
return False
return True
def _move(piece, dx, dy):
return {"shape": piece["shape"], "rot": piece["rot"], "x": piece["x"] + dx, "y": piece["y"] + dy}
def _rotate(piece, delta):
return {"shape": piece["shape"], "rot": (piece["rot"] + delta) % 4, "x": piece["x"], "y": piece["y"]}
def _kick_table(shape, rot_from, rot_to):
if shape == "O":
return [(0, 0)]
if shape == "I":
table = {
(0, 1): [(0, 0), (-2, 0), (1, 0), (-2, 1), (1, -2)],
(1, 0): [(0, 0), (2, 0), (-1, 0), (2, -1), (-1, 2)],
(1, 2): [(0, 0), (-1, 0), (2, 0), (-1, -2), (2, 1)],
(2, 1): [(0, 0), (1, 0), (-2, 0), (1, 2), (-2, -1)],
(2, 3): [(0, 0), (2, 0), (-1, 0), (2, -1), (-1, 2)],
(3, 2): [(0, 0), (-2, 0), (1, 0), (-2, 1), (1, -2)],
(3, 0): [(0, 0), (1, 0), (-2, 0), (1, 2), (-2, -1)],
(0, 3): [(0, 0), (-1, 0), (2, 0), (-1, -2), (2, 1)],
}
return table.get((rot_from, rot_to), [(0, 0)])
table = {
(0, 1): [(0, 0), (-1, 0), (-1, -1), (0, 2), (-1, 2)],
(1, 0): [(0, 0), (1, 0), (1, 1), (0, -2), (1, -2)],
(1, 2): [(0, 0), (1, 0), (1, 1), (0, -2), (1, -2)],
(2, 1): [(0, 0), (-1, 0), (-1, -1), (0, 2), (-1, 2)],
(2, 3): [(0, 0), (1, 0), (1, -1), (0, 2), (1, 2)],
(3, 2): [(0, 0), (-1, 0), (-1, 1), (0, -2), (-1, -2)],
(3, 0): [(0, 0), (-1, 0), (-1, 1), (0, -2), (-1, -2)],
(0, 3): [(0, 0), (1, 0), (1, -1), (0, 2), (1, 2)],
}
return table.get((rot_from, rot_to), [(0, 0)])
def _rotate_with_kick(board, piece, delta):
rot_from = piece["rot"] % 4
rot_to = (rot_from + delta) % 4
kicks = _kick_table(piece["shape"], rot_from, rot_to)
for idx, (dx, dy) in enumerate(kicks):
candidate = {"shape": piece["shape"], "rot": rot_to, "x": piece["x"] + dx, "y": piece["y"] + dy}
if not _collides(board, candidate):
return candidate, idx
return piece, None
def _corner_occupied(board, x, y):
if x < 0 or x >= BOARD_WIDTH or y < 0 or y >= BOARD_HEIGHT:
return True
return board[y][x] != 0
def _tspin_type_from_corners(board, piece):
if piece["shape"] != "T":
return "none"
cx = piece["x"] + 1
cy = piece["y"] + 1
corners = {
"A": (cx - 1, cy - 1),
"B": (cx + 1, cy - 1),
"C": (cx - 1, cy + 1),
"D": (cx + 1, cy + 1),
}
rot = piece["rot"] % 4
if rot == 0:
front = ("A", "B")
back = ("C", "D")
elif rot == 1:
front = ("B", "D")
back = ("A", "C")
elif rot == 2:
front = ("C", "D")
back = ("A", "B")
else:
front = ("A", "C")
back = ("B", "D")
front_hits = sum(_corner_occupied(board, *corners[k]) for k in front)
back_hits = sum(_corner_occupied(board, *corners[k]) for k in back)
total_hits = front_hits + back_hits
if total_hits < 3:
return "none"
if front_hits == 2 and back_hits == 2:
return "tspin"
if front_hits == 2 and back_hits >= 1:
return "tspin"
if back_hits == 2 and front_hits >= 1:
return "mini"
return "none"
def _tspin_type(board, piece, last_action, last_rotate_kick):
if piece["shape"] != "T" or last_action != "rotate":
return "none"
corners = _tspin_type_from_corners(board, piece)
if corners == "none":
return "none"
if last_rotate_kick == 4:
return "tspin"
return corners
class TetriNode:
OUTPUT_NODE = True
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"action": (
[
"none",
"sync",
"left",
"right",
"down",
"rotate_cw",
"rotate_ccw",
"soft_drop",
"hard_drop",
"hold",
"new",
],
{"default": "none"},
),
"state": ("STRING", {"default": ""}),
"seed": (
"INT",
{
"default": 0,
"min": 0,
"max": 0xFFFFFFFFFFFFFFFF,
"control_after_generate": True,
"tooltip": "The random seed used for piece generation.",
},
),
"block_size": ("INT", {"default": 20, "min": 8, "max": 48}),
},
"optional": {
"background_image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE",)
RETURN_NAMES = ("matrix",)
FUNCTION = "step"
CATEGORY = "games"
def step(
self,
action,
state,
seed,
block_size,
background_image=None,
):
state_override = state
if action == "new":
state_obj = _default_state(seed)
else:
enforce_seed = action != "sync"
state_obj = _deserialize_state(state_override, seed, enforce_seed=enforce_seed)
options = _resolve_options(state_obj.get("options", {}))
palette = _resolve_colors(options)
style = _resolve_block_style(options)
capture = options.get("matrix_capture")
if action == "sync":
captured = _render_from_capture(capture)
if captured is not None:
return _wrap_result(
(
captured,
),
background_image,
)
ghost_enabled = _resolve_bool(options, "ghost_piece", True)
grid_enabled = _resolve_bool(options, "grid_enabled", True)
grid_default = "rgba(255,255,255,0.08)"
grid_color = _parse_rgba_color(options.get("grid_color", grid_default)) if grid_enabled else None
queue_size = options.get("queue_size", 6)
try:
queue_size = max(0, min(6, int(queue_size)))
except (TypeError, ValueError):
queue_size = 6
if action == "sync":
state_obj["seed"] = seed
output_block = block_size * OUTPUT_SCALE
render_style = _scale_block_style(style, OUTPUT_SCALE)
image = _render(
state_obj["board"],
state_obj["piece"],
output_block,
background_image,
palette,
ghost_enabled=ghost_enabled,
grid_color=grid_color,
style=render_style,
seed=state_obj.get("seed", seed),
)
return _wrap_result(
(
image,
),
background_image,
)
if state_obj.get("game_over"):
output_block = block_size * OUTPUT_SCALE
render_style = _scale_block_style(style, OUTPUT_SCALE)
image = _render(
state_obj["board"],
state_obj["piece"],
output_block,
background_image,
palette,
ghost_enabled=ghost_enabled,
grid_color=grid_color,
style=render_style,
seed=state_obj.get("seed", seed),
)
return _wrap_result(
(
image,
),
background_image,
)
board = state_obj["board"]
piece = state_obj["piece"]
next_shape = state_obj["next_piece_shape"]
if action == "left":
moved = _move(piece, -1, 0)
if not _collides(board, moved):
piece = moved
state_obj["last_action"] = "move"
state_obj["last_rotate_kick"] = None
elif action == "right":
moved = _move(piece, 1, 0)
if not _collides(board, moved):
piece = moved
state_obj["last_action"] = "move"
state_obj["last_rotate_kick"] = None
elif action in {"down", "soft_drop"}:
moved = _move(piece, 0, 1)
if not _collides(board, moved):
piece = moved
state_obj["last_action"] = "move"
state_obj["last_rotate_kick"] = None
if action == "soft_drop":
state_obj["score"] += 1
elif action == "rotate_cw":
piece, kick = _rotate_with_kick(board, piece, 1)
if piece["rot"] != state_obj["piece"]["rot"] or kick is not None:
state_obj["last_action"] = "rotate"
state_obj["last_rotate_kick"] = kick
elif action == "rotate_ccw":
piece, kick = _rotate_with_kick(board, piece, -1)
if piece["rot"] != state_obj["piece"]["rot"] or kick is not None:
state_obj["last_action"] = "rotate"
state_obj["last_rotate_kick"] = kick
elif action == "hard_drop":
drop_distance = 0
moved = _move(piece, 0, 1)
while not _collides(board, moved):
piece = moved
drop_distance += 1
moved = _move(piece, 0, 1)
if drop_distance:
state_obj["score"] += 2 * drop_distance
elif action == "hold":
if not state_obj.get("hold_used", False):
hold_shape = state_obj.get("hold_piece_shape")
state_obj["hold_used"] = True
state_obj["last_action"] = "hold"
state_obj["last_rotate_kick"] = None
state_obj["tspin"] = "none"
if hold_shape in SHAPES:
state_obj["hold_piece_shape"] = piece["shape"]
piece = _spawn_piece(hold_shape)
else:
state_obj["hold_piece_shape"] = piece["shape"]
piece = _spawn_piece(next_shape)
next_shape = _pop_shape(state_obj)
if _collides(board, piece):
state_obj["game_over"] = True
if action not in {"hard_drop", "down", "soft_drop"}:
moved = _move(piece, 0, 1)
if not _collides(board, moved):
piece = moved
else:
_lock_piece(board, piece)
state_obj["tspin"] = _tspin_type(
board, piece, state_obj["last_action"], state_obj["last_rotate_kick"]
)
board, cleared = _clear_lines(board)
_update_stats(state_obj, cleared)
level_before = state_obj.get("level", 1)
prev_b2b = state_obj.get("b2b_active", False)
gained, next_b2b = _score_action(
level_before, cleared, state_obj["tspin"], prev_b2b
)
state_obj["score"] += gained
state_obj["b2b_active"] = next_b2b
state_obj["lines_cleared_total"] += cleared
progression = state_obj.get("level_progression", "fixed")
if progression == "variable":
state_obj["goal_lines_total"] += _awarded_goal_lines(
cleared,
state_obj["tspin"],
prev_b2b,
)
else:
state_obj["goal_lines_total"] = float(state_obj["lines_cleared_total"])
state_obj["level"] = _calc_level(
state_obj.get("start_level", 1),
state_obj["goal_lines_total"],
progression,
)
piece = _spawn_piece(next_shape)
next_shape = _pop_shape(state_obj)
state_obj["hold_used"] = False
if _collides(board, piece):
state_obj["game_over"] = True
elif action in {"hard_drop", "down", "soft_drop"}:
moved = _move(piece, 0, 1)
if _collides(board, moved):
_lock_piece(board, piece)
state_obj["tspin"] = _tspin_type(
board, piece, state_obj["last_action"], state_obj["last_rotate_kick"]
)
board, cleared = _clear_lines(board)
_update_stats(state_obj, cleared)
level_before = state_obj.get("level", 1)
prev_b2b = state_obj.get("b2b_active", False)
gained, next_b2b = _score_action(
level_before, cleared, state_obj["tspin"], prev_b2b
)
state_obj["score"] += gained
state_obj["b2b_active"] = next_b2b
state_obj["lines_cleared_total"] += cleared
progression = state_obj.get("level_progression", "fixed")
if progression == "variable":
state_obj["goal_lines_total"] += _awarded_goal_lines(
cleared,
state_obj["tspin"],
prev_b2b,
)
else:
state_obj["goal_lines_total"] = float(state_obj["lines_cleared_total"])
state_obj["level"] = _calc_level(
state_obj.get("start_level", 1),
state_obj["goal_lines_total"],
progression,
)
piece = _spawn_piece(next_shape)
next_shape = _pop_shape(state_obj)
state_obj["hold_used"] = False
if _collides(board, piece):
state_obj["game_over"] = True
state_obj["board"] = board
state_obj["piece"] = piece
state_obj["next_piece_shape"] = next_shape
output_block = block_size * OUTPUT_SCALE
render_style = _scale_block_style(style, OUTPUT_SCALE)
image = _render(
board,
piece,
output_block,
background_image,
palette,
ghost_enabled=ghost_enabled,
grid_color=grid_color,
style=render_style,
seed=state_obj.get("seed", seed),
)
return _wrap_result(
(
image,
),
background_image,
)