feat(locker): add locker grid and loadout helpers
This commit is contained in:
parent
9a565986e9
commit
0fc898d227
1 changed files with 320 additions and 0 deletions
320
src/lib/locker/index.ts
Normal file
320
src/lib/locker/index.ts
Normal file
|
|
@ -0,0 +1,320 @@
|
||||||
|
import type { Payload } from "payload";
|
||||||
|
import type { Asset, LockerStorage } from "@/payload-types";
|
||||||
|
import type { GridDimensions } from "@/lib/logistics";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A single entry in a user's locker grid. Mirrors the shape of the
|
||||||
|
* `items` array on the locker-storages collection, but with the asset
|
||||||
|
* populated so the frontend can render footprints, thumbnails, etc.
|
||||||
|
*/
|
||||||
|
export type LockerGridItem = {
|
||||||
|
id: string;
|
||||||
|
assetId: number;
|
||||||
|
quantity: number;
|
||||||
|
gridX: number;
|
||||||
|
gridY: number;
|
||||||
|
rotated: boolean;
|
||||||
|
asset: Asset;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Equipment slot definitions for user loadouts. */
|
||||||
|
export type LoadoutSlotConfig = {
|
||||||
|
name: string;
|
||||||
|
label: string;
|
||||||
|
assetTypes: string[];
|
||||||
|
description: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LOADOUT_SLOTS: LoadoutSlotConfig[] = [
|
||||||
|
{ name: "primary", label: "Primary", assetTypes: ["weapon"], description: "Primary weapon" },
|
||||||
|
{ name: "secondary", label: "Secondary", assetTypes: ["weapon"], description: "Secondary weapon" },
|
||||||
|
{ name: "launcher", label: "Launcher", assetTypes: ["weapon"], description: "Launcher / heavy weapon" },
|
||||||
|
{ name: "uniform", label: "Uniform", assetTypes: ["uniform"], description: "Uniform" },
|
||||||
|
{ name: "vest", label: "Vest", assetTypes: ["vest"], description: "Carrier / plate carrier" },
|
||||||
|
{ name: "backpack", label: "Backpack", assetTypes: ["backpack"], description: "Backpack / rucksack" },
|
||||||
|
{ name: "helmet", label: "Helmet", assetTypes: ["helmet"], description: "Helmet" },
|
||||||
|
{ name: "facewear", label: "Facewear", assetTypes: ["facewear"], description: "Facewear / eyewear" },
|
||||||
|
{ name: "nvg", label: "NVG", assetTypes: ["nvg"], description: "Night vision device" },
|
||||||
|
{ name: "radio", label: "Radio", assetTypes: ["radio"], description: "Radio / comms" },
|
||||||
|
{ name: "optic", label: "Optic", assetTypes: ["optic"], description: "Optic attachment" },
|
||||||
|
{ name: "muzzle", label: "Muzzle", assetTypes: ["muzzle"], description: "Muzzle attachment" },
|
||||||
|
{ name: "bipod", label: "Bipod", assetTypes: ["bipod"], description: "Bipod attachment" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export type LoadoutSlotName = (typeof LOADOUT_SLOTS)[number]["name"];
|
||||||
|
export type LoadoutSlotValue = number | Asset | null;
|
||||||
|
|
||||||
|
export type LoadoutSlots = Partial<Record<LoadoutSlotName, LoadoutSlotValue>>;
|
||||||
|
|
||||||
|
export function getLoadoutSlot(name: string): LoadoutSlotConfig | null {
|
||||||
|
return LOADOUT_SLOTS.find((s) => s.name === name) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getAssetTypeLabel(assetType: Asset["assetType"]): string {
|
||||||
|
switch (assetType) {
|
||||||
|
case "weapon":
|
||||||
|
return "Weapon";
|
||||||
|
case "magazine":
|
||||||
|
return "Magazine";
|
||||||
|
case "optic":
|
||||||
|
return "Optic";
|
||||||
|
case "muzzle":
|
||||||
|
return "Muzzle";
|
||||||
|
case "bipod":
|
||||||
|
return "Bipod";
|
||||||
|
case "uniform":
|
||||||
|
return "Uniform";
|
||||||
|
case "vest":
|
||||||
|
return "Vest";
|
||||||
|
case "backpack":
|
||||||
|
return "Backpack";
|
||||||
|
case "helmet":
|
||||||
|
return "Helmet";
|
||||||
|
case "facewear":
|
||||||
|
return "Facewear";
|
||||||
|
case "nvg":
|
||||||
|
return "NVG";
|
||||||
|
case "radio":
|
||||||
|
return "Radio";
|
||||||
|
case "medical":
|
||||||
|
return "Medical";
|
||||||
|
case "tool":
|
||||||
|
return "Tool";
|
||||||
|
case "explosive":
|
||||||
|
return "Explosive";
|
||||||
|
case "miscellaneous":
|
||||||
|
return "Miscellaneous";
|
||||||
|
default:
|
||||||
|
return "Item";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ASSET_TYPE_COLORS: Record<Asset["assetType"], string> = {
|
||||||
|
weapon: "oklch(0.45 0.1 40)",
|
||||||
|
magazine: "oklch(0.45 0.12 90)",
|
||||||
|
optic: "oklch(0.45 0.1 250)",
|
||||||
|
muzzle: "oklch(0.45 0.1 200)",
|
||||||
|
bipod: "oklch(0.45 0.1 160)",
|
||||||
|
uniform: "oklch(0.4 0.09 130)",
|
||||||
|
vest: "oklch(0.42 0.09 110)",
|
||||||
|
backpack: "oklch(0.42 0.09 75)",
|
||||||
|
helmet: "oklch(0.45 0.08 250)",
|
||||||
|
facewear: "oklch(0.45 0.1 300)",
|
||||||
|
nvg: "oklch(0.45 0.12 290)",
|
||||||
|
radio: "oklch(0.45 0.1 30)",
|
||||||
|
medical: "oklch(0.45 0.14 20)",
|
||||||
|
tool: "oklch(0.45 0.1 80)",
|
||||||
|
explosive: "oklch(0.45 0.16 55)",
|
||||||
|
miscellaneous: "oklch(0.42 0 0)",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getAssetTypeColor(assetType: Asset["assetType"]): string {
|
||||||
|
return ASSET_TYPE_COLORS[assetType] ?? "oklch(0.42 0 0)";
|
||||||
|
}
|
||||||
|
|
||||||
|
type PayloadType = Awaited<ReturnType<typeof import("payload").getPayload>>;
|
||||||
|
|
||||||
|
/** Resolve the locker grid dimensions from Game Rules. */
|
||||||
|
export async function getLockerGridDimensions(
|
||||||
|
payload: PayloadType,
|
||||||
|
): Promise<{ width: number; height: number }> {
|
||||||
|
try {
|
||||||
|
const gameRules = await payload.findGlobal({
|
||||||
|
slug: "game-rules",
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
});
|
||||||
|
const rules = gameRules as unknown as { lockerGridWidth?: number; lockerGridHeight?: number };
|
||||||
|
return {
|
||||||
|
width: Math.max(4, rules.lockerGridWidth ?? 10),
|
||||||
|
height: Math.max(4, rules.lockerGridHeight ?? 6),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { width: 10, height: 6 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find-or-create the personal locker for a user. */
|
||||||
|
export async function ensureLockerStorage(
|
||||||
|
payload: PayloadType,
|
||||||
|
userId: number,
|
||||||
|
): Promise<LockerStorage> {
|
||||||
|
const existing = (await payload.find({
|
||||||
|
collection: "locker-storages",
|
||||||
|
where: { ownerUser: { equals: userId } },
|
||||||
|
limit: 1,
|
||||||
|
depth: 0,
|
||||||
|
overrideAccess: true,
|
||||||
|
})) as unknown as { docs: LockerStorage[] };
|
||||||
|
|
||||||
|
if (existing.docs.length > 0) return existing.docs[0];
|
||||||
|
|
||||||
|
const user = (await payload
|
||||||
|
.findByID({ collection: "users", id: userId, depth: 0, overrideAccess: true })
|
||||||
|
.catch(() => null)) as unknown as { username?: string; displayName?: string } | null;
|
||||||
|
const label = user?.displayName || user?.username || `User ${userId}`;
|
||||||
|
|
||||||
|
return (await payload.create({
|
||||||
|
collection: "locker-storages",
|
||||||
|
data: {
|
||||||
|
name: `${label}'s Locker`,
|
||||||
|
ownerUser: userId,
|
||||||
|
items: [],
|
||||||
|
},
|
||||||
|
overrideAccess: true,
|
||||||
|
depth: 0,
|
||||||
|
})) as unknown as LockerStorage;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Grid geometry helpers (asset-based equivalents of src/lib/logistics.ts) ---
|
||||||
|
|
||||||
|
/** Effective footprint of an asset, accounting for rotation. */
|
||||||
|
export function getLockerItemFootprint(asset: Asset, rotated: boolean): GridDimensions {
|
||||||
|
const storage = asset.storageDimensions;
|
||||||
|
const w = storage?.gridWidth ?? 1;
|
||||||
|
const h = storage?.gridHeight ?? 1;
|
||||||
|
return rotated ? { width: h, height: w } : { width: w, height: h };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockerIsStackable(asset: Asset): boolean {
|
||||||
|
return asset.storageDimensions?.stackable !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockerMaxStack(asset: Asset): number {
|
||||||
|
const max = asset.storageDimensions?.maxStackSize;
|
||||||
|
return typeof max === "number" && max > 0 ? max : Infinity;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function lockerIsRotatable(asset: Asset): boolean {
|
||||||
|
return asset.storageDimensions?.rotatable !== false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Cells occupied by an item as "x,y" strings. */
|
||||||
|
export function getLockerOccupiedCells(
|
||||||
|
gridX: number,
|
||||||
|
gridY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
): string[] {
|
||||||
|
const cells: string[] = [];
|
||||||
|
for (let dx = 0; dx < width; dx++) {
|
||||||
|
for (let dy = 0; dy < height; dy++) {
|
||||||
|
cells.push(`${gridX + dx},${gridY + dy}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if placing at (targetX, targetY) is in-bounds and non-overlapping. */
|
||||||
|
export function isLockerPlacementValid(
|
||||||
|
items: LockerGridItem[],
|
||||||
|
excludeId: string | null,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
): boolean {
|
||||||
|
if (targetX < 0 || targetY < 0) return false;
|
||||||
|
if (targetX + width > gridWidth || targetY + height > gridHeight) return false;
|
||||||
|
|
||||||
|
const targetCells = new Set(getLockerOccupiedCells(targetX, targetY, width, height));
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.id === excludeId) continue;
|
||||||
|
const fp = getLockerItemFootprint(item.asset, item.rotated);
|
||||||
|
const occupied = getLockerOccupiedCells(item.gridX, item.gridY, fp.width, fp.height);
|
||||||
|
for (const cell of occupied) {
|
||||||
|
if (targetCells.has(cell)) return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** First free spot scanning left-to-right, top-to-bottom, or null. */
|
||||||
|
export function findLockerEmptySpot(
|
||||||
|
items: LockerGridItem[],
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
): { x: number; y: number } | null {
|
||||||
|
for (let y = 0; y <= gridHeight - height; y++) {
|
||||||
|
for (let x = 0; x <= gridWidth - width; x++) {
|
||||||
|
if (isLockerPlacementValid(items, null, x, y, width, height, gridWidth, gridHeight)) {
|
||||||
|
return { x, y };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Repack all items tightly into the grid. Returns the re-arranged array. */
|
||||||
|
export function autoArrangeLockerItems(
|
||||||
|
items: LockerGridItem[],
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
): LockerGridItem[] {
|
||||||
|
const result: LockerGridItem[] = [];
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const fp = getLockerItemFootprint(item.asset, item.rotated);
|
||||||
|
const spot = findLockerEmptySpot(result, fp.width, fp.height, gridWidth, gridHeight);
|
||||||
|
if (spot) {
|
||||||
|
result.push({ ...item, gridX: spot.x, gridY: spot.y });
|
||||||
|
} else {
|
||||||
|
result.push({ ...item, gridX: 0, gridY: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Number of occupied cells vs total. */
|
||||||
|
export function getLockerGridOccupancy(
|
||||||
|
items: LockerGridItem[],
|
||||||
|
gridWidth: number,
|
||||||
|
gridHeight: number,
|
||||||
|
): { used: number; total: number } {
|
||||||
|
const occupied = new Set<string>();
|
||||||
|
for (const item of items) {
|
||||||
|
const fp = getLockerItemFootprint(item.asset, item.rotated);
|
||||||
|
for (const c of getLockerOccupiedCells(item.gridX, item.gridY, fp.width, fp.height)) {
|
||||||
|
occupied.add(c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { used: occupied.size, total: gridWidth * gridHeight };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Find the stack of the same asset overlapping the drop target, if any. */
|
||||||
|
export function findLockerMergeTarget(
|
||||||
|
items: LockerGridItem[],
|
||||||
|
dragged: LockerGridItem,
|
||||||
|
targetX: number,
|
||||||
|
targetY: number,
|
||||||
|
rotated?: boolean,
|
||||||
|
): LockerGridItem | null {
|
||||||
|
const effective = rotated ?? dragged.rotated;
|
||||||
|
const fp = getLockerItemFootprint(dragged.asset, effective);
|
||||||
|
const targetCells = new Set<string>();
|
||||||
|
for (let dx = 0; dx < fp.width; dx++) {
|
||||||
|
for (let dy = 0; dy < fp.height; dy++) {
|
||||||
|
targetCells.add(`${targetX + dx},${targetY + dy}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const other of items) {
|
||||||
|
if (other.id === dragged.id) continue;
|
||||||
|
if (other.assetId !== dragged.assetId) continue;
|
||||||
|
const otherFp = getLockerItemFootprint(other.asset, other.rotated);
|
||||||
|
for (let dx = 0; dx < otherFp.width; dx++) {
|
||||||
|
for (let dy = 0; dy < otherFp.height; dy++) {
|
||||||
|
if (targetCells.has(`${other.gridX + dx},${other.gridY + dy}`)) {
|
||||||
|
return other;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue