Compare commits

..

2 Commits

Author SHA1 Message Date
CodedSakura ea6813b415 finalize block tooltips, begin work on menubar 2026-07-08 17:20:05 +03:00
CodedSakura 53b5386465 upgrade atlas to support multi-resolution, finalize heatsink block tooltips 2026-07-08 14:23:50 +03:00
10 changed files with 354 additions and 137 deletions

View File

@ -2,6 +2,7 @@
<dictionary name="project">
<words>
<w>ncpf</w>
<w>nuclearcraft</w>
</words>
</dictionary>
</component>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -7,11 +7,28 @@
<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 id="top-menu" class="menu-bar">
<div data-section="File" data-alt="f">
<button data-alt="n" data-button="new-file">New</button>
<button data-alt="o" data-button="open-file">Open</button>
<div data-section="Recent Files" data-alt="r">
<button disabled>-</button>
</div>
</div>
<div data-section="Edit" data-alt="e">
<button data-alt="u" data-button="undo">Undo</button>
<button data-alt="r" data-button="redo" disabled>Redo</button>
</div>
<div data-section="Select" data-alt="s">
<button data-alt="i">Invert</button>
</div>
<div data-section="View" data-alt="v">
<div data-section="Test" data-alt="e">
<div data-section="Test" data-alt="e">
<button disabled>-</button>
</div>
</div>
</div>
</nav>
<nav id="left-menu" data-state="planning">
<span data-resize="left-menu"></span>

View File

