diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index a76cbce..c33baa9 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -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} diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 25daf44..c6aa5bd 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -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 = [["terrain", "地形"], ["topo", "等高线"], ["night", "夜间"]]; const scumCatalogKinds: ReadonlyArray = [["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 = [["all", "全部时段"], ["15m", "最近 15 分钟"], ["1h", "最近 1 小时"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["custom", "自定义起止"]]; +const scumMapPresetMs: Record = { "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?: (initial: T) => { current: T }; useState?: (initialState: T | (() => T)) => [T, StateSetter]; }; @@ -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>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); - const [mapCustomEnabled, setMapCustomEnabled] = usePluginState(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(react, scumMapDefaultView); + const [mapTimePreset, setMapTimePreset] = usePluginState(react, "all"); + const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, ""); + const [mapTimeTo, setMapTimeTo] = usePluginState(react, ""); + const [mapUsers, setMapUsers] = usePluginState(react, undefined); + const [mapTrackKey, setMapTrackKey] = usePluginState(react, ""); + const [mapPlayback, setMapPlayback] = usePluginState(react, scumMapPlaybackIdle); + const mapViewportRef = react.useRef ? react.useRef(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(react, undefined); const [mapZonesOverlay, setMapZonesOverlay] = usePluginState(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; giftName: string; setGiftName: StateSetter; giftItems: string; setGiftItems: StateSetter; giftCommands: string; setGiftCommands: StateSetter; giftClass: string; setGiftClass: StateSetter; giftAudience: string; setGiftAudience: StateSetter; giftNumber: string; setGiftNumber: StateSetter; giftAchievement: string; setGiftAchievement: StateSetter; giftAchievementNumber: string; setGiftAchievementNumber: StateSetter; deliveryGift: string; setDeliveryGift: StateSetter; deliveryPlayer: string; setDeliveryPlayer: StateSetter; - mapSearch: string; setMapSearch: StateSetter; mapLayers: Record; setMapLayers: StateSetter>; + mapLayers: Record; setMapLayers: StateSetter>; selectedMapPoint: string; setSelectedMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; - mapCustomEnabled: boolean | undefined; setMapCustomEnabled: StateSetter; mapCenterX: string; setMapCenterX: StateSetter; mapCenterY: string; setMapCenterY: StateSetter; mapWidthKm: string; setMapWidthKm: StateSetter; mapHeightKm: string; setMapHeightKm: StateSetter; - eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter; mapSettingsOpen: boolean; setMapSettingsOpen: StateSetter; + mapView: MapViewState; setMapView: StateSetter; mapViewportRef: { current: HTMLElement | null }; + mapTimePreset: string; setMapTimePreset: StateSetter; mapTimeFrom: string; setMapTimeFrom: StateSetter; mapTimeTo: string; setMapTimeTo: StateSetter; + mapUsers: string[] | undefined; setMapUsers: StateSetter; mapTrackKey: string; setMapTrackKey: StateSetter; mapPlayback: MapPlaybackState; setMapPlayback: StateSetter; + eventEditorOpen: boolean; setEventEditorOpen: StateSetter; produceEditorOpen: boolean; setProduceEditorOpen: StateSetter; giftEditorOpen: boolean; setGiftEditorOpen: StateSetter; mapBaseLayer: ScumMapLayer | undefined; setMapBaseLayer: StateSetter; mapZonesOverlay: boolean | undefined; setMapZonesOverlay: StateSetter; catalogSearch: string; setCatalogSearch: StateSetter; catalogKind: string; setCatalogKind: StateSetter; catalogOpen: boolean; setCatalogOpen: StateSetter; }; @@ -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-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)}×`) + ) ), - 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: "" }) : ""); })), + 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) { + 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) { + 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 { 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): MapTrack[] { + const grouped = new Map(); + 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(); + 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((all, track) => all.concat(track.points), []); } + +function mapVehicleClassIndex(data: SCUMSurfaceData): Map { + const index = new Map(); + 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 { +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 { + 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, 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); } diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index be071ec..ff02a75 100644 --- a/plugins/examples/scum-server-plugin/manifest.json +++ b/plugins/examples/scum-server-plugin/manifest.json @@ -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", diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index c286332..d94914d 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -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([ diff --git a/plugins/tests/scum-feature-module.test.ts b/plugins/tests/scum-feature-module.test.ts index 5a904d0..c0a43c1 100644 --- a/plugins/tests/scum-feature-module.test.ts +++ b/plugins/tests/scum-feature-module.test.ts @@ -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 }> = []; + const elements: Array<{ label: string; className: string; style?: Record }> = []; 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 | 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 | undefined }); + if (typeof type === "string") elements.push({ label: String(props?.["aria-label"] ?? ""), className: String(props?.className ?? ""), style: props?.style as Record | 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 });