base functionality - atlas creation, hover mechanics
This commit is contained in:
commit
8dcb131ff2
|
|
@ -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
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="project">
|
||||
<words>
|
||||
<w>ncpf</w>
|
||||
</words>
|
||||
</dictionary>
|
||||
</component>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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");
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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");
|
||||
});
|
||||
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
Loading…
Reference in New Issue