base functionality - atlas creation, hover mechanics

This commit is contained in:
CodedSakura 2026-07-07 00:14:49 +03:00
commit 8dcb131ff2
15 changed files with 570 additions and 0 deletions

10
.idea/.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Ignored default folder with query files
/queries/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml

View File

@ -0,0 +1,7 @@
<component name="ProjectDictionaryState">
<dictionary name="project">
<words>
<w>ncpf</w>
</words>
</dictionary>
</component>

7
.idea/misc.xml Normal file
View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DiscordProjectSettings">
<option name="show" value="ASK" />
<option name="description" value="" />
</component>
</project>

8
.idea/modules.xml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/nc-planner.iml" filepath="$PROJECT_DIR$/.idea/nc-planner.iml" />
</modules>
</component>
</project>

8
.idea/nc-planner.iml Normal file
View File

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

File diff suppressed because one or more lines are too long

50
index.html Normal file
View File

@ -0,0 +1,50 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>NC Planner</title>
<link rel="stylesheet" href="style.css">
<script type="module" src="scripts/main.js"></script>
</head>
<body>
<nav id="top-menu">
<button>File</button>
<button>Edit</button>
<button>Select</button>
<button>View</button>
</nav>
<nav id="left-menu" data-state="planning">
<section id="block-picker">
<label>
Filter:
<input data-input="filter">
</label>
<div class="list" id="block-list">
<template>
<div class="m1"></div>
</template>
</div>
</section>
</nav>
<main>
<div data-state="initialization">
Select configuration file:
<button data-button="loadConfiguration" data-configuration="default">Default (2026-07-06)</button>
<input type="file" data-input="configuration" accept=".ncpf.json" />
</div>
<div data-state="planning">
<div class="list" id="planner-list">
<template>
<div class="grid m2">
<template>
<div></div>
</template>
</div>
</template>
</div>
</div>
</main>
<nav id="right-menu"></nav>
<nav id="bottom-menu"></nav>
</body>
</html>

115
scripts/atlas.js Normal file
View File

@ -0,0 +1,115 @@
import { emitEvent } from "./events.js";
let atlasSize = 0; // in items
let atlasResolution = 0; // in px
export let atlasBlobURL = "about:blank";
export let atlasMetadata = [];
async function getTextureSize(data) {
const texture = data.texture;
if (!texture) {
data.size = [ 0, 0 ];
return;
}
await new Promise(resolve => {
const image = new Image();
image.addEventListener("load", () => {
data.size = [ image.width, image.height ];
data.image = image;
resolve();
});
image.src = `data:image/png;base64,${texture}`;
});
}
function putTextureOnCanvas(ctx, atlasSize, index, data) {
const atlasIndexX = index % atlasSize;
const atlasIndexY = (index - atlasIndexX) / atlasSize;
const x = atlasIndexX * data.size[0];
const y = atlasIndexY * data.size[1];
ctx.drawImage(data.image, x, y);
// free up image data
delete data.image;
}
export async function buildAtlas(data) {
emitEvent("config:process:atlas:start");
const overhaulSFRBlocks = [
...data.configuration["nuclearcraft:overhaul_sfr"]?.blocks,
...data.addons.flatMap(a => a.configuration["nuclearcraft:overhaul_sfr"]?.blocks)
]
.filter(b => b && b.modules && b.modules["plannerator:texture"])
.map(b => (
{
...b,
texture: b.modules["plannerator:texture"].texture,
}
));
if (overhaulSFRBlocks.length === 0) {
throw new Error("Configuration processing failed - no textured blocks found");
}
await Promise.all(overhaulSFRBlocks.map(getTextureSize));
// TODO: handle configurations with unique sizes
if (new Set(overhaulSFRBlocks.map(v => v.size.toString())).size > 1) {
throw new Error("Configuration processing failed - unable to handle multi-sized textures yet");
}
if (overhaulSFRBlocks[0].size[0] !== overhaulSFRBlocks[0].size[1]) {
throw new Error("Configuration processing failed - textures must be square");
}
// TODO: handle sizes other than 16px
if (overhaulSFRBlocks[0].size[0] !== 16) {
throw new Error("Configuration processing failed - textures must be 16px");
}
const atlasEdgeSizeBlocks = 2 ** Math.ceil(Math.log2(Math.sqrt(overhaulSFRBlocks.length)));
const atlasEdgeSize = atlasEdgeSizeBlocks * overhaulSFRBlocks[0].size[0];
const canvas = new OffscreenCanvas(atlasEdgeSize, atlasEdgeSize);
const ctx = canvas.getContext("2d");
await Promise.all(overhaulSFRBlocks.map((b, i) => putTextureOnCanvas(ctx, atlasEdgeSizeBlocks, i, b)));
const blob = await canvas.convertToBlob({ type: "image/png" });
atlasSize = atlasEdgeSizeBlocks;
atlasResolution = atlasEdgeSize;
atlasBlobURL = URL.createObjectURL(blob);
atlasMetadata = overhaulSFRBlocks;
console.log("Atlas blob: %s", atlasBlobURL);
emitEvent("config:process:atlas:end");
}
export function makeAtlasImage(index) {
if (index < -1) {
return null;
}
if (index === -1) {
const div = document.createElement("div");
div.classList.add("atlas-air");
return div;
}
const atlasIndexX = index % atlasSize;
const atlasIndexY = (index - atlasIndexX) / atlasSize;
const size = atlasMetadata[index].size;
const x = atlasIndexX * size[0];
const y = atlasIndexY * size[1];
const div = document.createElement("div");
div.classList.add("atlas-texture");
div.style.backgroundImage = `url(${atlasBlobURL})`;
div.style.setProperty("--atlas-resolution", `${atlasResolution}px`);
div.style.setProperty("--raw-x", `${x}px`);
div.style.setProperty("--raw-y", `${y}px`);
div.style.setProperty("--size-x", `${size[0]}px`);
div.style.setProperty("--size-y", `${size[1]}px`);
return div;
}

