diff --git a/platform_web/theme/base.css b/platform_web/theme/base.css index cd8f39f..23e5b6b 100644 --- a/platform_web/theme/base.css +++ b/platform_web/theme/base.css @@ -786,6 +786,17 @@ to{transform:translate(-50%,-50%) rotate(calc(var(--construct-drift) + 360deg))} .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-live{position:absolute;z-index:5;display:flex;align-items:center;gap:4px;transform:translate(-50%,-50%) scale(var(--map-inverse,1));pointer-events:none} +.map-track-live-dot{width:11px;height:11px;border:2px solid var(--surface-solid);border-radius:999px;background:var(--track-color,var(--gold));box-shadow:0 0 0 2px color-mix(in srgb,var(--track-color,var(--gold)) 42%,transparent)} +.map-track-live.is-playing .map-track-live-dot{width:16px;height:16px} +.map-track-live-icon{width:20px;height:20px;object-fit:contain;filter:drop-shadow(0 0 4px color-mix(in srgb,var(--track-color,var(--gold)) 70%,transparent))} +.map-track-live.is-playing .map-track-live-icon{width:27px;height:27px} +.map-track-live-label{max-width:104px;overflow:hidden;padding:1px 6px;border-radius:999px;background:color-mix(in srgb,var(--surface-solid) 82%,transparent);color:var(--track-color,var(--gold));font-size:10px;white-space:nowrap;text-overflow:ellipsis} +.map-hover-card{position:absolute;z-index:9;display:grid;gap:3px;min-width:168px;max-width:262px;padding:7px 9px;border:1px solid color-mix(in srgb,var(--accent) 55%,var(--line));border-radius:10px;background:color-mix(in srgb,var(--surface-solid) 94%,transparent);box-shadow:0 10px 26px color-mix(in srgb,#000 46%,transparent);transform:translate(-50%,calc(-100% - 12px)) scale(var(--map-inverse,1));transform-origin:50% 100%;pointer-events:none} +.map-hover-title{color:var(--ink);font-size:11px} +.map-hover-row{display:grid;grid-template-columns:56px 1fr;gap:6px;align-items:baseline} +.map-hover-row small{color:var(--ink-faint);font-size:10px} +.map-hover-row b{color:var(--ink-soft);font-size:11px;font-weight:600;word-break:break-all} .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;justify-content:flex-end;gap:8px;flex-wrap:wrap} .map-playback-bar .page-status{margin:0} diff --git a/plugins/examples/scum-server-plugin/features/page.ts b/plugins/examples/scum-server-plugin/features/page.ts index 2d0d597..d0f6772 100644 --- a/plugins/examples/scum-server-plugin/features/page.ts +++ b/plugins/examples/scum-server-plugin/features/page.ts @@ -19,7 +19,7 @@ import { type SCUMSurfaceData, type SCUMWorkspaceActions } from "./page-data.js"; -import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js"; +import { scumCatalogEntries, scumCatalogEntryFor, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl, type ScumCatalogEntry } from "./scum-catalog.js"; type StateSetter = (next: T | ((previous: T) => T)) => void; type InputEvent = { target?: { value?: string; checked?: boolean; open?: boolean }; stopPropagation?: () => void }; @@ -30,9 +30,10 @@ type CatalogPickTarget = "gift-items" | "produce-item"; type PlayerPanelKind = "closed" | "gifts" | "items" | "history"; type PlayerPanelState = { kind: PlayerPanelKind; playerId: string }; type MapViewState = { zoom: number; x: number; y: number }; -type MapPlaybackState = { playing: boolean; value: number; speed: number }; +type MapPlaybackState = { playing: boolean; value: number; speed: number; startedAt: number; startValue: number }; type MapTimeWindow = { from: number; to: number; label: string }; -type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean }; +type MapTrackPoint = { key: string; row: RecordMap; x: number; y: number; time: number; riding: boolean; rideCode: string }; +type MapRideSample = { key: string; code: string; time: number; scene: { x: number; y: number } }; 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 }; @@ -49,13 +50,19 @@ 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 scumMapPlaybackIdle: MapPlaybackState = { playing: false, value: 1, speed: 1, startedAt: 0, startValue: 1 }; +// Playback runs on wall-clock time so the 1x / 2x / 4x switch is instant and timer jitter cannot drift. +const scumMapPlaybackDurationMs = 30000; +const scumMapRideWindowMs = 10 * 1000; +const scumMapRideDistance = 1500; const scumMapTimePresets: ReadonlyArray = [["1h", "最近 1 小时"], ["15m", "最近 15 分钟"], ["6h", "最近 6 小时"], ["24h", "最近 24 小时"], ["7d", "最近 7 天"], ["all", "全部时段"], ["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 scumMapTrackColorCache = new Map(); const scumMapDefaultWindowMs = 60 * 60 * 1000; const mapDragState = { active: false, pointerId: -1, startX: 0, startY: 0, originX: 0, originY: 0, moved: false }; +// Latest rendered playback frame; the interval effect reads it to keep the follow camera in sync. +let mapPlaybackFrame: { tracks: MapTrack[]; span: { from: number; to: number } } | null = null; export type ReactLike = { createElement: (...args: any[]) => any; @@ -129,6 +136,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) const [deliveryPlayer, setDeliveryPlayer] = usePluginState(react, ""); const [mapLayers, setMapLayers] = usePluginState>(react, { players: true, vehicles: true, flags: true, regions: true, other: true }); const [selectedMapPoint, setSelectedMapPoint] = usePluginState(react, ""); + const [hoverMapPoint, setHoverMapPoint] = usePluginState(react, ""); const [mapView, setMapView] = usePluginState(react, scumMapDefaultView); const [mapTimePreset, setMapTimePreset] = usePluginState(react, pageQuery.from || pageQuery.to ? "custom" : "1h"); const [mapTimeFrom, setMapTimeFrom] = usePluginState(react, localDateTimeValue(defaultMapRange.from)); @@ -184,9 +192,14 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) }, [pageKey, state.status, mapViewportRef, setMapView]); if (react.useEffect) react.useEffect(() => { if (!mapPlayback.playing) return; - const timer = setInterval(() => setMapPlayback((previous) => advanceMapPlayback(previous)), 90); + const timer = setInterval(() => { + const next = advanceMapPlayback(mapPlayback, Date.now()); + setMapPlayback(next); + const camera = mapPlaybackFrame ? playbackFollowView(mapPlaybackFrame.tracks, next, mapPlaybackFrame.span, mapViewportRef.current) : undefined; + if (camera) setMapView(camera); + }, 90); return () => { clearInterval(timer); }; - }, [mapPlayback.playing, setMapPlayback]); + }, [mapPlayback.playing, mapPlayback.startedAt, mapPlayback.startValue, mapPlayback.speed, setMapPlayback, setMapView, mapViewportRef]); if (react.useEffect) react.useEffect(() => { if (pageKey !== "live-map") return; if (!focusUser) { setMapUsers(undefined); applyMapTimePreset(setMapTimePreset, setMapTimeFrom, setMapTimeTo, "1h"); return; } @@ -218,6 +231,7 @@ export function renderSCUMFeaturePage(react: ReactLike, input: SCUMPageContext) 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, mapLayers, setMapLayers, selectedMapPoint, setSelectedMapPoint, + hoverMapPoint, setHoverMapPoint, mapView, setMapView, mapViewportRef, mapTimePreset, setMapTimePreset, mapTimeFrom, setMapTimeFrom, mapTimeTo, setMapTimeTo, mapUsers, setMapUsers, mapTrackKey, setMapTrackKey, mapPlayback, setMapPlayback, eventEditorOpen, setEventEditorOpen, produceEditorOpen, setProduceEditorOpen, giftEditorOpen, setGiftEditorOpen, @@ -244,7 +258,7 @@ type ViewState = { 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; mapLayers: Record; setMapLayers: StateSetter>; - selectedMapPoint: string; setSelectedMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; + selectedMapPoint: string; setSelectedMapPoint: StateSetter; hoverMapPoint: string; setHoverMapPoint: StateSetter; setAction: StateSetter; refresh: () => void; 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; @@ -619,9 +633,11 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: for (const player of data.players) { const key = textField(player, "steamId", "gamePlayerId", "id"); const label = textField(player, "displayName", "name", "playerName"); if (key && label) playerLabels.set(key, label); } const tracks = mapTracks(data, bounds, window, view.mapUsers, catalog); const span = mapPlaybackSpan(window, tracks); - const cursor = view.mapPlayback.value >= 1 ? Infinity : span.from + (span.to - span.from) * view.mapPlayback.value; + mapPlaybackFrame = { tracks, span }; + const cursor = playbackCursor(span, view.mapPlayback); const zoom = view.mapView.zoom; const visible = points.filter((point) => view.mapLayers[layerOf(point)]); + const hovered = visible.find((point, index) => idOf(point, `point-${index}`) === view.hoverMapPoint); const selected = visible.find((point, index) => idOf(point, `point-${index}`) === view.selectedMapPoint) ?? visible[0]; 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) : []; @@ -645,8 +661,9 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: 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)) + mapTrackMarkers(e, tracks, cursor, catalog, view.mapPlayback.playing), + visible.map((point, index) => mapPointMarker(e, point, index, bounds, view, data)), + hovered ? mapHoverCard(e, hovered, data, mapPointStyle(hovered, bounds)) : null ), 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)) }, "+"), @@ -656,17 +673,91 @@ function mapSurface(e: ReactLike["createElement"], data: SCUMSurfaceData, input: e("span", { className: "map-zoom-badge" }, `${zoom.toFixed(2)}×`) ) ), - mapPlaybackBar(e, view, span, tracks.length > 0), + mapPlaybackBar(e, view, span, tracks, tracks.length > 0), mapTrackLegend(e, view, tracks, playerLabels), 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); +function mapPointMarker(e: ReactLike["createElement"], point: RecordMap, index: number, bounds: RecordMap, view: ViewState, data: SCUMSurfaceData) { + const layer = layerOf(point); + const ride = layer === "players" ? textField(point, "riddenVehicleId", "gameVehicleId") : ""; + const icon = layer === "vehicles" || ride ? 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: "" }) : ""); + const open = () => view.setHoverMapPoint(key); + const close = () => view.setHoverMapPoint((current) => (current === key ? "" : current)); + return e("button", { key, type: "button", className: `map-surface-dot map-layer-${layer}${ride ? " map-surface-dot-riding" : ""}`, title: `${pointTitle(point)} ${coords(point)}${ride ? ` · 乘坐 ${ride}` : ""}`, "aria-label": pointTitle(point), style: mapPointStyle(point, bounds), onPointerEnter: open, onPointerLeave: close, onFocus: open, onBlur: close, onClick: () => { if (!mapDragState.moved) view.setSelectedMapPoint(key); } }, + icon ? e("img", { src: icon, alt: "", loading: "lazy", decoding: "async" }) : "" + ); +} + +// Hover card: the map is unreadable without knowing whose dot it is and which vehicle it belongs to. +// Every row is driven by data the platform already returns, so a missing field drops the row instead +// of printing a placeholder. +function mapHoverCard(e: ReactLike["createElement"], point: RecordMap, data: SCUMSurfaceData, pointStyle: Record) { + const layer = layerOf(point); + const rows: Array<[string, string]> = []; + const push = (label: string, value: string) => { if (value) rows.push([label, value]); }; + const pushDate = (label: string, ...keys: string[]) => { if (textField(point, ...keys)) push(label, dateField(point, ...keys)); }; + if (layer === "players") { + push("用户", textField(point, "displayName", "name", "label") || pointTitle(point)); + push("SteamID", textField(point, "steamId", "gamePlayerId", "subjectId")); + push("队伍", mapSquadLabel(data, textField(point, "squadId", "squadName"))); + push("状态", boolField(point, "online") ? "在线" : "离线"); + pushDate("最后出现", "lastSeenAt", "lastActivityAt", "updatedAt"); + } else if (layer === "vehicles") { + push("载具", vehicleLabelFor(point)); + push("类名", textField(point, "className", "vehicleClass", "entityClass", "vehicleType")); + push("状态", textField(point, "status", "state") || (boolField(point, "exists", "existsInGame") ? "存在" : "")); + push("耐久", vehicleDurabilityLabel(point)); + push("车锁", vehicleLockLabel(point, data)); + pushDate("最后观测", "vehicleObservedAt", "lastObservedAt", "observedAt", "updatedAt"); + } else if (layer === "flags") { + push("领地旗", textField(point, "name", "label", "flagId", "id") || "领地旗"); + push("归属", mapOwnerLabel(data, point)); + push("队伍", mapSquadLabel(data, textField(point, "ownerSquadId", "squadId"))); + push("置信度", textField(point, "ownershipConfidence")); + } else { + push("名称", pointTitle(point)); + push("类型", textField(point, "type", "subjectType", "layer")); + push("来源", textField(point, "source")); + } + if (hasCoordinates(point)) push("坐标", coords(point)); + return e("div", { className: `map-hover-card map-hover-${layer}`, role: "tooltip", style: pointStyle }, + e("strong", { className: "map-hover-title" }, pointTitle(point)), + rows.map(([label, value]) => e("span", { key: label, className: "map-hover-row" }, e("small", null, label), e("b", null, value))) + ); +} + +function mapSquadLabel(data: SCUMSurfaceData, value: string): string { + if (!value) return ""; + const squad = data.squads.find((row) => textField(row, "squadId", "id", "_recordKey") === value); + return squad ? `${textField(squad, "name", "label") || value}(${value})` : value; +} + +function mapOwnerLabel(data: SCUMSurfaceData, point: RecordMap): string { + const steamId = textField(point, "ownerSteamId", "steamId", "ownerProfileId", "ownerId"); + if (!steamId) return ""; + const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id")].includes(steamId)); + return owner ? `${textField(owner, "displayName", "name") || steamId}(${steamId})` : steamId; +} + +function vehicleLockLabel(point: RecordMap, data: SCUMSurfaceData): string { + const locks = vehicleLockRecordsForPoint(data.vehicleLocks, point).sort((left, right) => rowTime(right, ["lockedAt", "createdAt"]) - rowTime(left, ["lockedAt", "createdAt"])); + const latest = locks[0]; + if (latest) { + const steamId = textField(latest, "steamId", "scumUserId"); + const owner = data.players.find((player) => [textField(player, "steamId"), textField(player, "gamePlayerId"), textField(player, "id"), textField(player, "scumUserId")].includes(steamId)); + const who = owner ? textField(owner, "displayName", "name") || steamId : steamId; + return `${who || "未知玩家"} 上锁 · ${dateField(latest, "lockedAt", "createdAt")}`; + } + if (boolField(point, "locked")) return "已上锁(未记录上锁人)"; + return textField(point, "lockState") || "未上锁"; +} + +function vehicleDurabilityLabel(point: RecordMap): string { + const value = textField(point, "durability", "vehicleDurability", "health", "vehicleHealth"); + return value ? `${value}${textField(point, "durabilityMax", "maxHealth") ? ` / ${textField(point, "durabilityMax", "maxHealth")}` : ""}` : ""; } function mapUserMenu(e: ReactLike["createElement"], view: ViewState, users: Array<{ key: string; label: string; count: number }>, labels: Map) { @@ -718,18 +809,26 @@ function persistMapLayers(view: ViewState, actions: SCUMWorkspaceActions | undef 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; +function mapPlaybackBar(e: ReactLike["createElement"], view: ViewState, span: { from: number; to: number }, tracks: MapTrack[], enabled: boolean) { + const cursor = playbackCursor(span, view.mapPlayback); + const playing = view.mapPlayback.playing; + const start = () => { + const value = view.mapPlayback.value >= 1 ? 0 : view.mapPlayback.value; + const next = rebaseMapPlayback(view.mapPlayback, { playing: true, value }); + view.setMapPlayback(next); + if (value === 0) { const camera = playbackStartView(tracks, view.mapViewportRef.current); if (camera) view.setMapView(camera); } + }; return e("div", { className: "map-playback-bar" }, e("details", { className: "scum-map-menu", "aria-label": "轨迹回放" }, - e("summary", { className: "icon-command" }, view.mapPlayback.playing ? "回放 播放中" : "回放"), + e("summary", { className: "icon-command" }, playing ? "回放 播放中" : "回放"), e("div", { className: "scum-map-menu-body" }, e("p", { className: "page-status" }, Number.isFinite(cursor) ? `回放位置 ${timeLabel(cursor)}` : "回放位置 全部"), e("div", { className: "console-row-actions" }, - 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("button", { type: "button", className: "icon-command", disabled: !enabled, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新") + e("button", { type: "button", className: playing ? "primary-command" : "icon-command", disabled: !enabled, "aria-label": playing ? "暂停轨迹回放" : "播放轨迹回放", onClick: () => { if (playing) view.setMapPlayback(rebaseMapPlayback(view.mapPlayback, { playing: false })); else start(); } }, playing ? "暂停" : "开始"), + e("button", { type: "button", className: "icon-command", disabled: !view.mapPlayback.playing && view.mapPlayback.value >= 1, onClick: () => view.setMapPlayback(scumMapPlaybackIdle) }, "回到最新") ), - 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`)))) + labeledField(e, "速度", e("select", { value: String(view.mapPlayback.speed), "aria-label": "轨迹回放速度", onChange: (event: InputEvent) => view.setMapPlayback((previous) => rebaseMapPlayback(previous, { speed: Number(inputValue(event)) || 1 })) }, [1, 2, 4].map((speed) => e("option", { key: speed, value: String(speed) }, `${speed}x`)))), + e("p", { className: "page-status" }, tracks.filter((track) => track.kind === "player").length === 1 ? "单人回放:镜头跟随并放到最大比例。" : "多人回放:镜头框住全部所选用户。") ) ) ); @@ -745,11 +844,16 @@ function mapTrackShapes(e: ReactLike["createElement"], tracks: MapTrack[], curso return shapes; } -function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map) { +function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], cursor: number, catalog: Map, playing: boolean) { const markers: unknown[] = []; for (const track of tracks) { const points = track.points.filter((point) => point.time <= cursor); if (!points.length) continue; + if (Number.isFinite(cursor)) { + const position = mapTrackPositionAt(track, cursor); + if (position) markers.push(mapLiveMarker(e, track, position, catalog, playing)); + 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)}` }, "起")); @@ -760,15 +864,28 @@ function mapTrackMarkers(e: ReactLike["createElement"], tracks: MapTrack[], curs 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 = trajectoryVehicleIcon(middle.row) || scumVehicleIconUrl(catalog.get(code) ?? code); + const code = middle.rideCode || mapRideCode(middle.row) || track.label; + const icon = trajectoryVehicleIcon(middle.row) || vehicleIconByCode(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: "" }) : "车")); + 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: "", loading: "lazy", decoding: "async" }) : "车")); } } return markers; } +// The moving marker is what makes playback readable: the selected users stay enlarged, and a ridden +// vehicle swaps the dot for the vehicle icon the way a navigation app draws a car instead of a pin. +function mapLiveMarker(e: ReactLike["createElement"], track: MapTrack, point: MapTrackPoint, catalog: Map, playing: boolean) { + const rideCode = point.rideCode; + const icon = rideCode ? vehicleIconByCode(catalog.get(rideCode) ?? rideCode) : ""; + const label = rideCode ? `${track.label} · ${scumVehicleEntry(rideCode)?.name || catalog.get(rideCode) || "载具"}` : track.label; + const className = ["map-track-live", playing ? "is-playing" : "", rideCode ? "map-track-live-riding" : "", track.kind === "vehicle" ? "map-track-live-vehicle" : ""].filter(Boolean).join(" "); + return e("span", { key: `${track.key}:live`, className, style: { ...mapSceneStyle(point), "--track-color": track.color }, title: `${label} ${timeLabel(point.time)}` }, + icon ? e("img", { className: "map-track-live-icon", src: icon, alt: "", loading: "lazy", decoding: "async" }) : e("span", { className: "map-track-live-dot" }), + e("strong", { className: "map-track-live-label" }, track.label) + ); +} + 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 { @@ -806,10 +923,11 @@ function applyMapTimePreset(setPreset: StateSetter, setFrom: StateSetter function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWindow, users: string[] | undefined, catalog: Map): MapTrack[] { const grouped = new Map(); const windowRows = data.trajectories.filter((row) => { const time = trajectoryOrder(row); return time >= window.from && time <= window.to; }); + const rideSamples = mapRideSamples(data, bounds); const riddenVehicles = new Set(); if (users !== undefined) for (const row of windowRows) { if (layerOf(row) === "vehicles" || !users.includes(mapPlayerTrackIdentity(row))) continue; - const vehicle = mapRiddenVehicleId(row); + const vehicle = playerRide(row, rideSamples, bounds)?.key ?? ""; if (vehicle) riddenVehicles.add(vehicle); } for (const row of windowRows) { @@ -821,10 +939,10 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind if (kind === "player" ? users !== undefined && !users.includes(identity) : users !== undefined && !riddenVehicles.has(identity)) continue; const time = trajectoryOrder(row); const scene = mapScenePoint(row, bounds); - const rideCode = mapRideCode(row); + const rideCode = kind === "player" ? playerRideCode(row, rideSamples, bounds) : 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: mapTrackColor(identity), 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) }); + track.points.push({ key: `${key}:${track.points.length}`, row, x: scene.x, y: scene.y, time, riding: Boolean(rideCode), 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); @@ -844,6 +962,19 @@ function mapTracks(data: SCUMSurfaceData, bounds: RecordMap, window: MapTimeWind function mapPlayerTrackIdentity(row: RecordMap): string { return textField(row, "steamId", "subjectId", "gamePlayerId", "displayName", "id"); } function mapRiddenVehicleId(row: RecordMap): string { return textField(row, "riddenVehicleId", "gameVehicleId", "vehicleId"); } +// Explicit ridden_vehicle_id wins; otherwise the vehicle sample from the same poll window is used. +function playerRide(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): MapRideSample | undefined { + const explicit = mapRiddenVehicleId(row); + if (explicit) return { key: explicit, code: mapRideCode(row), time: trajectoryOrder(row), scene: mapScenePoint(row, bounds) }; + return mapRiddenSample(samples, row, bounds); +} + +function playerRideCode(row: RecordMap, samples: MapRideSample[], bounds: RecordMap): string { + const ride = playerRide(row, samples, bounds); + if (!ride) return ""; + return ride.code || "vehicle"; +} + function mapTrackUsers(data: SCUMSurfaceData, window: MapTimeWindow): Array<{ key: string; label: string; count: number }> { const users = new Map(); for (const row of data.trajectories) { @@ -896,16 +1027,101 @@ function mapVehicleClassIndex(data: SCUMSurfaceData): Map { function mapRideCode(row: RecordMap): string { return textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); } +// Playback covers the recorded samples, not the whole filter window: a 24h window with two hours of +// data would otherwise spend most of the run showing an empty map. 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]; + if (times[1] > times[0]) return { from: times[0], to: times[1] }; 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); +export function advanceMapPlayback(previous: MapPlaybackState, now = Date.now()): MapPlaybackState { + if (!previous.playing) return previous; + const elapsed = Math.max(0, now - previous.startedAt); + const value = previous.startValue + elapsed / scumMapPlaybackDurationMs * (previous.speed || 1); return value >= 1 ? { ...previous, playing: false, value: 1 } : { ...previous, value }; } +function rebaseMapPlayback(previous: MapPlaybackState, patch: Partial, now = Date.now()): MapPlaybackState { + const current = advanceMapPlayback(previous, now); + return { ...current, ...patch, startedAt: now, startValue: patch.value ?? current.value }; +} + +function playbackCursor(span: { from: number; to: number }, playback: MapPlaybackState): number { + return playback.value >= 1 ? Infinity : span.from + (span.to - span.from) * playback.value; +} + +// A single selected user fills the viewport at the maximum zoom and is followed; several users are +// framed together, so two neighbouring players still reach the maximum zoom while two players on +// opposite corners fall back to the minimum. +function playbackStartView(tracks: MapTrack[], node: HTMLElement | null): MapViewState | undefined { + const players = tracks.filter((track) => track.kind === "player" && track.points.length); + if (players.length === 1) return focusMapView(players[0].points[0], node, scumMapZoomMax); + if (players.length > 1) return fitMapView(mapTrackPointList(players), node, 0.8); + return undefined; +} + +function playbackFollowView(tracks: MapTrack[], playback: MapPlaybackState, span: { from: number; to: number }, node: HTMLElement | null): MapViewState | undefined { + if (!playback.playing) return undefined; + const players = tracks.filter((track) => track.kind === "player" && track.points.length); + if (players.length !== 1) return undefined; + const position = mapTrackPositionAt(players[0], playbackCursor(span, playback)); + return position ? focusMapView(position, node, scumMapZoomMax) : undefined; +} + +// The player trajectory table only carries ridden_vehicle_id when the collector fills it. Until then +// every player sample is matched against the vehicle samples recorded in the same poll window, so the +// surrounding vehicle still shows up as a ride marker instead of a walk segment. +function mapRideSamples(data: SCUMSurfaceData, bounds: RecordMap): MapRideSample[] { + const samples: MapRideSample[] = []; + for (const row of data.trajectories) { + if (layerOf(row) !== "vehicles") continue; + const position = positionOf(row); + if (!hasCoordinates(position)) continue; + const time = trajectoryOrder(row); + const key = textField(row, "gameVehicleId", "vehicleId", "subjectId", "id"); + if (!key) continue; + samples.push({ key, code: mapRideCode(row), time, scene: mapScenePoint(row, bounds) }); + } + return samples.sort((left, right) => left.time - right.time); +} + +function mapRiddenSample(samples: MapRideSample[], row: RecordMap, bounds: RecordMap): MapRideSample | undefined { + const position = positionOf(row); + if (!hasCoordinates(position)) return undefined; + const time = trajectoryOrder(row); + const scene = mapScenePoint(row, bounds); + let best: MapRideSample | undefined; + let bestDistance = Infinity; + for (const sample of samples) { + const delta = Math.abs(sample.time - time); + if (delta > scumMapRideWindowMs) continue; + const distance = Math.hypot(sample.scene.x - scene.x, sample.scene.y - scene.y); + if (distance > scumMapRideDistance || distance >= bestDistance) continue; + best = sample; + bestDistance = distance; + } + return best; +} + +function mapTrackPositionAt(track: MapTrack, cursor: number): MapTrackPoint | undefined { + const points = track.points; + if (!points.length) return undefined; + if (!Number.isFinite(cursor)) return points[points.length - 1]; + if (cursor < points[0].time) return undefined; + for (let index = 1; index < points.length; index += 1) { + const previous = points[index - 1]; + const next = points[index]; + if (cursor > next.time) continue; + const gap = next.time - previous.time; + if (gap <= 0 || gap > scumMapSegmentGapMs) return { ...previous, time: cursor }; + const ratio = (cursor - previous.time) / gap; + const riding = ratio < 0.5 ? previous.riding : next.riding; + return { key: `${track.key}:live`, row: riding ? next.row : previous.row, x: previous.x + (next.x - previous.x) * ratio, y: previous.y + (next.y - previous.y) * ratio, time: cursor, riding, rideCode: riding ? (next.rideCode || previous.rideCode) : "" }; + } + return points[points.length - 1]; +} + function zoomMapView(previous: MapViewState, factor: number, anchorX: number, anchorY: number): MapViewState { const zoom = clampMapZoom(previous.zoom * factor); if (zoom === previous.zoom) return previous; @@ -921,6 +1137,12 @@ function defaultMapView(node: HTMLElement | null): MapViewState { return width > height && height > 0 ? { zoom: clampMapZoom(width / height), x: 0, y: 0 } : scumMapDefaultView; } +function focusMapView(point: MapTrackPoint, node: HTMLElement | null, zoom: number): MapViewState { + const size = Math.max(240, Number(node?.clientHeight) || 640); + const clamped = clampMapZoom(zoom); + return { zoom: clamped, x: -(point.x / scumMapSize - 0.5) * size * clamped, y: -(point.y / scumMapSize - 0.5) * size * clamped }; +} + 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; @@ -1005,7 +1227,7 @@ function giftCatalogIcons(gift: RecordMap): string[] { function trajectoryVehicleIcon(row: RecordMap): string { const code = textField(row, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId", "vehicleId"); - return code ? scumVehicleIconUrl(code) : ""; + return code ? scumVehicleIconUrl(code) || scumVehicleFallbackIconUrl(code) : ""; } function catalogPicker(e: ReactLike["createElement"], view: ViewState, target: CatalogPickTarget) { @@ -1086,9 +1308,35 @@ function trajectoryRecordsForPoint(rows: RecordMap[], point: RecordMap): RecordM 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); } function trajectoryOrder(row: RecordMap): number { const stamp = Date.parse(textField(row, "sampledAt", "observedAt", "createdAt")); return Number.isNaN(stamp) ? 0 : stamp; } -function vehicleIconFor(point: RecordMap): string { const explicit = textField(point, "imagePath", "image_path"); if (explicit) return explicit.startsWith("/") ? explicit : `/${explicit}`; return scumVehicleIconUrl(normalizedVehicleClass(textField(point, "className", "vehicleClass", "entityClass", "vehicleType"))); } +// The machine-side collector reports its own dump paths (for example "/original/Rager_ES.webp"); +// those files are not part of the plugin bundle, so only URLs the bundle really serves may reach an . +export function bundledVehicleImageUrl(value: string): string { + const url = value.trim(); + if (!url || /^https?:\/\//i.test(url)) return ""; + if (/^(?:data|blob):/i.test(url)) return url; + return /^\/(?:@fs|assets|src|node_modules)\//.test(url) ? url : ""; +} + +function vehicleIconFor(point: RecordMap): string { + const explicit = bundledVehicleImageUrl(textField(point, "imagePath", "image_path")); + if (explicit) return explicit; + return vehicleIconByCode(textField(point, "className", "vehicleClass", "entityClass", "vehicleType")); +} + +function vehicleIconByCode(code: string): string { + if (!code.trim()) return ""; + const normalized = normalizedVehicleClass(code); + return scumVehicleIconUrl(normalized) || scumVehicleFallbackIconUrl(normalized); +} + +function vehicleLabelFor(point: RecordMap): string { + const code = textField(point, "className", "vehicleClass", "entityClass", "vehicleType", "riddenVehicleId", "gameVehicleId"); + return scumVehicleEntry(code)?.name || textField(point, "displayName", "label", "name") || code || "未知载具"; +} function vehicleClassKey(value: string): string { return normalizedVehicleClass(value.replace(/^#spawnvehicle\s+/i, "")); } -function normalizedVehicleClass(value: string): string { return value.replace(/_C$/i, "").split(".").pop()?.trim() ?? value.trim(); } +// Live classes arrive as "_ES" (spawner suffix) or "_C" (blueprint suffix); the catalog +// keys the bare name, so every lookup path strips both before comparing. +function normalizedVehicleClass(value: string): string { return (value.split(".").pop() ?? value).replace(/_(?:ES|C)$/i, "").trim(); } function shortHash(value: string): string { return value ? `${value.slice(0, 10)}…${value.slice(-6)}` : ""; } function runAction(setAction: StateSetter, pending: string, task: () => Promise) { setAction({ status: "pending", message: pending }); void task().then((message) => setAction({ status: "ok", message })).catch((error) => setAction({ status: "error", message: errorMessage(error, "操作失败。") })); } diff --git a/plugins/examples/scum-server-plugin/features/scum-catalog.ts b/plugins/examples/scum-server-plugin/features/scum-catalog.ts index 980e5c7..4211a45 100644 --- a/plugins/examples/scum-server-plugin/features/scum-catalog.ts +++ b/plugins/examples/scum-server-plugin/features/scum-catalog.ts @@ -5135,10 +5135,23 @@ export function scumCatalogIconUrl(icon: string): string { export const scumCatalogByCommand: ReadonlyMap = new Map(scumCatalogEntries.map((entry) => [entry.command.toLowerCase(), entry])); export const scumCatalogByCode: ReadonlyMap = new Map(scumCatalogEntries.map((entry) => [entry.code.toLowerCase(), entry])); -function vehicleAliasKeys(code: string): string[] { - const normalized = code.replace(/^#spawnvehicle\s+/i, "").trim(); - const withoutVariant = normalized.replace(/_([a-z])$/i, ""); - return [...new Set([normalized, withoutVariant, withoutVariant.replace(/_([a-z])$/i, "")].map((value) => value.toLowerCase()))]; +// The live server reports spawned vehicles as "_ES" (the collector's VehicleClassSuffix), +// while the catalog keys them as "BP_" / "BPC_". UE object paths add a package prefix. +// Every key below is derived from the same code so both sides meet on the bare vehicle name. +export function vehicleAliasKeys(code: string): string[] { + const normalized = (code.replace(/^#spawnvehicle\s+/i, "").trim().split(".").pop() ?? "").trim(); + if (!normalized) return []; + const withoutSuffix = normalized.replace(/_(?:ES|C)$/i, ""); + const bare = withoutSuffix.replace(/^(?:BPC_|BP_)/i, ""); + const variants = new Set([normalized, withoutSuffix, bare]); + const keys = new Set(); + for (const value of variants) { + const base = value.replace(/^(?:bpc_|bp_)/i, ""); + keys.add(value.toLowerCase()); + keys.add(`bpc_${base}`.toLowerCase()); + keys.add(`bp_${base}`.toLowerCase()); + } + return [...keys]; } const vehicleIconAliases: Record = {}; @@ -5155,23 +5168,83 @@ export function scumVehicleIconPath(vehicleCode: string): string { // Colour variants and re-releases ship no icon of their own; they render the icon of their base vehicle. const scumVehicleFamilyIcons: ReadonlyArray = [ - ["bp_dirtbike", "items/vehicles/ico_motorcycle_01_a.webp"], - ["bp_cruiser", "items/vehicles/ico_cruiser.webp"], - ["bp_bicycle_mountain_bike", "items/vehicles/ico_bicycle_02_a.webp"], - ["bpc_bicycle_mountain_bike", "items/vehicles/ico_bicycle_02_a.webp"], - ["bpc_bicycle_citybike", "items/vehicles/ico_bicycle_01_a.webp"], - ["bpc_motorboat_01", "items/vehicles/ico_motorboat_02.webp"], - ["bp_kinglet_scout", "items/vehicles/ico_kinglet_duster_a.webp"], + ["dirtbike", "items/vehicles/ico_motorcycle_01_a.webp"], + ["sportbike", "items/vehicles/ico_motorcycle_01_a.webp"], + ["cruiser", "items/vehicles/ico_cruiser.webp"], + ["citybike", "items/vehicles/ico_bicycle_01_a.webp"], + ["mountainbike", "items/vehicles/ico_bicycle_02_a.webp"], + ["bicycle_mountain_bike", "items/vehicles/ico_bicycle_02_a.webp"], + ["bicycle_citybike", "items/vehicles/ico_bicycle_01_a.webp"], + ["sidecarbike", "items/vehicles/ico_sidecarbike.webp"], + ["motorboat_01", "items/vehicles/ico_motorboat_02.webp"], + ["barba", "items/vehicles/motoredboat_a.webp"], + ["dinghy", "items/vehicles/ico_motorboat_02.webp"], + ["sup", "items/vehicles/ico_sup_vicinity.webp"], + ["bigraft", "items/vehicles/ico_improraftbig.webp"], + ["smallraft", "items/vehicles/ico_improsmallraft.webp"], + ["wheelbarrow_improvised", "items/vehicles/ico_improwheelbarrow.webp"], + ["wheelbarrow_metal", "items/vehicles/ico_wheelbarrow.webp"], + ["wheelbarrow", "items/vehicles/ico_wheelbarrow.webp"], + ["laika", "items/vehicles/ico_laika.webp"], + ["rager", "items/vehicles/ico_rager.webp"], + ["ris", "items/vehicles/ico_ris.webp"], + ["wolfswagen", "items/vehicles/ico_wolfswagen.webp"], + ["tractor", "items/vehicles/ico_tractor_01_a.webp"], + ["kinglet_scout", "items/vehicles/ico_kinglet_duster_a.webp"], + ["kinglet_duster", "items/vehicles/ico_kinglet_duster_a.webp"], + ["kinglet_mariner", "items/vehicles/ico_kinglet_mariner.webp"], ]; -export function scumVehicleIconUrl(vehicleCode: string): string { - const direct = scumVehicleIconPath(vehicleCode); - if (direct) return scumCatalogIconUrl(direct); - const key = vehicleCode.replace(/^#spawnvehicle\s+/i, "").trim().toLowerCase(); - for (const [prefix, icon] of scumVehicleFamilyIcons) { if (key.startsWith(prefix)) return scumCatalogIconUrl(icon); } +function scumVehicleFamilyIcon(vehicleCode: string): string { + const key = (vehicleCode.replace(/^#spawnvehicle\s+/i, "").trim().split(".").pop() ?? "").toLowerCase().replace(/^(bpc_|bp_)/, ""); + for (const [prefix, icon] of scumVehicleFamilyIcons) { if (key === prefix || key.startsWith(`${prefix}_`)) return icon; } return ""; } +export function scumVehicleIconPathFor(vehicleCode: string): string { + const direct = scumVehicleIconPath(vehicleCode); + if (direct) return direct; + return scumVehicleFamilyIcon(vehicleCode); +} + +export function scumVehicleIconUrl(vehicleCode: string): string { + const path = scumVehicleIconPathFor(vehicleCode); + return path ? scumCatalogIconUrl(path) : ""; +} + +// Vehicles whose class ships no icon of its own (Pickup / Quad / SUV / Sportbike and any future +// re-release) still need a marker. These inline glyphs cost no extra request and never 404. +const scumVehicleFallbackGlyphs: ReadonlyArray = [ + ["boat", "M3 15h18l-2 5H5l-2-5Zm3 0 3-7h6l3 7M8 11V8h8v3"], + ["bike", "M5 18a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM8 15l3-6h4l2 6M11 9h3"], + ["motorcycle", "M5 18a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm14 0a3 3 0 1 0 0-6 3 3 0 0 0 0 6ZM8 15l3-5h3l2 1 3 4M11 10h4"], + ["cart", "M4 6h11l2 8H6L4 6Zm2 12a1.6 1.6 0 1 0 0-3.2A1.6 1.6 0 0 0 6 18Zm10 0a1.6 1.6 0 1 0 0-3.2A1.6 1.6 0 0 0 16 18Z"], + ["car", "M4 16v-3l2-5h12l2 5v3M4 16h16M6.5 16a1.5 1.5 0 1 0 0 .01M17.5 16a1.5 1.5 0 1 0 0 .01M4 13h16"], +]; + +export function scumVehicleFallbackIconUrl(vehicleCode: string): string { + const key = (vehicleCode.replace(/^#spawnvehicle\s+/i, "").trim().split(".").pop() ?? "").toLowerCase(); + const family = scumVehicleFamilyIcon(vehicleCode); + const kind = family.includes("boat") || family.includes("raft") || family.includes("sup") || /boat|raft|dinghy|sup|barba/.test(key) ? "boat" + : family.includes("bicycle") ? "bike" + : family.includes("motorcycle") || family.includes("sidecarbike") || /bike|motorcycle/.test(key) ? "motorcycle" + : family.includes("wheelbarrow") || /wheelbarrow/.test(key) ? "cart" + : "car"; + const glyph = scumVehicleFallbackGlyphs.find(([name]) => name === kind)?.[1] ?? ""; + const svg = ``; + return `data:image/svg+xml,${encodeURIComponent(svg)}`; +} + +export function scumVehicleEntry(vehicleCode: string): ScumCatalogEntry | undefined { + for (const key of vehicleAliasKeys(vehicleCode)) { + const icon = vehicleIconAliases[key]; + if (!icon) continue; + const entry = scumCatalogEntries.find((value) => value.kind === "vehicle" && value.icon === icon); + if (entry) return entry; + } + return scumCatalogEntryFor(vehicleCode); +} + export function scumCatalogEntryFor(value: string): ScumCatalogEntry | undefined { const key = value.trim().toLowerCase(); return scumCatalogByCommand.get(key) ?? scumCatalogByCode.get(key) ?? scumCatalogByCommand.get(`#spawnitem ${key}`) ?? scumCatalogByCommand.get(`#spawnvehicle ${key}`); diff --git a/plugins/examples/scum-server-plugin/manifest.json b/plugins/examples/scum-server-plugin/manifest.json index 08dc97b..f0c8d19 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.25", + "version": "0.1.26", "kind": "game-plugin", "tags": [ "scum", diff --git a/plugins/tests/manifest-validation.test.ts b/plugins/tests/manifest-validation.test.ts index 9af4c56..05aa4be 100644 --- a/plugins/tests/manifest-validation.test.ts +++ b/plugins/tests/manifest-validation.test.ts @@ -357,7 +357,7 @@ describe("plugin manifest validation", () => { const serialized = JSON.stringify(manifest).toLowerCase(); expect(serialized).not.toContain("local-proof"); - expect(manifest.version).toBe("0.1.25"); + expect(manifest.version).toBe("0.1.26"); 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-catalog.test.ts b/plugins/tests/scum-catalog.test.ts index 02c0fc8..9c6ea09 100644 --- a/plugins/tests/scum-catalog.test.ts +++ b/plugins/tests/scum-catalog.test.ts @@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs"; import { dirname, join, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -import { scumCatalogEntries, scumCatalogIconUrl } from "../examples/scum-server-plugin/features/scum-catalog.js"; +import { scumCatalogEntries, scumCatalogIconUrl, scumVehicleEntry, scumVehicleFallbackIconUrl, scumVehicleIconUrl } from "../examples/scum-server-plugin/features/scum-catalog.js"; const pluginRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../examples/scum-server-plugin"); const catalogSource = readFileSync(join(pluginRoot, "features/scum-catalog.ts"), "utf8"); @@ -19,4 +19,20 @@ describe("scum catalog icons", () => { const missing = scumCatalogEntries.filter((entry) => entry.icon && !existsSync(join(pluginRoot, "assets/scum-catalog", entry.icon))); expect(missing.map((entry) => entry.icon)).toEqual([]); }); + + it("matches the live server's _ES vehicle classes to bundled icons", () => { + const liveClasses = ["Rager_ES", "Laika_ES", "RIS_ES", "WolfsWagen_ES", "WheelBarrow_Metal_ES", "Barba_ES", "Tractor_ES", "Dinghy_ES", "Cruiser_ES", "SidecarBike_ES", "Dirtbike_ES", "CityBike_ES", "SUP_ES", "MountainBike_ES", "Kinglet_Duster_ES", "WheelBarrow_Improvised_ES"]; + for (const code of liveClasses) expect(scumVehicleIconUrl(code)).toContain("scum-catalog"); + expect(scumVehicleEntry("Rager_ES")?.name).toBe("Rager"); + expect(scumVehicleEntry("BPC_Rager_C")?.name).toBe("Rager"); + expect(scumVehicleEntry("Laika_ES")?.name).toBe("Laika"); + expect(scumVehicleEntry("WheelBarrow_Metal_ES")?.name).toBe("Metal Wheelbarrow"); + }); + + it("draws an inline glyph for models whose class ships no icon", () => { + expect(scumVehicleIconUrl("Pickup_01_A_ES")).toBe(""); + expect(scumVehicleFallbackIconUrl("Pickup_01_A_ES")).toContain("data:image/svg+xml"); + expect(scumVehicleFallbackIconUrl("Quad_01_D_ES")).toContain("data:image/svg+xml"); + expect(decodeURIComponent(scumVehicleFallbackIconUrl("Dinghy_ES"))).toContain(" { expect(view.nodes).toContain("details:轨迹回放"); expect(view.texts).not.toContain("轨迹列表"); expect(view.elements.map((element) => element.label)).toContain("轨迹颜色图例"); - expect(view.buttons.map((button) => button.label)).toEqual(expect.arrayContaining(["全选", "清空", "不限", "回放", "回到最新"])); + 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)"); + expect(pageSource).toContain("map-hover-card"); + expect(pageSource).toContain("onPointerEnter"); }); it("draws ridden-vehicle trajectory segments with vehicle icons and per-user selection", () => { @@ -405,6 +407,31 @@ describe("SCUM plugin feature module", () => { expect(pageSource).toContain("setInterval(refresh, scumSurfaceRefreshMs)"); expect(pageSource).toContain("clearInterval(interval)"); }); + it("never lets a collector dump path reach a map image", () => { + const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图" }); + expect(bundledVehicleImageUrl("/original/Rager_ES.webp")).toBe(""); + expect(bundledVehicleImageUrl("/@fs/tmp/scum-catalog/items/vehicles/ico_rager.webp")).toBe("/@fs/tmp/scum-catalog/items/vehicles/ico_rager.webp"); + expect(bundledVehicleImageUrl("data:image/svg+xml,%3Csvg")).toBe("data:image/svg+xml,%3Csvg"); + expect(view.images.some((src) => src.includes("/original/"))).toBe(false); + // vitest resolves bundled assets to file:// URLs; the browser sees /@fs/ or /assets/. + expect(view.images.every((src) => src === "" || /^(?:data|blob|file):/.test(src) || /^\/(?:@fs|assets)\//.test(src))).toBe(true); + expect(view.images.some((src) => src.includes("ico_"))).toBe(true); + }); + + it("does not assign a vehicle fallback icon to ordinary player markers", () => { + const view = renderAndCollect({ pageKey: "live-map", pageTitle: "实时地图", data: { ...surfaceData, vehicles: [], vehicleLocks: [], trajectories: [], mapPoints: [], flags: [], mapRegions: [] } }); + expect(view.images.some((src) => src.startsWith("data:image/svg+xml"))).toBe(false); + }); + + it("runs playback on wall-clock time so the speed switch is instant", () => { + const half = advanceMapPlayback({ playing: true, value: 0, speed: 1, startedAt: 1_000, startValue: 0 }, 16_000); + expect(half.value).toBeCloseTo(0.5, 6); + const done = advanceMapPlayback({ playing: true, value: 0, speed: 4, startedAt: 0, startValue: 0 }, 7_500); + expect(done.playing).toBe(false); + expect(done.value).toBe(1); + const paused = advanceMapPlayback({ playing: false, value: 0.3, speed: 1, startedAt: 0, startValue: 0 }, 900_000); + expect(paused.value).toBeCloseTo(0.3, 6); + }); }); function pluginDataActions(overrides: Partial<{ list: (collection: string, key?: string) => Promise }> = {}) { @@ -434,6 +461,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin 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; className: string; style?: Record }> = []; + const images: string[] = []; 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 = { @@ -443,6 +471,7 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin 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 }); + if (type === "img") images.push(String(props?.src ?? "")); return { type, props, children }; }, useEffect: () => undefined, @@ -462,5 +491,5 @@ function renderAndCollect(options: { data?: SCUMSurfaceData; permissions?: strin availability: { available: true, features: [{ key: "player.intelligence", available: true }] }, workspaceActions: actions }); - return { nodes, texts, buttons, inputs, elements, actions }; + return { nodes, texts, buttons, inputs, elements, images, actions }; }