Rebuild the SCUM live map with zoom, pan, and trajectory tracking

This commit is contained in:
npc0-hue
2026-09-16 10:48:54 +08:00
parent f2a9c7b1ca
commit 0ce264836f
5 changed files with 401 additions and 56 deletions
+27 -1
View File
@@ -775,7 +775,33 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))}
.console-stat-strip>div,.operations-pulse-strip>div{display:grid;gap:3px;min-width:0;padding:9px 10px;border:1px solid color-mix(in srgb,var(--line) 78%,transparent);border-radius:6px;background:color-mix(in srgb,var(--surface-solid) 78%,var(--accent-soft))}
.console-stat-strip dt,.operations-pulse-strip dt{color:var(--ink-faint);font-size:11px}
.console-stat-strip dd,.operations-pulse-strip dd{margin:0;color:var(--ink);font-size:18px;font-weight:850}
.map-surface-board{position:relative;min-height:clamp(420px,62vh,880px);border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);background-size:cover;background-position:center}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%);cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-surface-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-trajectory-dot{position:absolute;z-index:2;width:4px;height:4px;border-radius:999px;background:color-mix(in srgb,var(--accent) 82%,transparent);box-shadow:0 0 8px color-mix(in srgb,var(--accent) 66%,transparent);transform:translate(-50%,-50%);pointer-events:none}.map-trajectory-dot.map-layer-vehicles{width:5px;height:5px;background:color-mix(in srgb,var(--gold) 86%,transparent)}
.map-viewport{position:relative;height:clamp(420px,64vh,880px);border:1px solid color-mix(in srgb,var(--line) 76%,transparent);border-radius:14px;overflow:hidden;background:radial-gradient(circle at 50% 50%,color-mix(in srgb,var(--accent-soft) 42%,transparent),transparent 58%),linear-gradient(135deg,color-mix(in srgb,var(--surface-solid) 78%,#000),#05070d);touch-action:none;cursor:grab;user-select:none;-webkit-user-select:none}
.map-viewport:active{cursor:grabbing}
.map-scene{position:absolute;top:50%;left:50%;height:100%;aspect-ratio:1;transform-origin:center;will-change:transform}
.map-scene-image{position:absolute;inset:0;background-repeat:no-repeat;background-size:100% 100%;background-position:center}
.map-track-layer{position:absolute;inset:0;z-index:2;width:100%;height:100%;pointer-events:none}.map-grid-overlay{position:absolute;inset:0;z-index:1;pointer-events:none}.map-grid-line{position:absolute;background:color-mix(in srgb,var(--line) 62%,transparent)}.map-grid-line-v{top:0;bottom:0;width:1px}.map-grid-line-h{left:0;right:0;height:1px}.map-grid-label{position:absolute;transform:translate(-50%,-50%);padding:1px 5px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);text-shadow:0 1px 4px rgba(0,0,0,.55)}.map-grid-col-label{top:10px}.map-grid-row-label{left:12px}.map-surface-dot{position:absolute;z-index:3;width:9px;height:9px;padding:0;border:0;border-radius:999px;background:var(--accent);box-shadow:0 0 16px color-mix(in srgb,var(--accent) 80%,transparent);transform:translate(-50%,-50%) scale(var(--map-inverse,1));cursor:pointer}.map-surface-dot img{display:block;width:100%;height:100%;object-fit:contain;filter:drop-shadow(0 0 8px color-mix(in srgb,var(--accent) 76%,transparent))}.map-surface-dot.map-layer-vehicles{width:26px;height:26px;background:color-mix(in srgb,var(--surface-solid) 72%,transparent)}.map-surface-dot.map-layer-flags{background:var(--gold)}.map-surface-dot.map-layer-regions{background:var(--success)}.map-surface-dot-riding{outline:2px solid var(--gold);box-shadow:0 0 0 4px color-mix(in srgb,var(--gold) 24%,transparent),0 0 18px color-mix(in srgb,var(--gold) 80%,transparent)}.map-view-controls{position:absolute;top:10px;right:10px;z-index:6;display:flex;align-items:center;gap:4px;padding:4px;border:1px solid color-mix(in srgb,var(--line) 72%,transparent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);-webkit-backdrop-filter:blur(8px);backdrop-filter:blur(8px)}
.map-view-controls .icon-command{min-height:24px;padding:0 8px;font-size:11px}
.map-zoom-badge{padding:0 6px;color:var(--ink-faint);font:800 10px/1 var(--font-mono)}
.map-track-endpoint{position:absolute;z-index:4;display:grid;place-items:center;width:18px;height:18px;border:1px solid var(--accent);border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 84%,transparent);color:var(--ink);font:800 10px/1 var(--font-mono);transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none}
.map-track-endpoint.map-layer-vehicles{border-color:var(--gold)}
.map-ride-marker{position:absolute;z-index:4;display:grid;place-items:center;width:22px;height:22px;border:1px solid color-mix(in srgb,var(--gold) 70%,var(--line));border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 76%,transparent);color:var(--gold);font:800 9px/1 var(--font-mono);transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none}
.map-ride-marker img{width:100%;height:100%;object-fit:contain}
.map-track-swatch{display:inline-block;width:10px;height:10px;margin-right:6px;border-radius:999px;vertical-align:middle}
.map-playback-bar{display:flex;align-items:center;gap:8px;flex-wrap:wrap;padding:6px 10px;border:1px solid color-mix(in srgb,var(--line) 70%,transparent);border-radius:10px;background:var(--control-surface)}
.map-playback-bar input[type=range]{flex:1 1 240px;min-width:120px;min-height:22px;margin:0;accent-color:var(--accent)}
.map-playback-bar .page-status{margin:0}
.scum-map-menu{position:relative;flex:0 0 auto}
.scum-map-menu>summary{display:inline-flex;align-items:center;min-height:26px;padding:0 9px;font-size:11px;list-style:none;cursor:pointer}
.scum-map-menu>summary::-webkit-details-marker{display:none}
.scum-map-menu[open]>summary{border-color:var(--accent);color:var(--ink)}
.scum-map-menu-body{position:absolute;top:calc(100% + 6px);left:0;z-index:9;display:grid;gap:6px;width:max-content;max-width:320px;padding:10px;border:1px solid var(--line-strong);border-radius:10px;background:var(--panel-material),var(--surface-solid);box-shadow:var(--panel-shadow)}
.scum-map-menu-body .scum-layer-toggle{justify-content:flex-start}
.scum-map-menu-body .page-status{margin:0;font-size:11px;white-space:normal}
.scum-map-bar{gap:6px}
.scum-map-bar .console-field{flex:0 0 auto}
.scum-map-bar select{min-height:26px;min-width:0;padding:0 8px;font-size:12px}
.scum-map-bar .page-status{font-size:11px}
.scum-map-tracks .console-row.is-selected{border-color:var(--accent);color:var(--ink)}
.console-row-list,.operations-endpoint-list,.operations-job-list{display:grid;gap:6px;margin-top:10px}
.console-row,.operations-endpoint-row,.operations-job-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;align-items:center;gap:10px;min-width:0;padding:8px 10px;border:1px solid var(--line);border-radius:6px;background:var(--control-surface);color:var(--ink-soft);text-align:left}
.console-row-button,.operations-job-row{width:100%;cursor:pointer}
@@ -29,16 +29,36 @@ type ScumMapLayer = "terrain" | "topo" | "night";
type CatalogPickTarget = "gift-items" | "produce-item";
type PlayerPanelKind = "closed" | "gifts" | "items" | "history" | "trajectory";
type PlayerPanelState = { kind: PlayerPanelKind; playerId: string };
type MapViewState = { zoom: number; x: number; y: number };
type MapPlaybackState = { playing: boolean; value: number; speed: number };
type MapTimeWindow = { from: number; to: number; label: string };
type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean };
type MapTrackSegment = { key: string; mode: "walk" | "ride"; points: MapTrackPoint[] };
type MapTrack = { key: string; kind: "player" | "vehicle"; label: string; color: string; points: MapTrackPoint[]; walk: number; ride: number; from: number; to: number; vehicles: string[] };
type PointerEventLike = { pointerId?: number; clientX?: number; clientY?: number; button?: number; currentTarget?: { setPointerCapture?: (pointerId?: number) => void; releasePointerCapture?: (pointerId?: number) => void }; preventDefault?: () => void; stopPropagation?: () => void };
const scumMapLayerImages = { terrain: new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href, topo: new URL("../assets/map/scum-map-topo-4096.webp", import.meta.url).href, night: new URL("../assets/map/scum-map-night-4096.webp", import.meta.url).href, zones: new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href } as const;
const scumMapBaseLayers: ReadonlyArray<readonly [ScumMapLayer, string]> = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]];
const scumCatalogKinds: ReadonlyArray<readonly [string, string]> = [["all", "全部"], ["item", "物品"], ["vehicle", "载具"], ["zombie", "丧尸"], ["animal", "动物"], ["armed-npc", "NPC"]];
const scumCatalogPickLimit = 48;
const scumMapSize = 4096;
const scumSurfaceRefreshMs = 15000;
const scumMapZoomMin = 1;
const scumMapZoomMax = 8;
const scumMapTrackPointLimit = 400;
const scumMapTrackLimit = 24;
const scumMapSegmentGapMs = 15 * 60 * 1000;
const scumMapSegmentJump = 52000;
const scumMapDefaultView: MapViewState = { zoom: 1, x: 0, y: 0 };
const scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1 };
const scumMapTimePresets: ReadonlyArray<readonly [string, string]> = [["all", "全部时段"], ["15m", "最近 15 分钟"], ["1h", "最近 1 小时"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["custom", "自定义起止"]];
const scumMapPresetMs: Record<string, number> = { "15m": 15 * 60 * 1000, "1h": 60 * 60 * 1000, "6h": 6 * 60 * 60 * 1000, "24h": 24 * 60 * 60 * 1000, "7d": 7 * 24 * 60 * 60 * 1000 };
const scumMapTrackColors = ["#4ECDC4", "#FF8A6B", "#F7DC6F", "#85C1E9", "#BB8FCE", "#98D8C8", "#FFA07A", "#45B7D1"];
const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false };
export type ReactLike = {
createElement: (...args: any[]) => any;
useEffect?: (effect: () => void | (() => void), deps: readonly unknown[]) => void;
useRef?: <T>(initial: T) => { current: T };
useState?: <T>(initialState: T | (() => T)) => [T, StateSetter<T>];
};
@@ -99,18 +119,19 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const [giftAchievementNumber, setGiftAchievementNumber] = usePluginState(react, "0");
const [deliveryGift, setDeliveryGift] = usePluginState(react, "");
const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, "");
const [mapSearch, setMapSearch] = usePluginState(react, "");
const [mapLayers, setMapLayers] = usePluginState<Record<MapLayer, boolean>>(react, { players: true, vehicles: true, flags: true, regions: true, other: true });
const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, "");
const [mapCustomEnabled, setMapCustomEnabled] = usePluginState<boolean | undefined>(react, undefined);
const [mapCenterX, setMapCenterX] = usePluginState(react, "");
const [mapCenterY, setMapCenterY] = usePluginState(react, "");
const [mapWidthKm, setMapWidthKm] = usePluginState(react, "");
const [mapHeightKm, setMapHeightKm] = usePluginState(react, "");
const [mapView, setMapView] = usePluginState<MapViewState>(react, scumMapDefaultView);
const [mapTimePreset, setMapTimePreset] = usePluginState(react, "all");
const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, "");
const [mapTimeTo, setMapTimeTo] = usePluginState(react, "");
const [mapUsers, setMapUsers] = usePluginState<string[] | undefined>(react, undefined);
const [mapTrackKey, setMapTrackKey] = usePluginState(react, "");
const [mapPlayback, setMapPlayback] = usePluginState<MapPlaybackState>(react, scumMapPlaybackIdle);
const mapViewportRef = react.useRef ? react.useRef<HTMLElement | null>(null) : { current: null as HTMLElement | null };
const [eventEditorOpen, setEventEditorOpen] = usePluginState(react, false);
const [produceEditorOpen, setProduceEditorOpen] = usePluginState(react, false);
const [giftEditorOpen, setGiftEditorOpen] = usePluginState(react, false);
const [mapSettingsOpen, setMapSettingsOpen] = usePluginState(react, false);
const [mapBaseLayer, setMapBaseLayer] = usePluginState<ScumMapLayer | undefined>(react, undefined);
const [mapZonesOverlay, setMapZonesOverlay] = usePluginState<boolean | undefined>(react, undefined);
const [catalogSearch, setCatalogSearch] = usePluginState(react, "");
@@ -141,6 +162,23 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
const interval = setInterval(refresh, scumSurfaceRefreshMs);
return () => { disposed = true; generation += 1; clearInterval(interval); };
}, [input.serverInstanceId, pageKey, input.workspaceActions, playerPanel.kind, refreshSignal]);
if (react.useEffect) react.useEffect(() => {
const node = mapViewportRef.current;
if (pageKey !== "live-map" || !node || typeof node.addEventListener !== "function") return;
const onWheel = (event: { deltaY: number; clientX: number; clientY: number; preventDefault: () => void }) => {
event.preventDefault();
const rect = node.getBoundingClientRect();
setMapView((previous) => zoomMapView(previous, event.deltaY < 0 ? 1.16 : 1 / 1.16, event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2));
};
node.addEventListener("wheel", onWheel, { passive: false });
setMapView((previous) => (previous.zoom === scumMapZoomMin && previous.x === 0 && previous.y === 0 ? defaultMapView(node) : previous));
return () => { node.removeEventListener("wheel", onWheel); };
}, [pageKey, state.status, mapViewportRef, setMapView]);
if (react.useEffect) react.useEffect(() => {
if (!mapPlayback.playing) return;
const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90);
return () => { clearInterval(timer); };
}, [mapPlayback.playing, setMapPlayback]);
const data = state.status === "ready" ? state.data : emptySCUMSurfaceData;
return e("section", { className: "console-panel scum-workbench", "aria-label": input.pageTitle ?? surfaceTitle(pageKey) },
@@ -153,9 +191,10 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext)
eventCommand, setEventCommand, eventClass, setEventClass, eventPlacard, setEventPlacard, eventPercent, setEventPercent, eventDuration, setEventDuration, eventNpc, setEventNpc, eventItem, setEventItem, eventZombie, setEventZombie, eventAnimal, setEventAnimal,
produceEventId, setProduceEventId, produceId, setProduceId, produceTradeGoodsId, setProduceTradeGoodsId, producePercent, setProducePercent, produceValue, setProduceValue, produceRadius, setProduceRadius, produceX, setProduceX, produceY, setProduceY, produceZ, setProduceZ,
giftTab, setGiftTab, giftCode, setGiftCode, giftName, setGiftName, giftItems, setGiftItems, giftCommands, setGiftCommands, giftClass, setGiftClass, giftAudience, setGiftAudience, giftNumber, setGiftNumber, giftAchievement, setGiftAchievement, giftAchievementNumber, setGiftAchievementNumber,
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapSearch, setMapSearch, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
mapCustomEnabled, setMapCustomEnabled, mapCenterX, setMapCenterX, mapCenterY, setMapCenterY, mapWidthKm, setMapWidthKm, mapHeightKm, setMapHeightKm,
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, mapSettingsOpen, setMapSettingsOpen,
deliveryGift, setDeliveryGift, deliveryPlayer, setDeliveryPlayer, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint,
mapView, setMapView, mapViewportRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo,
mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback,
eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen,
mapBaseLayer, setMapBaseLayer, mapZonesOverlay, setMapZonesOverlay, catalogSearch, setCatalogSearch, catalogKind, setCatalogKind, catalogOpen, setCatalogOpen,
setAction, refresh: () => setRefreshSignal((value) => value + 1)
}) : null
@@ -178,10 +217,12 @@ type ViewState = {
giftCode: string; setGiftCode: StateSetter<string>; giftName: string; setGiftName: StateSetter<string>; giftItems: string; setGiftItems: StateSetter<string>; giftCommands: string; setGiftCommands: StateSetter<string>;
giftClass: string; setGiftClass: StateSetter<string>; giftAudience: string; setGiftAudience: StateSetter<string>; giftNumber: string; setGiftNumber: StateSetter<string>; giftAchievement: string; setGiftAchievement: StateSetter<string>; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter<string>;
deliveryGift: string; setDeliveryGift: StateSetter<string>; deliveryPlayer: string; setDeliveryPlayer: StateSetter<string>;
mapSearch: string; setMapSearch: StateSetter<string>; mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
mapLayers: Record<MapLayer, boolean>; setMapLayers: StateSetter<Record<MapLayer, boolean>>;
selectedMapPoint: string; setSelectedMapPoint: StateSetter<string>; setAction: StateSetter<ActionState>; refresh: () => void;
mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter<boolean | undefined>; mapCenterX: string; setMapCenterX: StateSetter<string>; mapCenterY: string; setMapCenterY: StateSetter<string>; mapWidthKm: string; setMapWidthKm: StateSetter<string>; mapHeightKm: string; setMapHeightKm: StateSetter<string>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter<boolean>; mapSettingsOpen: boolean; setMapSettingsOpen: StateSetter<boolean>;
mapView: MapViewState; setMapView: StateSetter<MapViewState>; mapViewportRef: { current: HTMLElement | null };
mapTimePreset: string; setMapTimePreset: StateSetter<string>; mapTimeFrom: string; setMapTimeFrom: StateSetter<string>; mapTimeTo: string; setMapTimeTo: StateSetter<string>;
mapUsers: string[] | undefined; setMapUsers: StateSetter<string[] | undefined>; mapTrackKey: string; setMapTrackKey: StateSetter<string>; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter<MapPlaybackState>;
eventEditorOpen: boolean; setEventEditorOpen: StateSetter<boolean>; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter<boolean>; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter<boolean>;
mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter<ScumMapLayer | undefined>; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter<boolean | undefined>;
catalogSearch: string; setCatalogSearch: StateSetter<string>; catalogKind: string; setCatalogKind: StateSetter<string>; catalogOpen: boolean; setCatalogOpen: StateSetter<boolean>;
};
@@ -530,42 +571,294 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input:
const points = collectMapPoints(data);
const settings = data.mapSettings.find((value) => textField(value, "_recordKey", "id") === "current") ?? data.mapSettings[0];
const bounds = resolveMapBounds(settings);
const customEnabled = view.mapCustomEnabled ?? Boolean(settings && boolField(settings, "customMapEnabled"));
const centerX = view.mapCenterX || textField(settings, "centerX", "mapX") || String((bounds.worldMinX + bounds.worldMaxX) / 2);
const centerY = view.mapCenterY || textField(settings, "centerY", "mapY") || String((bounds.worldMinY + bounds.worldMaxY) / 2);
const widthKm = view.mapWidthKm || textField(settings, "widthKm", "mapWidth") || String((bounds.worldMaxX - bounds.worldMinX) / 100000);
const heightKm = view.mapHeightKm || textField(settings, "heightKm", "mapHeight") || String((bounds.worldMaxY - bounds.worldMinY) / 100000);
const baseLayer = view.mapBaseLayer ?? mapLayerSetting(settings);
const zonesOverlay = view.mapZonesOverlay ?? Boolean(settings && boolField(settings, "buildingZones"));
const search = view.mapSearch.trim().toLowerCase();
const visible = points.filter((point) => view.mapLayers[layerOf(point)] && matchesText(point, search, "name", "label", "subjectId", "subjectType", "layer"));
const window = mapTimeWindow(view);
const catalog = mapVehicleClassIndex(data);
const tracks = mapTracks(data, bounds, window, view, catalog);
const span = mapPlaybackSpan(window, tracks);
const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value;
const zoom = view.mapView.zoom;
const visible = points.filter((point) => view.mapLayers[layerOf(point)]);
const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0];
const trails = visibleTrajectoryPoints(data.trajectories, view.mapLayers, search).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 180).reverse();
const selectedTrails = selected ? trajectoryRecordsForPoint(data.trajectories, selected).sort((left, right) => trajectoryOrder(right) - trajectoryOrder(left)).slice(0, 8) : [];
const selectedLocks = selected && layerOf(selected) === "vehicles" ? vehicleLockRecordsForPoint(data.vehicleLocks, selected).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])).slice(0, 5) : [];
return e("div", { className: "console-record-list" },
e("div", { className: "resource-filter-bar scum-filter-bar" },
labeledField(e, "筛选地图点", e("input", { value: view.mapSearch, "aria-label": "筛选地图点", placeholder: "名称 / 类型 / ID", onChange: (event: InputEvent) => view.setMapSearch(inputValue(event)) })),
e("div", { className: "scum-layer-group", role: "group", "aria-label": "地图图层开关" }, (["players", "vehicles", "flags", "regions", "other"] as MapLayer[]).map((layer) => e("label", { key: layer, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer)))),
e("div", { className: "scum-layer-group", role: "group", "aria-label": "地图底图" },
scumMapBaseLayers.map(([key, label]) => e("button", { key, type: "button", className: baseLayer === key ? "icon-command is-active" : "icon-command", "aria-pressed": baseLayer === key, onClick: () => view.setMapBaseLayer(key) }, label)),
e("label", { className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => view.setMapZonesOverlay(Boolean(event.target?.checked)) }), "建筑区")),
e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个可见点`)
return e("div", { className: "console-record-list scum-map-surface" },
e("div", { className: "resource-filter-bar scum-filter-bar scum-map-bar" },
labeledField(e, "时间", e("select", { value: view.mapTimePreset, "aria-label": "轨迹时间范围", onChange: (event: InputEvent) => view.setMapTimePreset(inputValue(event)) }, scumMapTimePresets.map(([key, label]) => e("option", { key, value: key }, label)))),
view.mapTimePreset === "custom" ? labeledField(e, "起", e("input", { type: "datetime-local", value: view.mapTimeFrom, "aria-label": "轨迹起始时间", onChange: (event: InputEvent) => view.setMapTimeFrom(inputValue(event)) })) : null,
view.mapTimePreset === "custom" ? labeledField(e, "止", e("input", { type: "datetime-local", value: view.mapTimeTo, "aria-label": "轨迹结束时间", onChange: (event: InputEvent) => view.setMapTimeTo(inputValue(event)) })) : null,
mapUserMenu(e, view, mapTrackUsers(data, window)),
mapLayerMenu(e, view, zonesOverlay, (next) => { persistMapLayers(view, actions, baseLayer, next); }),
labeledField(e, "底图", e("select", { value: baseLayer, "aria-label": "地图底图", onChange: (event: InputEvent) => { const next = mapBaseLayerValue(inputValue(event)); view.setMapBaseLayer(next); persistMapLayers(view, actions, next, zonesOverlay); } }, scumMapBaseLayers.map(([key, label]) => e("option", { key, value: key }, label)))),
e("span", { className: "page-status scum-map-summary" }, `${visible.length} / ${points.length} 个点 · ${tracks.length} 条轨迹 · ${window.label}`)
),
e("details", { className: "console-module scum-editor scum-map-settings", open: view.mapSettingsOpen, onToggle: (event: InputEvent) => view.setMapSettingsOpen(detailOpen(event)) }, e("summary", null, e("strong", null, "地图范围与图层"), e("span", { className: "page-status" }, `${mapLayerLabel(baseLayer)}${zonesOverlay ? " + 建筑区" : ""} · ${customEnabled ? "自定义范围" : "SCUM 默认范围"}`)),
e("div", { className: "console-row-actions" },
e("label", null, e("input", { type: "checkbox", checked: customEnabled, "aria-label": "启用自定义地图", onChange: (event: InputEvent) => view.setMapCustomEnabled(Boolean(event.target?.checked)) }), "启用自定义地图"),
labeledField(e, "中心 X", e("input", { value: centerX, "aria-label": "地图中心 X", type: "number", onChange: (event: InputEvent) => view.setMapCenterX(inputValue(event)) })),
labeledField(e, "中心 Y", e("input", { value: centerY, "aria-label": "地图中心 Y", type: "number", onChange: (event: InputEvent) => view.setMapCenterY(inputValue(event)) })),
labeledField(e, "宽度 (公里)", e("input", { value: widthKm, "aria-label": "地图宽度公里", type: "number", onChange: (event: InputEvent) => view.setMapWidthKm(inputValue(event)) })),
labeledField(e, "高度 (公里)", e("input", { value: heightKm, "aria-label": "地图高度公里", type: "number", onChange: (event: InputEvent) => view.setMapHeightKm(inputValue(event)) })),
e("button", { type: "button", className: "primary-command", disabled: !actions?.pluginData, onClick: () => runAction(view.setAction, "正在保存地图范围…", async () => { await saveMapSettings(actions ?? {}, { customMapEnabled: customEnabled, centerX: numberInput(centerX, 0), centerY: numberInput(centerY, 0), widthKm: numberInput(widthKm, 15.24), heightKm: numberInput(heightKm, 15.24), baseLayer, buildingZones: zonesOverlay }); view.refresh(); return "地图范围已保存。"; }) }, "保存地图范围"))
e("div", { className: "map-viewport", "aria-label": "SCUM 地图视图", ref: view.mapViewportRef,
onPointerDown: (event: PointerEventLike) => { if (event.button && event.button !== 0) return; mapDragState.active = true; mapDragState.pointerId = event.pointerId ?? -1; mapDragState.startX = event.clientX ?? 0; mapDragState.startY = event.clientY ?? 0; mapDragState.originX = view.mapView.x; mapDragState.originY = view.mapView.y; mapDragState.moved = false; event.currentTarget?.setPointerCapture?.(event.pointerId); },
onPointerMove: (event: PointerEventLike) => { if (!mapDragState.active || (event.pointerId ?? -1) !== mapDragState.pointerId) return; const deltaX = (event.clientX ?? 0) - mapDragState.startX; const deltaY = (event.clientY ?? 0) - mapDragState.startY; if (Math.abs(deltaX) + Math.abs(deltaY) > 3) mapDragState.moved = true; const originX = mapDragState.originX; const originY = mapDragState.originY; view.setMapView((previous) => ({ ...previous, x: originX + deltaX, y: originY + deltaY })); },
onPointerUp: (event: PointerEventLike) => { if ((event.pointerId ?? -1) !== mapDragState.pointerId) return; mapDragState.active = false; event.currentTarget?.releasePointerCapture?.(event.pointerId); },
onPointerCancel: () => { mapDragState.active = false; },
onDoubleClick: () => { view.setMapView((previous) => zoomMapView(previous, 1.6, 0, 0)); } },
e("div", { className: "map-scene", style: { transform: `translate(-50%, -50%) translate(${view.mapView.x}px, ${view.mapView.y}px) scale(${zoom})`, "--map-inverse": String(1 / zoom) } },
e("div", { className: "map-scene-image", "aria-label": "SCUM 地图图层", style: mapBoardStyle(baseLayer, zonesOverlay) }),
mapGridOverlay(e),
e("svg", { className: "map-track-layer", viewBox: `0 0 ${scumMapSize} ${scumMapSize}`, preserveAspectRatio: "none", "aria-hidden": "true" }, mapTrackShapes(e, tracks, cursor, 18 / zoom)),
mapTrackMarkers(e, tracks, cursor, catalog),
visible.map((point, index) => mapPointMarker(e, point, index, bounds, view))
),
e("div", { className: "map-surface-board", "aria-label": "SCUM 地图图层", style: mapBoardStyle(baseLayer, zonesOverlay) }, mapGridOverlay(e), trails.map((point, index) => e("span", { key: `trail-${index}-${idOf(point, "sample")}`, className: `map-trajectory-dot map-layer-${layerOf(point)}`, title: `${pointTitle(point)} ${dateField(point, "sampledAt", "observedAt")}`, style: mapPointStyle(point, bounds) })), visible.map((point, index) => { const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; return e("button", { key: idOf(point, `point-${index}`), type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => view.setSelectedMapPoint(idOf(point, `point-${index}`)) }, vehicleIconFor(point) ? e("img", { src: vehicleIconFor(point), alt: "" }) : ""); })),
e("div", { className: "map-view-controls" },
e("button", { type: "button", className: "icon-command", "aria-label": "地图放大", onClick: () => view.setMapView((previous) => zoomMapView(previous, 1.25, 0, 0)) }, ""),
e("button", { type: "button", className: "icon-command", "aria-label": "地图缩小", onClick: () => view.setMapView((previous) => zoomMapView(previous, 0.8, 0, 0)) }, ""),
e("button", { type: "button", className: "icon-command", "aria-label": "地图复位", onClick: () => view.setMapView(defaultMapView(view.mapViewportRef.current)) }, "复位"),
e("button", { type: "button", className: "icon-command", "aria-label": "适应轨迹范围", disabled: !tracks.length, onClick: () => view.setMapView(fitMapView(mapTrackPointList(tracks), view.mapViewportRef.current, 0.8)) }, "适应轨迹"),
e("span", { className: "map-zoom-badge" }, `${zoom.toFixed(2)}×`)
)
),
mapPlaybackBar(e, view, span, tracks.length > 0),
mapTrackList(e, view, tracks, catalog),
selected ? mapPointStrip(e, selected, selectedTrails, selectedLocks) : null
);
}
function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState) {
const ride = layerOf(point) === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : "";
const icon = vehicleIconFor(point);
const key = idOf(point, `point-${index}`);
return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layerOf(point)}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, icon ? e("img", { src: icon, alt: "" }) : "");
}
function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>) {
const selected = view.mapUsers;
const summary = selected === undefined ? "全部" : selected.length ? `${selected.length}/${users.length}` : "未选";
return e("details", { className: "scum-map-menu", "aria-label": "轨迹用户筛选" },
e("summary", { className: "icon-command" }, `用户 ${summary}`),
e("div", { className: "scum-map-menu-body" },
e("div", { className: "console-row-actions" },
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(users.map((user) => user.key)) }, "全选"),
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers([]) }, "清空"),
e("button", { type: "button", className: "icon-command", onClick: () => view.setMapUsers(undefined) }, "不限")
),
users.length ? users.map((user) => e("label", { key: user.key, className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: selected === undefined || selected.includes(user.key), onChange: (event: InputEvent) => view.setMapUsers(toggleMapUser(selected, user.key, Boolean(event.target?.checked), users)) }), `${user.label} · ${user.count}`)) : e("p", { className: "page-status" }, "当前时间范围内没有用户轨迹。")
)
);
}
function mapLayerMenu(e: ReactLike["createElement"], view: ViewState, zonesOverlay: boolean, onZones: (next: boolean) => void) {
const layers = ["players", "vehicles", "flags", "regions", "other"] as MapLayer[];
const enabled = layers.filter((layer) => view.mapLayers[layer]).length;
return e("details", { className: "scum-map-menu", "aria-label": "地图图层筛选" },
e("summary", { className: "icon-command" }, `图层 ${enabled}/${layers.length}${zonesOverlay ? " + 建筑区" : ""}`),
e("div", { className: "scum-map-menu-body" },
layers.map((layer) => e("label", { key: layer, className: "scum-layer-toggle", title: layer === "regions" ? "区域来自平台 scum_map_regions 记录,例如安全区或活动区域" : undefined }, e("input", { type: "checkbox", checked: view.mapLayers[layer], onChange: (event: InputEvent) => view.setMapLayers((previous) => ({ ...previous, [layer]: Boolean(event.target?.checked) })) }), layerLabel(layer))),
e("label", { key: "zones", className: "scum-layer-toggle" }, e("input", { type: "checkbox", checked: zonesOverlay, onChange: (event: InputEvent) => onZones(Boolean(event.target?.checked)) }), "建筑区"),
e("p", { className: "page-status" }, "区域 = 平台 scum_map_regions 里的地图区域记录(安全区 / 活动区域),没有写入记录时该图层为空。")
)
);
}
function persistMapLayers(view: ViewState, actions: SCUMWorkspaceActions | undefined, baseLayer: ScumMapLayer, zones: boolean) {
if (!actions?.pluginData) return;
runAction(view.setAction, "正在保存地图图层…", async () => { await saveMapSettings(actions, { baseLayer, buildingZones: zones }); view.refresh(); return "地图图层已保存。"; });
}
function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: { from: number; to: number }, enabled: boolean) {
const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value;
return e("div", { className: "map-playback-bar" },
e("button", { type: "button", className: view.mapPlayback.playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": view.mapPlayback.playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => view.setMapPlayback((previous) => ({ ...previous, playing: !previous.playing, value: !previous.playing && previous.value >= 1 ? 0 : previous.value })) }, view.mapPlayback.playing ? "暂停" : "回放"),
e("input", { type: "range", min: "0", max: "1000", step: "1", value: String(Math.round(view.mapPlayback.value * 1000)), "aria-label": "轨迹回放进度", disabled: !enabled, onChange: (event: InputEvent) => view.setMapPlayback((previous) => ({ ...previous, playing: false, value: Math.min(1, Math.max(0, Number(inputValue(event)) / 1000)) })) }),
e("span", { className: "page-status" }, Number.isFinite(cursor) ? `回放 ${timeLabel(cursor)}` : "回放 全部"),
labeledField(e, "速度", e("select", { value: String(view.mapPlayback.speed), "aria-label": "轨迹回放速度", onChange: (event: InputEvent) => view.setMapPlayback((previous) => ({ ...previous, speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`)))),
e("button", { type: "button", className: "icon-command", disabled: !enabled, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新")
);
}
function mapTrackList(e: ReactLike["createElement"], view: ViewState, tracks: MapTrack[], catalog: Map<string, string>) {
return e("article", { className: "console-module scum-map-tracks" },
e("div", { className: "panel-header" }, e("h2", null, "轨迹列表"), e("span", { className: "page-status" }, `${tracks.length} 条 · 虚线 步行 · 实线 乘车 · 色块对应用户`)),
tracks.length ? e("div", { className: "console-row-list" }, tracks.map((track) => e("button", { key: track.key, type: "button", className: view.mapTrackKey === track.key ? "console-row console-row-button is-selected" : "console-row console-row-button", onClick: () => { view.setMapTrackKey(track.key); view.setMapView(fitMapView(track.points, view.mapViewportRef.current, 0.8)); } },
e("span", null, e("span", { className: "map-track-swatch", style: { background: track.color } }), e("strong", null, track.label), e("small", null, `${track.kind === "player" ? "用户" : "载具"} · ${track.points.length} 点 · 步行 ${track.walk} / 乘车 ${track.ride}`)),
e("strong", null, `${timeLabel(track.from)}${timeLabel(track.to)}`),
e("span", { className: "scum-catalog-icons" }, track.vehicles.slice(0, 3).map((code, index) => { const icon = scumVehicleIconUrl(catalog.get(code) ?? code); return icon ? e("img", { key: `${code}-${index}`, className: "scum-catalog-icon", src: icon, alt: "" }) : null; }))
))) : e("p", { className: "page-status" }, "当前时间范围内的轨迹记录为空。")
);
}
function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, stroke: number) {
const shapes: unknown[] = [];
for (const track of tracks) for (const segment of mapTrackSegments(track)) {
const points = segment.points.filter((point) => point.time <= cursor);
if (points.length < 2) continue;
shapes.push(e("polyline", { key: `${segment.key}:${points.length}`, points: points.map((point) => `${point.x.toFixed(1)},${point.y.toFixed(1)}`).join(" "), fill: "none", stroke: track.color, strokeWidth: segment.mode === "ride" ? stroke * 1.2 : stroke, strokeLinecap: "round", strokeLinejoin: "round", strokeDasharray: segment.mode === "walk" ? `${stroke * 2.4} ${stroke * 1.6}` : undefined, opacity: segment.mode === "ride" ? 0.95 : 0.75 }));
}
return shapes;
}
function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map<string, string>) {
const markers: unknown[] = [];
for (const track of tracks) {
const points = track.points.filter((point) => point.time <= cursor);
if (!points.length) continue;
const first = points[0];
const last = points[points.length - 1];
markers.push(e("span", { key: `${track.key}:start`, className: `map-track-endpoint map-track-start map-layer-${track.kind === "player" ? "players" : "vehicles"}`, style: mapSceneStyle(first), title: `${track.label} 起点 ${timeLabel(first.time)}` }, "起"));
if (last !== first) markers.push(e("span", { key: `${track.key}:end`, className: "map-track-endpoint map-track-end", style: mapSceneStyle(last), title: `${track.label} 最新 ${timeLabel(last.time)}` }, "终"));
let placed = 0;
for (const segment of mapTrackSegments(track)) {
if (placed >= 4 || segment.mode !== "ride" || segment.points.length < 2) continue;
const visible = segment.points.filter((point) => point.time <= cursor);
if (!visible.length) continue;
const middle = visible[Math.floor(visible.length / 2)];
const code = mapRideCode(middle.row) || track.label;
const icon = scumVehicleIconUrl(catalog.get(code) ?? code);
placed += 1;
markers.push(e("span", { key: `${segment.key}:ride`, className: "map-ride-marker", style: mapSceneStyle(middle), title: `${track.label} 乘坐 ${code}` }, icon ? e("img", { src: icon, alt: "" }) : "车"));
}
}
return markers;
}
function mapSceneStyle(point: MapTrackPoint): Record<string, string> { return { left: `${Math.max(0, Math.min(100, point.x / scumMapSize * 100))}%`, top: `${Math.max(0, Math.min(100, point.y / scumMapSize * 100))}%` }; }
function mapTimeWindow(view: ViewState): MapTimeWindow {
if (view.mapTimePreset === "custom") {
const from = Date.parse(view.mapTimeFrom);
const to = Date.parse(view.mapTimeTo);
const safeFrom = Number.isNaN(from) ? -Infinity : from;
const safeTo = Number.isNaN(to) ? Infinity : to;
return { from: safeFrom, to: safeTo, label: `${timeLabel(safeFrom)} ${timeLabel(safeTo)}` };
}
const span = scumMapPresetMs[view.mapTimePreset];
if (!span) return { from: -Infinity, to: Infinity, label: "全部时段" };
const to = Date.now();
const label = scumMapTimePresets.find(([key]) => key === view.mapTimePreset)?.[1] ?? "最近";
return { from: to - span, to, label };
}
function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, view: ViewState, catalog: Map<string, string>): MapTrack[] {
const grouped = new Map<string, MapTrack>();
for (const row of data.trajectories) {
const position = positionOf(row);
if (!hasCoordinates(position)) continue;
const kind: "player" | "vehicle" = layerOf(row) === "vehicles" ? "vehicle" : "player";
const identity = kind === "player" ? textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id") : textField(row, "gameVehicleId", "vehicleId", "subjectId", "id");
if (!identity) continue;
if (kind === "player" && view.mapUsers !== undefined && !view.mapUsers.includes(identity)) continue;
const time = trajectoryOrder(row);
if (time < window.from || time > window.to) continue;
const scene = mapScenePoint(row, bounds);
const rideCode = mapRideCode(row);
const key = `${kind}:${identity}`;
const track = grouped.get(key) ?? { key, kind, label: (kind === "player" ? textField(row, "displayName", "name") : textField(row, "label", "name", "className")) || identity, color: scumMapTrackColors[stableIndex(identity, scumMapTrackColors.length)], points: [], walk: 0, ride: 0, from: time, to: time, vehicles: [] };
track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode) });
if (rideCode && !track.vehicles.includes(rideCode)) track.vehicles.push(rideCode);
track.from = Math.min(track.from, time);
track.to = Math.max(track.to, time);
grouped.set(key, track);
}
const tracks = [...grouped.values()];
for (const track of tracks) {
track.points.sort((left, right) => left.time - right.time);
if (track.points.length > scumMapTrackPointLimit) track.points = track.points.slice(-scumMapTrackPointLimit);
track.walk = track.points.filter((point) => !point.riding).length;
track.ride = track.points.length - track.walk;
track.vehicles = track.vehicles.map((code) => catalog.get(code) ?? code);
}
return tracks.sort((left, right) => right.to - left.to).slice(0, scumMapTrackLimit);
}
function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> {
const users = new Map<string, { key: string; label: string; count: number }>();
for (const row of data.trajectories) {
if (layerOf(row) === "vehicles") continue;
const identity = textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id");
if (!identity) continue;
const time = trajectoryOrder(row);
if (time < window.from || time > window.to) continue;
const current = users.get(identity) ?? { key: identity, label: textField(row, "displayName", "name") || identity, count: 0 };
current.count += 1;
users.set(identity, current);
}
return [...users.values()].sort((left, right) => right.count - left.count || left.label.localeCompare(right.label));
}
function toggleMapUser(selected: string[] | undefined, key: string, checked: boolean, users: Array<{ key: string }>): string[] {
const base = new Set(selected ?? users.map((user) => user.key));
if (checked) base.add(key);
else base.delete(key);
return [...base];
}
function mapTrackSegments(track: MapTrack): MapTrackSegment[] {
const segments: MapTrackSegment[] = [];
let current: MapTrackPoint[] = [];
let mode: "walk" | "ride" = track.kind === "vehicle" ? "ride" : "walk";
for (const point of track.points) {
const nextMode: "walk" | "ride" = track.kind === "vehicle" || point.riding ? "ride" : "walk";
const previous = current[current.length - 1];
const broken = previous ? nextMode !== mode || point.time - previous.time > scumMapSegmentGapMs || Math.hypot(point.x - previous.x, point.y - previous.y) > scumMapSegmentJump : false;
if (broken) { segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current }); current = []; }
mode = nextMode;
current.push(point);
}
if (current.length) segments.push({ key: `${track.key}:segment-${segments.length}`, mode, points: current });
return segments;
}
function mapTrackPointList(tracks: MapTrack[]): MapTrackPoint[] { return tracks.reduce<MapTrackPoint[]>((all, track) => all.concat(track.points), []); }
function mapVehicleClassIndex(data: SCUMSurfaceData): Map<string, string> {
const index = new Map<string, string>();
for (const vehicle of data.vehicles) {
const code = textField(vehicle, "className", "vehicleClass", "entityClass", "vehicleType");
if (!code) continue;
for (const key of [textField(vehicle, "gameVehicleId"), textField(vehicle, "vehicleId"), textField(vehicle, "id"), textField(vehicle, "label")].filter(Boolean)) index.set(key, code);
}
return index;
}
function mapRideCode(row: RecordMap): string { return textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); }
function mapPlaybackSpan(window: MapTimeWindow, tracks: MapTrack[]): { from: number; to: number } {
const times = tracks.length ? [Math.min(...tracks.map((track) => track.from)), Math.max(...tracks.map((track) => track.to))] : [0, 0];
return { from: Number.isFinite(window.from) ? window.from : times[0], to: Number.isFinite(window.to) ? window.to : times[1] };
}
function advanceMapPlayback(previous: MapPlaybackState): MapPlaybackState {
const value = previous.value + 0.012 * (previous.speed || 1);
return value >= 1 ? { ...previous, playing: false, value: 1 } : { ...previous, value };
}
function zoomMapView(previous: MapViewState, factor: number, anchorX: number, anchorY: number): MapViewState {
const zoom = clampMapZoom(previous.zoom * factor);
if (zoom === previous.zoom) return previous;
const ratio = zoom / previous.zoom;
return { zoom, x: anchorX - (anchorX - previous.x) * ratio, y: anchorY - (anchorY - previous.y) * ratio };
}
function clampMapZoom(value: number): number { return Math.min(scumMapZoomMax, Math.max(scumMapZoomMin, Number.isFinite(value) ? value : scumMapZoomMin)); }
function defaultMapView(node: HTMLElement | null): MapViewState {
const width = Number(node?.clientWidth) || 0;
const height = Number(node?.clientHeight) || 0;
return width > height && height > 0 ? { zoom: clampMapZoom(width / height), x: 0, y: 0 } : scumMapDefaultView;
}
function fitMapView(points: MapTrackPoint[], node: HTMLElement | null, padding: number): MapViewState {
if (!points.length) return scumMapDefaultView;
let minX = Infinity; let minY = Infinity; let maxX = -Infinity; let maxY = -Infinity;
for (const point of points) { minX = Math.min(minX, point.x); maxX = Math.max(maxX, point.x); minY = Math.min(minY, point.y); maxY = Math.max(maxY, point.y); }
const span = Math.max(48, maxX - minX, maxY - minY);
const zoom = clampMapZoom(scumMapSize * padding / span);
const size = Math.max(240, Number(node?.clientHeight) || 640);
return { zoom, x: -((minX + maxX) / 2 / scumMapSize - 0.5) * size * zoom, y: -((minY + maxY) / 2 / scumMapSize - 0.5) * size * zoom };
}
function mapBaseLayerValue(value: string): ScumMapLayer { return value === "topo" || value === "night" ? value : "terrain"; }
function stableIndex(value: string, length: number): number { let hash = 0; for (let index = 0; index < value.length; index += 1) hash = (hash * 31 + value.charCodeAt(index)) % 1000003; return hash % length; }
function timeLabel(value: number): string { if (!Number.isFinite(value)) return "不限"; const date = new Date(value); return Number.isNaN(date.getTime()) ? "unknown" : date.toLocaleString(); }
function mapPointStrip(e: ReactLike["createElement"], point: RecordMap, trails: RecordMap[], locks: RecordMap[]) {
const icon = layerOf(point) === "vehicles" ? vehicleIconFor(point) : "";
return e("article", { className: "console-module map-point-strip" },
@@ -683,13 +976,18 @@ function layerOf(point: RecordMap): MapLayer { const value = textField(point, "l
function layerLabel(layer: MapLayer): string { return layer === "players" ? "用户" : layer === "vehicles" ? "载具" : layer === "flags" ? "旗帜" : layer === "regions" ? "区域" : "其他"; }
function pointTitle(point: RecordMap): string { return textField(point, "name", "label", "subjectName") || textField(point, "subjectType", "type") || textField(point, "subjectId", "id") || "地图点"; }
function mapPointIdentity(point: RecordMap): string { const subject = textField(point, "subjectId", "gamePlayerId", "vehicleId", "flagId", "regionId"); if (subject) return `${layerOf(point)}:${subject}`; const record = textField(point, "id", "_recordKey"); if (record) return `${layerOf(point)}:${record}`; return `${layerOf(point)}:${pointTitle(point).toLowerCase()}:${numField(point, "x", "locationX")}:${numField(point, "y", "locationY")}`; }
export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record<string, string> {
export function mapScenePoint(point: RecordMap, bounds: RecordMap): { x: number; y: number } {
const x = Number(field(point, "x", "locationX") ?? 0); const y = Number(field(point, "y", "locationY") ?? 0);
const minX = Number(field(bounds, "worldMinX")); const minY = Number(field(bounds, "worldMinY")); const maxX = Number(field(bounds, "worldMaxX")); const maxY = Number(field(bounds, "worldMaxY"));
const mapX = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? scumMapSize - (x - minX) * scumMapSize / (maxX - minX) : scumMapSize / 2;
const mapY = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? scumMapSize - (y - minY) * scumMapSize / (maxY - minY) : scumMapSize / 2;
const left = mapX / scumMapSize * 100;
const top = mapY / scumMapSize * 100;
const sceneX = Number.isFinite(minX) && Number.isFinite(maxX) && maxX > minX ? scumMapSize - (x - minX) * scumMapSize / (maxX - minX) : scumMapSize / 2;
const sceneY = Number.isFinite(minY) && Number.isFinite(maxY) && maxY > minY ? scumMapSize - (y - minY) * scumMapSize / (maxY - minY) : scumMapSize / 2;
return { x: sceneX, y: sceneY };
}
export function mapPointStyle(point: RecordMap, bounds: RecordMap): Record<string, string> {
const scene = mapScenePoint(point, bounds);
const left = scene.x / scumMapSize * 100;
const top = scene.y / scumMapSize * 100;
return { left: `${Math.max(1, Math.min(99, left))}%`, top: `${Math.max(1, Math.min(99, top))}%` };
}
@@ -698,7 +996,6 @@ function mapGridOverlay(e: ReactLike["createElement"]) {
return e("div", { className: "map-grid-overlay", "aria-hidden": "true" }, breaks.map((value) => e("span", { key: `v-${value}`, className: "map-grid-line map-grid-line-v", style: { left: `${value}%` } })), breaks.map((value) => e("span", { key: `h-${value}`, className: "map-grid-line map-grid-line-h", style: { top: `${value}%` } })), cols.map((label, index) => e("span", { key: `c-${label}`, className: "map-grid-label map-grid-col-label", style: { left: `${(index + 0.5) * 20}%` } }, label)), rows.map((label, index) => e("span", { key: `r-${label}`, className: "map-grid-label map-grid-row-label", style: { top: `${(index + 0.5) * 20}%` } }, label)));
}
function visibleTrajectoryPoints(rows: RecordMap[], layers: Record<MapLayer, boolean>, search: string): RecordMap[] { return rows.filter((row) => (layerOf(row) === "players" || layerOf(row) === "vehicles") && layers[layerOf(row)] && hasCoordinates(positionOf(row)) && matchesText(row, search, "displayName", "label", "subjectId", "steamId", "vehicleId", "subjectType")); }
function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "subjectId"), textField(point, "steamId"), textField(point, "gamePlayerId"), textField(point, "vehicleId"), textField(point, "id")].filter(Boolean)); const layer = layerOf(point); return rows.filter((row) => layerOf(row) === layer && trajectoryIdentity(row).some((identity) => ids.has(identity))); }
function vehicleLockRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordMap[] { const ids = new Set([textField(point, "id"), textField(point, "scumVehicleId"), textField(point, "vehicleId"), textField(point, "gameVehicleId"), textField(point, "entityId"), textField(point, "subjectId")].filter(Boolean)); return rows.filter((row) => [textField(row, "scumVehicleId"), textField(row, "vehicleId"), textField(row, "gameVehicleId")].some((identity) => ids.has(identity))); }
function trajectoryIdentity(row: RecordMap): string[] { return [textField(row, "subjectId"), textField(row, "steamId"), textField(row, "gamePlayerId"), textField(row, "vehicleId"), textField(row, "id")].filter(Boolean); }
@@ -3,7 +3,7 @@
"id": "game.scum",
"name": "SCUM Server",
"description": "First-party SCUM game server operations plugin with platform-mediated lifecycle and plugin-owned RCON data flows.",
"version": "0.1.18",
"version": "0.1.19",
"kind": "game-plugin",
"tags": [
"scum",
+1 -1
View File
@@ -313,7 +313,7 @@ describe("plugin manifest validation", () => {
const serialized = JSON.stringify(manifest).toLowerCase();
expect(serialized).not.toContain("local-proof");
expect(manifest.version).toBe("0.1.18");
expect(manifest.version).toBe("0.1.19");
expect(installAction.environment?.SERVER_TEMPLATE).toBe("scum-server");
expect(manifest.permissions).toEqual(expect.arrayContaining(["server.game-client.read", "server.game-client.command", "server.game-client.maintenance"]));
expect(manifest.gameClientBridge.commands.map((command) => command.type)).toEqual(expect.arrayContaining([
+31 -9
View File
@@ -282,30 +282,52 @@ describe("SCUM plugin feature module", () => {
it("renders map layers, filter controls, points, and selected-point details", () => {
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
expect(view.nodes).toContain("div:SCUM 地图图层");
expect(view.inputs.map((input) => input.label)).toContain("筛选地图点");
expect(view.nodes).toContain("div:SCUM 地图视图");
expect(view.inputs.map((input) => input.label)).toContain("轨迹时间范围");
expect(view.inputs.map((input) => input.label)).not.toContain("筛选地图点");
for (const layer of ["用户", "载具", "旗帜", "区域", "其他"]) expect(view.texts).toContain(layer);
expect(view.texts).toContain("地图点详情");
expect(view.texts).toContain("Airfield");
expect(view.texts.join("\n")).toContain("X 800 / Y 900 / Z 10");
expect(pageSource).toContain('new URL("../assets/map/scum-map-terrain-4096.webp", import.meta.url).href');
expect(view.elements.find((element) => element.label === "SCUM 地图图层")?.style?.backgroundImage).toContain("scum-map-terrain-4096.webp");
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["启用自定义地图", "地图中心 X", "地图中心 Y", "地图宽度公里", "地图高度公里"]));
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["", "", "复位", "适应轨迹"]));
const vehicleView = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, players: [], mapPoints: [], flags: [], mapRegions: [] } });
expect(vehicleView.texts).toEqual(expect.arrayContaining(["BPC_Laika_C · unknown · 已上锁 · 访问 unknown", "scum-user-1", "76561198000000001"]));
});
it("keeps the live map full width with one compact filter row", () => {
it("keeps the live map full width with one compact filter row and dropdown menus", () => {
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" });
expect(view.texts).not.toContain("载具目录");
expect(view.texts).not.toContain("地图点");
expect(view.texts.some((text) => /^\d+ \/ \d+ 个可见点$/.test(text))).toBe(true);
expect(view.texts.some((text) => /\d+ \/ \d+ 个点 · \d+ 条轨迹/.test(text))).toBe(true);
for (const layer of ["地形", "等高线", "夜间", "建筑区"]) expect(view.texts).toContain(layer);
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["地形", "等高线", "夜间"]));
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["筛选地图点"]));
expect(view.buttons.map((button) => button.label)).not.toEqual(expect.arrayContaining(["地形", "等高线", "夜间"]));
expect(view.inputs.map((input) => input.label)).toEqual(expect.arrayContaining(["轨迹时间范围", "地图底图", "轨迹回放进度"]));
expect(view.inputs.map((input) => input.label)).not.toContain("启用自定义地图");
expect(view.texts).not.toContain("地图范围与图层");
expect(view.texts).not.toContain("自定义范围");
expect(view.elements.map((element) => element.label)).toEqual(expect.arrayContaining(["轨迹用户筛选", "地图图层筛选"]));
expect(view.texts).toContain("轨迹列表");
expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "不限", "回放", "回到最新"]));
expect(pageSource).toContain('new URL("../assets/map/scum-map-building-zones-4096.png", import.meta.url).href');
expect(pageSource).toContain("mapBoardStyle(baseLayer, zonesOverlay)");
});
it("draws ridden-vehicle trajectory segments with vehicle icons and per-user selection", () => {
const riding: SCUMSurfaceData = { ...surfaceData, trajectories: [
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 10, y: 20, z: 3, sampledAt: "2026-08-10T00:00:01Z" },
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 60, y: 80, z: 3, sampledAt: "2026-08-10T00:00:02Z" },
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 120, y: 140, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:03Z" },
{ subjectType: "player", subjectId: "76561198000000001", steamId: "76561198000000001", displayName: "Mira", x: 180, y: 140, z: 3, riddenVehicleId: "veh-1", gameVehicleId: "veh-1", sampledAt: "2026-08-10T00:00:04Z" }
] };
const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: riding });
expect(view.nodes).toContain("polyline:");
expect(view.texts).toEqual(expect.arrayContaining(["用户 · 4 点 · 步行 2 / 乘车 2"]));
expect(view.elements.filter((element) => element.className.includes("map-ride-marker")).length).toBeGreaterThan(0);
expect(view.elements.filter((element) => element.className.includes("map-track-endpoint")).length).toBeGreaterThan(1);
expect(view.texts).toContain("Mira · 4");
});
it("keeps the extracted icon catalog closed until an editor asks for it", () => {
const gifts = renderAndCollect({ pageKey: "gifts", pageTitle: "礼包管理" });
expect(gifts.texts).toContain("从 SCUM 目录选择礼包物品");
@@ -378,13 +400,13 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin
const texts: string[] = [];
const buttons: Array<{ label: string; disabled: boolean; onClick?: () => void }> = [];
const inputs: Array<{ label: string; value: unknown; onChange?: (event: unknown) => void }> = [];
const elements: Array<{ label: string; style?: Record<string, unknown> }> = [];
const elements: Array<{ label: string; className: string; style?: Record<string, unknown> }> = [];
const collectText = (value: unknown): void => { if (typeof value === "string") texts.push(value); else if (Array.isArray(value)) value.forEach(collectText); else if (value && typeof value === "object" && "children" in value) collectText((value as { children?: unknown }).children); };
let stateCall = 0;
const react = {
createElement: (type: unknown, props: Record<string, unknown> | null, ...children: unknown[]) => {
if (typeof type === "string") nodes.push(`${type}:${String(props?.["aria-label"] ?? "")}`);
if (typeof type === "string") elements.push({ label: String(props?.["aria-label"] ?? ""), style: props?.style as Record<string, unknown> | undefined });
if (typeof type === "string") elements.push({ label: String(props?.["aria-label"] ?? ""), className: String(props?.className ?? ""), style: props?.style as Record<string, unknown> | undefined });
children.forEach(collectText);
if (type === "button") buttons.push({ label: String(children[0]), disabled: Boolean(props?.disabled), onClick: props?.onClick as (() => void) | undefined });
if (type === "input" || type === "select" || type === "textarea") inputs.push({ label: String(props?.["aria-label"] ?? ""), value: props?.value ?? props?.checked, onChange: props?.onChange as ((event: unknown) => void) | undefined });