Add portable inventory placement previews
This commit is contained in:
parent
3aca3b7a1b
commit
2c4a9adb05
10 changed files with 457 additions and 35 deletions
10
packages/eve-browser-lowering/dist/index.d.ts
vendored
10
packages/eve-browser-lowering/dist/index.d.ts
vendored
|
|
@ -171,6 +171,16 @@ export declare function projectSemanticListItem(node: EveSurfaceComponent): EveS
|
|||
export declare function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[];
|
||||
export declare function createWorldActionIntent(command: string, action: Record<string, unknown>, options?: EveBrowserLoweringOptions): EveCommandIntent;
|
||||
export declare function createInventoryDropIntent(source: Record<string, unknown>, target: Record<string, unknown>, destinationX: number, destinationY: number, options?: EveBrowserLoweringOptions): EveCommandIntent | undefined;
|
||||
export interface InventoryPlacementCell {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
export interface InventoryPlacementPreview {
|
||||
valid: boolean;
|
||||
reason: "valid" | "outside-grid" | "outside-valid-shape" | "occupied";
|
||||
cells: InventoryPlacementCell[];
|
||||
}
|
||||
export declare function createInventoryPlacementPreview(source: Record<string, unknown>, target: Record<string, unknown>, targetChildren: EveSurfaceComponent[], destinationX: number, destinationY: number): InventoryPlacementPreview;
|
||||
export declare function applyEveSurfaceStyles(styles: EveSurfaceStyles | undefined, body?: HTMLElement): void;
|
||||
export declare function createEveCommandIntent(commandId: string, props?: Record<string, unknown>, options?: EveBrowserLoweringOptions): EveCommandIntent;
|
||||
export declare function emptyState(message: string): HTMLElement;
|
||||
|
|
|
|||
108
packages/eve-browser-lowering/dist/index.js
vendored
108
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -1042,8 +1042,30 @@ function renderInventoryGrid(node, props, layout, style, options) {
|
|||
event.preventDefault();
|
||||
if (event.dataTransfer)
|
||||
event.dataTransfer.dropEffect = "move";
|
||||
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
|
||||
if (!encoded)
|
||||
return;
|
||||
try {
|
||||
const source = JSON.parse(encoded);
|
||||
const rect = board.getBoundingClientRect();
|
||||
const cellSize = Math.max(1, numberProp(props.cellSize, 72));
|
||||
const cellGap = Math.max(0, numberProp(props.cellGap, 4));
|
||||
const pitch = cellSize + cellGap;
|
||||
const x = Math.max(0, Math.floor((event.clientX - rect.left) / pitch));
|
||||
const y = Math.max(0, Math.floor((event.clientY - rect.top) / pitch));
|
||||
showInventoryPlacementPreview(board, source, props, node.children || [], x, y, cellSize);
|
||||
}
|
||||
catch {
|
||||
clearInventoryPlacementPreview(board);
|
||||
}
|
||||
});
|
||||
board.addEventListener("dragleave", event => {
|
||||
if (event.relatedTarget instanceof Node && board.contains(event.relatedTarget))
|
||||
return;
|
||||
clearInventoryPlacementPreview(board);
|
||||
});
|
||||
board.addEventListener("drop", event => {
|
||||
clearInventoryPlacementPreview(board);
|
||||
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
|
||||
if (!encoded)
|
||||
return;
|
||||
|
|
@ -1060,6 +1082,8 @@ function renderInventoryGrid(node, props, layout, style, options) {
|
|||
const pitch = cellSize + cellGap;
|
||||
const x = Math.max(0, Math.min(columns - 1, Math.floor((event.clientX - rect.left) / pitch)));
|
||||
const y = Math.max(0, Math.min(rows - 1, Math.floor((event.clientY - rect.top) / pitch)));
|
||||
if (!createInventoryPlacementPreview(source, props, node.children || [], x, y).valid)
|
||||
return;
|
||||
const intent = createInventoryDropIntent(source, props, x, y, options);
|
||||
if (!intent)
|
||||
return;
|
||||
|
|
@ -1108,7 +1132,7 @@ function renderInventoryItem(node, props, layout, style, options) {
|
|||
if (!item.draggable || !event.dataTransfer)
|
||||
return;
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify(props));
|
||||
event.dataTransfer.setData("application/vnd.gamecult.eve.inventory-item+json", JSON.stringify({ ...props, id: node.id }));
|
||||
});
|
||||
let widthCells = positiveInt(props.shapeWidth, 1);
|
||||
let heightCells = positiveInt(props.shapeHeight, 1);
|
||||
|
|
@ -1153,7 +1177,10 @@ export function createInventoryDropIntent(source, target, destinationX, destinat
|
|||
const sourceIndex = numberProp(source.sourceIndex, -1);
|
||||
const targetIndex = numberProp(target.targetIndex, -1);
|
||||
const targetKind = firstString(target.targetKind, "");
|
||||
const payload = {
|
||||
const payload = {};
|
||||
copyInventoryPayloadProps(source, payload);
|
||||
copyInventoryPayloadProps(target, payload);
|
||||
Object.assign(payload, {
|
||||
sourceKind,
|
||||
originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""),
|
||||
originIndex: sourceIndex,
|
||||
|
|
@ -1162,16 +1189,91 @@ export function createInventoryDropIntent(source, target, destinationX, destinat
|
|||
quantity: Math.max(1, numberProp(source.quantity, 1)),
|
||||
sourceX: numberProp(source.x, -2147483648),
|
||||
sourceY: numberProp(source.y, -2147483648),
|
||||
sourceRotation: firstString(source.rotation, "None"),
|
||||
destinationKind: targetKind,
|
||||
destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""),
|
||||
destinationIndex: targetIndex,
|
||||
destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1,
|
||||
destinationX: Math.trunc(destinationX),
|
||||
destinationY: Math.trunc(destinationY),
|
||||
destinationRotation: firstString(target.dropRotation, source.rotation, "None"),
|
||||
hasDestinationPosition: true,
|
||||
};
|
||||
});
|
||||
return createEveCommandIntent(command, { action: payload }, options);
|
||||
}
|
||||
export function createInventoryPlacementPreview(source, target, targetChildren, destinationX, destinationY) {
|
||||
const columns = Math.max(1, positiveInt(target.columns, 1));
|
||||
const rows = Math.max(1, positiveInt(target.rows, 1));
|
||||
const cells = inventoryItemCells(source).map(cell => ({
|
||||
x: Math.trunc(destinationX) + cell.x,
|
||||
y: Math.trunc(destinationY) + cell.y,
|
||||
}));
|
||||
const validCells = new Set(parseInventoryCells(target.validCells).map(cell => `${cell.x}:${cell.y}`));
|
||||
const sourceId = firstString(source.id, "");
|
||||
const occupied = new Set();
|
||||
for (const child of targetChildren || []) {
|
||||
if (child.kind !== "inventory.item" || (sourceId && child.id === sourceId))
|
||||
continue;
|
||||
const props = objectProps(child.props);
|
||||
const x = Math.trunc(numberProp(props.x, 0));
|
||||
const y = Math.trunc(numberProp(props.y, 0));
|
||||
for (const cell of inventoryItemCells(props))
|
||||
occupied.add(`${x + cell.x}:${y + cell.y}`);
|
||||
}
|
||||
let reason = "valid";
|
||||
if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows)) {
|
||||
reason = "outside-grid";
|
||||
}
|
||||
else if (validCells.size > 0 && cells.some(cell => !validCells.has(`${cell.x}:${cell.y}`))) {
|
||||
reason = "outside-valid-shape";
|
||||
}
|
||||
else if (cells.some(cell => occupied.has(`${cell.x}:${cell.y}`))) {
|
||||
reason = "occupied";
|
||||
}
|
||||
return { valid: reason === "valid", reason, cells };
|
||||
}
|
||||
function inventoryItemCells(item) {
|
||||
const explicit = parseInventoryCells(item.shapeCells);
|
||||
if (explicit.length > 0)
|
||||
return explicit;
|
||||
let width = Math.max(1, positiveInt(item.shapeWidth, 1));
|
||||
let height = Math.max(1, positiveInt(item.shapeHeight, 1));
|
||||
const rotation = firstString(item.rotation, "").toLowerCase();
|
||||
if (["clockwise", "counterclockwise", "right", "left", "rotate90", "rotate270", "clockwise90", "clockwise270"].includes(rotation)) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
return Array.from({ length: width * height }, (_, index) => ({ x: index % width, y: Math.floor(index / width) }));
|
||||
}
|
||||
function parseInventoryCells(value) {
|
||||
if (typeof value !== "string")
|
||||
return [];
|
||||
return value.split(";").flatMap(token => {
|
||||
const [x, y] = token.split(",").map(Number);
|
||||
return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : [];
|
||||
});
|
||||
}
|
||||
function copyInventoryPayloadProps(source, payload) {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key.startsWith("payload.") && key.length > "payload.".length)
|
||||
payload[key.slice("payload.".length)] = value;
|
||||
}
|
||||
}
|
||||
function clearInventoryPlacementPreview(board) {
|
||||
board.querySelectorAll(".cultui-inventory-placement-preview").forEach(element => element.remove());
|
||||
}
|
||||
function showInventoryPlacementPreview(board, source, target, targetChildren, x, y, cellSize) {
|
||||
clearInventoryPlacementPreview(board);
|
||||
const preview = createInventoryPlacementPreview(source, target, targetChildren, x, y);
|
||||
for (const cell of preview.cells) {
|
||||
const ghost = el("div", `cultui-inventory-placement-preview ${preview.valid ? "is-valid" : "is-invalid"}`);
|
||||
ghost.style.gridColumn = String(cell.x + 1);
|
||||
ghost.style.gridRow = String(cell.y + 1);
|
||||
ghost.style.width = cssSize(String(cellSize));
|
||||
ghost.style.height = cssSize(String(cellSize));
|
||||
ghost.style.pointerEvents = "none";
|
||||
board.append(ghost);
|
||||
}
|
||||
}
|
||||
function renderInputBindingMap(node, props, options) {
|
||||
const root = el("section", "cultui-binding-map");
|
||||
assignId(root, node);
|
||||
|
|
|
|||
4
packages/eve-browser-lowering/package-lock.json
generated
4
packages/eve-browser-lowering/package-lock.json
generated
|
|
@ -1,12 +1,12 @@
|
|||
{
|
||||
"name": "@gamecult/eve-browser-lowering",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@gamecult/eve-browser-lowering",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"devDependencies": {
|
||||
"@types/jsdom": "^30.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "@gamecult/eve-browser-lowering",
|
||||
"version": "0.1.0",
|
||||
"version": "0.1.1",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
"types": "dist/index.d.ts",
|
||||
|
|
|
|||
|
|
@ -1254,8 +1254,27 @@ function renderInventoryGrid(
|
|||
if (!hasInventoryDropCommand(props)) return;
|
||||
event.preventDefault();
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = "move";
|
||||
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
|
||||
if (!encoded) return;
|
||||
try {
|
||||
const source = JSON.parse(encoded) as Record<string, unknown>;
|
||||
const rect = board.getBoundingClientRect();
|
||||
const cellSize = Math.max(1, numberProp(props.cellSize, 72));
|
||||
const cellGap = Math.max(0, numberProp(props.cellGap, 4));
|
||||
const pitch = cellSize + cellGap;
|
||||
const x = Math.max(0, Math.floor((event.clientX - rect.left) / pitch));
|
||||
const y = Math.max(0, Math.floor((event.clientY - rect.top) / pitch));
|
||||
showInventoryPlacementPreview(board, source, props, node.children || [], x, y, cellSize);
|
||||
} catch {
|
||||
clearInventoryPlacementPreview(board);
|
||||
}
|
||||
});
|
||||
board.addEventListener("dragleave", event => {
|
||||
if (event.relatedTarget instanceof Node && board.contains(event.relatedTarget)) return;
|
||||
clearInventoryPlacementPreview(board);
|
||||
});
|
||||
board.addEventListener("drop", event => {
|
||||
clearInventoryPlacementPreview(board);
|
||||
const encoded = event.dataTransfer?.getData("application/vnd.gamecult.eve.inventory-item+json") || "";
|
||||
if (!encoded) return;
|
||||
let source: Record<string, unknown>;
|
||||
|
|
@ -1270,6 +1289,7 @@ function renderInventoryGrid(
|
|||
const pitch = cellSize + cellGap;
|
||||
const x = Math.max(0, Math.min(columns - 1, Math.floor((event.clientX - rect.left) / pitch)));
|
||||
const y = Math.max(0, Math.min(rows - 1, Math.floor((event.clientY - rect.top) / pitch)));
|
||||
if (!createInventoryPlacementPreview(source, props, node.children || [], x, y).valid) return;
|
||||
const intent = createInventoryDropIntent(source, props, x, y, options);
|
||||
if (!intent) return;
|
||||
event.preventDefault();
|
||||
|
|
@ -1324,7 +1344,7 @@ function renderInventoryItem(
|
|||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData(
|
||||
"application/vnd.gamecult.eve.inventory-item+json",
|
||||
JSON.stringify(props),
|
||||
JSON.stringify({ ...props, id: node.id }),
|
||||
);
|
||||
});
|
||||
let widthCells = positiveInt(props.shapeWidth, 1);
|
||||
|
|
@ -1379,7 +1399,10 @@ export function createInventoryDropIntent(
|
|||
const sourceIndex = numberProp(source.sourceIndex, -1);
|
||||
const targetIndex = numberProp(target.targetIndex, -1);
|
||||
const targetKind = firstString(target.targetKind, "");
|
||||
const payload: Record<string, unknown> = {
|
||||
const payload: Record<string, unknown> = {};
|
||||
copyInventoryPayloadProps(source, payload);
|
||||
copyInventoryPayloadProps(target, payload);
|
||||
Object.assign(payload, {
|
||||
sourceKind,
|
||||
originEntityKey: firstString(source.sourceEntityKey, source.entityKey, ""),
|
||||
originIndex: sourceIndex,
|
||||
|
|
@ -1388,17 +1411,118 @@ export function createInventoryDropIntent(
|
|||
quantity: Math.max(1, numberProp(source.quantity, 1)),
|
||||
sourceX: numberProp(source.x, -2147483648),
|
||||
sourceY: numberProp(source.y, -2147483648),
|
||||
sourceRotation: firstString(source.rotation, "None"),
|
||||
destinationKind: targetKind,
|
||||
destinationEntityKey: firstString(target.targetEntityKey, target.entityKey, ""),
|
||||
destinationIndex: targetIndex,
|
||||
destinationCargoIndex: targetKind === "cargo" ? targetIndex : -1,
|
||||
destinationX: Math.trunc(destinationX),
|
||||
destinationY: Math.trunc(destinationY),
|
||||
destinationRotation: firstString(target.dropRotation, source.rotation, "None"),
|
||||
hasDestinationPosition: true,
|
||||
};
|
||||
});
|
||||
return createEveCommandIntent(command, { action: payload }, options);
|
||||
}
|
||||
|
||||
export interface InventoryPlacementCell {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface InventoryPlacementPreview {
|
||||
valid: boolean;
|
||||
reason: "valid" | "outside-grid" | "outside-valid-shape" | "occupied";
|
||||
cells: InventoryPlacementCell[];
|
||||
}
|
||||
|
||||
export function createInventoryPlacementPreview(
|
||||
source: Record<string, unknown>,
|
||||
target: Record<string, unknown>,
|
||||
targetChildren: EveSurfaceComponent[],
|
||||
destinationX: number,
|
||||
destinationY: number,
|
||||
): InventoryPlacementPreview {
|
||||
const columns = Math.max(1, positiveInt(target.columns, 1));
|
||||
const rows = Math.max(1, positiveInt(target.rows, 1));
|
||||
const cells = inventoryItemCells(source).map(cell => ({
|
||||
x: Math.trunc(destinationX) + cell.x,
|
||||
y: Math.trunc(destinationY) + cell.y,
|
||||
}));
|
||||
const validCells = new Set(parseInventoryCells(target.validCells).map(cell => `${cell.x}:${cell.y}`));
|
||||
const sourceId = firstString(source.id, "");
|
||||
const occupied = new Set<string>();
|
||||
for (const child of targetChildren || []) {
|
||||
if (child.kind !== "inventory.item" || (sourceId && child.id === sourceId)) continue;
|
||||
const props = objectProps(child.props);
|
||||
const x = Math.trunc(numberProp(props.x, 0));
|
||||
const y = Math.trunc(numberProp(props.y, 0));
|
||||
for (const cell of inventoryItemCells(props)) occupied.add(`${x + cell.x}:${y + cell.y}`);
|
||||
}
|
||||
|
||||
let reason: InventoryPlacementPreview["reason"] = "valid";
|
||||
if (cells.some(cell => cell.x < 0 || cell.y < 0 || cell.x >= columns || cell.y >= rows)) {
|
||||
reason = "outside-grid";
|
||||
} else if (validCells.size > 0 && cells.some(cell => !validCells.has(`${cell.x}:${cell.y}`))) {
|
||||
reason = "outside-valid-shape";
|
||||
} else if (cells.some(cell => occupied.has(`${cell.x}:${cell.y}`))) {
|
||||
reason = "occupied";
|
||||
}
|
||||
return { valid: reason === "valid", reason, cells };
|
||||
}
|
||||
|
||||
function inventoryItemCells(item: Record<string, unknown>): InventoryPlacementCell[] {
|
||||
const explicit = parseInventoryCells(item.shapeCells);
|
||||
if (explicit.length > 0) return explicit;
|
||||
let width = Math.max(1, positiveInt(item.shapeWidth, 1));
|
||||
let height = Math.max(1, positiveInt(item.shapeHeight, 1));
|
||||
const rotation = firstString(item.rotation, "").toLowerCase();
|
||||
if (["clockwise", "counterclockwise", "right", "left", "rotate90", "rotate270", "clockwise90", "clockwise270"].includes(rotation)) {
|
||||
[width, height] = [height, width];
|
||||
}
|
||||
return Array.from({ length: width * height }, (_, index) => ({ x: index % width, y: Math.floor(index / width) }));
|
||||
}
|
||||
|
||||
function parseInventoryCells(value: unknown): InventoryPlacementCell[] {
|
||||
if (typeof value !== "string") return [];
|
||||
return value.split(";").flatMap(token => {
|
||||
const [x, y] = token.split(",").map(Number);
|
||||
return Number.isInteger(x) && Number.isInteger(y) ? [{ x, y }] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function copyInventoryPayloadProps(source: Record<string, unknown>, payload: Record<string, unknown>): void {
|
||||
for (const [key, value] of Object.entries(source)) {
|
||||
if (key.startsWith("payload.") && key.length > "payload.".length)
|
||||
payload[key.slice("payload.".length)] = value;
|
||||
}
|
||||
}
|
||||
|
||||
function clearInventoryPlacementPreview(board: HTMLElement): void {
|
||||
board.querySelectorAll(".cultui-inventory-placement-preview").forEach(element => element.remove());
|
||||
}
|
||||
|
||||
function showInventoryPlacementPreview(
|
||||
board: HTMLElement,
|
||||
source: Record<string, unknown>,
|
||||
target: Record<string, unknown>,
|
||||
targetChildren: EveSurfaceComponent[],
|
||||
x: number,
|
||||
y: number,
|
||||
cellSize: number,
|
||||
): void {
|
||||
clearInventoryPlacementPreview(board);
|
||||
const preview = createInventoryPlacementPreview(source, target, targetChildren, x, y);
|
||||
for (const cell of preview.cells) {
|
||||
const ghost = el("div", `cultui-inventory-placement-preview ${preview.valid ? "is-valid" : "is-invalid"}`);
|
||||
ghost.style.gridColumn = String(cell.x + 1);
|
||||
ghost.style.gridRow = String(cell.y + 1);
|
||||
ghost.style.width = cssSize(String(cellSize));
|
||||
ghost.style.height = cssSize(String(cellSize));
|
||||
ghost.style.pointerEvents = "none";
|
||||
board.append(ghost);
|
||||
}
|
||||
}
|
||||
|
||||
function renderInputBindingMap(
|
||||
node: EveSurfaceComponent,
|
||||
props: Record<string, unknown>,
|
||||
|
|
|
|||
|
|
@ -1086,6 +1086,22 @@ body[data-pixel-art="true"] .cultui-background {
|
|||
padding: 14px;
|
||||
}
|
||||
|
||||
.cultui-inventory-placement-preview {
|
||||
z-index: 20;
|
||||
border: 1px solid currentColor;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.cultui-inventory-placement-preview.is-valid {
|
||||
color: #45e6a8;
|
||||
background: rgb(69 230 168 / 34%);
|
||||
}
|
||||
|
||||
.cultui-inventory-placement-preview.is-invalid {
|
||||
color: #ff5148;
|
||||
background: rgb(255 81 72 / 38%);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.cultui-graph {
|
||||
right: 12px;
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { applyEveStateBindingValue, createEveCommandIntent, createInventoryDropIntent, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js";
|
||||
import { applyEveStateBindingValue, createEveCommandIntent, createInventoryDropIntent, createInventoryPlacementPreview, createWorldActionIntent, normalizeFieldsDocument, projectSemanticListItem, projectSemanticListItems, projectWorldScene, resolveRequiredPluginAdapters, selectAdvertisedSurface } from "../dist/index.js";
|
||||
|
||||
test("applies provider state bindings to component props instead of receipt diagnostics", () => {
|
||||
const component = { props: { value: 1.2 } };
|
||||
|
|
@ -70,6 +70,8 @@ test("inventory drops select the provider-advertised operation and preserve spat
|
|||
targetEntityKey: "zone.0.entity.1",
|
||||
targetIndex: -1,
|
||||
"dropCommand.cargo": "aetheria.daemon.commands.EquipItem",
|
||||
"payload.shipId": "hangar.ship.1",
|
||||
"payload.expectedHangarRevision": "42",
|
||||
}, 7, 9, {
|
||||
activeSurfaceId: "aetheria.refit",
|
||||
clientId: "browser.inventory-test",
|
||||
|
|
@ -83,6 +85,31 @@ test("inventory drops select the provider-advertised operation and preserve spat
|
|||
assert.equal(intent.payload.destinationX, 7);
|
||||
assert.equal(intent.payload.destinationY, 9);
|
||||
assert.equal(intent.payload.hasDestinationPosition, true);
|
||||
assert.equal(intent.payload.shipId, "hangar.ship.1");
|
||||
assert.equal(intent.payload.expectedHangarRevision, "42");
|
||||
});
|
||||
|
||||
test("inventory placement previews preserve irregular cells and reject occupancy", () => {
|
||||
const source = { id: "moving", shapeCells: "0,0;1,0;0,1" };
|
||||
const target = {
|
||||
columns: 4,
|
||||
rows: 3,
|
||||
validCells: "0,0;1,0;2,0;3,0;0,1;1,1;2,1;3,1;0,2;1,2;2,2;3,2",
|
||||
};
|
||||
const children = [{
|
||||
id: "installed",
|
||||
kind: "inventory.item",
|
||||
props: { x: 2, y: 1, shapeCells: "0,0" },
|
||||
children: [],
|
||||
}];
|
||||
|
||||
assert.deepEqual(createInventoryPlacementPreview(source, target, children, 0, 0), {
|
||||
valid: true,
|
||||
reason: "valid",
|
||||
cells: [{ x: 0, y: 0 }, { x: 1, y: 0 }, { x: 0, y: 1 }],
|
||||
});
|
||||
assert.equal(createInventoryPlacementPreview(source, target, children, 1, 1).reason, "occupied");
|
||||
assert.equal(createInventoryPlacementPreview(source, target, children, 3, 2).reason, "outside-grid");
|
||||
});
|
||||
|
||||
test("inventory drops fail closed without an operation for the source kind", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,10 @@
|
|||
# Eve Surface Contract
|
||||
|
||||
Inventory surfaces may publish irregular `shapeCells`, optional grid
|
||||
`validCells`, and `payload.*` fields. Lowerers use those fields for transient
|
||||
placement previews and forward the namespaced payload through typed drop
|
||||
operations; providers remain the final placement authority.
|
||||
|
||||
Eve owns this renderer-neutral package. It defines the retained CultUI surface
|
||||
document, provider advertisement, asset catalog, input vocabulary, and builder
|
||||
primitives shared by providers and lowerers. Headless daemons depend on this
|
||||
|
|
@ -33,4 +38,3 @@ there.
|
|||
|
||||
EveUnity must not carry another live copy of this package. Its Unity Scene and
|
||||
UI Toolkit packages depend on the released Eve-owned package.
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,57 @@ namespace GameCult.Eve.Surface
|
|||
public const string ItemKind = "inventory.item";
|
||||
public const string DragSessionKind = "inventory.drag_session";
|
||||
|
||||
public static bool TryCreatePlacementPreview(
|
||||
EveSurfaceComponent source,
|
||||
EveSurfaceComponent target,
|
||||
int destinationX,
|
||||
int destinationY,
|
||||
out EveInventoryPlacementPreview? preview)
|
||||
{
|
||||
preview = null;
|
||||
if (source == null || target == null ||
|
||||
!string.Equals(source.Kind, ItemKind, StringComparison.Ordinal) ||
|
||||
!string.Equals(target.Kind, GridKind, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var columns = Math.Max(1, Int(target.GetProp("columns"), 1));
|
||||
var rows = Math.Max(1, Int(target.GetProp("rows"), 1));
|
||||
var localCells = ItemCells(source);
|
||||
var cells = localCells
|
||||
.Select(cell => new EveInventoryCell(destinationX + cell.X, destinationY + cell.Y))
|
||||
.ToArray();
|
||||
var validCells = ParseCells(target.GetProp("validCells"));
|
||||
var occupied = new HashSet<(int X, int Y)>();
|
||||
foreach (var item in target.Children.Where(child =>
|
||||
string.Equals(child.Kind, ItemKind, StringComparison.Ordinal) &&
|
||||
!ReferenceEquals(child, source) &&
|
||||
!string.Equals(child.Id, source.Id, StringComparison.Ordinal)))
|
||||
{
|
||||
var originX = Int(item.GetProp("x"), 0);
|
||||
var originY = Int(item.GetProp("y"), 0);
|
||||
foreach (var cell in ItemCells(item))
|
||||
occupied.Add((originX + cell.X, originY + cell.Y));
|
||||
}
|
||||
|
||||
var reason = "valid";
|
||||
if (cells.Any(cell => cell.X < 0 || cell.Y < 0 || cell.X >= columns || cell.Y >= rows))
|
||||
reason = "outside-grid";
|
||||
else if (validCells.Count > 0 && cells.Any(cell => !validCells.Contains((cell.X, cell.Y))))
|
||||
reason = "outside-valid-shape";
|
||||
else if (cells.Any(cell => occupied.Contains((cell.X, cell.Y))))
|
||||
reason = "occupied";
|
||||
|
||||
preview = new EveInventoryPlacementPreview(
|
||||
string.Equals(reason, "valid", StringComparison.Ordinal),
|
||||
reason,
|
||||
destinationX,
|
||||
destinationY,
|
||||
cells);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryCreateDropRequest(
|
||||
EveSurfaceDocument document,
|
||||
EveSurfaceComponent source,
|
||||
|
|
@ -39,31 +90,34 @@ namespace GameCult.Eve.Surface
|
|||
var sourceIndex = Int(source.GetProp("sourceIndex"), -1);
|
||||
var targetIndex = Int(target.GetProp("targetIndex"), -1);
|
||||
var targetKind = target.GetProp("targetKind");
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["sourceKind"] = sourceKind,
|
||||
["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey")),
|
||||
["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture),
|
||||
["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1)
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
["itemKey"] = source.GetProp("itemKey"),
|
||||
["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture),
|
||||
["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture),
|
||||
["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture),
|
||||
["destinationKind"] = targetKind,
|
||||
["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey")),
|
||||
["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture),
|
||||
["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1)
|
||||
.ToString(CultureInfo.InvariantCulture),
|
||||
["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture),
|
||||
["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture),
|
||||
["hasDestinationPosition"] = "true"
|
||||
};
|
||||
var payload = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
CopyPayloadProps(source, payload);
|
||||
CopyPayloadProps(target, payload);
|
||||
payload["sourceKind"] = sourceKind;
|
||||
payload["originEntityKey"] = source.GetProp("sourceEntityKey", source.GetProp("entityKey"));
|
||||
payload["originIndex"] = sourceIndex.ToString(CultureInfo.InvariantCulture);
|
||||
payload["originCargoIndex"] = (string.Equals(sourceKind, "cargo", StringComparison.Ordinal) ? sourceIndex : -1)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
payload["itemKey"] = source.GetProp("itemKey");
|
||||
payload["quantity"] = Math.Max(1, Int(source.GetProp("quantity"), 1)).ToString(CultureInfo.InvariantCulture);
|
||||
payload["sourceX"] = Int(source.GetProp("x"), int.MinValue).ToString(CultureInfo.InvariantCulture);
|
||||
payload["sourceY"] = Int(source.GetProp("y"), int.MinValue).ToString(CultureInfo.InvariantCulture);
|
||||
payload["sourceRotation"] = source.GetProp("rotation", "None");
|
||||
payload["destinationKind"] = targetKind;
|
||||
payload["destinationEntityKey"] = target.GetProp("targetEntityKey", target.GetProp("entityKey"));
|
||||
payload["destinationIndex"] = targetIndex.ToString(CultureInfo.InvariantCulture);
|
||||
payload["destinationCargoIndex"] = (string.Equals(targetKind, "cargo", StringComparison.Ordinal) ? targetIndex : -1)
|
||||
.ToString(CultureInfo.InvariantCulture);
|
||||
payload["destinationX"] = destinationX.ToString(CultureInfo.InvariantCulture);
|
||||
payload["destinationY"] = destinationY.ToString(CultureInfo.InvariantCulture);
|
||||
payload["destinationRotation"] = target.GetProp("dropRotation", source.GetProp("rotation", "None"));
|
||||
payload["hasDestinationPosition"] = "true";
|
||||
var template = document.Commands.FirstOrDefault(candidate =>
|
||||
string.Equals(candidate.Command, command, StringComparison.Ordinal));
|
||||
var idempotencyKey = "eve-inventory-drop-" + Guid.NewGuid().ToString("N");
|
||||
var operation = template == null
|
||||
? CultMesh.OperationInvocation(command)
|
||||
: CultMesh.OperationInvocation(template.Operation);
|
||||
? CultMesh.OperationInvocation(command, idempotencyKey: idempotencyKey)
|
||||
: CultMesh.OperationInvocation(template.Operation, idempotencyKey);
|
||||
request = new EveSurfaceCommandRequest(
|
||||
document.ProviderId,
|
||||
document.Surface.Id,
|
||||
|
|
@ -74,11 +128,96 @@ namespace GameCult.Eve.Surface
|
|||
return true;
|
||||
}
|
||||
|
||||
private static void CopyPayloadProps(
|
||||
EveSurfaceComponent component,
|
||||
IDictionary<string, string> payload)
|
||||
{
|
||||
const string prefix = "payload.";
|
||||
foreach (var prop in component.Props)
|
||||
{
|
||||
if (!prop.Key.StartsWith(prefix, StringComparison.Ordinal) ||
|
||||
prop.Key.Length == prefix.Length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
payload[prop.Key.Substring(prefix.Length)] = prop.Value ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
private static int Int(string value, int fallback) =>
|
||||
int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: fallback;
|
||||
|
||||
private static IReadOnlyList<EveInventoryCell> ItemCells(EveSurfaceComponent item)
|
||||
{
|
||||
var explicitCells = ParseCells(item.GetProp("shapeCells"));
|
||||
if (explicitCells.Count > 0)
|
||||
return explicitCells.Select(cell => new EveInventoryCell(cell.X, cell.Y)).ToArray();
|
||||
|
||||
var width = Math.Max(1, Int(item.GetProp("shapeWidth"), 1));
|
||||
var height = Math.Max(1, Int(item.GetProp("shapeHeight"), 1));
|
||||
var rotation = item.GetProp("rotation");
|
||||
if (string.Equals(rotation, "Clockwise90", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(rotation, "Clockwise270", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
(width, height) = (height, width);
|
||||
}
|
||||
return Enumerable.Range(0, height)
|
||||
.SelectMany(y => Enumerable.Range(0, width).Select(x => new EveInventoryCell(x, y)))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private static HashSet<(int X, int Y)> ParseCells(string value)
|
||||
{
|
||||
var cells = new HashSet<(int X, int Y)>();
|
||||
foreach (var token in (value ?? "").Split(new[] { ';' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var coordinates = token.Split(',');
|
||||
if (coordinates.Length == 2 &&
|
||||
int.TryParse(coordinates[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var x) &&
|
||||
int.TryParse(coordinates[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var y))
|
||||
{
|
||||
cells.Add((x, y));
|
||||
}
|
||||
}
|
||||
return cells;
|
||||
}
|
||||
}
|
||||
|
||||
public sealed class EveInventoryPlacementPreview
|
||||
{
|
||||
public EveInventoryPlacementPreview(
|
||||
bool isValid,
|
||||
string reason,
|
||||
int destinationX,
|
||||
int destinationY,
|
||||
IReadOnlyList<EveInventoryCell> cells)
|
||||
{
|
||||
IsValid = isValid;
|
||||
Reason = reason ?? "";
|
||||
DestinationX = destinationX;
|
||||
DestinationY = destinationY;
|
||||
Cells = cells ?? Array.Empty<EveInventoryCell>();
|
||||
}
|
||||
|
||||
public bool IsValid { get; }
|
||||
public string Reason { get; }
|
||||
public int DestinationX { get; }
|
||||
public int DestinationY { get; }
|
||||
public IReadOnlyList<EveInventoryCell> Cells { get; }
|
||||
}
|
||||
|
||||
public readonly struct EveInventoryCell
|
||||
{
|
||||
public EveInventoryCell(int x, int y)
|
||||
{
|
||||
X = x;
|
||||
Y = y;
|
||||
}
|
||||
|
||||
public int X { get; }
|
||||
public int Y { get; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "org.gamecult.eve.surface",
|
||||
"displayName": "Eve Surface Contract",
|
||||
"version": "0.3.1",
|
||||
"version": "0.3.2",
|
||||
"unity": "2021.3",
|
||||
"description": "Renderer-neutral C# contracts and builders for gamecult.eve.surface.v1 documents.",
|
||||
"dependencies": {
|
||||
|
|
|
|||
Loading…
Reference in a new issue