Implement Sliding-Blocks and Restricted-Sliding-Blocks

This commit is contained in:
2025-08-30 14:57:47 +02:00
parent 0767a8be17
commit 4738e5f2d4
23 changed files with 1304 additions and 75 deletions

View File

@ -1,9 +1,5 @@
<!doctype html>
<html>
<!-- TODO: We want a toolbar at the top, the model visualization on the left -->
<!-- and the graph on the right. -->
<!-- The toolbar should have a model selector, an initial state editor, -->
<!-- an initial state selector (from presets) and a generate button. -->
<body style="margin: 5px">
<div
id="toolbar"
@ -18,8 +14,14 @@
gap: 5px;
"
>
<button id="select_model_button">Select Model</button>
<button id="select_model_button">Switch Model</button>
<button disabled id="model_name"></button>
<button id="select_state_button">Select State</button>
<button disabled id="state_name"></button>
<button id="up_button">UP</button>
<button id="down_button">DOWN</button>
<button id="left_button">LEFT</button>
<button id="right_button">RIGHT</button>
<button id="generate_graph_button">Generate Graph</button>
<button id="reset_view_button">Reset View</button>
<button id="clear_graph_button">Clear Graph</button>
@ -39,7 +41,9 @@
border-color: black;
border-width: 1px;
"
></div>
>
<canvas id="model_canvas" style="width: 100%; height: 100%"></canvas>
</div>
<div
id="graph"
style="

17
src/constants.js Normal file
View File

@ -0,0 +1,17 @@
export const RIGHT = 0;
export const DOWN = 1;
export const LEFT = 2;
export const UP = 3;
export const DIRECTIONS = [
[1, 0], // Right
[0, 1], // Down
[-1, 0], // Left
[0, -1], // Up
];
export const HORIZONTAL = 0;
export const VERTICAL = 1;
export const invert_direction = (direction) => {
return (DIRECTIONS.indexOf(direction) + 2) % DIRECTIONS.length;
};

View File

