diff --git a/index.html b/index.html
index e244128..d6663c2 100644
--- a/index.html
+++ b/index.html
@@ -1,9 +1,5 @@
-
-
-
-
-
+
+
+
+
+
+
+
@@ -39,7 +41,9 @@
border-color: black;
border-width: 1px;
"
- >
+ >
+
+
{
- // 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";
+ });
+};
diff --git a/src/main.js b/src/main.js
index 2a4b6dc..c77b615 100644
--- a/src/main.js
+++ b/src/main.js
@@ -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);
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/cross_three.js b/src/simulator/restricted_sliding_blocks/initial_states/cross_three.js
new file mode 100644
index 0000000..f39466e
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/cross_three.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/cross_two.js b/src/simulator/restricted_sliding_blocks/initial_states/cross_two.js
new file mode 100644
index 0000000..85f03e8
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/cross_two.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/five.js b/src/simulator/restricted_sliding_blocks/initial_states/five.js
new file mode 100644
index 0000000..f55b25b
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/five.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/four.js b/src/simulator/restricted_sliding_blocks/initial_states/four.js
new file mode 100644
index 0000000..29e7e30
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/four.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/head_on.js b/src/simulator/restricted_sliding_blocks/initial_states/head_on.js
new file mode 100644
index 0000000..0671375
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/head_on.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/single.js b/src/simulator/restricted_sliding_blocks/initial_states/single.js
new file mode 100644
index 0000000..da7ad3f
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/single.js
@@ -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]],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/three.js b/src/simulator/restricted_sliding_blocks/initial_states/three.js
new file mode 100644
index 0000000..7c91a78
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/three.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/initial_states/two.js b/src/simulator/restricted_sliding_blocks/initial_states/two.js
new file mode 100644
index 0000000..ec96511
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/initial_states/two.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/restricted_sliding_blocks/model.js b/src/simulator/restricted_sliding_blocks/model.js
new file mode 100644
index 0000000..63a4e53
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/model.js
@@ -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,
+};
diff --git a/src/simulator/restricted_sliding_blocks/state.js b/src/simulator/restricted_sliding_blocks/state.js
new file mode 100644
index 0000000..95191f8
--- /dev/null
+++ b/src/simulator/restricted_sliding_blocks/state.js
@@ -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;
+};
diff --git a/src/simulator/sliding_blocks/initial_states/four.js b/src/simulator/sliding_blocks/initial_states/four.js
new file mode 100644
index 0000000..2c18bce
--- /dev/null
+++ b/src/simulator/sliding_blocks/initial_states/four.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/sliding_blocks/initial_states/klotski.js b/src/simulator/sliding_blocks/initial_states/klotski.js
new file mode 100644
index 0000000..77a554c
--- /dev/null
+++ b/src/simulator/sliding_blocks/initial_states/klotski.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/sliding_blocks/initial_states/single.js b/src/simulator/sliding_blocks/initial_states/single.js
new file mode 100644
index 0000000..788319d
--- /dev/null
+++ b/src/simulator/sliding_blocks/initial_states/single.js
@@ -0,0 +1,6 @@
+export const single = {
+ name: "Single",
+ width: 4,
+ height: 4,
+ board: [[0, 0, 0, 0, 0]],
+};
diff --git a/src/simulator/sliding_blocks/initial_states/small_klotski_like.js b/src/simulator/sliding_blocks/initial_states/small_klotski_like.js
new file mode 100644
index 0000000..3c8da64
--- /dev/null
+++ b/src/simulator/sliding_blocks/initial_states/small_klotski_like.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/sliding_blocks/initial_states/three.js b/src/simulator/sliding_blocks/initial_states/three.js
new file mode 100644
index 0000000..7fc3e19
--- /dev/null
+++ b/src/simulator/sliding_blocks/initial_states/three.js
@@ -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],
+ ],
+};
diff --git a/src/simulator/sliding_blocks/initial_states/two.js b/src/simulator/sliding_blocks/initial_states/two.js
new file mode 100644
index 0000000..5166915
--- /dev/null
+++ b/src/simulator/sliding_blocks/initial_states/two.js
@@ -0,0 +1,9 @@
+export const two = {
+ name: "Two",
+ width: 3,
+ height: 3,
+ board: [
+ [0, 0, 0, 0, 0],
+ [1, 2, 0, 2, 0],
+ ],
+};
diff --git a/src/simulator/sliding_blocks/model.js b/src/simulator/sliding_blocks/model.js
new file mode 100644
index 0000000..069740c
--- /dev/null
+++ b/src/simulator/sliding_blocks/model.js
@@ -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,
+};
diff --git a/src/simulator/sliding_blocks/state.js b/src/simulator/sliding_blocks/state.js
new file mode 100644
index 0000000..e06518b
--- /dev/null
+++ b/src/simulator/sliding_blocks/state.js
@@ -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;
+};
diff --git a/src/viewport.js b/src/viewport.js
new file mode 100644
index 0000000..edf8be0
--- /dev/null
+++ b/src/viewport.js
@@ -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];
+};