diff --git a/src/components/ColumnModals/NewColumnModal.tsx b/src/components/ColumnModals/NewColumnModal.tsx index 9f3a8abae..2cb73d39e 100644 --- a/src/components/ColumnModals/NewColumnModal.tsx +++ b/src/components/ColumnModals/NewColumnModal.tsx @@ -152,7 +152,7 @@ export default function NewColumnModal({ name: columnLabel, fieldName: fieldKey, key: fieldKey, - config: {}, + config: getFieldProp("defaultConfig", type) ?? {}, }, index: columnModal!.index, }); diff --git a/src/components/MapStartPos.tsx b/src/components/MapStartPos.tsx new file mode 100644 index 000000000..f53df30e3 --- /dev/null +++ b/src/components/MapStartPos.tsx @@ -0,0 +1,932 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import AddLocationAltIcon from "@mui/icons-material/AddLocationAlt"; +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import CodeIcon from "@mui/icons-material/Code"; +import ContentCopyIcon from "@mui/icons-material/ContentCopy"; +import FitScreenIcon from "@mui/icons-material/FitScreen"; +import { + ButtonGroup, + Tooltip, + IconButton, + TextField, + MenuItem, + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Stack, + Popover, + Button, + Alert, + Tabs, + Tab, + Typography, + Paper, + Divider, +} from "@mui/material"; + +import { + StartPos, + Position, + Role, + ROLES, + POSITION_NAME_RE, + configLabel, +} from "./startpos/types"; +import { MapDimensions, clampToBounds } from "./startpos/geometry"; +import { StartPosState } from "./startpos/state"; +import { loadStartPos, saveStartPos } from "./startpos/serialization"; +import { validateStartPos } from "./startpos/validation"; + +export interface MapStartPosProps { + textureUrl: string; + dimensions: MapDimensions; + startPos: StartPos | unknown; + updatedStartPos?: (startPos: StartPos) => void; + editable?: boolean; + onClose?: () => void; +} + +const CLICK_DRAG_THRESHOLD = 3; +const ZOOM_STEP = 1.15; +const MIN_SPAN = 0.1; +const MAX_SPAN = 4; +const SIDE_COLORS = [ + "#2196f3", + "#ef5350", + "#66bb6a", + "#ffa726", + "#ab47bc", + "#26c6da", + "#d4e157", + "#8d6e63", +]; +function rgba(hex: string, a: number): string { + const n = parseInt(hex.slice(1), 16); + return `rgba(${(n >> 16) & 255},${(n >> 8) & 255},${n & 255},${a})`; +} + +interface SpawnUse { + role?: Role; + baseCenter?: string; + sideIdx: number; + startIdx: number; +} + +function serialize(sp: StartPos): string { + return JSON.stringify(saveStartPos(sp)); +} + +export default function MapStartPos(props: MapStartPosProps) { + const { dimensions } = props; + const W = dimensions.widthElmos; + const H = dimensions.heightElmos; + // Match the in-game render (map_start_position_suggestions.lua): a fixed + // 300-elmo circle. Name and role share one label size (in elmo units). + const R = 300; + const LABEL_SIZE = 110; + + const initial = useRef(loadStartPos(props.startPos)); + const [state, setState] = useState(() => + StartPosState.fromStartPos(initial.current) + ); + + const [configIdx, setConfigIdx] = useState(0); + const [addMode, setAddMode] = useState(false); + const [deleteMode, setDeleteMode] = useState(false); + // Raw-JSON view/edit; null = closed. + const [jsonDraft, setJsonDraft] = useState(null); + const [jsonError, setJsonError] = useState(null); + const [selected, setSelected] = useState<{ + name: string; + anchorEl: Element; + } | null>(null); + + const dragging = useRef(null); + const pendingClick = useRef<{ + name: string; + anchorEl: Element; + clientX: number; + clientY: number; + } | null>(null); + + // SVG viewBox drives zoom/pan; fit (the whole map) is 0,0..W,H. + const [view, setView] = useState(() => ({ x: 0, y: 0, w: W, h: H })); + const viewRef = useRef(view); + viewRef.current = view; + const panning = useRef<{ + clientX: number; + clientY: number; + viewX: number; + viewY: number; + scale: number; + } | null>(null); + const atFit = view.x === 0 && view.y === 0 && view.w === W && view.h === H; + + // Cursor-anchored wheel zoom, bound natively (so preventDefault holds) via a + // ref callback so it re-binds to whichever SVG is mounted (inline/fullscreen). + const wheelCleanup = useRef<(() => void) | null>(null); + const attachSvg = useCallback( + (node: SVGSVGElement | null) => { + wheelCleanup.current?.(); + wheelCleanup.current = null; + if (!node || !props.editable) return; + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + const ctm = node.getScreenCTM(); + if (!ctm) return; + const px = (e.clientX - ctm.e) / ctm.a; + const py = (e.clientY - ctm.f) / ctm.d; + const v = viewRef.current; + const factor = e.deltaY < 0 ? 1 / ZOOM_STEP : ZOOM_STEP; + const span = Math.min(Math.max((v.w / W) * factor, MIN_SPAN), MAX_SPAN); + const k = (W * span) / v.w; + setView({ + x: px - (px - v.x) * k, + y: py - (py - v.y) * k, + w: W * span, + h: H * span, + }); + }; + node.addEventListener("wheel", onWheel, { passive: false }); + wheelCleanup.current = () => node.removeEventListener("wheel", onWheel); + }, + [props.editable, W, H] + ); + + const positionNames = Object.keys(state.positions); + const errors = validateStartPos(state.toStartPos()); + const errorsByConfig = new Map(); + errors.forEach((e) => { + const list = errorsByConfig.get(e.configIdx) ?? []; + list.push(e.message); + errorsByConfig.set(e.configIdx, list); + }); + const outOfBounds = positionNames.filter((n) => { + const p = state.positions[n]; + return p.x < 0 || p.x > W || p.y < 0 || p.y > H; + }); + + const team = state.team; + const activeConfig = configIdx < team.length ? configIdx : 0; + const config = team[activeConfig]; + + const erroredConfigs = Array.from( + new Set( + Array.from(errorsByConfig.keys()) + .filter((i) => team[i]) + .map((i) => configLabel(team[i].teamCount, team[i].playersPerTeam)) + ) + ); + + // For the active config, map each used spawn -> its role/team/slot. + const spawnUse = new Map(); + config?.sides.forEach((side, sideIdx) => + side.starts.forEach((start, startIdx) => { + if (start.spawnPoint) { + spawnUse.set(start.spawnPoint, { + role: start.role, + baseCenter: start.baseCenter, + sideIdx, + startIdx, + }); + } + }) + ); + + useEffect(() => { + if (selected && !(selected.name in state.positions)) setSelected(null); + }, [state, selected]); + + useEffect(() => { + if (configIdx >= team.length && team.length > 0) + setConfigIdx(team.length - 1); + }, [team.length, configIdx]); + + useEffect(() => { + setView({ x: 0, y: 0, w: W, h: H }); + }, [W, H]); + + // Explicit save, mirroring the startbox editor: edits stay local until the + // user saves, so a stray click or drag never mutates the stored data. + const [savedSerialized, setSavedSerialized] = useState(() => + serialize(initial.current) + ); + const dirty = serialize(state.toStartPos()) !== savedSerialized; + + function saveEdits() { + if (!props.updatedStartPos) return; + const sp = state.toStartPos(); + props.updatedStartPos(saveStartPos(sp)); + setSavedSerialized(serialize(sp)); + } + + function openJson() { + setJsonDraft(JSON.stringify(saveStartPos(state.toStartPos()), null, 2)); + setJsonError(null); + } + + function applyJson() { + try { + const parsed = JSON.parse(jsonDraft ?? ""); + setState(StartPosState.fromStartPos(loadStartPos(parsed))); + setJsonDraft(null); + setJsonError(null); + } catch (e) { + setJsonError((e as Error).message); + } + } + + function svgPoint(event: React.MouseEvent): Position { + const svg = + event.currentTarget instanceof SVGSVGElement + ? event.currentTarget + : event.currentTarget.ownerSVGElement!; + const ctm = svg.getScreenCTM()!; + return { + x: (event.clientX - ctm.e) / ctm.a, + y: (event.clientY - ctm.f) / ctm.d, + }; + } + + function onBackgroundClick( + event: React.MouseEvent + ) { + if (!addMode) return; + const elmo = clampToBounds(svgPoint(event), dimensions); + const name = state.nextPositionName(); + setState(state.addPosition(name, elmo)); + setAddMode(false); + } + + function onMouseMove(event: React.MouseEvent) { + if (panning.current) { + const p = panning.current; + setView((v) => ({ + ...v, + x: p.viewX - (event.clientX - p.clientX) / p.scale, + y: p.viewY - (event.clientY - p.clientY) / p.scale, + })); + return; + } + if (pendingClick.current) { + const dx = event.clientX - pendingClick.current.clientX; + const dy = event.clientY - pendingClick.current.clientY; + if (dx * dx + dy * dy >= CLICK_DRAG_THRESHOLD * CLICK_DRAG_THRESHOLD) { + dragging.current = pendingClick.current.name; + pendingClick.current = null; + } + } + if (!dragging.current) return; + event.preventDefault(); + const elmo = clampToBounds(svgPoint(event), dimensions); + setState((s) => s.movePosition(dragging.current!, elmo)); + } + + function endInteraction() { + if (pendingClick.current) { + const pc = pendingClick.current; + setSelected({ name: pc.name, anchorEl: pc.anchorEl }); + pendingClick.current = null; + } + dragging.current = null; + panning.current = null; + } + + function onBackgroundMouseDown( + event: React.MouseEvent + ) { + if (!props.editable || addMode) return; + const ctm = event.currentTarget.getScreenCTM(); + if (!ctm) return; + event.preventDefault(); + panning.current = { + clientX: event.clientX, + clientY: event.clientY, + viewX: view.x, + viewY: view.y, + scale: ctm.a, + }; + } + + const mapView = ( + + + {positionNames.map((name) => { + const p = state.positions[name]; + const use = spawnUse.get(name); + const isSelected = selected?.name === name; + + // A position outside the map bounds would render off-canvas and be + // unreachable, so pin it to the nearest edge (flagged) while the modal + // and stored value keep the true coords for correction. + const oob = p.x < 0 || p.x > W || p.y < 0 || p.y > H; + const d = oob ? clampToBounds(p, dimensions) : p; + const color = deleteMode + ? "#e53935" + : oob + ? "#ff5252" + : use + ? SIDE_COLORS[use.sideIdx % SIDE_COLORS.length] + : "#8a93a3"; + + // Shrink the name to fit across the circle if it's long. + const nameSize = Math.min(LABEL_SIZE, (1.7 * R) / (name.length * 0.62)); + + return ( + + e.stopPropagation()} + onMouseDown={(e) => { + if (!props.editable) return; + e.stopPropagation(); + if (deleteMode) { + setState(state.removePosition(name)); + setDeleteMode(false); + return; + } + pendingClick.current = { + name, + anchorEl: e.currentTarget, + clientX: e.clientX, + clientY: e.clientY, + }; + }} + > + + {oob ? `${name} - off map at (${p.x}, ${p.y})` : name} + + + + {name} + + {use?.role && ( + + {use.role} + + )} + + ); + })} + + ); + + // Whole-map fit (matching the startbox editor): the SVG letterboxes via + // preserveAspectRatio="meet", so the container is just a defined box and the + // whole map fits inside it in both axes rather than being cropped or stretched. + const mapWrapStyle: React.CSSProperties = { width: "100%", height: "60vh" }; + const mapBox = ( +
+ {mapView} +
+ ); + + if (!props.editable) { + // In-cell preview: scale to fit the row height (aspect preserved), so a + // portrait map fits vertically rather than overflowing from the cell width. + return ( +
+
+ {mapView} +
+
+ ); + } + + const selectedPos = selected ? state.positions[selected.name] : null; + const selectedUse = selected ? spawnUse.get(selected.name) : undefined; + + const editorView = ( + <> + + + + + { + setAddMode(!addMode); + setDeleteMode(false); + }} + > + + + + + + + { + setDeleteMode(!deleteMode); + setAddMode(false); + }} + > + + + + + + + + + + setView({ x: 0, y: 0, w: W, h: H })} + > + + + + + + +
+ + + + + + + + + + + + + {mapBox} + + + + + Configurations + {team.length > 0 ? ( + setConfigIdx(v)} + variant="scrollable" + scrollButtons="auto" + sx={{ minHeight: 36, flexGrow: 1 }} + > + {team.map((t, i) => { + const hasErr = (errorsByConfig.get(i)?.length ?? 0) > 0; + return ( + + ); + })} + + ) : ( + + none yet + + )} + + + + {config && ( + <> + + + + setState( + state.setTeamCount(activeConfig, Number(e.target.value)) + ) + } + sx={{ width: 90 }} + inputProps={{ min: 1 }} + /> + + setState( + state.setPlayersPerTeam( + activeConfig, + Number(e.target.value) + ) + ) + } + sx={{ width: 120 }} + inputProps={{ min: 1 }} + /> + + Click a marker to set its team & role + + + { + setState(state.removeTeam(activeConfig)); + setConfigIdx(Math.max(0, activeConfig - 1)); + }} + > + + + + + {(errorsByConfig.get(activeConfig)?.length ?? 0) > 0 && ( + +
    + {errorsByConfig.get(activeConfig)!.map((e, i) => ( +
  • {e}
  • + ))} +
+
+ )} + + )} +
+
+ + setSelected(null)} + anchorOrigin={{ vertical: "bottom", horizontal: "center" }} + transformOrigin={{ vertical: "top", horizontal: "center" }} + disableRestoreFocus + PaperProps={{ sx: { p: 1.5, minWidth: 240 } }} + > + {selected !== null && selectedPos !== null && ( + { + setState(state.renamePosition(selected.name, newName)); + setSelected({ ...selected, name: newName }); + }} + onMove={(p) => + setState( + state.movePosition(selected.name, clampToBounds(p, dimensions)) + ) + } + onSetTeam={(sideIdx) => + setState(state.setSpawnTeam(activeConfig, selected.name, sideIdx)) + } + onSetStart={(patch) => { + if (selectedUse) + setState( + state.setStart( + activeConfig, + selectedUse.sideIdx, + selectedUse.startIdx, + patch + ) + ); + }} + onDelete={() => { + setState(state.removePosition(selected.name)); + setSelected(null); + }} + /> + )} + + + {outOfBounds.length > 0 && ( + + {outOfBounds.length} position(s) lie outside the map and are pinned to + the edge (red, dashed): {outOfBounds.join(", ")}. Click one to correct + its coordinates. + + )} + + setJsonDraft(null)} + fullWidth + maxWidth="sm" + > + StartPos JSON + + setJsonDraft(e.target.value)} + error={jsonError !== null} + helperText={jsonError ?? " "} + InputProps={{ sx: { fontFamily: "monospace", fontSize: 12 } }} + /> + + + +
+ + + +
+ + ); + + return ( + props.onClose?.()} + fullWidth + maxWidth="xl" + PaperProps={{ sx: { height: "90vh" } }} + > + Start positions + + {editorView} + + + {erroredConfigs.length > 0 && ( + + Fix {erroredConfigs.join(", ")} to save + + )} + + + + + ); +} + +interface PositionEditorProps { + name: string; + pos: Position; + use?: SpawnUse; + teamCount: number; + positionNames: string[]; + onRename: (newName: string) => void; + onMove: (pos: Position) => void; + onSetTeam: (sideIdx: number | null) => void; + onSetStart: (patch: { role?: Role; baseCenter?: string }) => void; + onDelete: () => void; +} + +function PositionEditor(props: PositionEditorProps) { + const [name, setName] = useState(props.name); + const nameError = + name !== props.name && + (name.trim() === "" || + !POSITION_NAME_RE.test(name) || + props.positionNames.includes(name)); + + function commitName() { + if (!nameError && name !== props.name) props.onRename(name); + else setName(props.name); + } + + return ( + + setName(e.target.value)} + onBlur={commitName} + onKeyDown={(e) => e.key === "Enter" && commitName()} + /> + {props.use && ( + + + props.onSetStart({ + role: (e.target.value || undefined) as Role | undefined, + }) + } + > + + none + + {ROLES.map((r) => ( + + {r} + + ))} + + + props.onSetStart({ baseCenter: e.target.value || undefined }) + } + > + + none + + {props.positionNames.map((n) => ( + + {n} + + ))} + + + )} + + {props.teamCount > 0 && ( + + props.onSetTeam( + e.target.value === "" ? null : Number(e.target.value) + ) + } + > + + not in this config + + {Array.from({ length: props.teamCount }, (_, i) => ( + + Team {i + 1} + + ))} + + )} + + + + props.onMove({ x: Number(e.target.value), y: props.pos.y }) + } + /> + + props.onMove({ x: props.pos.x, y: Number(e.target.value) }) + } + /> + + + + + ); +} diff --git a/src/components/fields/MapStartPos/DisplayCell.tsx b/src/components/fields/MapStartPos/DisplayCell.tsx new file mode 100644 index 000000000..624f0f4a1 --- /dev/null +++ b/src/components/fields/MapStartPos/DisplayCell.tsx @@ -0,0 +1,28 @@ +import { IDisplayCellProps } from "@src/components/fields/types"; +import MapStartPos from "@src/components/MapStartPos"; +import { useMapMeta } from "./mapMetaEffect"; + +export default function MapStartPosView({ + value, + column, + _rowy_ref, + rowHeight, +}: IDisplayCellProps) { + const { textureUrl, dimensions } = useMapMeta(_rowy_ref, column); + if (textureUrl === null) { + return <>No image texture URL; + } + if (dimensions === null) { + return <>No map dimensions; + } + return ( +
+ +
+ ); +} diff --git a/src/components/fields/MapStartPos/Settings.tsx b/src/components/fields/MapStartPos/Settings.tsx new file mode 100644 index 000000000..a0c9c0ebb --- /dev/null +++ b/src/components/fields/MapStartPos/Settings.tsx @@ -0,0 +1,30 @@ +import { ISettingsProps } from "@src/components/fields/types"; +import { TextField } from "@mui/material"; + +const Settings = ({ config, onChange }: ISettingsProps) => { + return ( + <> + onChange("mapTextureParentTable")(e.target.value)} + value={config.mapTextureParentTable} + /> + onChange("mapTextureUrlPath")(e.target.value)} + /> + onChange("mapDimensionsPath")(e.target.value)} + /> + + ); +}; +export default Settings; diff --git a/src/components/fields/MapStartPos/SideDrawerField.tsx b/src/components/fields/MapStartPos/SideDrawerField.tsx new file mode 100644 index 000000000..ec3174635 --- /dev/null +++ b/src/components/fields/MapStartPos/SideDrawerField.tsx @@ -0,0 +1,79 @@ +import { useState } from "react"; +import { ISideDrawerFieldProps } from "@src/components/fields/types"; +import MapStartPos from "@src/components/MapStartPos"; +import { StartPos } from "@src/components/startpos/types"; +import { Alert, Button } from "@mui/material"; +import EditIcon from "@mui/icons-material/Edit"; +import { useMapMeta } from "./mapMetaEffect"; + +// In the grid, rowy renders this inside the cell popover and passes +// showPopoverCell, so we open the editor immediately and dismiss the popover on +// close. In the side drawer there's no popover, so we open it behind a button. +type Props = ISideDrawerFieldProps & { + showPopoverCell?: (open: boolean) => void; +}; + +export default function MapStartPosPanel({ + value, + disabled, + onChange, + onSubmit, + _rowy_ref, + column, + showPopoverCell, +}: Props) { + const { textureUrl, dimensions } = useMapMeta(_rowy_ref, column); + const [open, setOpen] = useState(false); + + const updated = (startPos: StartPos) => { + onChange(startPos); + onSubmit(); + }; + + if (textureUrl === null) { + return No image texture URL; + } + if (dimensions === null) { + return ( + + Map dimensions missing or unparseable (expected "W x H"). + + ); + } + + if (showPopoverCell) { + return ( + showPopoverCell(false)} + /> + ); + } + + return ( + <> + + {open && ( + setOpen(false)} + /> + )} + + ); +} diff --git a/src/components/fields/MapStartPos/index.tsx b/src/components/fields/MapStartPos/index.tsx new file mode 100644 index 000000000..9dc565fa5 --- /dev/null +++ b/src/components/fields/MapStartPos/index.tsx @@ -0,0 +1,49 @@ +import { lazy } from "react"; +import { IFieldConfig, FieldType } from "@src/components/fields/types"; +import withRenderTableCell from "@src/components/Table/TableCell/withRenderTableCell"; + +import MapStartPosIcon from "@mui/icons-material/PinDrop"; +import DisplayCell from "./DisplayCell"; + +const SideDrawerField = lazy( + () => + import( + "./SideDrawerField" /* webpackChunkName: "SideDrawerField-MapStartPos" */ + ) +); + +const Settings = lazy( + () => import("./Settings" /* webpackChunkName: "Settings-MapStartPos" */) +); + +export const config: IFieldConfig = { + type: FieldType.mapStartPos, + name: "Map StartPos", + group: "BAR Custom", + dataType: + "{ positions: Record; team?: any[]; }", + initialValue: { positions: {} }, + defaultConfig: { + mapTextureParentTable: 0, + mapTextureUrlPath: "startboxTextureUrl", + mapDimensionsPath: "dimensions", + }, + icon: , + description: "Map StartPos", + TableCell: withRenderTableCell(DisplayCell, SideDrawerField, "popover", { + usesRowData: true, + disablePadding: true, + }), + SideDrawerField, + settings: Settings, + csvExportFormatter: (value: any) => + JSON.stringify(value ?? { positions: {} }), + csvImportParser: (value: string) => { + try { + return JSON.parse(value); + } catch { + return value; + } + }, +}; +export default config; diff --git a/src/components/fields/MapStartPos/mapMetaEffect.ts b/src/components/fields/MapStartPos/mapMetaEffect.ts new file mode 100644 index 000000000..f33b3653b --- /dev/null +++ b/src/components/fields/MapStartPos/mapMetaEffect.ts @@ -0,0 +1,53 @@ +import { useState, useEffect } from "react"; + +import { useAtom } from "jotai"; +import { firebaseDbAtom } from "@src/sources/ProjectSourceFirebase"; +import { projectScope } from "@src/atoms/projectScope"; +import { onSnapshot, doc } from "firebase/firestore"; +import type { ColumnConfig, TableRowRef } from "@src/types/table"; + +import { + parseDimensions, + MapDimensions, +} from "@src/components/startpos/geometry"; + +export interface MapMeta { + textureUrl: string | null; + dimensions: MapDimensions | null; +} + +// Reads the map texture URL and the map's dimensions ("W x H" in map units) +// from the row (or a parent row) that holds them. +export function useMapMeta( + _rowy_ref: TableRowRef, + column: ColumnConfig +): MapMeta { + const [firebaseDb] = useAtom(firebaseDbAtom, projectScope); + const [meta, setMeta] = useState({ + textureUrl: null, + dimensions: null, + }); + + const parent = column.config?.mapTextureParentTable; + const urlField = column.config?.mapTextureUrlPath; + const dimField = column.config?.mapDimensionsPath; + + useEffect(() => { + if (!firebaseDb) return; + if (parent === undefined || !urlField || !dimField) return; + + // parent counts how many tables up the texture row lives; 0 = this row. + const segments = _rowy_ref.path.split("/"); + const path = segments.slice(0, segments.length - parent * 2); + if (path.length === 0) return; + + return onSnapshot(doc(firebaseDb, path.join("/")), (snap) => { + setMeta({ + textureUrl: snap.get(urlField) || null, + dimensions: parseDimensions(snap.get(dimField)), + }); + }); + }, [firebaseDb, _rowy_ref.path, parent, urlField, dimField]); + + return meta; +} diff --git a/src/components/fields/index.ts b/src/components/fields/index.ts index 4b16d1f2f..e90296d97 100644 --- a/src/components/fields/index.ts +++ b/src/components/fields/index.ts @@ -44,6 +44,7 @@ import CreatedAt from "./CreatedAt"; import UpdatedAt from "./UpdatedAt"; import User from "./User"; import Id from "./Id"; +import MapStartPos from "./MapStartPos"; import { ColumnConfig } from "@src/types/table"; // Export field configs in order for FieldsDropdown @@ -99,6 +100,8 @@ export const FIELDS: IFieldConfig[] = [ /** METADATA */ User, Id, + /** BAR Custom */ + MapStartPos, ]; /** diff --git a/src/components/fields/types.ts b/src/components/fields/types.ts index a11e1e10f..8b7c1ccab 100644 --- a/src/components/fields/types.ts +++ b/src/components/fields/types.ts @@ -32,6 +32,8 @@ export interface IFieldConfig { SideDrawerField: React.ComponentType; settings?: React.ComponentType; settingsValidator?: (config: Record) => Record; + /** Seeded into a new column's `config` when a column of this type is created */ + defaultConfig?: Record; filter?: { operators: IFilterOperator[]; customInput?: React.ComponentType; diff --git a/src/components/startpos/geometry.ts b/src/components/startpos/geometry.ts new file mode 100644 index 000000000..a2d17b03b --- /dev/null +++ b/src/components/startpos/geometry.ts @@ -0,0 +1,28 @@ +import { Position, ELMOS_PER_UNIT } from "./types"; + +export interface MapDimensions { + widthElmos: number; + heightElmos: number; +} + +// "12 x 20" -> { widthElmos: 6144, heightElmos: 10240 } +export function parseDimensions( + dimensions: string | null | undefined +): MapDimensions | null { + if (!dimensions) return null; + const m = String(dimensions).match( + /^\s*(\d+(?:\.\d+)?)\s*[x×]\s*(\d+(?:\.\d+)?)\s*$/i + ); + if (!m) return null; + const w = parseFloat(m[1]); + const h = parseFloat(m[2]); + if (!(w > 0) || !(h > 0)) return null; + return { widthElmos: w * ELMOS_PER_UNIT, heightElmos: h * ELMOS_PER_UNIT }; +} + +export function clampToBounds(p: Position, dims: MapDimensions): Position { + return { + x: Math.round(Math.min(Math.max(p.x, 0), dims.widthElmos)), + y: Math.round(Math.min(Math.max(p.y, 0), dims.heightElmos)), + }; +} diff --git a/src/components/startpos/serialization.ts b/src/components/startpos/serialization.ts new file mode 100644 index 000000000..07b352c78 --- /dev/null +++ b/src/components/startpos/serialization.ts @@ -0,0 +1,78 @@ +import { StartPos, Positions, TeamConf, Role, ROLES } from "./types"; + +const ROLE_SET = new Set(ROLES); + +// The stored cell value is normally a Firestore map, but tolerate a JSON +// string too (some columns store startPos as text). +export function loadStartPos(value: unknown): StartPos { + let raw: any = value; + if (typeof raw === "string") { + const trimmed = raw.trim(); + if (trimmed === "") return { positions: {} }; + try { + raw = JSON.parse(trimmed); + } catch { + return { positions: {} }; + } + } + if (!raw || typeof raw !== "object") return { positions: {} }; + + const positions: Positions = {}; + if (raw.positions && typeof raw.positions === "object") { + for (const [name, p] of Object.entries(raw.positions)) { + if (p && typeof p === "object") { + positions[name] = { x: Number(p.x) || 0, y: Number(p.y) || 0 }; + } + } + } + + const team: TeamConf[] = Array.isArray(raw.team) + ? raw.team.map((t: any) => ({ + playersPerTeam: Number(t?.playersPerTeam) || 1, + teamCount: Number(t?.teamCount) || 1, + sides: Array.isArray(t?.sides) + ? t.sides.map((s: any) => ({ + starts: Array.isArray(s?.starts) + ? s.starts.map((start: any) => { + const out: { + spawnPoint: string; + baseCenter?: string; + role?: Role; + } = { spawnPoint: String(start?.spawnPoint ?? "") }; + if (start?.baseCenter) + out.baseCenter = String(start.baseCenter); + if (start?.role && ROLE_SET.has(start.role)) + out.role = start.role; + return out; + }) + : [], + })) + : [], + })) + : []; + + return { positions, team }; +} + +// Editor state -> stored cell value. Drops empty team[] and optional fields +// that aren't set so the saved JSON matches the schema's expectations. +export function saveStartPos(sp: StartPos): StartPos { + const out: StartPos = { positions: sp.positions }; + if (sp.team && sp.team.length > 0) { + out.team = sp.team.map((t) => ({ + playersPerTeam: t.playersPerTeam, + teamCount: t.teamCount, + sides: t.sides.map((s) => ({ + starts: s.starts.map((start) => { + const o: { spawnPoint: string; baseCenter?: string; role?: Role } = { + spawnPoint: start.spawnPoint, + }; + if (start.baseCenter) o.baseCenter = start.baseCenter; + if (start.role) o.role = start.role; + return o; + }), + })), + })); + } + return out; +} diff --git a/src/components/startpos/state.ts b/src/components/startpos/state.ts new file mode 100644 index 000000000..2a914ef65 --- /dev/null +++ b/src/components/startpos/state.ts @@ -0,0 +1,190 @@ +import { Position, Positions, Start, TeamConf, StartPos } from "./types"; + +function clone(v: T): T { + return JSON.parse(JSON.stringify(v)); +} + +// Immutable editor state for a whole startPos document. +export class StartPosState { + constructor( + public readonly positions: Positions, + public readonly team: TeamConf[] + ) {} + + static fromStartPos(sp: StartPos): StartPosState { + return new StartPosState(clone(sp.positions), clone(sp.team ?? [])); + } + + toStartPos(): StartPos { + return { positions: this.positions, team: this.team }; + } + + // Lowest unused "P" name. + nextPositionName(): string { + let n = 1; + while (`P${n}` in this.positions) n++; + return `P${n}`; + } + + addPosition(name: string, pos: Position): StartPosState { + return new StartPosState({ ...this.positions, [name]: pos }, this.team); + } + + movePosition(name: string, pos: Position): StartPosState { + if (!(name in this.positions)) return this; + return new StartPosState({ ...this.positions, [name]: pos }, this.team); + } + + removePosition(name: string): StartPosState { + if (!(name in this.positions)) return this; + const positions = { ...this.positions }; + delete positions[name]; + + // Drop refs to the deleted position so a config can't carry a dangling + // spawnPoint/baseCenter. + const team = this.team.map((t) => ({ + ...t, + sides: t.sides.map((s) => ({ + starts: s.starts + .filter((start) => start.spawnPoint !== name) + .map((start) => + start.baseCenter === name + ? { ...start, baseCenter: undefined } + : start + ), + })), + })); + + return new StartPosState(positions, team); + } + + // Rename a position and update every spawnPoint/baseCenter that referenced it. + renamePosition(oldName: string, newName: string): StartPosState { + if (oldName === newName || !(oldName in this.positions)) return this; + + const positions: Positions = {}; + for (const [k, v] of Object.entries(this.positions)) { + positions[k === oldName ? newName : k] = v; + } + + const team = this.team.map((t) => ({ + ...t, + sides: t.sides.map((s) => ({ + starts: s.starts.map((start) => { + const next = { ...start }; + if (next.spawnPoint === oldName) next.spawnPoint = newName; + if (next.baseCenter === oldName) next.baseCenter = newName; + return next; + }), + })), + })); + + return new StartPosState(positions, team); + } + + private updateTeam( + idx: number, + fn: (t: TeamConf) => TeamConf + ): StartPosState { + const team = this.team.map((t, i) => (i === idx ? fn(t) : t)); + return new StartPosState(this.positions, team); + } + + // New configs start with empty teams; spawns are assigned by clicking + // markers on the map (setSpawnTeam), not pre-filled. + addTeam(): StartPosState { + const conf: TeamConf = { + playersPerTeam: 1, + teamCount: 2, + sides: [{ starts: [] }, { starts: [] }], + }; + return new StartPosState(this.positions, [...this.team, conf]); + } + + removeTeam(idx: number): StartPosState { + return new StartPosState( + this.positions, + this.team.filter((_, i) => i !== idx) + ); + } + + setTeamCount(idx: number, teamCount: number): StartPosState { + const n = Math.max(1, Math.floor(teamCount) || 1); + return this.updateTeam(idx, (t) => { + const sides = t.sides.slice(0, n); + while (sides.length < n) sides.push({ starts: [] }); + return { ...t, teamCount: n, sides }; + }); + } + + // playersPerTeam is the declared target; actual assignment is validated + // against it rather than auto-filled. + setPlayersPerTeam(idx: number, players: number): StartPosState { + const n = Math.max(1, Math.floor(players) || 1); + return this.updateTeam(idx, (t) => ({ ...t, playersPerTeam: n })); + } + + // Move a spawn to a team (side) within a config, or remove it from the + // config when sideIdx is null. Preserves the spawn's role/baseCenter. + setSpawnTeam( + configIdx: number, + spawnName: string, + sideIdx: number | null + ): StartPosState { + const team = this.team[configIdx]; + if (!team) return this; + + let carried: Start | undefined; + const sides = team.sides.map((s) => ({ + starts: s.starts.filter((st) => { + if (st.spawnPoint === spawnName) { + carried = st; + return false; + } + return true; + }), + })); + + if (sideIdx !== null && sideIdx >= 0 && sideIdx < sides.length) { + const start: Start = { spawnPoint: spawnName }; + if (carried?.role) start.role = carried.role; + if (carried?.baseCenter) start.baseCenter = carried.baseCenter; + sides[sideIdx] = { starts: [...sides[sideIdx].starts, start] }; + } + + return new StartPosState( + this.positions, + this.team.map((t, i) => (i === configIdx ? { ...t, sides } : t)) + ); + } + + setStart( + teamIdx: number, + sideIdx: number, + startIdx: number, + patch: Partial + ): StartPosState { + return this.updateTeam(teamIdx, (t) => ({ + ...t, + sides: t.sides.map((s, si) => + si !== sideIdx + ? s + : { + starts: s.starts.map((start, sti) => + sti !== startIdx + ? start + : normalizeStart({ ...start, ...patch }) + ), + } + ), + })); + } +} + +// Drop optional keys when cleared so saved JSON stays minimal. +function normalizeStart(start: Start): Start { + const out: Start = { spawnPoint: start.spawnPoint }; + if (start.baseCenter) out.baseCenter = start.baseCenter; + if (start.role) out.role = start.role; + return out; +} diff --git a/src/components/startpos/types.ts b/src/components/startpos/types.ts new file mode 100644 index 000000000..ab202213a --- /dev/null +++ b/src/components/startpos/types.ts @@ -0,0 +1,56 @@ +// startPos data model, matching the maps-metadata schema (StartPosConf). +// https://github.com/beyond-all-reason/maps-metadata/wiki/Rowy-Maps-fields-legend + +export const ROLES = [ + "air", + "air/front", + "air/sea", + "air/tech", + "front", + "front/sea", + "front/tech", + "sea", + "sea/tech", + "tech", +] as const; + +export type Role = typeof ROLES[number]; + +export interface Position { + x: number; + y: number; +} + +export type Positions = Record; + +export interface Start { + spawnPoint: string; + baseCenter?: string; + role?: Role; +} + +export interface Side { + starts: Start[]; +} + +export interface TeamConf { + playersPerTeam: number; + teamCount: number; + sides: Side[]; +} + +export interface StartPos { + positions: Positions; + team?: TeamConf[]; +} + +// Position names: digits, letters, spaces, and _ . - +export const POSITION_NAME_RE = /^[a-zA-Z0-9 _.-]+$/; + +// dimensions are stored as map units (e.g. "12 x 20"); 1 unit = 512 elmos. +export const ELMOS_PER_UNIT = 512; + +// 2 teams x 1 player -> "1v1"; 3 teams x 2 -> "2v2v2". +export function configLabel(teamCount: number, playersPerTeam: number): string { + return Array.from({ length: teamCount }, () => playersPerTeam).join("v"); +} diff --git a/src/components/startpos/validation.ts b/src/components/startpos/validation.ts new file mode 100644 index 000000000..096cba039 --- /dev/null +++ b/src/components/startpos/validation.ts @@ -0,0 +1,45 @@ +import { StartPos, configLabel } from "./types"; + +export interface ConfigError { + configIdx: number; + message: string; +} + +// Same checks as maps-metadata scripts/js/src/check_startpos.ts, so map makers +// hit the same problems here rather than at metadata-generation time. +export function validateStartPos(sp: StartPos): ConfigError[] { + const errors: ConfigError[] = []; + const positionNames = new Set(Object.keys(sp.positions)); + const seenConfs = new Set(); + + (sp.team || []).forEach((team, ti) => { + const label = configLabel(team.teamCount, team.playersPerTeam); + const add = (message: string) => errors.push({ configIdx: ti, message }); + + const confKey = `${team.teamCount}|${team.playersPerTeam}`; + if (seenConfs.has(confKey)) add(`Duplicate ${label} config`); + seenConfs.add(confKey); + + if (team.teamCount !== team.sides.length) + add(`Needs ${team.teamCount} teams but has ${team.sides.length}`); + + team.sides.forEach((side, si) => { + const short = team.playersPerTeam - side.starts.length; + const n = Math.abs(short); + const noun = `start position${n === 1 ? "" : "s"}`; + if (short > 0) add(`Team ${si + 1} needs ${n} more ${noun}`); + else if (short < 0) add(`Team ${si + 1} has ${n} ${noun} too many`); + + side.starts.forEach((start, sti) => { + const where = `Team ${si + 1} start ${sti + 1}`; + if (!start.spawnPoint) add(`${where} is empty`); + else if (!positionNames.has(start.spawnPoint)) + add(`${where} points to missing position "${start.spawnPoint}"`); + if (start.baseCenter && !positionNames.has(start.baseCenter)) + add(`${where} points to missing base "${start.baseCenter}"`); + }); + }); + }); + + return errors; +} diff --git a/src/constants/fields.ts b/src/constants/fields.ts index b05c6b896..8fdf2797c 100644 --- a/src/constants/fields.ts +++ b/src/constants/fields.ts @@ -52,4 +52,6 @@ export enum FieldType { user = "USER", id = "ID", last = "LAST", + // BAR CUSTOM + mapStartPos = "MAP_STARTPOS", }