From 4738e5f2d45065c8348a5b310f846b01eb2b6b6d Mon Sep 17 00:00:00 2001 From: Christoph Urlacher Date: Sat, 30 Aug 2025 14:57:47 +0200 Subject: [PATCH] Implement Sliding-Blocks and Restricted-Sliding-Blocks --- index.html | 16 +- src/constants.js | 17 ++ src/graph.js | 85 ++---- src/main.js | 198 +++++++++++++- .../initial_states/cross_three.js | 12 + .../initial_states/cross_two.js | 11 + .../initial_states/five.js | 14 + .../initial_states/four.js | 13 + .../initial_states/head_on.js | 11 + .../initial_states/single.js | 8 + .../initial_states/three.js | 12 + .../initial_states/two.js | 11 + .../restricted_sliding_blocks/model.js | 228 ++++++++++++++++ .../restricted_sliding_blocks/state.js | 251 ++++++++++++++++++ .../sliding_blocks/initial_states/four.js | 11 + .../sliding_blocks/initial_states/klotski.js | 20 ++ .../sliding_blocks/initial_states/single.js | 6 + .../initial_states/small_klotski_like.js | 15 ++ .../sliding_blocks/initial_states/three.js | 10 + .../sliding_blocks/initial_states/two.js | 9 + src/simulator/sliding_blocks/model.js | 202 ++++++++++++++ src/simulator/sliding_blocks/state.js | 207 +++++++++++++++ src/viewport.js | 12 + 23 files changed, 1304 insertions(+), 75 deletions(-) create mode 100644 src/constants.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/cross_three.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/cross_two.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/five.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/four.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/head_on.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/single.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/three.js create mode 100644 src/simulator/restricted_sliding_blocks/initial_states/two.js create mode 100644 src/simulator/restricted_sliding_blocks/model.js create mode 100644 src/simulator/restricted_sliding_blocks/state.js create mode 100644 src/simulator/sliding_blocks/initial_states/four.js create mode 100644 src/simulator/sliding_blocks/initial_states/klotski.js create mode 100644 src/simulator/sliding_blocks/initial_states/single.js create mode 100644 src/simulator/sliding_blocks/initial_states/small_klotski_like.js create mode 100644 src/simulator/sliding_blocks/initial_states/three.js create mode 100644 src/simulator/sliding_blocks/initial_states/two.js create mode 100644 src/simulator/sliding_blocks/model.js create mode 100644 src/simulator/sliding_blocks/state.js create mode 100644 src/viewport.js 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]; +};