diff --git a/backend/app/api/v1/endpoints/map.py b/backend/app/api/v1/endpoints/map.py index 56dce52..5a0465a 100644 --- a/backend/app/api/v1/endpoints/map.py +++ b/backend/app/api/v1/endpoints/map.py @@ -62,6 +62,20 @@ async def upload_map(file: UploadFile = File(...), user=Depends(get_current_user raise HTTPException(status_code=500, detail=str(e)) +@router.post("/open") +async def open_map(file: UploadFile = File(...)): + try: + content = await file.read() + chkt = get_chkt(BytesIO(content)) + chk = CHK(chkt) + dat = DAT() + + raw_map = get_map(chk, dat) + return {"file_name": file.filename, "raw_map": raw_map} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + @router.get("/test_map") async def get_test_map(): with open("./example/various_units.scx", "rb") as f: diff --git a/backend/app/services/rawdata/chk.py b/backend/app/services/rawdata/chk.py index 8aae8b1..2f4de39 100644 --- a/backend/app/services/rawdata/chk.py +++ b/backend/app/services/rawdata/chk.py @@ -413,11 +413,14 @@ def sprites(self) -> list[chk_types.Sprite]: @cached_property def strings(self) -> list[chk_types.String]: - str_bytes = self.chkt.getsection("STRx") - string_count = struct.unpack("I", str_bytes[0:4])[0] + str_section = "STRx" if b"STRx" in self.chkt.sections else "STR " + size = 2 if str_section == "STR " else 4 + format = "H" if str_section == "STR " else "I" + str_bytes = self.chkt.getsection(str_section) + string_count = struct.unpack(format, str_bytes[0:size])[0] offsets = [ - struct.unpack("I", str_bytes[i : i + 4])[0] - for i in range(4, 4 + 4 * string_count, 4) + struct.unpack(format, str_bytes[i : i + size])[0] + for i in range(size, size + size * string_count, size) ] result: list[chk_types.String] = [] diff --git a/backend/example/A_Tale_of_Two_Cities.scx b/backend/example/A_Tale_of_Two_Cities.scx new file mode 100644 index 0000000..7e84b59 Binary files /dev/null and b/backend/example/A_Tale_of_Two_Cities.scx differ diff --git a/backend/example/goe.scx b/backend/example/goe.scx new file mode 100644 index 0000000..ee5edb8 Binary files /dev/null and b/backend/example/goe.scx differ diff --git a/frontend/components/layout/app-menu.tsx b/frontend/components/layout/app-menu.tsx index ff4debe..4639bdf 100644 --- a/frontend/components/layout/app-menu.tsx +++ b/frontend/components/layout/app-menu.tsx @@ -8,7 +8,9 @@ import { } from "../ui/menubar"; import { useUsemapStore } from "@/components/pages/editor-page"; import api from "@/lib/api"; +import { UsemapActions } from "@/store/mapStore"; import { Usemap } from "@/types/schemas/project/Usemap"; +import { useRef } from "react"; async function onClickBuild(usemap: Usemap | null) { if (!usemap) return; @@ -35,8 +37,46 @@ async function onClickBuild(usemap: Usemap | null) { } } +async function onClickOpenUsemap( + file: File, + openUsemap: UsemapActions["openUsemap"], +) { + try { + openUsemap(file); + } catch (error) { + console.error("Failed to upload map:", error); + alert("Failed to load map file. Please try again."); + } +} + +async function onClickSave(usemap: Usemap) { + const a = document.createElement("a"); + const blob = new Blob([JSON.stringify(usemap)], { type: "application/json" }); + const url = window.URL.createObjectURL(blob); + a.download = `${usemap.scenario_property.name.content}.wproject`; + a.href = url; + + document.body.appendChild(a); + a.click(); + a.remove(); + window.URL.revokeObjectURL(url); +} + export function AppMenu() { const rawMap = useUsemapStore((state) => state.usemap); + const openUsemap = useUsemapStore((state) => state.openUsemap); + + const usemapInputRef = useRef(null); + + const handleFileSelect = async ( + event: React.ChangeEvent, + ) => { + const file = event.target.files?.[0]; + if (file) { + await onClickOpenUsemap(file, openUsemap); + event.target.value = ""; + } + }; return ( @@ -44,12 +84,16 @@ export function AppMenu() { File New Project - onClickBuild(rawMap)}> - Download - - Open + usemapInputRef.current?.click()}> + Open + Open Recents + + rawMap && onClickSave(rawMap)}> + Save + + onClickBuild(rawMap)}>Build @@ -80,6 +124,14 @@ export function AppMenu() { Redo + + ); } diff --git a/frontend/components/ui/menubar.tsx b/frontend/components/ui/menubar.tsx index 3edf641..22587a4 100644 --- a/frontend/components/ui/menubar.tsx +++ b/frontend/components/ui/menubar.tsx @@ -171,6 +171,7 @@ export function MenubarItem({ ); } + return (
  • diff --git a/frontend/hooks/useImage.ts b/frontend/hooks/useImage.ts index dca9115..1089eea 100644 --- a/frontend/hooks/useImage.ts +++ b/frontend/hooks/useImage.ts @@ -152,7 +152,9 @@ export function useViewportImage(): ViewportImageBundle { terrainImage.current = getTerrainImage( usemap.terrain, - usemap.entities.filter((entity) => entity.data && entity.data.kind === "Tile").map(e => e.data) as Tile[], + usemap.entities + .filter((entity) => entity.data && entity.data.kind === "Tile") + .map((e) => e.data) as Tile[], tileGroup, tilesetData, ); @@ -161,8 +163,12 @@ export function useViewportImage(): ViewportImageBundle { const requiredImageIDs = useMemo(() => { const result = new Set(); if (usemap) { - const unitEntities = usemap.entities.filter((entity) => entity.data && entity.data.kind === "Unit").map(e => e.data) as Unit[]; - const spriteEntities = usemap.entities.filter((entity) => entity.data && entity.data.kind === "Sprite").map(e => e.data) as Sprite[]; + const unitEntities = usemap.entities + .filter((entity) => entity.data && entity.data.kind === "Unit") + .map((e) => e.data) as Unit[]; + const spriteEntities = usemap.entities + .filter((entity) => entity.data && entity.data.kind === "Sprite") + .map((e) => e.data) as Sprite[]; unitEntities.forEach((unit) => { result.add(unit.unit_definition.specification.graphics.sprite.image.id); @@ -183,7 +189,8 @@ export function useViewportImage(): ViewportImageBundle { const bmp = await getPlacedUnitImage( usemap.terrain, usemap.entities - .filter((entity) => entity.data && entity.data.kind === "Unit").map(e => e.data) as Unit[], + .filter((entity) => entity.data && entity.data.kind === "Unit") + .map((e) => e.data) as Unit[], imagesData, ); setUnitImage(bmp); @@ -197,7 +204,8 @@ export function useViewportImage(): ViewportImageBundle { const bmp = await getPlacedSpriteImages( usemap.terrain, usemap.entities - .filter((entity) => entity.data && entity.data.kind === "Sprite").map(e => e.data) as Sprite[], + .filter((entity) => entity.data && entity.data.kind === "Sprite") + .map((e) => e.data) as Sprite[], imagesData, ); setSpriteImage(bmp); @@ -209,7 +217,8 @@ export function useViewportImage(): ViewportImageBundle { locationImage.current = getLocationImage( usemap.terrain, usemap.entities - .filter((entity) => entity.data && entity.data.kind === "Location").map(e => e.data) as Location[], + .filter((entity) => entity.data && entity.data.kind === "Location") + .map((e) => e.data) as Location[], ); }, [usemap?.entities]); diff --git a/frontend/store/mapStore.ts b/frontend/store/mapStore.ts index 37e8df9..e46c4f3 100644 --- a/frontend/store/mapStore.ts +++ b/frontend/store/mapStore.ts @@ -1,7 +1,8 @@ "use client"; -import { Usemap } from "@/types/schemas/project/Usemap"; +import { Usemap, UsemapSchema } from "@/types/schemas/project/Usemap"; import { createStore } from "zustand"; +import { immer } from "zustand/middleware/immer"; import { produce } from "immer"; import { Entity } from "@/types/schemas/entities/Entity"; import { Asset } from "@/types/asset"; @@ -29,9 +30,11 @@ export type UsemapState = { }; export type UsemapActions = { + fetchUsemap: (mapName: string) => Promise; + setUsemap: (usemap: Usemap) => void; + openUsemap: (file: File) => Promise; addEntity: (entity: Asset) => void; deleteEntity: (entity: Asset) => void; - fetchUsemap: (mapName: string) => Promise; updateEntityAssetName: (id: number, name: string) => void; updateEntity: (id: number, path: string[], value: any) => void; addAsset: (asset: Asset) => void; @@ -51,66 +54,103 @@ export const createUsemapStore = () => { return obj; }; - return createStore()((set) => ({ - usemap: null, - fetchUsemap: async (mapName: string) => { - try { - const res = await api.get(`/api/v1/maps/${mapName}`); - set({ usemap: res.data }); - } catch (error) { - console.error("Failed to fetch usemap:", error); - } - }, - addEntity: (entity: Asset) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.entities.push(entity); - }), - })), - deleteEntity: (entity: Asset) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.entities = draft.entities.filter( - (e: Asset) => e.id !== entity.id, - ); - // maybe can replaced with delete draft.entities[entity.id]? - }), - })), - updateEntityAssetName: (id: number, name: string) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.entities = draft.entities.map((e) => - e.id === id ? { ...e, name: name } : e, - ); - }), - })), - updateEntity: (id: number, path: string[], value: any) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.entities = draft.entities.map((e) => - e.id === id ? { ...e, data: update(e.data, path, value) } : e, - ); - }), - })), - addAsset: (asset: Asset) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.assets.push(asset); - }), - })), - deleteAsset: (asset: Asset) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.assets = draft.assets.filter((a) => a.id !== asset.id); - }), - })), - updateAsset: (id: number, path: string[], value: any) => - set((state) => ({ - usemap: produce(state.usemap, (draft: Usemap) => { - draft.assets = draft.assets.map((a) => - a.id === id ? { ...a, data: update(a.data, path, value) } : a, - ); - }), - })), - })); + return createStore()( + immer((set) => ({ + usemap: null, + fetchUsemap: async (mapName: string) => { + try { + const res = await api.get(`/api/v1/maps/${mapName}`); + set({ usemap: res.data }); + } catch (error) { + console.error("Failed to fetch usemap:", error); + } + }, + openUsemap: async (file: File) => { + try { + const extension = file.name.split(".").pop()?.toLowerCase(); + if (extension === "wproject") { + const jsonData = JSON.parse(await file.text()); + + // 검증만 하고 원본 데이터 사용 + const validation = UsemapSchema.safeParse(jsonData); + if (!validation.success) { + console.warn("Schema validation failed:", validation.error); + // 경고만 하고 계속 진행하거나 에러 처리 + } + + set({ usemap: jsonData }); // 원본 데이터 사용 + } else if (["chk", "scx", "scm"].includes(extension || "")) { + const formData = new FormData(); + formData.append("file", file); + + const response = await api.post("/api/v1/maps/open/", formData, { + headers: { + "Content-Type": "multipart/form-data", + }, + }); + + const { raw_map } = response.data; + if (raw_map) { + set({ usemap: raw_map }); + } + } + } catch (error) { + console.error("Failed to upload usemap:", error); + throw error; // 에러를 다시 던져서 컴포넌트에서 처리할 수 있도록 + } + }, + setUsemap: (usemap: Usemap) => set({ usemap: usemap }), + addEntity: (entity: Asset) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.entities.push(entity); + }), + })), + deleteEntity: (entity: Asset) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.entities = draft.entities.filter( + (e: Asset) => e.id !== entity.id, + ); + // maybe can replaced with delete draft.entities[entity.id]? + }), + })), + updateEntityAssetName: (id: number, name: string) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.entities = draft.entities.map((e) => + e.id === id ? { ...e, name: name } : e, + ); + }), + })), + updateEntity: (id: number, path: string[], value: any) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.entities = draft.entities.map((e) => + e.id === id ? { ...e, data: update(e.data, path, value) } : e, + ); + }), + })), + addAsset: (asset: Asset) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.assets.push(asset); + }), + })), + deleteAsset: (asset: Asset) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.assets = draft.assets.filter((a) => a.id !== asset.id); + }), + })), + updateAsset: (id: number, path: string[], value: any) => + set((state) => ({ + usemap: produce(state.usemap, (draft: Usemap) => { + draft.assets = draft.assets.map((a) => + a.id === id ? { ...a, data: update(a.data, path, value) } : a, + ); + }), + })), + })), + ); }; diff --git a/frontend/store/projectStore.ts b/frontend/store/projectStore.ts new file mode 100644 index 0000000..c95a5ba --- /dev/null +++ b/frontend/store/projectStore.ts @@ -0,0 +1,39 @@ +import { Project, ProjectSchema } from "@/types/schemas/project/Project"; +import { create } from "zustand"; + +export interface ProjectState { + project: Project | null; +} + +export interface ProjectActions { + /** + * Open Webditor Project from .wproject extension file. + * @param file Webditor Project File (.wproject) + * @returns Open Result + */ + openProject: (file: File) => Promise; + /** + * Set project state from passed project object. + * @param project + */ + setProject: (project: Project) => void; +} + +export type ProjectStore = ProjectState & ProjectActions; + +export const useProjectStore = create()((set) => ({ + project: null, + openProject: async (file: File) => { + try { + const parseResult = ProjectSchema.parse( + JSON.stringify(await file.text()), + ); + set({ project: parseResult }); + return true; + } catch (err) { + alert(`Failed to open project ${file.name}: ${err}`); + return false; + } + }, + setProject: (project: Project) => set({ project: project }), +}));