Add CCW rotation and refine HUD
- add rotate_cw/rotate_ccw bindings and display updates in options/controls - refine HUD layout/labels, borders, spacing, and correct lines_cleared output - support customizable tetromino/background colors and background images in live UI
This commit is contained in:
@@ -26,6 +26,7 @@ Main gameplay node.
|
||||
**Inputs**
|
||||
- `tetrinode_options` (TETRINODE_OPTIONS): Custom keybindings for moving left/right, rotating the piece, dropping the piece, play/pause, and reset
|
||||
- `seed` (INT): Seed used for piece sequence
|
||||
- `background_image` (IMAGE, optional): Background image for the game board (scaled to cover, then center-cropped)
|
||||
|
||||
**Outputs**
|
||||
- `image` (IMAGE): current board
|
||||
@@ -40,6 +41,12 @@ Optional node that provides keybindings.
|
||||
|
||||

|
||||
|
||||
**Inputs**
|
||||
|
||||
- `move_left`, `move_right`, `rotate_cw`, `rotate_ccw`, `drop`, `reset`, `pause`: Key bindings
|
||||
- `color_i`, `color_j`, `color_l`, `color_o`, `color_s`, `color_t`, `color_z`: Hex colors for each tetromino (e.g. `#55D6FF`)
|
||||
- `background_color`: Hex color for the board background (ignored when a background image is connected)
|
||||
|
||||
**Outputs**
|
||||
|
||||
- `tetrinode_options` (TETRINODE_OPTIONS): Custom keybindings for moving left/right, rotating the piece, dropping the piece, play/pause, and reset
|
||||
@@ -50,7 +57,8 @@ Displayed inside the node UI and reflected in the game input handler.
|
||||
|
||||
- Move Left: `A`
|
||||
- Move Right: `D`
|
||||
- Rotate: `W`
|
||||
- Rotate CW: `W`
|
||||
- Rotate CCW: `Q`
|
||||
- Drop: `S`
|
||||
- Reset: `R`
|
||||
- Pause: `P`
|
||||
|
||||
+352
-20
@@ -187,7 +187,6 @@ function stepDown(state) {
|
||||
state.board = result.board;
|
||||
if (result.cleared > 0) {
|
||||
state.lines += result.cleared;
|
||||
state.score += result.cleared;
|
||||
}
|
||||
spawnNext(state);
|
||||
}
|
||||
@@ -340,6 +339,173 @@ function drawBlockSized(ctx, x, y, size, color) {
|
||||
ctx.fillRect(x, y, size - 1, size - 1);
|
||||
}
|
||||
|
||||
function getInputDataByName(node, name) {
|
||||
const idx = node?.inputs?.findIndex((inp) => inp?.name === name);
|
||||
if (idx == null || idx < 0) return null;
|
||||
if (typeof node.getInputData !== "function") return null;
|
||||
return node.getInputData(idx);
|
||||
}
|
||||
|
||||
function coerceImageSource(value) {
|
||||
if (!value) return null;
|
||||
if (
|
||||
value instanceof HTMLImageElement ||
|
||||
value instanceof HTMLCanvasElement ||
|
||||
value instanceof ImageBitmap ||
|
||||
value instanceof OffscreenCanvas ||
|
||||
value instanceof HTMLVideoElement
|
||||
) {
|
||||
return value;
|
||||
}
|
||||
if (value.image) {
|
||||
const img = value.image;
|
||||
if (
|
||||
img instanceof HTMLImageElement ||
|
||||
img instanceof HTMLCanvasElement ||
|
||||
img instanceof ImageBitmap ||
|
||||
img instanceof OffscreenCanvas
|
||||
) {
|
||||
return img;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function toByteArray(data) {
|
||||
if (!data) return null;
|
||||
if (data instanceof Uint8ClampedArray) return data;
|
||||
let array = data;
|
||||
if (data instanceof Uint8Array) {
|
||||
return new Uint8ClampedArray(data.buffer, data.byteOffset, data.byteLength);
|
||||
}
|
||||
if (data instanceof Float32Array || data instanceof Float64Array) {
|
||||
array = Array.from(data);
|
||||
}
|
||||
if (Array.isArray(array)) {
|
||||
let max = 0;
|
||||
for (let i = 0; i < array.length; i += 1) {
|
||||
const v = array[i];
|
||||
if (Number.isFinite(v) && v > max) max = v;
|
||||
}
|
||||
const scale = max <= 1 ? 255 : 1;
|
||||
const out = new Uint8ClampedArray(array.length);
|
||||
for (let i = 0; i < array.length; i += 1) {
|
||||
const v = array[i];
|
||||
const value = Number.isFinite(v) ? v * scale : 0;
|
||||
out[i] = Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildCanvasFromData(value) {
|
||||
if (!value || !value.data || !value.width || !value.height) return null;
|
||||
const width = Math.max(1, Math.floor(value.width));
|
||||
const height = Math.max(1, Math.floor(value.height));
|
||||
const bytes = toByteArray(value.data);
|
||||
if (!bytes) return null;
|
||||
const expected3 = width * height * 3;
|
||||
const expected4 = width * height * 4;
|
||||
let pixels = bytes;
|
||||
if (bytes.length === expected3) {
|
||||
pixels = new Uint8ClampedArray(expected4);
|
||||
for (let i = 0; i < width * height; i += 1) {
|
||||
const src = i * 3;
|
||||
const dst = i * 4;
|
||||
pixels[dst] = bytes[src];
|
||||
pixels[dst + 1] = bytes[src + 1];
|
||||
pixels[dst + 2] = bytes[src + 2];
|
||||
pixels[dst + 3] = 255;
|
||||
}
|
||||
} else if (bytes.length !== expected4) {
|
||||
return null;
|
||||
}
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return null;
|
||||
const imgData = new ImageData(pixels, width, height);
|
||||
ctx.putImageData(imgData, 0, 0);
|
||||
return canvas;
|
||||
}
|
||||
|
||||
function getLinkedImageSource(node, name) {
|
||||
const resolved = getInputLink(node, name);
|
||||
if (!resolved) return null;
|
||||
const { origin, link } = resolved;
|
||||
const slot = link?.origin_slot ?? 0;
|
||||
if (!origin) return null;
|
||||
if (Array.isArray(origin.imgs)) {
|
||||
const candidate = origin.imgs[slot] || origin.imgs[0];
|
||||
const source = coerceImageSource(candidate);
|
||||
if (source) return source;
|
||||
}
|
||||
if (Array.isArray(origin.images)) {
|
||||
const candidate = origin.images[slot] || origin.images[0];
|
||||
const source = coerceImageSource(candidate);
|
||||
if (source) return source;
|
||||
}
|
||||
const direct = coerceImageSource(origin.image || origin.img || origin._img || origin._image);
|
||||
if (direct) return direct;
|
||||
return null;
|
||||
}
|
||||
|
||||
function getBackgroundSource(node) {
|
||||
const raw = getInputDataByName(node, "background_image");
|
||||
let value = Array.isArray(raw) ? raw[0] : raw;
|
||||
let source = null;
|
||||
if (!value) {
|
||||
source = getLinkedImageSource(node, "background_image");
|
||||
value = source;
|
||||
}
|
||||
if (!value && !source) return null;
|
||||
if (!node.__tetrisBg) node.__tetrisBg = {};
|
||||
if (node.__tetrisBg.value === value && node.__tetrisBg.source) {
|
||||
return node.__tetrisBg.source;
|
||||
}
|
||||
if (!source) {
|
||||
source = coerceImageSource(value);
|
||||
}
|
||||
if (!source) {
|
||||
source = buildCanvasFromData(value);
|
||||
}
|
||||
node.__tetrisBg = { value, source };
|
||||
return source || null;
|
||||
}
|
||||
|
||||
function drawBoardBackground(ctx, source, boardX, boardY, boardW, boardH, fallbackColor) {
|
||||
if (!source) {
|
||||
ctx.fillStyle = fallbackColor || COLORS.X;
|
||||
ctx.fillRect(boardX, boardY, boardW, boardH);
|
||||
return;
|
||||
}
|
||||
const srcW =
|
||||
source.videoWidth ||
|
||||
source.naturalWidth ||
|
||||
source.width ||
|
||||
source.displayWidth ||
|
||||
0;
|
||||
const srcH =
|
||||
source.videoHeight ||
|
||||
source.naturalHeight ||
|
||||
source.height ||
|
||||
source.displayHeight ||
|
||||
0;
|
||||
if (!srcW || !srcH) {
|
||||
ctx.fillStyle = fallbackColor || COLORS.X;
|
||||
ctx.fillRect(boardX, boardY, boardW, boardH);
|
||||
return;
|
||||
}
|
||||
const scale = Math.max(boardW / srcW, boardH / srcH);
|
||||
const cropW = boardW / scale;
|
||||
const cropH = boardH / scale;
|
||||
const sx = Math.max(0, (srcW - cropW) / 2);
|
||||
const sy = Math.max(0, (srcH - cropH) / 2);
|
||||
ctx.drawImage(source, sx, sy, cropW, cropH, boardX, boardY, boardW, boardH);
|
||||
}
|
||||
|
||||
function drawNode(node, ctx) {
|
||||
const live = node.__tetrisLive;
|
||||
if (!live) return;
|
||||
@@ -349,15 +515,19 @@ function drawNode(node, ctx) {
|
||||
syncSeed(live.state, node);
|
||||
const { state } = live;
|
||||
const { boardX, boardY, boardW, boardH, sideX, sideY, blockSize } = getLayout(node);
|
||||
const palette = getColorPalette(node);
|
||||
|
||||
ctx.fillStyle = COLORS.X;
|
||||
ctx.fillRect(boardX, boardY, boardW, boardH);
|
||||
const bgSource = getBackgroundSource(node);
|
||||
drawBoardBackground(ctx, bgSource, boardX, boardY, boardW, boardH, palette.X);
|
||||
ctx.strokeStyle = "rgba(150,150,150,0.8)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(boardX + 0.5, boardY + 0.5, boardW - 1, boardH - 1);
|
||||
|
||||
for (let y = 0; y < GRID_H; y += 1) {
|
||||
for (let x = 0; x < GRID_W; x += 1) {
|
||||
const cell = state.board[y][x];
|
||||
if (cell) {
|
||||
drawBlockSized(ctx, boardX + x * blockSize, boardY + y * blockSize, blockSize, COLORS[cell]);
|
||||
drawBlockSized(ctx, boardX + x * blockSize, boardY + y * blockSize, blockSize, palette[cell]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -369,16 +539,27 @@ function drawNode(node, ctx) {
|
||||
boardX + x * blockSize,
|
||||
boardY + y * blockSize,
|
||||
blockSize,
|
||||
COLORS[state.piece.shape],
|
||||
palette[state.piece.shape],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const scoreFontSize = Math.max(10, Math.floor(blockSize * 0.55));
|
||||
ctx.fillStyle = COLORS.Text;
|
||||
const lineGap = Math.floor(scoreFontSize * 0.6);
|
||||
const linesLabelY = sideY + scoreFontSize + 1;
|
||||
const linesValueY = linesLabelY + scoreFontSize + 2;
|
||||
const scoreLabelY = linesValueY + lineGap + scoreFontSize;
|
||||
const scoreValueY = scoreLabelY + scoreFontSize + 2;
|
||||
ctx.font = `bold ${scoreFontSize}px sans-serif`;
|
||||
ctx.fillText("Lines Cleared:", sideX, linesLabelY);
|
||||
ctx.fillText("Score:", sideX, scoreLabelY);
|
||||
ctx.font = `${scoreFontSize}px sans-serif`;
|
||||
ctx.fillText(`Score: ${state.score}`, sideX, sideY + scoreFontSize + 1);
|
||||
ctx.fillText("Next:", sideX, sideY + scoreFontSize * 2 + 5);
|
||||
ctx.fillText(`${state.lines}`, sideX, linesValueY);
|
||||
ctx.fillText(`${state.score}`, sideX, scoreValueY);
|
||||
const nextLabelY = scoreValueY + scoreFontSize + lineGap * 2;
|
||||
ctx.font = `bold ${scoreFontSize}px sans-serif`;
|
||||
ctx.fillText("Next Piece:", sideX, nextLabelY);
|
||||
|
||||
const preview = SHAPES[state.nextShape][0];
|
||||
let minX = 99;
|
||||
@@ -402,29 +583,41 @@ function drawNode(node, ctx) {
|
||||
drawBlockSized(
|
||||
ctx,
|
||||
sideX + gx * blockSize,
|
||||
sideY + scoreFontSize * 2 + 13 + gy * blockSize,
|
||||
nextLabelY + 10 + gy * blockSize,
|
||||
blockSize,
|
||||
COLORS[state.nextShape],
|
||||
palette[state.nextShape],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const infoY = sideY + scoreFontSize * 2 + 13 + PREVIEW_GRID * blockSize + PADDING;
|
||||
const previewY = nextLabelY + 10;
|
||||
ctx.strokeStyle = "rgba(150,150,150,0.8)";
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(sideX + 0.5, previewY + 0.5, PREVIEW_GRID * blockSize - 1, PREVIEW_GRID * blockSize - 1);
|
||||
|
||||
const bindings = getControlBindings(node);
|
||||
const infoLines = [
|
||||
"Controls:",
|
||||
`Move Left: ${bindings.moveLeft}`,
|
||||
`Move Right: ${bindings.moveRight}`,
|
||||
`Rotate: ${bindings.rotate}`,
|
||||
`Drop: ${bindings.drop}`,
|
||||
`Reset: ${bindings.reset}`,
|
||||
`Pause: ${bindings.pause}`,
|
||||
`Move Left - ${bindings.moveLeft.toUpperCase()}`,
|
||||
`Move Right - ${bindings.moveRight.toUpperCase()}`,
|
||||
`Rotate CW - ${bindings.rotateCw.toUpperCase()}`,
|
||||
`Rotate CCW - ${bindings.rotateCcw.toUpperCase()}`,
|
||||
`Drop - ${bindings.drop.toUpperCase()}`,
|
||||
`Reset - ${bindings.reset.toUpperCase()}`,
|
||||
`Pause - ${bindings.pause.toUpperCase()}`,
|
||||
];
|
||||
const fontSize = Math.max(10, Math.floor(blockSize * 0.55));
|
||||
const lineHeight = fontSize + 3;
|
||||
const infoBlockHeight = infoLines.length * lineHeight;
|
||||
const infoY = Math.max(
|
||||
previewY + PREVIEW_GRID * blockSize + PADDING * 1.4,
|
||||
boardY + boardH - infoBlockHeight
|
||||
);
|
||||
ctx.fillStyle = COLORS.Text;
|
||||
ctx.font = `bold ${fontSize}px sans-serif`;
|
||||
ctx.fillText(infoLines[0], sideX, infoY);
|
||||
ctx.font = `${fontSize}px sans-serif`;
|
||||
for (let i = 0; i < infoLines.length; i += 1) {
|
||||
for (let i = 1; i < infoLines.length; i += 1) {
|
||||
ctx.fillText(infoLines[i], sideX, infoY + i * lineHeight);
|
||||
}
|
||||
|
||||
@@ -450,6 +643,18 @@ function drawNode(node, ctx) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureBackgroundUpdater(node) {
|
||||
const live = node.__tetrisLive;
|
||||
if (!live || live.bgTimer) return;
|
||||
live.bgTimer = setInterval(() => {
|
||||
const bg = getBackgroundSource(node);
|
||||
if (bg && live.bgSource !== bg) {
|
||||
live.bgSource = bg;
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
}, 250);
|
||||
}
|
||||
|
||||
function syncSeed(state, node) {
|
||||
const nextSeed = getSeedValue(node, { allowRandomize: false });
|
||||
if (Number.isInteger(nextSeed)) {
|
||||
@@ -516,6 +721,10 @@ function stopTimer(node) {
|
||||
clearInterval(live.state.timer);
|
||||
live.state.timer = null;
|
||||
}
|
||||
if (live?.bgTimer) {
|
||||
clearInterval(live.bgTimer);
|
||||
live.bgTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function getSelectedLiveNode(allowFallback = false) {
|
||||
@@ -558,9 +767,12 @@ function handleKey(event) {
|
||||
case bindings.moveRight:
|
||||
move(state, 1, 0);
|
||||
break;
|
||||
case bindings.rotate:
|
||||
case bindings.rotateCw:
|
||||
rotate(state, 1);
|
||||
break;
|
||||
case bindings.rotateCcw:
|
||||
rotate(state, -1);
|
||||
break;
|
||||
case bindings.drop:
|
||||
hardDrop(state);
|
||||
break;
|
||||
@@ -604,6 +816,31 @@ function applyWidgetHiding(node) {
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOptionsDivider(node) {
|
||||
if (!node?.widgets) return;
|
||||
if (node.__tetrisDividerAdded) return;
|
||||
const pauseIndex = node.widgets.findIndex((w) => w.name === "pause");
|
||||
if (pauseIndex < 0) return;
|
||||
const divider = {
|
||||
type: "tetrinode_divider",
|
||||
name: "divider",
|
||||
draw(ctx, _, width, y, height) {
|
||||
ctx.strokeStyle = "rgba(235,235,235,0.25)";
|
||||
ctx.beginPath();
|
||||
const lineY = y + 4;
|
||||
ctx.moveTo(10, lineY);
|
||||
ctx.lineTo(width - 10, lineY);
|
||||
ctx.stroke();
|
||||
},
|
||||
computeSize(width) {
|
||||
return [width, 12];
|
||||
},
|
||||
};
|
||||
node.widgets.splice(pauseIndex + 1, 0, divider);
|
||||
node.__tetrisDividerAdded = true;
|
||||
node.setDirtyCanvas(true, true);
|
||||
}
|
||||
|
||||
function isValidLinkId(link) {
|
||||
return Number.isInteger(link) && link >= 0;
|
||||
}
|
||||
@@ -728,7 +965,8 @@ function getControlBindings(node) {
|
||||
const defaultBindings = {
|
||||
moveLeft: "a",
|
||||
moveRight: "d",
|
||||
rotate: "w",
|
||||
rotateCw: "w",
|
||||
rotateCcw: "q",
|
||||
drop: "s",
|
||||
reset: "r",
|
||||
pause: "p",
|
||||
@@ -755,13 +993,102 @@ function getControlBindings(node) {
|
||||
return {
|
||||
moveLeft: lookup("move_left", defaultBindings.moveLeft),
|
||||
moveRight: lookup("move_right", defaultBindings.moveRight),
|
||||
rotate: lookup("rotate", defaultBindings.rotate),
|
||||
rotateCw: lookup("rotate_cw", defaultBindings.rotateCw),
|
||||
rotateCcw: lookup("rotate_ccw", defaultBindings.rotateCcw),
|
||||
drop: lookup("drop", defaultBindings.drop),
|
||||
reset: lookup("reset", defaultBindings.reset),
|
||||
pause: lookup("pause", defaultBindings.pause),
|
||||
};
|
||||
}
|
||||
|
||||
function parseHexColor(value) {
|
||||
if (typeof value !== "string") return null;
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
const hex = trimmed.startsWith("#") ? trimmed.slice(1) : trimmed;
|
||||
if (!/^[0-9a-fA-F]{6}$/.test(hex)) return null;
|
||||
const r = Number.parseInt(hex.slice(0, 2), 16);
|
||||
const g = Number.parseInt(hex.slice(2, 4), 16);
|
||||
const b = Number.parseInt(hex.slice(4, 6), 16);
|
||||
return `rgb(${r},${g},${b})`;
|
||||
}
|
||||
|
||||
function coerceStringValue(value) {
|
||||
if (value == null) return null;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
||||
if (typeof value === "object" && "value" in value) {
|
||||
const inner = value.value;
|
||||
if (typeof inner === "string") return inner;
|
||||
if (typeof inner === "number" && Number.isFinite(inner)) return String(inner);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getLinkedStringValue(node, name) {
|
||||
const resolved = getInputLink(node, name);
|
||||
if (!resolved) return null;
|
||||
const { link, origin } = resolved;
|
||||
const output = origin.outputs?.[link.origin_slot];
|
||||
if (output && output.value !== undefined) {
|
||||
return coerceStringValue(output.value);
|
||||
}
|
||||
const outputName = output?.name;
|
||||
if (outputName && origin.widgets) {
|
||||
const widgetIndex = origin.widgets.findIndex((w) => w.name === outputName);
|
||||
if (widgetIndex >= 0) {
|
||||
const widgetValue = origin.widgets[widgetIndex]?.value;
|
||||
const coerced = coerceStringValue(widgetValue);
|
||||
if (coerced) return coerced;
|
||||
if (origin.widgets_values && origin.widgets_values.length > widgetIndex) {
|
||||
const stored = coerceStringValue(origin.widgets_values[widgetIndex]);
|
||||
if (stored) return stored;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (origin.widgets_values && origin.widgets_values.length) {
|
||||
const coerced = coerceStringValue(origin.widgets_values[0]);
|
||||
if (coerced) return coerced;
|
||||
}
|
||||
if (origin.widgets && origin.widgets.length) {
|
||||
const coerced = coerceStringValue(origin.widgets[0]?.value);
|
||||
if (coerced) return coerced;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getColorPalette(node) {
|
||||
const palette = { ...COLORS };
|
||||
const linked = getLinkedOptionsNode(node);
|
||||
if (!linked?.widgets) return palette;
|
||||
const lookup = (name) => {
|
||||
const linkedValue = getLinkedStringValue(linked, name);
|
||||
const coercedInput = coerceStringValue(linkedValue);
|
||||
if (coercedInput) {
|
||||
const parsed = parseHexColor(coercedInput);
|
||||
if (parsed) return parsed;
|
||||
}
|
||||
const widget = linked.widgets.find((w) => w.name === name);
|
||||
const widgetValue = coerceStringValue(widget?.value);
|
||||
return parseHexColor(widgetValue);
|
||||
};
|
||||
const 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 (const [key, shape] of Object.entries(mapping)) {
|
||||
const parsed = lookup(key);
|
||||
if (parsed) palette[shape] = parsed;
|
||||
}
|
||||
return palette;
|
||||
}
|
||||
|
||||
function getLinkedOptionsNode(node) {
|
||||
if (!node?.inputs) return null;
|
||||
const input = node.inputs.find((inp) => inp?.name === "tetrinode_options");
|
||||
@@ -830,6 +1157,10 @@ function applySeedAfterGenerate(node) {
|
||||
app.registerExtension({
|
||||
name: EXT_NAME,
|
||||
async nodeCreated(node) {
|
||||
if (node?.comfyClass === "TetriNodeOptions") {
|
||||
ensureOptionsDivider(node);
|
||||
return;
|
||||
}
|
||||
if (node?.comfyClass !== NODE_CLASS) return;
|
||||
applyWidgetHiding(node);
|
||||
ensureSeedControlWidget(node);
|
||||
@@ -871,6 +1202,7 @@ app.registerExtension({
|
||||
node.__tetrisLive.state.running = false;
|
||||
updateBackendState(node);
|
||||
ensureTimer(node);
|
||||
ensureBackgroundUpdater(node);
|
||||
},
|
||||
async setup() {
|
||||
window.addEventListener("keydown", handleKey, true);
|
||||
|
||||
+144
-22
@@ -118,24 +118,98 @@ def _clear_lines(board):
|
||||
return remaining, cleared
|
||||
|
||||
|
||||
def _render(board, piece, block_size):
|
||||
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 _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_colors(options_payload):
|
||||
colors = dict(COLORS)
|
||||
if not options_payload:
|
||||
return colors
|
||||
payload = options_payload
|
||||
if isinstance(options_payload, str):
|
||||
try:
|
||||
payload = json.loads(options_payload)
|
||||
except json.JSONDecodeError:
|
||||
return colors
|
||||
if not isinstance(payload, dict):
|
||||
return colors
|
||||
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 _render(board, piece, block_size, background_image=None, colors=None):
|
||||
width = BOARD_WIDTH * block_size
|
||||
height = BOARD_HEIGHT * block_size
|
||||
img = Image.new("RGB", (width, height), COLORS["X"])
|
||||
palette = colors or COLORS
|
||||
bg = _prepare_background(background_image, width, height)
|
||||
if bg is not None:
|
||||
img = bg
|
||||
else:
|
||||
img = Image.new("RGB", (width, height), palette["X"])
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
for y in range(BOARD_HEIGHT):
|
||||
for x in range(BOARD_WIDTH):
|
||||
cell = board[y][x]
|
||||
if cell:
|
||||
color = COLORS[cell]
|
||||
color = palette[cell]
|
||||
x0 = x * block_size
|
||||
y0 = y * block_size
|
||||
draw.rectangle([x0, y0, x0 + block_size - 2, y0 + block_size - 2], fill=color)
|
||||
|
||||
for x, y in _piece_cells(piece):
|
||||
if 0 <= y < BOARD_HEIGHT and 0 <= x < BOARD_WIDTH:
|
||||
color = COLORS[piece["shape"]]
|
||||
color = palette[piece["shape"]]
|
||||
x0 = x * block_size
|
||||
y0 = y * block_size
|
||||
draw.rectangle([x0, y0, x0 + block_size - 2, y0 + block_size - 2], fill=color)
|
||||
@@ -144,10 +218,11 @@ def _render(board, piece, block_size):
|
||||
return torch.from_numpy(arr)[None, ...]
|
||||
|
||||
|
||||
def _render_next_piece(shape, block_size):
|
||||
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), COLORS["X"])
|
||||
img = Image.new("RGB", (size, size), palette["X"])
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
cells = SHAPES[shape][0]
|
||||
@@ -166,7 +241,7 @@ def _render_next_piece(shape, block_size):
|
||||
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=COLORS[shape])
|
||||
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, ...]
|
||||
@@ -290,6 +365,7 @@ class TetriNode:
|
||||
},
|
||||
"optional": {
|
||||
"tetrinode_options": ("TETRINODE_OPTIONS",),
|
||||
"background_image": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -298,7 +374,8 @@ class TetriNode:
|
||||
FUNCTION = "step"
|
||||
CATEGORY = "games"
|
||||
|
||||
def step(self, action, state, seed, block_size, tetrinode_options=""):
|
||||
def step(self, action, state, seed, block_size, tetrinode_options="", background_image=None):
|
||||
palette = _resolve_colors(tetrinode_options)
|
||||
if action == "new":
|
||||
state_obj = _default_state(seed)
|
||||
else:
|
||||
@@ -307,14 +384,26 @@ class TetriNode:
|
||||
|
||||
if action == "sync":
|
||||
state_obj["seed"] = seed
|
||||
image = _render(state_obj["board"], state_obj["piece"], block_size)
|
||||
preview = _render_next_piece(state_obj["next_piece_shape"], block_size)
|
||||
return (image, json.dumps(state_obj), 0, state_obj["score"], preview)
|
||||
image = _render(state_obj["board"], state_obj["piece"], block_size, background_image, palette)
|
||||
preview = _render_next_piece(state_obj["next_piece_shape"], block_size, palette)
|
||||
return (
|
||||
image,
|
||||
json.dumps(state_obj),
|
||||
state_obj["lines_cleared_total"],
|
||||
state_obj["score"],
|
||||
preview,
|
||||
)
|
||||
|
||||
if state_obj.get("game_over"):
|
||||
image = _render(state_obj["board"], state_obj["piece"], block_size)
|
||||
preview = _render_next_piece(state_obj["next_piece_shape"], block_size)
|
||||
return (image, json.dumps(state_obj), 0, state_obj["score"], preview)
|
||||
image = _render(state_obj["board"], state_obj["piece"], block_size, background_image, palette)
|
||||
preview = _render_next_piece(state_obj["next_piece_shape"], block_size, palette)
|
||||
return (
|
||||
image,
|
||||
json.dumps(state_obj),
|
||||
state_obj["lines_cleared_total"],
|
||||
state_obj["score"],
|
||||
preview,
|
||||
)
|
||||
|
||||
board = state_obj["board"]
|
||||
piece = state_obj["piece"]
|
||||
@@ -355,7 +444,6 @@ class TetriNode:
|
||||
_lock_piece(board, piece)
|
||||
board, cleared = _clear_lines(board)
|
||||
lines_cleared = cleared
|
||||
state_obj["score"] += cleared
|
||||
state_obj["lines_cleared_total"] += cleared
|
||||
piece = _spawn_piece(next_shape)
|
||||
next_shape = _pop_shape(state_obj)
|
||||
@@ -367,7 +455,6 @@ class TetriNode:
|
||||
_lock_piece(board, piece)
|
||||
board, cleared = _clear_lines(board)
|
||||
lines_cleared = cleared
|
||||
state_obj["score"] += cleared
|
||||
state_obj["lines_cleared_total"] += cleared
|
||||
piece = _spawn_piece(next_shape)
|
||||
next_shape = _pop_shape(state_obj)
|
||||
@@ -378,9 +465,9 @@ class TetriNode:
|
||||
state_obj["piece"] = piece
|
||||
state_obj["next_piece_shape"] = next_shape
|
||||
|
||||
image = _render(board, piece, block_size)
|
||||
preview = _render_next_piece(next_shape, block_size)
|
||||
return (image, json.dumps(state_obj), lines_cleared, state_obj["score"], preview)
|
||||
image = _render(board, piece, block_size, background_image, palette)
|
||||
preview = _render_next_piece(next_shape, block_size, palette)
|
||||
return (image, json.dumps(state_obj), state_obj["lines_cleared_total"], state_obj["score"], preview)
|
||||
|
||||
|
||||
class TetriNodeOptions:
|
||||
@@ -390,10 +477,19 @@ class TetriNodeOptions:
|
||||
"required": {
|
||||
"move_left": ("STRING", {"default": "A"}),
|
||||
"move_right": ("STRING", {"default": "D"}),
|
||||
"rotate": ("STRING", {"default": "W"}),
|
||||
"rotate_cw": ("STRING", {"default": "W"}),
|
||||
"rotate_ccw": ("STRING", {"default": "Q"}),
|
||||
"drop": ("STRING", {"default": "S"}),
|
||||
"reset": ("STRING", {"default": "R"}),
|
||||
"pause": ("STRING", {"default": "P"}),
|
||||
"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"}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -402,13 +498,39 @@ class TetriNodeOptions:
|
||||
FUNCTION = "build"
|
||||
CATEGORY = "games"
|
||||
|
||||
def build(self, move_left, move_right, rotate, drop, reset, pause):
|
||||
def build(
|
||||
self,
|
||||
move_left,
|
||||
move_right,
|
||||
rotate_cw,
|
||||
rotate_ccw,
|
||||
drop,
|
||||
reset,
|
||||
pause,
|
||||
color_i,
|
||||
color_j,
|
||||
color_l,
|
||||
color_o,
|
||||
color_s,
|
||||
color_t,
|
||||
color_z,
|
||||
background_color,
|
||||
):
|
||||
payload = {
|
||||
"move_left": move_left,
|
||||
"move_right": move_right,
|
||||
"rotate": rotate,
|
||||
"rotate_cw": rotate_cw,
|
||||
"rotate_ccw": rotate_ccw,
|
||||
"drop": drop,
|
||||
"reset": reset,
|
||||
"pause": pause,
|
||||
"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,
|
||||
}
|
||||
return (json.dumps(payload),)
|
||||
|
||||
Reference in New Issue
Block a user