@ -2,8 +2,7 @@ import { emitEvent } from "./events.js";
let atlasSize = 0; // in items
let atlasResolution = 0; // in px
export let atlasBlobURL = "about:blank";
export let atlasMetadata = [];
export let atlases = [];
async function getTextureSize(data) {
const texture = data.texture;
@ -34,54 +33,68 @@ function putTextureOnCanvas(ctx, atlasSize, index, data) {
}
export async function buildAtlas(data) {
// TODO: atlas caching
emitEvent("config:process:atlas:start");
const overhaulSFRBlocks = [
...data.configuration["nuclearcraft:overhaul_sfr"]?.blocks,
...data.addons.flatMap(a => a.configuration["nuclearcraft:overhaul_sfr"]?.blocks)
const textures = [
...Object.entries(data.configuration),
...data.addons.flatMap(a => Object.entries(a.configuration)),
]
.filter(b => b && b.modules && b.modules["plannerator:texture"])
.map(b => (
{
...b,
texture: b.modules["plannerator:texture"].texture,
}
));
.flatMap(([config, data]) => Object.entries(data)
.map(([type, entries]) => [config, type, entries]))
.flatMap(([c, t, e]) => Array.isArray(e)
? [[c, t, e]]
: t === "modules" && e["plannerator:global_elements"]
? [[c, "plannerator:global_elements", e["plannerator:global_elements"].elements]]
: [])
.flatMap(([c, t, l]) => l.map((v) => [c, t, v]))
.filter(([,, b]) => b && b.modules && b.modules["plannerator:texture"])
.map(([c, t, b]) => ({
configuration: c,
configurationList: t,
texture: b.modules["plannerator:texture"].texture,
...(Object.fromEntries(Object.entries(b).filter(([k]) => k !== "modules")))
}));
if (overhaulSFRBlocks.length === 0) {
if (textures.length === 0) {
throw new Error("Configuration processing failed - no textured blocks found");
}
await Promise.all(overhaulSFRBlocks.map(getTextureSize));
await Promise.all(textures.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");
atlases = [];
let offset = 0;
for (const t of Object.values(Object.groupBy(textures, t => t.size.toString()))) {
if (t[0].size[0] !== t[0].size[1]) {
throw new Error("Configuration processing failed - textures must be square");
}
const atlasEdgeSizeBlocks = 2 ** Math.ceil(Math.log2(Math.sqrt(t.length)));
const atlasEdgeSize = atlasEdgeSizeBlocks * t[0].size[0];
const canvas = new OffscreenCanvas(atlasEdgeSize, atlasEdgeSize);
const ctx = canvas.getContext("2d");
await Promise.all(t.map((b, i) => putTextureOnCanvas(ctx, atlasEdgeSizeBlocks, i, b)));
t.forEach((m, i) => {
m.index = offset + i;
delete m.texture;
});
const blob = await canvas.convertToBlob({ type: "image/png" });
atlases.push({
offset,
url: URL.createObjectURL(blob),
metadata: t,
size: atlasEdgeSizeBlocks,
resolution: atlasEdgeSize,
length: t.length,
});
offset += t.length;
}
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);
console.log(atlases);
emitEvent("config:process:atlas:end");
}
@ -96,21 +109,44 @@ export function makeAtlasImage(index) {
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 originalIndex = index;
for (const atlas of atlases) {
if (index < atlas.length) {
const atlasIndexX = index % atlas.size;
const atlasIndexY = (index - atlasIndexX) / atlas.size;
const size = atlas.metadata[index].size;
const x = atlasIndexX * size[0];
const y = atlasIndexY * size[1];
const div = document.createElement("div");
div.draggable = false;
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`);
const div = document.createElement("div");
div.draggable = false;
div.dataset.originalIndex = originalIndex;
div.classList.add("atlas-texture");
div.style.backgroundImage = `url(${atlas.url})`;
div.style.setProperty("--atlas-resolution", `${atlas.resolution}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;
return div;
}
index -= atlas.length;
}
return null;
}
export function getAtlasMetadata(index) {
if (index < -1) return null;
if (index === -1) return {};
for (const atlas of atlases) {
if (index < atlas.length - 1) {
return atlas.metadata[index];
}
index -= atlas.length;
}
return null;
}

View File

@ -36,6 +36,11 @@ export class Filter {
test(...data) {
for (const datum of data) {
if (typeof datum !== "string") {
console.log("cannot test %o", datum);
continue;
}
const normalized = datum.trim().toLocaleLowerCase().trim();
for (const n of this.negate) {
if (normalized.includes(n)) {

View File

@ -5,6 +5,7 @@ import { initializePlanner } from "./planner.js";
import { initializeMenuResizing } from "./resize-menus.js";
import { getState, setState, STATE } from "./state.js";
import { initializeTooltips } from "./tooltip.js";
import { initializeMenuBar } from "./menu-bar.js";
window.addEventListener("load", () => {
emitEvent("setup-start");
@ -37,8 +38,9 @@ window.addEventListener("load", () => {
initializePlanner();
initializeMenuResizing();
initializeTooltips();
initializeMenuBar();
emitEvent("setup-end");
void loadConfiguration("default");
void loadConfiguration("addons");
});

1
scripts/menu-bar.js Normal file
View File

@ -0,0 +1 @@
export function initializeMenuBar() {}

73
scripts/ncpf-utils.js Normal file
View File

@ -0,0 +1,73 @@
import { configuration } from "./configuration.js";
function findInPossible(entryList, entry) {
switch (entry.type) {
case "legacy_block":
return entryList.find(v => v.name === entry.name
&& v.metadata === entry.metadata
&& (!entry.blockstate || JSON.stringify(v.blockstate) === JSON.stringify(entry.blockstate)));
case "oredict":
return entryList.find(v => v.oredict === entry.oredict);
default:
console.warn("unknown type", entry.type);
}
return null;
}
export function resolveBlockName(block, { configurationHint }) {
if (block.type === "module") {
switch (block.name) {
case "nuclearcraft:overhaul_sfr:fuel_cell":
return "Fuel Cell";
case "nuclearcraft:overhaul_sfr:moderator":
return "Moderator";
case "nuclearcraft:overhaul_sfr:casing":
return "Casing";
case "nuclearcraft:overhaul_sfr:reflector":
return "Reflector";
case "nuclearcraft:overhaul_sfr:neutron_shield":
return "Neutron Shield";
case "nuclearcraft:overhaul_sfr:irradiator":
return "Irradiator";
default:
console.warn("unknown module", block);
return null;
}
}
const possibleBlocks = [
...Object.entries(configuration.configuration),
...configuration.addons.flatMap(a => Object.entries(a.configuration)),
]
.filter(([k, v]) => (!configurationHint || k === configurationHint) && "blocks" in v)
.flatMap(([, v]) => v.blocks);
const entry = findInPossible(possibleBlocks, block);
if (!entry) return null;
return entry.modules["plannerator:display_name"].display_name;
}
export function lookupFromMetadata(metadata) {
if (!metadata.configuration || !metadata.configurationList || !metadata.type)
return null;
const possibleEntries = [
...Object.entries(configuration.configuration),
...configuration.addons.flatMap(a => Object.entries(a.configuration)),
]
.filter(([k, v]) => k === metadata.configuration && metadata.configurationList in v)
.flatMap(([, v]) => v[metadata.configurationList])
.filter(v => v.type === metadata.type);
return findInPossible(possibleEntries, metadata);
}

View File

@ -1,8 +1,9 @@
import {atlasMetadata, makeAtlasImage} from "./atlas.js";
import {DomList} from "./dom-list.js";
import { atlases, makeAtlasImage } from "./atlas.js";
import { DomList } from "./dom-list.js";
import { emitEvent, listenTo } from "./events.js";
import { Filter } from "./filter.js";
import { createTooltip } from "./tooltip.js";
import { lookupFromMetadata, resolveBlockName } from "./ncpf-utils.js";
let suppressHover = false;
let multiblockSize = [7, 7, 5]; // width (screen x), depth (screen y), height (xy grids)
@ -103,9 +104,7 @@ function listClickEvent(e) {
}
export function initializePlanner() {
listenTo("block-picker:filter", e => {
buildBlockPicker();
});
listenTo("block-picker:filter", buildBlockPicker);
listenTo("config:end", buildBlockPicker);
@ -114,81 +113,25 @@ export function initializePlanner() {
function buildBlockPicker() {
const filter = Filter.fromInput("block-filter");
console.log(filter);
const grid = new DomList(document.querySelector("#block-list"));
grid.clear();
atlasMetadata.forEach((m, i) => {
const item = grid.makeItem();
item.dataset.index = i.toString(10);
if (m.modules["plannerator:display_name"].display_name === "Lithium Heat Sink") {
console.log(m);
void {
"cooling": 130,
"rules": [
{
"rules": [
{
"rules": [
{
"min": 2,
"max": 2,
"block": {
"metadata": 1,
"name": "nuclearcraft:solid_fission_sink2",
"type": "legacy_block",
"blockstate": {
"type": "lead"
}
},
"type": "between"
},
{
"min": 1,
"max": 1,
"block": {
"metadata": 1,
"name": "nuclearcraft:solid_fission_sink2",
"type": "legacy_block",
"blockstate": {
"type": "lead"
}
},
"type": "axial"
}
],
"type": "and"
},
{
"min": 1,
"max": 6,
"block": {
"name": "nuclearcraft:overhaul_sfr:casing",
"type": "module"
},
"type": "between"
const blocks = atlases.flatMap(a => a.metadata)
.filter(m => m.configuration === "nuclearcraft:overhaul_sfr" && m.configurationList === "blocks");
console.log(blocks);
blocks.forEach(m => {
const item = grid.makeItem();
item.dataset.index = m.index.toString(10);
const tooltipData = [...getBlockTooltip(m)];
item.dataset.tooltip = createTooltip(tooltipData).toString(10);
if (!filter.test(...tooltipData)) {
item.classList.add("hidden");
}
],
"type": "and"
}
]
}
}
item.dataset.tooltip = createTooltip([
m.modules["plannerator:display_name"].display_name,
...("nuclearcraft:overhaul_sfr:heat_sink" in m.modules ? [
"Heatsink",
`Cooling: ${m.modules["nuclearcraft:overhaul_sfr:heat_sink"].cooling ?? "???"} H/t`,
] : []),
]).toString(10);
if (!filter.test(m.modules["plannerator:display_name"].display_name)) {
item.classList.add("hidden");
}
item.appendChild(makeAtlasImage(i));
item.addEventListener("click", listClickEvent);
grid.addItem(item);
});
item.appendChild(makeAtlasImage(m.index));
item.addEventListener("click", listClickEvent);
grid.addItem(item);
});
}
function buildPlanner() {
@ -226,3 +169,140 @@ function buildPlanner() {
list.addItem(item);
}
}
function* getBlockTooltip(metadata) {
const data = lookupFromMetadata(metadata);
if (!data) {
console.warn("failed to get data from metadata", metadata);
return;
}
yield data.modules["plannerator:display_name"].display_name;
let hasAdditionalData = false;
if ("nuclearcraft:overhaul_sfr:heat_sink" in data.modules) {
hasAdditionalData = true;
const heatSink = data.modules["nuclearcraft:overhaul_sfr:heat_sink"];
yield "Heatsink";
if (heatSink.cooling) {
yield `Cooling: ${heatSink.cooling} H/t`;
}
if (heatSink.rules) {
for (const rule of heatSink.rules) {
yield "Requires " + parseRuleAsTooltip(rule, { configurationHint: metadata.configuration });
}
}
}
if ("nuclearcraft:overhaul_sfr:casing" in data.modules) {
hasAdditionalData = true;
yield "Casing";
}
if ("nuclearcraft:overhaul_sfr:controller" in data.modules) {
hasAdditionalData = true;
yield "Controller";
}
if ("nuclearcraft:overhaul_sfr:fuel_cell" in data.modules) {
hasAdditionalData = true;
yield "Fuel Cell";
}
if ("nuclearcraft:overhaul_sfr:port" in data.modules) {
hasAdditionalData = true;
if (data.modules["nuclearcraft:overhaul_sfr:port"].output) {
yield "Output Port";
} else {
yield "Input Port";
}
}
if ("nuclearcraft:overhaul_sfr:irradiator" in data.modules) {
hasAdditionalData = true;
yield "Irradiator";
}
if ("nuclearcraft:overhaul_sfr:conductor" in data.modules) {
hasAdditionalData = true;
yield "Conductor";
}
if ("nuclearcraft:overhaul_sfr:moderator" in data.modules) {
hasAdditionalData = true;
const moderator = data.modules["nuclearcraft:overhaul_sfr:moderator"];
yield "Moderator";
yield `Flux: ${moderator.flux}`;
yield `Efficiency: ${moderator.efficiency}`;
}
if ("nuclearcraft:overhaul_sfr:reflector" in data.modules) {
hasAdditionalData = true;
const reflector = data.modules["nuclearcraft:overhaul_sfr:reflector"];
yield "Reflector";
yield `Efficiency: ${reflector.efficiency}`;
yield `Reflectivity: ${reflector.reflectivity}`;
}
if ("nuclearcraft:overhaul_sfr:neutron_shield" in data.modules) {
hasAdditionalData = true;
const shield = data.modules["nuclearcraft:overhaul_sfr:neutron_shield"];
yield "Neutron Shield";
yield `Efficiency: ${shield.efficiency}`;
yield `Heat per Flux: ${shield.heat_per_flux}`;
}
if (!hasAdditionalData) {
console.log("unknown block", data);
}
}
function parseRuleAsTooltip(rule, props) {
switch (rule.type) {
case "and":
return rule.rules.map(r => parseRuleAsTooltip(r, props)).join(" AND ");
case "or":
return rule.rules.map(r => parseRuleAsTooltip(r, props)).join(" OR ");
case "between": {
const block = resolveBlockName(rule.block, props);
if (!block) {
console.warn("could not find block %o", rule.block);
return "???";
}
if (rule.min === rule.max) {
return `exactly ${rule.min} ${block}`;
} else if (rule.max === 6) {
return `at least ${rule.min} ${block}`;
} else if (rule.min === 0) {
return `no more than ${rule.max} ${block}`;
} else {
return `between ${rule.min} and ${rule.max} ${block}`;
}
}
case "axial": {
const block = resolveBlockName(rule.block, props);
if (!block) {
console.warn("could not find block %o", rule.block);
return "???";
}
if (rule.min === rule.max) {
return `exactly ${rule.min} axial ${block}`;
} else if (rule.max === 6) {
return `at least ${rule.min} axial ${block}`;
} else if (rule.min === 0) {
return `no more than ${rule.max} axial ${block}`;
} else {
return `between ${rule.min} and ${rule.max} axial ${block}`;
}
}
default:
console.warn("unknown rule: %o", rule);
return "???";
}
}