65
scripts/configuration.js Normal file
View File

@ -0,0 +1,65 @@
import { buildAtlas } from "./atlas.js";
import { emitEvent } from "./events.js";
import { setState, STATE } from "./state.js";
export let configuration = {};
export async function loadConfiguration(event) {
setState(STATE.initialization);
emitEvent("config:load:start");
let configData = null;
if (typeof event === "string") {
configData = await fetch(`./configurations/${event}.ncpf.json`)
.then(data => data.json());
} else if (event.target.dataset.configuration) {
const configuration = event.target.dataset.configuration;
console.log(configuration);
configData = await fetch(`./configurations/${configuration}.ncpf.json`)
.then(data => data.json());
} else if (event.target.files[0]) {
const file = event.target.files[0];
configData = await new Promise(resolve => {
const reader = new FileReader();
reader.addEventListener("load", () => {
resolve(JSON.parse(reader.result));
});
reader.readAsText(file);
});
} else {
throw new Error("Configuration load failed - unrecognised event");
}
if ((configData.designs ?? []).length > 0) {
throw new Error("Configuration load failed - contains designs, likely not full configuration");
}
emitEvent("config:load:end");
try {
await processConfiguration(configData);
} catch (e) {
emitEvent("config:fail");
throw e;
}
configuration = configData;
emitEvent("config:end");
}
export async function processConfiguration(data) {
emitEvent("config:process:start");
console.log(data);
try {
await buildAtlas(data);
} catch (e) {
console.error(e);
emitEvent("config:process:fail");
throw e;
}
emitEvent("config:process:end");
}

21
scripts/dom-list.js Normal file
View File

@ -0,0 +1,21 @@
export class DomList {
list;
template;
constructor(rootElem) {
this.list = rootElem;
this.template = this.list.querySelector("template").content.firstElementChild;
}
clear() {
this.list.replaceChildren(...this.list.querySelectorAll("template, [data-keep]"));
}
makeItem() {
return document.importNode(this.template, true);
}
addItem(item) {
this.list.appendChild(item);
}
}

21
scripts/dom-utils.js Normal file
View File

