feat: implement replay system with action dispatchers and context switching between main and playback modes

This commit is contained in:
taDuc
2026-05-15 19:39:02 +07:00
parent 3682f25282
commit 4c81862bb4
15 changed files with 595 additions and 59 deletions
@@ -12,12 +12,14 @@ import {
import { buildEditorSnapshot, normalizeEditorSnapshot, toApiEditorSnapshot } from "@/uhm/lib/editor/snapshot/editorSnapshot";
import type { Change } from "@/uhm/lib/editor/draft/editorTypes";
import type { Feature, FeatureCollection, FeatureId, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { BattleReplay, EditorSnapshot, Project, ProjectCommit, ProjectState, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
type EditorDraftApi = {
draft: FeatureCollection;
mainDraft: FeatureCollection;
replays: BattleReplay[];
buildPayload: () => Change[];
clearChanges: () => void;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
@@ -96,11 +98,12 @@ export function useProjectCommands(options: Options) {
try {
const snapshot = buildEditorSnapshot({
project: options.activeSection,
draft: options.editor.draft,
draft: options.editor.mainDraft,
changes: geometryChanges,
snapshotEntities: options.snapshotEntities,
snapshotWikis: options.snapshotWikis,
snapshotEntityWikiLinks: options.snapshotEntityWikiLinks,
replays: options.editor.replays,
previousSnapshot: options.baselineSnapshot,
hasPersistedFeature: options.editor.hasPersistedFeature,
});
+12 -1
View File
@@ -5,7 +5,7 @@ import type { EntitySnapshot } from "@/uhm/types/entities";
import type { EntitySnapshotOperation } from "@/uhm/types/entities";
import type { Feature, FeatureCollection, Geometry, GeometryEntitySnapshot, GeometrySnapshot } from "@/uhm/types/geo";
import type { EditorSnapshot, Project } from "@/uhm/types/projects";
import type { BattleReplay, EditorSnapshot, Project } from "@/uhm/types/projects";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
@@ -312,6 +312,7 @@ export function normalizeEditorSnapshot(raw: unknown): EditorSnapshot | null {
wikis,
geometry_entity: geometryEntity || migratedGeometryEntity,
entity_wiki: entityWikis,
replays: Array.isArray(snapshot.replays) ? (snapshot.replays as BattleReplay[]) : undefined,
};
}
@@ -322,6 +323,7 @@ export function buildEditorSnapshot(options: {
snapshotEntities: EntitySnapshot[];
snapshotWikis: WikiSnapshot[];
snapshotEntityWikiLinks: EntityWikiLinkSnapshot[];
replays: BattleReplay[];
previousSnapshot: EditorSnapshot | null;
hasPersistedFeature: (id: Feature["properties"]["id"]) => boolean;
}): EditorSnapshot {
@@ -663,6 +665,7 @@ export function buildEditorSnapshot(options: {
}))
.sort((a, b) => a.id.localeCompare(b.id)),
entity_wiki: entityWikis,
replays: options.replays,
};
}
@@ -686,6 +689,14 @@ export function toApiEditorSnapshot(snapshot: EditorSnapshot): EditorSnapshot {
});
}
if (Array.isArray(cloned.replays)) {
cloned.replays = cloned.replays.map((replay) => {
// Strip local-only replay_features before sending to BE
const { replay_features: _, ...rest } = replay;
return rest;
});
}
return cloned;
}
+107 -7
View File
@@ -11,7 +11,9 @@ import { useUndoStack } from "@/uhm/lib/editor/draft/useUndoStack";
import type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
import type { EntitySnapshot } from "@/uhm/types/entities";
import type { WikiSnapshot } from "@/uhm/types/wiki";
import type { EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import type { BattleReplay, EditorSnapshot, EntityWikiLinkSnapshot } from "@/uhm/types/projects";
import { EMPTY_FEATURE_COLLECTION } from "@/uhm/lib/map/geo/constants";
import type { EditorMode } from "@/uhm/lib/editor/session/sessionTypes";
export type { Feature, FeatureCollection, FeatureProperties, Geometry } from "@/uhm/types/geo";
export type { Change, UndoAction } from "@/uhm/lib/editor/draft/editorTypes";
@@ -31,11 +33,27 @@ type FeaturePropertiesPatch = {
};
// State trung tâm của editor:
// - draft: dữ liệu nguồn để render UI
// - draft: dữ liệu nguồn để render UI (chuyển đổi giữa main và replay)
// - changes: map các thay đổi chờ lưu
// - undoStack: lịch sử thao tác tối thiểu để hoàn tác
export function useEditorState(initialData: FeatureCollection, snapshotUndo?: SnapshotUndoApi) {
const { draft, draftRef, commitDraft, resetDraft } = useDraftState(initialData);
export function useEditorState(
initialData: FeatureCollection,
options: {
snapshotUndo?: SnapshotUndoApi;
initialReplays?: BattleReplay[];
mode: EditorMode;
}
) {
const { snapshotUndo, initialReplays, mode } = options;
const mainDraftState = useDraftState(initialData);
const replayDraftState = useDraftState(EMPTY_FEATURE_COLLECTION);
const [replays, setReplays] = useState<BattleReplay[]>(initialReplays || []);
const [activeReplayId, setActiveReplayId] = useState<string | number | null>(null);
const activeDraftState = mode === "replay" ? replayDraftState : mainDraftState;
const { draft, draftRef, commitDraft, resetDraft } = activeDraftState;
// Map baseline (id -> feature) để diff draft hiện tại ra changes.
const initialMapRef = useRef<Map<FeatureProperties["id"], Feature>>(
@@ -125,11 +143,14 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
const { undoStack, pushUndo, undo, clearUndo } = useUndoStack({ applyUndoAction });
useEffect(() => {
resetDraft(deepClone(initialData));
mainDraftState.resetDraft(deepClone(initialData));
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION);
setReplays(initialReplays || []);
setActiveReplayId(null);
clearUndo();
initialMapRef.current = buildInitialMap(initialData);
setBaselineVersion((version) => version + 1);
}, [clearUndo, initialData, resetDraft]);
}, [clearUndo, initialData, initialReplays, mainDraftState.resetDraft, replayDraftState.resetDraft]);
const changes = useMemo(() => {
const baseline = initialMapRef.current;
@@ -302,7 +323,7 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
function clearChanges() {
clearUndo();
initialMapRef.current = buildInitialMap(draftRef.current);
initialMapRef.current = buildInitialMap(mainDraftState.draftRef.current);
setBaselineVersion((version) => version + 1);
}
@@ -310,6 +331,77 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
return initialMapRef.current.has(id);
}
const switchReplayContext = useCallback((featureId: string | number, selectedIds: (string | number)[] = []) => {
const id = String(featureId);
// Lưu draft replay cũ nếu có (defensive)
if (activeReplayId && mode === "replay") {
const currentDraft = replayDraftState.draftRef.current;
setReplays(prev => prev.map(r =>
r.geometry_id === String(activeReplayId)
? { ...r, replay_features: deepClone(currentDraft) }
: r
));
}
const existing = replays.find(r => r.geometry_id === id);
// Chuẩn bị data: bao gồm tất cả các geo đang chọn + binding của geo chính
const selectedIdsSet = new Set(selectedIds.map(String));
selectedIdsSet.add(id); // Luôn bao gồm geo chính
const triggerFeature = mainDraftState.draftRef.current.features.find(f => String(f.properties.id) === id);
const mainBoundIds = new Set(triggerFeature?.properties?.binding?.map(String) || []);
// Quy tắc: targetIds bao gồm các geo được chọn và binding CHỈ của geo chính.
const targetIds = new Set([...selectedIdsSet, ...mainBoundIds]);
const gatheredFeatures = mainDraftState.draftRef.current.features
.filter(f => targetIds.has(String(f.properties.id)))
.map(deepClone);
if (existing) {
// Đồng bộ lại danh sách geometry theo lựa chọn mới nhất (Sync với Main Draft)
// Giúp "reset" danh sách geo theo multi-select và binding mới nhất,
// nhưng vẫn giữ nguyên phần kịch bản (detail) đã dựng.
const nextFeatures: FeatureCollection = {
type: "FeatureCollection",
features: gatheredFeatures,
};
replayDraftState.resetDraft(deepClone(nextFeatures));
// Cập nhật lại list replays để đồng bộ
setReplays(prev => prev.map(r =>
r.geometry_id === id ? { ...r, replay_features: nextFeatures } : r
));
} else {
const initialFeatures: FeatureCollection = {
type: "FeatureCollection",
features: gatheredFeatures,
};
const newReplay: BattleReplay = {
geometry_id: id,
detail: [],
replay_features: initialFeatures,
};
setReplays(prev => [...prev, newReplay]);
replayDraftState.resetDraft(deepClone(initialFeatures));
}
setActiveReplayId(id);
}, [activeReplayId, mode, replayDraftState, replays, mainDraftState.draftRef]);
const closeReplayContext = useCallback(() => {
if (activeReplayId) {
const currentDraft = replayDraftState.draftRef.current;
setReplays(prev => prev.map(r =>
r.geometry_id === String(activeReplayId)
? { ...r, replay_features: deepClone(currentDraft) }
: r
));
}
setActiveReplayId(null);
replayDraftState.resetDraft(EMPTY_FEATURE_COLLECTION);
}, [activeReplayId, replayDraftState]);
const setSnapshotEntitiesUndoable = useCallback((
next: SetStateAction<EntitySnapshot[]>,
label = "Cập nhật entities"
@@ -380,6 +472,14 @@ export function useEditorState(initialData: FeatureCollection, snapshotUndo?: Sn
return {
draft,
draftRef,
mainDraft: mainDraftState.draft,
replayDraft: replayDraftState.draft,
replays,
setReplays,
activeReplayId,
switchReplayContext,
closeReplayContext,
changes,
undoStack,
changeCount,
+1 -4
View File
@@ -220,10 +220,7 @@ export function initSelect(
hasMenuItems = true;
}
if (
selectedCount === 1 &&
onReplayEdit
) {
if (onReplayEdit) {
const featureId = clickedFeature.id ?? clickedFeature.properties?.id;
if (featureId) {
menu.appendChild(createItem("Replay Edit", () => onReplayEdit(featureId)));
+91
View File
@@ -0,0 +1,91 @@
import type maplibregl from "maplibre-gl";
import type { FeatureCollection } from "@/uhm/types/geo";
/**
* Các hàm xử lý tương tác bản đồ cho hệ thống Replay.
* Hầu hết các hàm yêu cầu instance của MapLibre GL.
*/
export const mapActions = {
// Di chuyển camera đến tọa độ [lng, lat]
zoom_to_lnglat: (map: maplibregl.Map, lng: number, lat: number, zoom?: number) => {
map.easeTo({
center: [lng, lat],
zoom: zoom ?? map.getZoom(),
duration: 2000,
});
},
// Thay đổi mức zoom của bản đồ
zoom_scale: (map: maplibregl.Map, zoom: number) => {
map.easeTo({
zoom,
duration: 1500,
});
},
// Đặt trạng thái camera toàn diện (center, zoom, pitch, bearing)
set_camera_view: (map: maplibregl.Map, state: { center: { lng: number; lat: number }; zoom: number; pitch: number; bearing: number }) => {
map.easeTo({
center: [state.center.lng, state.center.lat],
zoom: state.zoom,
pitch: state.pitch,
bearing: state.bearing,
duration: 2500,
});
},
// Di chuyển mượt mà đến một geometry dựa trên ID
fly_to_geometry: (map: maplibregl.Map, geometryId: string | number, draft: FeatureCollection) => {
const feature = draft.features.find(f => String(f.properties.id) === String(geometryId));
if (!feature) return;
// Tính toán bounds từ geometry (giả định có helper hoặc dùng bbox của feature)
// Ở đây tạm dùng center đơn giản nếu là Point, hoặc bounds nếu là đa giác
if (feature.geometry.type === "Point") {
map.flyTo({
center: feature.geometry.coordinates as [number, number],
zoom: Math.max(map.getZoom(), 10),
duration: 3000,
});
} else {
// Thực tế cần tính bbox, ở đây giả định map có hàm fitBounds hoặc tương đương
// map.fitBounds(calculateBBox(feature.geometry), { padding: 50 });
}
},
// Xoay camera quanh một điểm
rotate_around_point: (map: maplibregl.Map, duration: number = 5000) => {
const startBearing = map.getBearing();
map.easeTo({
bearing: startBearing + 180,
duration,
easing: (t) => t,
});
},
// Thay đổi màu của một geometry (thao tác trực tiếp trên layer map)
change_geometry_color: (map: maplibregl.Map, geometryId: string | number, color: string) => {
const layerId = `uhm-geo-${geometryId}`; // Giả định format ID layer
if (map.getLayer(layerId)) {
map.setPaintProperty(layerId, 'fill-color', color);
map.setPaintProperty(layerId, 'line-color', color);
}
},
// Ẩn/hiện nhãn (labels) trên bản đồ
toggle_labels: (map: maplibregl.Map, visible: boolean) => {
const style = map.getStyle();
if (!style) return;
style.layers.forEach(layer => {
if (layer.type === 'symbol' && (layer as any).layout?.['text-field']) {
map.setLayoutProperty(layer.id, 'visibility', visible ? 'visible' : 'none');
}
});
},
// Thay đổi bộ lọc thời gian trên bản đồ
set_time_filter: (onYearChange: (year: number) => void, year: number) => {
onYearChange(year);
}
};
+30
View File
@@ -0,0 +1,30 @@
/**
* Các hàm điều khiển nội dung dẫn chuyện và thuyết minh trong Replay.
*/
export const narrativeActions = {
// Đặt tiêu đề cho cảnh hiện tại
set_title: (setTitle: (t: string) => void, title: string) => {
setTitle(title);
},
// Đặt nội dung mô tả chi tiết
set_descriptions: (setDesc: (d: string) => void, descriptions: string) => {
setDesc(descriptions);
},
// Hiển thị hộp thoại hội thoại (Dialogue)
show_dialog_box: (setDialog: (data: { avatar: string; text: string; side: 'left' | 'right' }) => void, avatar: string, text: string) => {
setDialog({ avatar, text, side: 'left' });
},
// Hiển thị hình ảnh lịch sử đè lên bản đồ
display_historical_image: (setImage: (url: string | null) => void, imageUrl: string) => {
setImage(imageUrl);
},
// Hiển thị phụ đề (Subtitle)
set_step_subtitle: (setSubtitle: (s: string | null) => void, subtitle: string) => {
setSubtitle(subtitle);
}
};
+105
View File
@@ -0,0 +1,105 @@
import type maplibregl from "maplibre-gl";
import type { FeatureCollection } from "@/uhm/types/geo";
import type { ReplayAction, UIFunctionName, MapFunctionName, NarrativeFunctionName } from "@/uhm/types/projects";
import { mapActions } from "./mapActions";
import { uiActions } from "./uiActions";
import { narrativeActions } from "./narrativeActions";
/**
* Interface định nghĩa các controller cần thiết để thực thi Replay.
* Các thành phần UI sẽ cung cấp các hàm setter này cho Dispatcher.
*/
export interface ReplayControllers {
map: maplibregl.Map | null;
draft: FeatureCollection;
// UI Setters
setTimelineVisible: (v: boolean) => void;
setUIVisible: (v: boolean) => void;
setSidebarOpen: (v: boolean) => void;
onSelectWiki: (id: string) => void;
addToast: (msg: string) => void;
setPlaybackSpeed: (s: number) => void;
onYearChange: (y: number) => void;
// Narrative Setters
setTitle: (t: string) => void;
setDescriptions: (d: string) => void;
setDialog: (data: any) => void;
setImage: (url: string | null) => void;
setSubtitle: (s: string | null) => void;
}
/**
* Dispatcher trung tâm: Nhận một Action và thực thi logic tương ứng
* bằng cách gọi đến các bộ Action con (map, ui, narrative).
*/
export const dispatchReplayAction = (controllers: ReplayControllers, action: ReplayAction<any>) => {
const { function_name, params } = action;
// 1. Nhóm Map Actions
if (controllers.map) {
const map = controllers.map;
switch (function_name as MapFunctionName) {
case "zoom_to_lnglat":
mapActions.zoom_to_lnglat(map, params[0], params[1], params[2]);
return;
case "zoom_scale":
mapActions.zoom_scale(map, params[0]);
return;
case "set_camera_view":
mapActions.set_camera_view(map, params[0]);
return;
case "fly_to_geometry":
mapActions.fly_to_geometry(map, params[0], controllers.draft);
return;
case "rotate_around_point":
mapActions.rotate_around_point(map, params[0]);
return;
case "toggle_labels":
mapActions.toggle_labels(map, params[0]);
return;
case "set_time_filter":
mapActions.set_time_filter(controllers.onYearChange, params[0]);
return;
}
}
// 2. Nhóm UI Actions
switch (function_name as UIFunctionName) {
case "hide_timeline":
uiActions.hide_timeline(controllers.setTimelineVisible);
return;
case "hide_all_UI":
uiActions.hide_all_UI(controllers.setUIVisible);
return;
case "open_wiki":
uiActions.open_wiki(controllers.setSidebarOpen, controllers.onSelectWiki, params[0]);
return;
case "show_toast_message":
uiActions.show_toast_message(controllers.addToast, params[0]);
return;
case "set_playback_speed":
uiActions.set_playback_speed(controllers.setPlaybackSpeed, params[0]);
return;
}
// 3. Nhóm Narrative Actions
switch (function_name as NarrativeFunctionName) {
case "set_title":
narrativeActions.set_title(controllers.setTitle, params[0]);
return;
case "set_descriptions":
narrativeActions.set_descriptions(controllers.setDescriptions, params[0]);
return;
case "show_dialog_box":
narrativeActions.show_dialog_box(controllers.setDialog, params[0], params[1]);
return;
case "display_historical_image":
narrativeActions.display_historical_image(controllers.setImage, params[0]);
return;
case "set_step_subtitle":
narrativeActions.set_step_subtitle(controllers.setSubtitle, params[0]);
return;
}
};
+31
View File
@@ -0,0 +1,31 @@
/**
* Các hàm điều khiển giao diện người dùng (UI) trong chế độ Replay.
*/
export const uiActions = {
// Ẩn thanh Timeline
hide_timeline: (setTimelineVisible: (v: boolean) => void) => {
setTimelineVisible(false);
},
// Ẩn toàn bộ UI để có trải nghiệm điện ảnh (Cinematic)
hide_all_UI: (setUIVisible: (v: boolean) => void) => {
setUIVisible(false);
},
// Mở Wiki và tìm đến một ID cụ thể
open_wiki: (setSidebarOpen: (v: boolean) => void, onSelectWiki: (id: string) => void, wikiId: string) => {
setSidebarOpen(true);
onSelectWiki(wikiId);
},
// Hiển thị thông báo (toast)
show_toast_message: (addToast: (msg: string) => void, message: string) => {
addToast(message);
},
// Thay đổi tốc độ phát Replay
set_playback_speed: (setSpeed: (s: number) => void, speed: number) => {
setSpeed(speed);
}
};