@ -1,44 +1,8 @@
import ForceGraph3D from "3d-force-graph";
import { get_viewport_dims } from "./viewport.js";
import { states_are_equal } from "./simulator/sliding_blocks/state.js";
export const generate_sample_data = () => {
// Example data:
// {
// "nodes": [
// {
// "id": "id1",
// "name": "name1",
// "val": 1
// },
// {
// "id": "id2",
// "name": "name2",
// "val": 10
// },
// ...
// ],
// "links": [
// {
// "source": "id1",
// "target": "id2"
// },
// ...
// ]
// }
let data = {
nodes: [...Array(50).keys()].map((i) => ({ id: i })),
links: [...Array(50).keys()]
.filter((id) => id)
.map((id) => ({
source: id,
target: Math.round(Math.random() * (id - 1)),
})),
};
return data;
};
export const generate_graph = (data) => {
// TODO: Highlight the current state by coloring the node
export const generate_graph = (data, node_click_handler) => {
let graph = ForceGraph3D()(document.getElementById("graph"))
// Input the data into the graph
.graphData(data)
@ -47,38 +11,35 @@ export const generate_graph = (data) => {
.backgroundColor("#FFFFFF")
.nodeColor(["#555555"])
.linkColor(["#000000"])
.nodeRelSize([15])
.nodeResolution([1])
.linkResolution([1])
// Set up the interactions
.onNodeHover(
(node) => (document.body.style.cursor = node ? "pointer" : null),
)
.onNodeClick((node) => {
// TODO: Visualize the clicked state in the GameView
const distance = 40;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
graph.cameraPosition(
{ x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio },
{ x: node.x, y: node.y, z: node.z },
1000,
);
node_click_handler(node, graph);
});
graph.d3Force("link").distance(35);
// graph.warmupTicks([100]);
// graph.cooldownTicks([0]);
reset_graph_view(graph);
return graph;
};
const get_viewport_dims = () => {
let vw = Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0,
export const node_click_zoom = (node, graph) => {
const distance = 40;
const distRatio = 1 + distance / Math.hypot(node.x, node.y, node.z);
graph.cameraPosition(
{ x: node.x * distRatio, y: node.y * distRatio, z: node.z * distRatio },
{ x: node.x, y: node.y, z: node.z },
1000,
);
let vh = Math.max(
document.documentElement.clientHeight || 0,
window.innerHeight || 0,
);
return [vw, vh];
};
export const reset_graph_view = (graph) => {
@ -92,3 +53,13 @@ export const reset_graph_view = (graph) => {
.height(vh - 43)
.zoomToFit();
};
export const highlight_node = (states, graph, current_state) => {
graph.nodeColor((node) => {
if (states_are_equal(states[node.id], current_state)) {
return "#FF0000";
}
return "#555555";
});
};

View File

@ -1,28 +1,202 @@
import "./graph.js";
import {
generate_graph,
reset_graph_view,
generate_sample_data,
} from "./graph.js";
import { generate_graph, reset_graph_view, highlight_node } from "./graph.js";
import { DIRECTIONS, DOWN, LEFT, RIGHT, UP } from "./constants.js";
import { restricted_sliding_blocks_model } from "./simulator/restricted_sliding_blocks/model.js";
import { sliding_blocks_model } from "./simulator/sliding_blocks/model.js";
import { get_viewport_dims } from "./viewport.js";
//
// Application state
//
let current_model = 0;
let models = [restricted_sliding_blocks_model, sliding_blocks_model];
let model = models[current_model];
let current_initial_state = 0;
let initial_state = model.initial_states[current_initial_state];
let current_state = initial_state;
let selected_element = null;
let states = null;
let data = null;
let graph = null;
const clear = () => {
//
// Helpers
//
const clear_graph = () => {
document.getElementById("graph").innerHTML = "";
clear_visualization();
model.visualize(initial_state);
states = [initial_state];
current_state = initial_state;
data = {
nodes: [{ id: 0 }],
links: [],
};
graph = null;
data = null;
graph = generate_graph(data, node_click_view_state);
highlight_node(states, graph, current_state);
};
const clear_visualization = () => {
const canvas = document.getElementById("model_canvas");
const context = canvas.getContext("2d");
const [vw, vh] = get_viewport_dims();
canvas.width = vw / 2 - 9.5;
canvas.height = vh - 43;
context.clearRect(0, 0, canvas.width, canvas.height);
};
//
// Set up the page when loaded
//
window.onload = () => {
document.getElementById("model_name").innerHTML = model.name;
document.getElementById("state_name").innerHTML = initial_state.name;
clear_visualization();
model.visualize(initial_state);
states = [initial_state];
data = {
nodes: [{ id: 0 }],
links: [],
};
graph = generate_graph(data, node_click_view_state);
highlight_node(states, graph, current_state);
};
//
// Node click handlers
//
const node_click_view_state = (node, graph) => {
clear_visualization();
current_state = states[node.id];
model.visualize(current_state);
highlight_node(states, graph, current_state);
};
//
// Set up model event-handlers
//
document.getElementById("model_canvas").addEventListener("click", (event) => {
const element = model.select(current_state, event.offsetX, event.offsetY);
if (element !== null) {
clear_visualization();
selected_element = element;
model.visualize(current_state);
model.highlight(current_state, selected_element);
}
});
const move = (direction) => {
if (selected_element === null) {
return;
}
const [state, element] = model.move(
states,
graph,
current_state,
selected_element,
DIRECTIONS[direction],
);
if (state !== null) {
current_state = state;
selected_element = element;
clear_visualization();
model.visualize(current_state);
model.highlight(current_state, selected_element);
highlight_node(states, graph, current_state);
}
};
document.getElementById("up_button").addEventListener("click", () => {
move(UP);
});
document.getElementById("down_button").addEventListener("click", () => {
move(DOWN);
});
document.getElementById("left_button").addEventListener("click", () => {
move(LEFT);
});
document.getElementById("right_button").addEventListener("click", () => {
move(RIGHT);
});
document.addEventListener("keyup", (event) => {
if (event.code === "ArrowUp" || event.code === "KeyW") {
move(UP);
} else if (event.code === "ArrowDown" || event.code === "KeyS") {
move(DOWN);
} else if (event.code === "ArrowLeft" || event.code === "KeyA") {
move(LEFT);
} else if (event.code === "ArrowRight" || event.code === "KeyD") {
move(RIGHT);
}
});
//
// Set up button event-handlers
//
document.getElementById("select_model_button").addEventListener("click", () => {
clear_graph();
clear_visualization();
current_model = (current_model + 1) % models.length;
model = models[current_model];
current_initial_state = 0;
initial_state = model.initial_states[current_initial_state];
selected_element = null;
current_state = initial_state;
states = [initial_state];
graph = null;
graph = generate_graph(data, node_click_view_state);
model.visualize(initial_state);
document.getElementById("model_name").innerHTML = model.name;
document.getElementById("state_name").innerHTML = initial_state.name;
});
document.getElementById("select_state_button").addEventListener("click", () => {
clear_graph();
clear_visualization();
current_initial_state =
(current_initial_state + 1) % model.initial_states.length;
initial_state = model.initial_states[current_initial_state];
current_state = initial_state;
selected_element = null;
states = [initial_state];
graph = null;
graph = generate_graph(data, node_click_view_state);
model.visualize(initial_state);
document.getElementById("state_name").innerHTML = initial_state.name;
});
document
.getElementById("generate_graph_button")
.addEventListener("click", () => {
clear();
data = generate_sample_data();
graph = generate_graph(data);
clear_graph();
model.generate(states, initial_state, graph, null);
reset_graph_view(graph);
});
document.getElementById("reset_view_button").addEventListener("click", () => {
reset_graph_view(graph);
});
document.getElementById("clear_graph_button").addEventListener("click", clear);
document
.getElementById("clear_graph_button")
.addEventListener("click", clear_graph);

View File

@ -0,0 +1,12 @@
import { VERTICAL, HORIZONTAL } from "../../../constants.js";
export const cross_three = {
name: "Cross Three",
width: 7,
height: 7,
board: [
[0, HORIZONTAL, 0, 2, 1, 2],
[1, VERTICAL, 4, 0, 4, 1],
[2, HORIZONTAL, 5, 4, 6, 4],
],
};

View File

@ -0,0 +1,11 @@
import { VERTICAL, HORIZONTAL } from "../../../constants.js";
export const cross_two = {
name: "Cross Two",
width: 7,
height: 7,
board: [
[0, HORIZONTAL, 0, 3, 1, 3],
[1, VERTICAL, 3, 0, 3, 1],
],
};

View File

@ -0,0 +1,14 @@
import { VERTICAL } from "../../../constants.js";
export const five = {
name: "Five",
width: 5,
height: 5,
board: [
[0, VERTICAL, 0, 0, 0, 1],
[1, VERTICAL, 1, 0, 1, 1],
[2, VERTICAL, 2, 0, 2, 1],
[3, VERTICAL, 3, 0, 3, 1],
[4, VERTICAL, 4, 0, 4, 1],
],
};

View File

@ -0,0 +1,13 @@
import { VERTICAL } from "../../../constants.js";
export const four = {
name: "Four",
width: 5,
height: 5,
board: [
[0, VERTICAL, 0, 0, 0, 1],
[1, VERTICAL, 1, 0, 1, 1],
[2, VERTICAL, 2, 0, 2, 1],
[3, VERTICAL, 3, 0, 3, 1],
],
};

View File

@ -0,0 +1,11 @@
import { VERTICAL, HORIZONTAL } from "../../../constants.js";
export const head_on = {
name: "Head On",
width: 7,
height: 7,
board: [
[0, VERTICAL, 3, 0, 3, 1],
[1, VERTICAL, 3, 5, 3, 6],
],
};

View File

@ -0,0 +1,8 @@
import { VERTICAL } from "../../../constants.js";
export const single = {
name: "Single",
width: 5,
height: 5,
board: [[0, VERTICAL, 0, 0, 0, 1]],
};

View File

@ -0,0 +1,12 @@
import { VERTICAL } from "../../../constants.js";
export const three = {
name: "Three",
width: 5,
height: 5,
board: [
[0, VERTICAL, 0, 0, 0, 1],
[1, VERTICAL, 1, 0, 1, 1],
[2, VERTICAL, 2, 0, 2, 1],
],
};

View File

@ -0,0 +1,11 @@
import { VERTICAL } from "../../../constants.js";
export const two = {
name: "Two",
width: 5,
height: 5,
board: [
[0, VERTICAL, 0, 0, 0, 1],
[1, VERTICAL, 1, 0, 1, 1],
],
};

View File

@ -0,0 +1,228 @@
import { single } from "./initial_states/single.js";
import { two } from "./initial_states/two.js";
import { three } from "./initial_states/three.js";
import { four } from "./initial_states/four.js";
import { five } from "./initial_states/five.js";
import { head_on } from "./initial_states/head_on.js";
import { cross_two } from "./initial_states/cross_two.js";
import { cross_three } from "./initial_states/cross_three.js";
import {
get_moves,
block_is_movable,
index_of_state,
remove_block,
move_block,
insert_block,
move_state_block,
arrays_are_equal,
} from "./state.js";
import { DIRECTIONS, LEFT, RIGHT, UP, DOWN } from "../../constants.js";
import { HORIZONTAL, VERTICAL } from "../../constants.js";
const initial_states = [
single,
two,
three,
four,
// five,
head_on,
cross_two,
cross_three,
];
const generate = (states, initial_state, graph, previous_move) => {
const stack = [[initial_state, previous_move]];
let last_print = 0;
while (stack.length > 0) {
const [current_state, prev_move] = stack.pop();
const moves = get_moves(prev_move, current_state);
for (const m of moves) {
const [block, direction] = m;
const states_before = states.length;
const [new_state, new_block] = move(
states,
graph,
current_state,
block,
direction,
);
const states_after = states.length;
if (states_after - last_print > 250) {
console.log(`Generating: Found ${states_after} states...`);
last_print = states_after;
}
if (states_after > states_before) {
stack.push([new_state, m]);
}
}
}
};
const select = (state, offsetX, offsetY) => {
const canvas = document.getElementById("model_canvas");
const square_width = canvas.width / state.width;
const square_height = canvas.height / state.height;
const x = Math.floor(offsetX / square_width);
const y = Math.floor(offsetY / square_height);
for (let i = 0; i < state.board.length; i++) {
const [id, dir, x0, y0, x1, y1] = state.board[i];
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) {
return [id, dir, x0, y0, x1, y1];
}
}
return null;
};
const move = (states, graph, state, block, direction) => {
if (
block[1] === HORIZONTAL &&
(arrays_are_equal(direction, DIRECTIONS[UP]) ||
arrays_are_equal(direction, DIRECTIONS[DOWN]))
) {
console.log("Can't move block horizontally");
return [null, null];
}
if (
block[1] === VERTICAL &&
(arrays_are_equal(direction, DIRECTIONS[LEFT]) ||
arrays_are_equal(direction, DIRECTIONS[RIGHT]))
) {
console.log("Can't move block vertically");
return [null, null];
}
if (!block_is_movable(state, block, direction)) {
return [null, null];
}
// let new_state = remove_block(state, block);
// let new_block = move_block(block, direction);
// insert_block(new_state, block);
let new_state = structuredClone(state);
let new_block = move_block(block, direction);
move_state_block(new_state, block, direction);
// TODO: Make states into a hashmap?
let index = index_of_state(states, new_state);
let new_link = null;
let new_node = null;
if (index !== null) {
// We already had this state, just generate a link
new_link = {
source: index_of_state(states, state), // We're coming from this state...
target: index, // ...and ended up here, at a previous state.
};
} else {
states.push(new_state);
new_node = {
id: states.length - 1,
};
new_link = {
source: index_of_state(states, state), // We're coming from this state...
target: states.length - 1, // ...and ended up here, at a new state.
};
}
const data = graph.graphData();
// TODO: Faster without this?
const has_link = (data, link) => {
// for (let l of data.links) {
// if (l.source.id === link.source && l.target.id === link.target) {
// return true;
// }
// if (l.source.id === link.target && l.target.id === link.source) {
// return true;
// }
// }
return false;
};
if (new_node !== null) {
graph.graphData({
nodes: [...data.nodes, new_node],
links: [...data.links, new_link],
});
} else if (!has_link(data, new_link)) {
graph.graphData({
nodes: data.nodes,
links: [...data.links, new_link],
});
}
return [new_state, new_block];
};
const rect = (context, x0, y0, x1, y1, square_width, square_height, color) => {
const x = x0 * square_width;
const y = y0 * square_height;
const width = (x1 - x0 + 1) * square_width;
const height = (y1 - y0 + 1) * square_height;
context.fillStyle = color;
context.fillRect(x, y, width, height);
context.strokeStyle = "#000000";
context.lineWidth = 1;
context.strokeRect(x, y, width, height);
};
const visualize = (state) => {
const canvas = document.getElementById("model_canvas");
const context = canvas.getContext("2d");
const square_width = canvas.width / state.width;
const square_height = canvas.height / state.height;
// console.log(`Canvas: (${canvas.width}x${canvas.height})`);
// console.log(`Klotski 1x1 Size: (${square_width}x${square_height})`);
for (let i = 0; i < state.board.length; i++) {
const [id, dir, x0, y0, x1, y1] = state.board[i];
rect(context, x0, y0, x1, y1, square_width, square_height, "#555555");
}
};
const highlight = (state, block) => {
const [id, dir, x0, y0, x1, y1] = block;
const canvas = document.getElementById("model_canvas");
const context = canvas.getContext("2d");
const square_width = canvas.width / state.width;
const square_height = canvas.height / state.height;
rect(context, x0, y0, x1, y1, square_width, square_height, "#AAAAAA");
};
const visualize_path = (from_state, to_state) => {
// Find path (general graph helper function)
// For each state in path: visualize(state)
};
export const restricted_sliding_blocks_model = {
name: "Restricted Sliding Blocks",
generate,
visualize,
highlight,
visualize_path,
initial_states,
select,
move,
};

View File

@ -0,0 +1,251 @@
import {
RIGHT,
DOWN,
LEFT,
UP,
DIRECTIONS,
invert_direction,
HORIZONTAL,
VERTICAL,
} from "../../constants.js";
export const arrays_are_equal = (array, other_array) => {
if (array.length != other_array.length) {
return false;
}
for (let i = 0; i < array.length; i++) {
if (array[i] !== other_array[i]) {
return false;
}
}
return true;
};
export const state_contains_block = (state, block) => {
return (
state.board.length >= block[0] &&
arrays_are_equal(state.board[block[0]], block)
);
// for (let i = 0; i < state.board.length / 4; i++) {
// const other_block = state.board.slice(i * 4, (i + 1) * 4);
//
// if (arrays_are_equal(block, other_block)) {
// return true;
// }
// }
//
// return false;
};
export const states_are_equal = (state, other_state) => {
if (state.board.length != other_state.board.length) {
return false;
}
for (let i = 0; i < state.board.length; i++) {
if (!arrays_are_equal(state.board[i], other_state.board[i])) {
return false;
}
}
return true;
// for (let i = 0; i < state.board.length / 4; i++) {
// const block = state.board.slice(i * 4, (i + 1) * 4);
//
// if (!state_contains_block(other_state, block)) {
// return false;
// }
// }
//
// return true;
};
export const index_of_state = (states, state) => {
for (let i = 0; i < states.length; i++) {
if (states_are_equal(states[i], state)) {
return i;
}
}
return null;
};
export const remove_block = (state, block) => {
let new_state = structuredClone(state);
new_state.board.splice(block[0], 1);
return new_state;
// for (let i = 0; i < state.board.length / 4; i++) {
// const other_block = state.board.slice(i * 4, (i + 1) * 4);
//
// if (arrays_are_equal(block, other_block)) {
// new_state.board.splice(i * 4, 4);
// }
// }
//
// return new_state;
};
export const insert_block = (state, block) => {
state.board.splice(block[0], 0, block);
};
export const move_block = (block, direction) => {
const [id, dir, x0, y0, x1, y1] = block;
const [dx, dy] = direction;
if (
dir === HORIZONTAL &&
(arrays_are_equal(direction, DIRECTIONS[UP]) ||
arrays_are_equal(direction, DIRECTIONS[DOWN]))
) {
console.log("Can't move block horizontally");
return null;
}
if (
dir === VERTICAL &&
(arrays_are_equal(direction, DIRECTIONS[LEFT]) ||
arrays_are_equal(direction, DIRECTIONS[RIGHT]))
) {
console.log("Can't move block vertically");
return null;
}
return [id, dir, x0 + dx, y0 + dy, x1 + dx, y1 + dy];
};
export const move_state_block = (state, block, direction) => {
const [id, dir, x0, y0, x1, y1] = state.board[block[0]];
const [dx, dy] = direction;
if (
dir === HORIZONTAL &&
(arrays_are_equal(direction, DIRECTIONS[UP]) ||
arrays_are_equal(direction, DIRECTIONS[DOWN]))
) {
console.log("Can't move block horizontally");
return;
}
if (
dir === VERTICAL &&
(arrays_are_equal(direction, DIRECTIONS[LEFT]) ||
arrays_are_equal(direction, DIRECTIONS[RIGHT]))
) {
console.log("Can't move block vertically");
return;
}
state.board[block[0]] = [id, dir, x0 + dx, y0 + dy, x1 + dx, y1 + dy];
};
export const block_collides_with_other_block = (block, other_block) => {
const [id0, dir0, x0, y0, x1, y1] = block;
const [id1, dir1, x2, y2, x3, y3] = other_block;
// Don't check for self-collisions
if (id0 === id1) {
return false;
}
// Creates a set containing all x or y coordinates occupied by the block
const span = (a0, a1) => {
const start = Math.min(a0, a1);
const end = Math.max(a0, a1);
let set = new Set();
for (let i = start; i <= end; i++) {
set.add(i);
}
return set;
};
// Checks if two sets intersect
const intersects = (set0, set1) => {
for (const e of set0)
if (set1.has(e)) {
return true;
}
return false;
};
const xs0 = span(x0, x1);
const ys0 = span(y0, y1);
const xs1 = span(x2, x3);
const ys1 = span(y2, y3);
// If block and other_block have a shared x and y coordinate, they intersect
return intersects(xs0, xs1) && intersects(ys0, ys1);
};
export const block_collides = (state, block) => {
for (let i = 0; i < state.board.length; i++) {
const other_block = state.board[i];
if (block_collides_with_other_block(block, other_block)) {
return true;
}
}
return false;
};
export const block_is_movable = (state, block, direction) => {
// Move the block, then check if the block would intersect or collide
const [id, dir, x0, y0, x1, y1] = move_block(block, direction);
// Check collisions with board borders
if (x0 < 0 || x1 >= state.width) {
return false;
}
if (y0 < 0 || y1 >= state.height) {
return false;
}
// Check collisions with other blocks.
if (block_collides(state, [id, dir, x0, y0, x1, y1])) {
return false;
}
return true;
};
export const get_moves = (last_move, state) => {
// Example move: [block, direction]
let moves = [];
for (let i = 0; i < state.board.length; i++) {
const block = state.board[i];
let dirs;
if (block[1] === VERTICAL) {
dirs = [DIRECTIONS[UP], DIRECTIONS[DOWN]];
} else if (block[1] === HORIZONTAL) {
dirs = [DIRECTIONS[LEFT], DIRECTIONS[RIGHT]];
}
for (let direction of dirs) {
if (
last_move !== null &&
arrays_are_equal(last_move[0], block) &&
arrays_are_equal(last_move[1], invert_direction(direction))
) {
// We don't want to move the block back to where we came from...
continue;
}
if (block_is_movable(state, block, direction)) {
moves.push([block, direction]);
}
}
}
return moves;
};

View File

@ -0,0 +1,11 @@
export const four = {
name: "Four",
width: 3,
height: 3,
board: [
[0, 0, 0, 0, 0],
[1, 2, 0, 2, 0],
[2, 0, 2, 0, 2],
[3, 2, 2, 2, 2],
],
};

View File

@ -0,0 +1,20 @@
export const klotski = {
name: "Klotski",
width: 4,
height: 5,
board: [
// Center column
[0, 1, 0, 2, 1],
[1, 1, 2, 2, 2],
[2, 1, 3, 1, 3],
[3, 2, 3, 2, 3],
// Left column
[4, 0, 0, 0, 1],
[5, 0, 2, 0, 3],
[6, 0, 4, 0, 4],
// Right column
[7, 3, 0, 3, 1],
[8, 3, 2, 3, 3],
[9, 3, 4, 3, 4],
],
};

View File

@ -0,0 +1,6 @@
export const single = {
name: "Single",
width: 4,
height: 4,
board: [[0, 0, 0, 0, 0]],
};

View File

@ -0,0 +1,15 @@
export const small_klotski_like = {
name: "Small Klotski Like",
width: 4,
height: 4,
board: [
[0, 0, 0, 1, 0],
[1, 0, 1, 0, 2],
[2, 0, 3, 0, 3],
[3, 1, 1, 2, 2],
[4, 1, 3, 2, 3],
[5, 3, 0, 3, 0],
[6, 3, 1, 3, 1],
[7, 3, 2, 3, 2],
],
};

View File

@ -0,0 +1,10 @@
export const three = {
name: "Three",
width: 3,
height: 3,
board: [
[0, 0, 0, 0, 0],
[1, 2, 0, 2, 0],
[2, 0, 2, 0, 2],
],
};

View File

@ -0,0 +1,9 @@
export const two = {
name: "Two",
width: 3,
height: 3,
board: [
[0, 0, 0, 0, 0],
[1, 2, 0, 2, 0],
],
};

View File

@ -0,0 +1,202 @@
import { single } from "./initial_states/single.js";
import { two } from "./initial_states/two.js";
import { three } from "./initial_states/three.js";
import { four } from "./initial_states/four.js";
import { small_klotski_like } from "./initial_states/small_klotski_like.js";
import { klotski } from "./initial_states/klotski.js";
import {
get_moves,
block_is_movable,
index_of_state,
remove_block,
move_block,
insert_block,
move_state_block,
} from "./state.js";
const initial_states = [single, two, three, four, small_klotski_like, klotski];
const generate = (states, initial_state, graph, previous_move) => {
const stack = [[initial_state, previous_move]];
let last_print = 0;
while (stack.length > 0) {
const [current_state, prev_move] = stack.pop();
const moves = get_moves(prev_move, current_state);
for (const m of moves) {
const [block, direction] = m;
const states_before = states.length;
const [new_state, new_block] = move(
states,
graph,
current_state,
block,
direction,
);
const states_after = states.length;
if (states_after - last_print > 250) {
console.log(`Generating: Found ${states_after} states...`);
last_print = states_after;
}
if (states_after > states_before) {
stack.push([new_state, m]);
}
}
}
};
const select = (state, offsetX, offsetY) => {
const canvas = document.getElementById("model_canvas");
const square_width = canvas.width / state.width;
const square_height = canvas.height / state.height;
const x = Math.floor(offsetX / square_width);
const y = Math.floor(offsetY / square_height);
for (let i = 0; i < state.board.length; i++) {
const [id, x0, y0, x1, y1] = state.board[i];
if (x >= x0 && x <= x1 && y >= y0 && y <= y1) {
return [id, x0, y0, x1, y1];
}
}
return null;
};
const move = (states, graph, state, block, direction) => {
if (!block_is_movable(state, block, direction)) {
return [null, null];
}
// let new_state = remove_block(state, block);
// let new_block = move_block(block, direction);
// insert_block(new_state, block);
let new_state;
try {
new_state = structuredClone(state);
} catch (e) {
console.log(e);
return [null, null];
}
let new_block = move_block(block, direction);
move_state_block(new_state, block, direction);
// TODO: Make states into a hashmap?
let index = index_of_state(states, new_state);
let new_link = null;
let new_node = null;
if (index !== null) {
// We already had this state, just generate a link
new_link = {
source: index_of_state(states, state), // We're coming from this state...
target: index, // ...and ended up here, at a previous state.
};
} else {
states.push(new_state);
new_node = {
id: states.length - 1,
};
new_link = {
source: index_of_state(states, state), // We're coming from this state...
target: states.length - 1, // ...and ended up here, at a new state.
};
}
const data = graph.graphData();
// TODO: Faster without this?
const has_link = (data, link) => {
// for (let l of data.links) {
// if (l.source.id === link.source && l.target.id === link.target) {
// return true;
// }
// if (l.source.id === link.target && l.target.id === link.source) {
// return true;
// }
// }
return false;
};
if (new_node !== null) {
graph.graphData({
nodes: [...data.nodes, new_node],
links: [...data.links, new_link],
});
} else if (!has_link(data, new_link)) {
graph.graphData({
nodes: data.nodes,
links: [...data.links, new_link],
});
}
return [new_state, new_block];
};
const rect = (context, x0, y0, x1, y1, square_width, square_height, color) => {
const x = x0 * square_width;
const y = y0 * square_height;
const width = (x1 - x0 + 1) * square_width;
const height = (y1 - y0 + 1) * square_height;
context.fillStyle = color;
context.fillRect(x, y, width, height);
context.strokeStyle = "#000000";
context.lineWidth = 1;
context.strokeRect(x, y, width, height);
};
const visualize = (state) => {
const canvas = document.getElementById("model_canvas");
const context = canvas.getContext("2d");
const square_width = canvas.width / state.width;
const square_height = canvas.height / state.height;
// console.log(`Canvas: (${canvas.width}x${canvas.height})`);
// console.log(`Klotski 1x1 Size: (${square_width}x${square_height})`);
for (let i = 0; i < state.board.length; i++) {
const [id, x0, y0, x1, y1] = state.board[i];
rect(context, x0, y0, x1, y1, square_width, square_height, "#555555");
}
};
const highlight = (state, block) => {
const [id, x0, y0, x1, y1] = block;
const canvas = document.getElementById("model_canvas");
const context = canvas.getContext("2d");
const square_width = canvas.width / state.width;
const square_height = canvas.height / state.height;
rect(context, x0, y0, x1, y1, square_width, square_height, "#AAAAAA");
};
const visualize_path = (from_state, to_state) => {
// Find path (general graph helper function)
// For each state in path: visualize(state)
};
export const sliding_blocks_model = {
name: "Sliding Blocks",
generate,
visualize,
highlight,
visualize_path,
initial_states,
select,
move,
};

View File

@ -0,0 +1,207 @@
import {
RIGHT,
DOWN,
LEFT,
UP,
DIRECTIONS,
invert_direction,
} from "../../constants.js";
export const arrays_are_equal = (array, other_array) => {
if (array.length != other_array.length) {
return false;
}
for (let i = 0; i < array.length; i++) {
if (array[i] !== other_array[i]) {
return false;
}
}
return true;
};
export const state_contains_block = (state, block) => {
return (
state.board.length >= block[0] &&
arrays_are_equal(state.board[block[0]], block)
);
// for (let i = 0; i < state.board.length / 4; i++) {
// const other_block = state.board.slice(i * 4, (i + 1) * 4);
//
// if (arrays_are_equal(block, other_block)) {
// return true;
// }
// }
//
// return false;
};
export const states_are_equal = (state, other_state) => {
if (state.board.length != other_state.board.length) {
return false;
}
for (let i = 0; i < state.board.length; i++) {
if (!arrays_are_equal(state.board[i], other_state.board[i])) {
return false;
}
}
return true;
// for (let i = 0; i < state.board.length / 4; i++) {
// const block = state.board.slice(i * 4, (i + 1) * 4);
//
// if (!state_contains_block(other_state, block)) {
// return false;
// }
// }
//
// return true;
};
export const index_of_state = (states, state) => {
for (let i = 0; i < states.length; i++) {
if (states_are_equal(states[i], state)) {
return i;
}
}
return null;
};
export const remove_block = (state, block) => {
let new_state = structuredClone(state);
new_state.board.splice(block[0], 1);
return new_state;
// for (let i = 0; i < state.board.length / 4; i++) {
// const other_block = state.board.slice(i * 4, (i + 1) * 4);
//
// if (arrays_are_equal(block, other_block)) {
// new_state.board.splice(i * 4, 4);
// }
// }
//
// return new_state;
};
export const insert_block = (state, block) => {
state.board.splice(block[0], 0, block);
};
export const move_block = (block, direction) => {
const [id, x0, y0, x1, y1] = block;
const [dx, dy] = direction;
return [id, x0 + dx, y0 + dy, x1 + dx, y1 + dy];
};
export const move_state_block = (state, block, direction) => {
const [id, x0, y0, x1, y1] = state.board[block[0]];
const [dx, dy] = direction;
state.board[block[0]] = [id, x0 + dx, y0 + dy, x1 + dx, y1 + dy];
};
export const block_collides_with_other_block = (block, other_block) => {
const [id0, x0, y0, x1, y1] = block;
const [id1, x2, y2, x3, y3] = other_block;
// Don't check for self-collisions
if (id0 === id1) {
return false;
}
// Creates a set containing all x or y coordinates occupied by the block
const span = (a0, a1) => {
const start = Math.min(a0, a1);
const end = Math.max(a0, a1);
let set = new Set();
for (let i = start; i <= end; i++) {
set.add(i);
}
return set;
};
// Checks if two sets intersect
const intersects = (set0, set1) => {
for (const e of set0)
if (set1.has(e)) {
return true;
}
return false;
};
const xs0 = span(x0, x1);
const ys0 = span(y0, y1);
const xs1 = span(x2, x3);
const ys1 = span(y2, y3);
// If block and other_block have a shared x and y coordinate, they intersect
return intersects(xs0, xs1) && intersects(ys0, ys1);
};
export const block_collides = (state, block) => {
for (let i = 0; i < state.board.length; i++) {
const other_block = state.board[i];
if (block_collides_with_other_block(block, other_block)) {
return true;
}
}
return false;
};
export const block_is_movable = (state, block, direction) => {
// Move the block, then check if the block would intersect or collide
const [id, x0, y0, x1, y1] = move_block(block, direction);
// Check collisions with board borders
if (x0 < 0 || x1 >= state.width) {
return false;
}
if (y0 < 0 || y1 >= state.height) {
return false;
}
// Check collisions with other blocks.
// We remove the block being checked from the board so it doesn't self-collide,
if (block_collides(state, [id, x0, y0, x1, y1])) {
return false;
}
return true;
};
export const get_moves = (last_move, state) => {
// Example move: [block, direction]
let moves = [];
for (let i = 0; i < state.board.length; i++) {
const block = state.board[i];
for (let direction of DIRECTIONS) {
if (
last_move !== null &&
arrays_are_equal(last_move[0], block) &&
arrays_are_equal(last_move[1], invert_direction(direction))
) {
// We don't want to move the block back to where we came from...
continue;
}
if (block_is_movable(state, block, direction)) {
moves.push([block, direction]);
}
}
}
return moves;
};

12
src/viewport.js Normal file
View File

@ -0,0 +1,12 @@
export const get_viewport_dims = () => {
let vw = Math.max(
document.documentElement.clientWidth || 0,
window.innerWidth || 0,
);
let vh = Math.max(
document.documentElement.clientHeight || 0,
window.innerHeight || 0,
);
return [vw, vh];
};