@ -0,0 +1,21 @@
export function getElements(dataType, dataValue, props = {}) {
// props: { additionalDataFilters: [key, value][], additionalPropertyFilters: [key, value][] }
const queryString = [
...[[dataType, dataValue], ...(props.additionalDataFilters ?? [])]
.map(([k, v]) => [`data-${k}`, v]),
...(props.additionalPropertyFilters ?? []),
].map(([k, v]) => v === '*' ? `[${k}]` : `[${k}='${v}']`).join("");
return document.querySelectorAll(queryString);
}
export function addEventToElement(dataType, dataValue, event, callback, props = {}) {
getElements(dataType, dataValue, props).forEach(e => e.addEventListener(event, callback));
}
export function addEventToInput(dataValue, event, callback, props = {}) {
addEventToElement("input", dataValue, event, callback, props);
}
export function addEventToButton(dataValue, event, callback, props = {}) {
addEventToElement("button", dataValue, event, callback, props);
}

17
scripts/events.js Normal file
View File

@ -0,0 +1,17 @@
export function emitEvent(name) {
window.dispatchEvent(new Event(`ncp:${name}`));
}
export function listenTo(name, callback) {
window.addEventListener(`ncp:${name}`, callback);
}
export function awaitEvent(name) {
return new Promise(resolve => {
const callback = (e) => {
resolve(e);
window.removeEventListener(`ncp:${name}`, callback);
};
window.addEventListener(`ncp:${name}`, callback);
});
}

116
scripts/main.js Normal file
View File

@ -0,0 +1,116 @@
import { atlasMetadata, makeAtlasImage } from "./atlas.js";
import { DomList } from "./dom-list.js";
import { addEventToButton, addEventToInput, getElements } from "./dom-utils.js";
import { emitEvent, listenTo } from "./events.js";
import { loadConfiguration } from "./configuration.js";
import { getState, setState, STATE } from "./state.js";
let multiblockSize = [ 7, 7, 5 ]; // width (screen x), depth (screen y), height (xy grids)
let suppressHover = false;
let activeItem = -1;
function cellHoverEvent(e) {
if (suppressHover) return;
const cells = document.querySelectorAll("#planner-list [data-x]");
cells.forEach(c => delete c.dataset.highlight);
if (e.type === "mouseenter") {
cells.forEach(c => {
if (c.dataset.y === e.target.dataset.y && c.dataset.z === e.target.dataset.z) {
c.dataset.highlight = "x";
}
if (c.dataset.x === e.target.dataset.x && c.dataset.z === e.target.dataset.z) {
c.dataset.highlight = "y";
}
if (c.dataset.x === e.target.dataset.x && c.dataset.y === e.target.dataset.y) {
c.dataset.highlight = "z";
}
if (c === e.target) {
delete c.dataset.highlight;
}
});
}
}
function listClickEvent(e) {
const items = document.querySelectorAll("#block-list [data-index]");
items.forEach(i => delete i.dataset.active);
activeItem = Number(e.currentTarget.dataset.index);
e.currentTarget.dataset.active = undefined;
}
window.addEventListener("load", () => {
emitEvent("setup-start");
window.addEventListener("keypress", e => {
console.log(e);
if (e.key === "H") {
suppressHover = !suppressHover;
}
});
getElements("state", "*").forEach(e => {
e.classList.add("hidden");
if (e.dataset.state === getState()) {
e.classList.remove("hidden");
}
});
addEventToInput("configuration", "input", loadConfiguration);
addEventToButton("loadConfiguration", "click", loadConfiguration)
listenTo("state", () => {
console.log("State changed", getState());
getElements("state", "*").forEach(e => {
e.classList.add("hidden");
if (e.dataset.state === getState()) {
e.classList.remove("hidden");
}
});
});
listenTo("config:end", () => setState(STATE.planning));
listenTo("config:end", () => {
console.log(atlasMetadata);
const grid = new DomList(document.querySelector("#block-list"));
grid.clear();
atlasMetadata.forEach((m, i) => {
const item = grid.makeItem();
item.dataset.index = i.toString(10);
item.appendChild(makeAtlasImage(i));
item.addEventListener("click", listClickEvent);
grid.addItem(item);
});
});
listenTo("config:end", () => {
const list = new DomList(document.querySelector("#planner-list"));
list.clear();
for (let z = 0; z < multiblockSize[2]; z++) {
const item = list.makeItem();
item.style.setProperty("--width", multiblockSize[1]);
const grid = new DomList(item);
grid.clear();
for (let y = 0; y < multiblockSize[1]; y++) {
for (let x = 0; x < multiblockSize[0]; x++) {
const cell = grid.makeItem();
cell.dataset.x = x.toString(10);
cell.dataset.y = y.toString(10);
cell.dataset.z = z.toString(10);
cell.appendChild(makeAtlasImage(-1));
cell.addEventListener("mouseenter", cellHoverEvent);
cell.addEventListener("mouseleave", cellHoverEvent);
grid.addItem(cell);
}
}
list.addItem(item);
}
});
emitEvent("setup-end");
void loadConfiguration("default");
});

