Files
soulctcher-TetriNode/tetris_node.py
T
Erik W. d5350147ab Bump to v1.2.0
Align live/output render for grid+ghost

Improve background handling and output node support

Update defaults, docs, and screenshots
2026-01-04 02:42:34 -05:00

1317 lines
42 KiB
Python

import json
import os
import random
import re
import numpy as np
import torch
from PIL import Image, ImageDraw
import folder_paths
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
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),
}
KEY_OPTIONS = [
"F1",
"A",
"B",
"C",
"D",
"E",
"F",
"G",
"H",
"I",
"J",
"K",
"L",
"M",
"N",
"O",
"P",
"Q",
"R",
"S",
"T",
"U",
"V",
"W",
"X",
"Y",
"Z",
"0",
"1",
"2",
"3",
"4",
"5",
"6",
"7",
"8",
"9",
"ArrowLeft",
"ArrowRight",
"ArrowUp",
"ArrowDown",
"Space",
"Enter",
"Escape",
"Tab",
"Backspace",
"Delete",
"Home",
"End",
"PageUp",
"PageDown",
"Insert",
"Control",
"Shift",
"Numpad0",
"Numpad1",
"Numpad2",
"Numpad3",
"Numpad4",
"Numpad5",
"Numpad6",
"Numpad7",
"Numpad8",
"Numpad9",
"NumpadAdd",
"NumpadSubtract",
"NumpadMultiply",
"NumpadDivide",
"NumpadDecimal",
"NumpadEnter",
"F1",
"F2",
"F3",
"F4",
"F5",
"F6",
"F7",
"F8",
"F9",
"F10",
"F11",
"F12",
"-",
"=",
"[",
"]",
"Backslash",
";",
"'",
",",
".",
"Slash",
"`",
]
KEY_OPTIONS_WITH_NONE = ["None", *KEY_OPTIONS]
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 _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 _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):
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(1, VISIBLE_HEIGHT):
ypos = y * block_size - 1
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):
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 <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH:
x0 = x * block_size
y0 = (y - HIDDEN_ROWS) * block_size
draw.rectangle(
[x0, y0, x0 + block_size - 1, y0 + block_size - 1],
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 _render(board, piece, block_size, background_image=None, colors=None, ghost_enabled=False, grid_color=None):
width = BOARD_WIDTH * block_size
height = VISIBLE_HEIGHT * block_size
palette = colors or COLORS
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)
draw = ImageDraw.Draw(img)
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
draw.rectangle([x0, y0, x0 + block_size - 1, y0 + block_size - 1], fill=color)
if ghost_enabled:
img = _draw_ghost(img, board, piece, block_size, palette[piece["shape"]])
draw = ImageDraw.Draw(img)
for x, y in _piece_cells(piece):
if HIDDEN_ROWS <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH:
color = palette[piece["shape"]]
x0 = x * block_size
y0 = (y - HIDDEN_ROWS) * block_size
draw.rectangle([x0, y0, x0 + block_size - 1, y0 + block_size - 1], fill=color)
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 - 1, y0 + block_size - 1], 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 - 1, y0 + block_size - 1],
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,
"goal_lines_total": 0.0,
"b2b_active": False,
"game_over": False,
"last_action": None,
"last_rotate_kick": None,
"tspin": "none",
}
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 _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 "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 "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(board, piece, last_action, last_rotate_kick):
if piece["shape"] != "T" or last_action != "rotate":
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 last_rotate_kick == 4:
return "tspin"
if front_hits == 2 and back_hits >= 1:
return "tspin"
if back_hits == 2 and front_hits >= 1:
return "mini"
return "none"
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": {
"tetrinode_options": ("TETRINODE_OPTIONS",),
"background_image": ("IMAGE",),
},
}
RETURN_TYPES = ("IMAGE", "STRING", "INT", "FLOAT", "FLOAT", "IMAGE", "IMAGE")
RETURN_NAMES = ("image", "state", "score", "lines_cleared", "goal", "next_piece", "queue")
FUNCTION = "step"
CATEGORY = "games"
def step(self, action, state, seed, block_size, tetrinode_options="", background_image=None):
options = _resolve_options(tetrinode_options)
palette = _resolve_colors(options)
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 == "new":
state_obj = _default_state(seed)
else:
enforce_seed = action != "sync"
state_obj = _deserialize_state(state, seed, enforce_seed=enforce_seed)
if action == "sync":
state_obj["seed"] = seed
output_block = block_size * OUTPUT_SCALE
image = _render(
state_obj["board"],
state_obj["piece"],
output_block,
background_image,
palette,
ghost_enabled=ghost_enabled,
grid_color=grid_color,
)
preview = _render_next_piece(state_obj["next_piece_shape"], output_block, palette)
queue = _render_queue(
_get_upcoming_shapes(state_obj, queue_size + 1)[1 : queue_size + 1],
output_block,
palette,
)
lines_total = (
state_obj["goal_lines_total"]
if state_obj.get("level_progression") == "variable"
else state_obj["lines_cleared_total"]
)
return _wrap_result(
(
image,
json.dumps(state_obj),
state_obj["score"],
lines_total,
_lines_to_next_level(
state_obj.get("level", 1),
lines_total,
state_obj.get("level_progression", "fixed"),
state_obj.get("start_level", 1),
),
preview,
queue,
),
background_image,
)
if state_obj.get("game_over"):
output_block = block_size * OUTPUT_SCALE
image = _render(
state_obj["board"],
state_obj["piece"],
output_block,
background_image,
palette,
ghost_enabled=ghost_enabled,
grid_color=grid_color,
)
preview = _render_next_piece(state_obj["next_piece_shape"], output_block, palette)
queue = _render_queue(
_get_upcoming_shapes(state_obj, queue_size + 1)[1 : queue_size + 1],
output_block,
palette,
)
lines_total = (
state_obj["goal_lines_total"]
if state_obj.get("level_progression") == "variable"
else state_obj["lines_cleared_total"]
)
return _wrap_result(
(
image,
json.dumps(state_obj),
state_obj["score"],
lines_total,
_lines_to_next_level(
state_obj.get("level", 1),
lines_total,
state_obj.get("level_progression", "fixed"),
state_obj.get("start_level", 1),
),
preview,
queue,
),
background_image,
)
board = state_obj["board"]
piece = state_obj["piece"]
next_shape = state_obj["next_piece_shape"]
lines_cleared = 0
if action == "left":
moved = _move(piece, -1, 0)
if not _collides(board, moved):
piece = moved
state_obj["last_action"] = "move"
elif action == "right":
moved = _move(piece, 1, 0)
if not _collides(board, moved):
piece = moved
state_obj["last_action"] = "move"
elif action in {"down", "soft_drop"}:
moved = _move(piece, 0, 1)
if not _collides(board, moved):
piece = moved
state_obj["last_action"] = "move"
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
state_obj["last_action"] = "move"
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)
lines_cleared = 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)
lines_cleared = 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
image = _render(
board,
piece,
output_block,
background_image,
palette,
ghost_enabled=ghost_enabled,
grid_color=grid_color,
)
preview = _render_next_piece(next_shape, output_block, palette)
queue = _render_queue(
_get_upcoming_shapes(state_obj, queue_size + 1)[1 : queue_size + 1],
output_block,
palette,
)
lines_out = (
state_obj["goal_lines_total"]
if state_obj.get("level_progression") == "variable"
else state_obj["lines_cleared_total"]
)
return _wrap_result(
(
image,
json.dumps(state_obj),
state_obj["score"],
lines_out,
_lines_to_next_level(
state_obj.get("level", 1),
lines_out,
state_obj.get("level_progression", "fixed"),
state_obj.get("start_level", 1),
),
preview,
queue,
),
background_image,
)
class TetriNodeOptions:
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"show_controls": ("BOOLEAN", {"default": True}),
"move_left": (KEY_OPTIONS, {"default": "ArrowLeft"}),
"move_left_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad4"}),
"move_right": (KEY_OPTIONS, {"default": "ArrowRight"}),
"move_right_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad6"}),
"rotate_cw": (KEY_OPTIONS, {"default": "ArrowUp"}),
"rotate_cw_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad5"}),
"rotate_cw_3": (KEY_OPTIONS_WITH_NONE, {"default": "X"}),
"rotate_cw_4": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad1"}),
"rotate_cw_5": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad9"}),
"rotate_ccw": (KEY_OPTIONS, {"default": "Control"}),
"rotate_ccw_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad3"}),
"rotate_ccw_3": (KEY_OPTIONS_WITH_NONE, {"default": "Z"}),
"rotate_ccw_4": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad7"}),
"soft_drop": (KEY_OPTIONS, {"default": "ArrowDown"}),
"soft_drop_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad2"}),
"hard_drop": (KEY_OPTIONS, {"default": "Space"}),
"hard_drop_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad8"}),
"hold": (KEY_OPTIONS, {"default": "Shift"}),
"hold_2": (KEY_OPTIONS_WITH_NONE, {"default": "Numpad0"}),
"hold_3": (KEY_OPTIONS_WITH_NONE, {"default": "C"}),
"reset": (KEY_OPTIONS, {"default": "R"}),
"reset_2": (KEY_OPTIONS_WITH_NONE, {"default": "None"}),
"pause": (KEY_OPTIONS, {"default": "Escape"}),
"pause_2": (KEY_OPTIONS_WITH_NONE, {"default": "F1"}),
"color_i": ("STRING", {"default": "#55D6FF"}),
"color_j": ("STRING", {"default": "#5669FF"}),
"color_l": ("STRING", {"default": "#FFA74F"}),
"color_o": ("STRING", {"default": "#FFE757"}),
"color_s": ("STRING", {"default": "#7AEB84"}),
"color_t": ("STRING", {"default": "#BB80FF"}),
"color_z": ("STRING", {"default": "#FF7676"}),
"background_color": ("STRING", {"default": "#32343E"}),
"ghost_piece": ("BOOLEAN", {"default": True}),
"next_piece": ("BOOLEAN", {"default": True}),
"hold_queue": ("BOOLEAN", {"default": True}),
"lock_down_mode": (["extended", "infinite", "classic"], {"default": "extended"}),
"start_level": ("INT", {"default": 1, "min": 1, "max": 15}),
"level_progression": (["fixed", "variable"], {"default": "fixed"}),
"queue_size": ("INT", {"default": 6, "min": 0, "max": 6}),
"grid_enabled": ("BOOLEAN", {"default": True}),
"grid_color": ("STRING", {"default": "rgba(255,255,255,0.2)"}),
}
}
RETURN_TYPES = ("TETRINODE_OPTIONS",)
RETURN_NAMES = ("tetrinode_options",)
FUNCTION = "build"
CATEGORY = "games"
def build(
self,
show_controls,
move_left,
move_left_2,
move_right,
move_right_2,
rotate_cw,
rotate_cw_2,
rotate_cw_3,
rotate_cw_4,
rotate_cw_5,
rotate_ccw,
rotate_ccw_2,
rotate_ccw_3,
rotate_ccw_4,
soft_drop,
soft_drop_2,
hard_drop,
hard_drop_2,
hold,
hold_2,
hold_3,
reset,
reset_2,
pause,
pause_2,
lock_down_mode,
start_level,
level_progression,
queue_size,
grid_enabled,
grid_color,
color_i,
color_j,
color_l,
color_o,
color_s,
color_t,
color_z,
background_color,
ghost_piece,
next_piece,
hold_queue,
):
payload = {
"show_controls": show_controls,
"move_left": move_left,
"move_left_2": move_left_2,
"move_right": move_right,
"move_right_2": move_right_2,
"rotate_cw": rotate_cw,
"rotate_cw_2": rotate_cw_2,
"rotate_cw_3": rotate_cw_3,
"rotate_cw_4": rotate_cw_4,
"rotate_cw_5": rotate_cw_5,
"rotate_ccw": rotate_ccw,
"rotate_ccw_2": rotate_ccw_2,
"rotate_ccw_3": rotate_ccw_3,
"rotate_ccw_4": rotate_ccw_4,
"soft_drop": soft_drop,
"soft_drop_2": soft_drop_2,
"hard_drop": hard_drop,
"hard_drop_2": hard_drop_2,
"hold": hold,
"hold_2": hold_2,
"hold_3": hold_3,
"reset": reset,
"reset_2": reset_2,
"pause": pause,
"pause_2": pause_2,
"lock_down_mode": lock_down_mode,
"start_level": start_level,
"level_progression": level_progression,
"queue_size": queue_size,
"grid_enabled": grid_enabled,
"grid_color": grid_color,
"color_i": color_i,
"color_j": color_j,
"color_l": color_l,
"color_o": color_o,
"color_s": color_s,
"color_t": color_t,
"color_z": color_z,
"background_color": background_color,
"ghost_piece": ghost_piece,
"next_piece": next_piece,
"hold_queue": hold_queue,
}
return (json.dumps(payload),)