17
scripts/state.js Normal file
View File

@ -0,0 +1,17 @@
import { emitEvent } from "./events.js";
export const STATE = {
initialization: 0,
planning: 1,
};
export let state = STATE.initialization;
export function setState(newState) {
state = newState;
emitEvent("state");
}
export function getState() {
return Object.entries(STATE).find(([,v]) => v === state)[0];
}

107
style.css Normal file
View File

@ -0,0 +1,107 @@
:root {
--scale-factor: 2;
--default-size: 16px;
}
[data-state].hidden {
display: none;
}
.list {
display: flex;
flex-wrap: wrap;
}
.grid {
display: grid;
grid-template-columns: repeat(var(--width), calc(var(--scale-factor) * var(--default-size)));
}
.m1 {
margin: 0.1em;
}
.m2 {
margin: 0.5em;
}
body {
display: grid;
grid-template-columns: var(--left-nav, 400px) 1fr var(--right-nav, 300px);
grid-template-rows: 40px 1fr var(--bottom-nav, 200px);
}
#top-menu {
grid-column: 1 / -1;
grid-row: 1;
}
#left-menu {
grid-column: 1;
grid-row: 2;
}
main {
grid-column: 2;
grid-row: 2;
}
#right-menu {
grid-column: 3;
grid-row: 2;
}
#bottom-menu {
grid-column: 1 / -1;
grid-row: 3;
}
.atlas-texture {
image-rendering: -moz-crisp-edges; /* Firefox fallback */
image-rendering: pixelated; /* Modern browsers */
background-size: calc(var(--scale-factor) * var(--atlas-resolution))
calc(var(--scale-factor) * var(--atlas-resolution));
width: calc(var(--scale-factor) * var(--size-x));
height: calc(var(--scale-factor) * var(--size-y));
background-position-x: calc(var(--scale-factor) * var(--raw-x) * -1);
background-position-y: calc(var(--scale-factor) * var(--raw-y) * -1);
}
.atlas-air {
width: calc(var(--scale-factor) * var(--size-x, var(--default-size)));
height: calc(var(--scale-factor) * var(--size-y, var(--default-size)));
outline: calc(var(--scale-factor) * 0.5px) solid var(--cell-empty-outline-color, #ccc);
}
.atlas-air:hover, .atlas-texture:hover {
outline: calc(var(--scale-factor) * 1px) solid var(--cell-hover-outline-color, #8888);
}
[data-highlight] {
position: relative;
}
[data-highlight] .atlas-texture:after, [data-highlight] .atlas-air:after {
content: '';
position: absolute;
}
[data-highlight='x'] .atlas-texture:after, [data-highlight='x'] .atlas-air:after {
inset:
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4)
0
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4)
0;
background-color: var(--higlight-color, #f808);
}
[data-highlight='y'] .atlas-texture:after, [data-highlight='y'] .atlas-air:after {
inset:
0
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4)
0
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4);
background-color: var(--higlight-color, #f808);
}
[data-highlight='z'] .atlas-texture:after, [data-highlight='z'] .atlas-air:after {
inset:
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4)
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4)
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4)
calc(var(--scale-factor) * var(--size-y, var(--default-size)) * 0.4);
background-color: var(--higlight-color, #f808);
}
#block-list [data-active] {
outline: calc(var(--scale-factor) * 1px) solid var(--block-active-outline-color, #000);
}