diff --git a/app/src/components/Editor.tsx b/app/src/components/Editor.tsx index 548a4b8..5c7f917 100644 --- a/app/src/components/Editor.tsx +++ b/app/src/components/Editor.tsx @@ -7,6 +7,7 @@ import './Editor.sass'; interface Props { user: string; identifier: string; + mapIdentifier?: string; } function pad(n: number) { @@ -14,7 +15,7 @@ function pad(n: number) { return '' + n; } -export default function Editor({ user, identifier }: Props) { +export default function Editor({ user, identifier, mapIdentifier }: Props) { const rootRef = useRef(null); const editorRef = useRef(null); const [ loadPercent, setLoadPercent ] = useState(0); @@ -30,10 +31,11 @@ export default function Editor({ user, identifier }: Props) { setLoadPercent(0.25); if (ignore || !rootRef.current) return; - editorRef.current = create(rootRef.current, setLoadPercent, user, identifier); + editorRef.current = create(rootRef.current, setLoadPercent, user, identifier, mapIdentifier); const resizeCallback = () => { const { width, height } = rootRef.current.getBoundingClientRect(); + console.log(rootRef.current); editorRef.current!.scale.resize(width, height); }; diff --git a/app/src/components/page/Editor.tsx b/app/src/components/page/Editor.tsx index 426e522..0d1bdf0 100644 --- a/app/src/components/page/Editor.tsx +++ b/app/src/components/page/Editor.tsx @@ -1,5 +1,6 @@ +import qs from 'query-string'; import * as Preact from 'preact'; -import { useParams } from 'react-router-dom'; +import { useParams, useLocation } from 'react-router-dom'; import Editor from '../Editor'; @@ -7,10 +8,10 @@ import './Editor.sass'; export default function EditorPage() { const { user, campaign } = useParams<{ user: string; campaign: string }>(); - + const map = qs.parse(useLocation().search).map as string | undefined; return (
- +
); } diff --git a/app/src/components/view/NewMapForm.tsx b/app/src/components/view/NewMapForm.tsx index 71ea85f..56d772a 100644 --- a/app/src/components/view/NewMapForm.tsx +++ b/app/src/components/view/NewMapForm.tsx @@ -12,7 +12,7 @@ import Button from '../Button'; export default function NewMapForm() { const history = useHistory(); const [ ,, mergeData ] = useAppData(); - const { campaign } = useParams<{ campaign: string }>(); + const { user, campaign } = useParams<{ user: string; campaign: string }>(); const [ queryState, setQueryState ] = useState<'idle' | 'querying'>('idle'); @@ -35,7 +35,7 @@ export default function NewMapForm() { else { const data = await res.json(); await mergeData(data); - history.push(`/campaign/${campaign}/maps`); + history.push(`/u/${user}/c/${campaign}/maps`); } }; diff --git a/app/src/editor/EditorData.ts b/app/src/editor/EditorData.ts index 171de49..25f3105 100644 --- a/app/src/editor/EditorData.ts +++ b/app/src/editor/EditorData.ts @@ -2,15 +2,12 @@ import { Asset } from './util/Asset'; import { Socket } from 'socket.io-client'; import * as DB from '../../../common/DBStructs'; -export interface ExternalData { - identifier: string; +export default interface EditorData { socket: Socket; -} -export default interface EditorData extends ExternalData { campaign: DB.Campaign; assets: Asset[]; - map: DB.Map; + map?: string; display: 'edit' | 'view'; onProgress: (progress: number | undefined) => void; diff --git a/app/src/editor/Main.ts b/app/src/editor/Main.ts index 3d78e23..87fb960 100644 --- a/app/src/editor/Main.ts +++ b/app/src/editor/Main.ts @@ -1,13 +1,12 @@ import Phaser from 'phaser'; -import { io } from 'socket.io-client'; import * as Scene from './scene/Scenes'; -export default function create(root: HTMLElement, onProgress: (progress: number) => void, user: string, identifier: string) { +export default function create(root: HTMLElement, onProgress: (progress: number) => void, + user: string, identifier: string, mapIdentifier?: string) { + const bounds = root.getBoundingClientRect(); - const socket = io(); - const game = new Phaser.Game({ disableContextMenu: true, render: { antialias: false }, @@ -20,6 +19,6 @@ export default function create(root: HTMLElement, onProgress: (progress: number) scene: Scene.list }); - game.scene.start('InitScene', { user, onProgress, identifier, socket }); + game.scene.start('InitScene', { user, onProgress, identifier, mapIdentifier }); return game; } diff --git a/app/src/editor/action/Action.ts b/app/src/editor/action/Action.ts index 57d0f00..66e021d 100644 --- a/app/src/editor/action/Action.ts +++ b/app/src/editor/action/Action.ts @@ -1,6 +1,6 @@ import { Vec2 } from '../util/Vec'; import { Layer } from '../util/Layer'; -import { TokenData } from '../map/token/Token'; +import { TokenRenderData, TokenData } from '../map/token/Token'; export interface Tile { type: 'tile'; @@ -21,7 +21,7 @@ export interface PlaceToken { export interface ModifyToken { type: 'modify_token'; - tokens: { pre: TokenData[]; post: TokenData[] }; + tokens: { pre: TokenRenderData[]; post: TokenRenderData[] }; } export interface DeleteToken { diff --git a/app/src/editor/action/ActionManager.ts b/app/src/editor/action/ActionManager.ts index fc9a150..89b0da3 100755 --- a/app/src/editor/action/ActionManager.ts +++ b/app/src/editor/action/ActionManager.ts @@ -2,23 +2,24 @@ import * as Phaser from 'phaser'; import IO from 'socket.io-client'; import Map from '../map/Map'; -// import Token from '../map/token/Token'; import type { Action } from './Action'; import ActionEvent from './ActionEvent'; import EventHandler from '../EventHandler'; import InputManager from '../InputManager'; +const SAVE_INTERVAL = 5 * 1000; + export default class ActionManager { + readonly event = new EventHandler(); + private map: Map = null as any; private socket: IO.Socket = null as any; - // private scene: Phaser.Scene = null as any; - private history: Action[] = []; private head: number = -1; - - private evtHandler = new EventHandler(); + private history: Action[] = []; private historyHeldTime: number = 0; + private editTime: number | false = false; init(_scene: Phaser.Scene, map: Map, socket: IO.Socket) { this.map = map; @@ -47,14 +48,20 @@ export default class ActionManager { this.historyHeldTime++; } else this.historyHeldTime = 0; + + if (this.editTime && Date.now() - SAVE_INTERVAL > this.editTime) { + this.socket.emit('serialize', this.map.identifier, this.map.save()); + this.editTime = 0; + } } push(item: Action): void { this.history.splice(this.head + 1, this.history.length - this.head, item); this.head = this.history.length - 1; + if (!this.editTime) this.editTime = Date.now(); this.socket.emit('action', item); - this.evtHandler.dispatch({ event: 'push', head: this.head, length: this.history.length }); + this.event.dispatch({ event: 'push', head: this.head, length: this.history.length }); } apply(item: Action): void { @@ -81,19 +88,17 @@ export default class ActionManager { break; case 'delete_token': - item.tokens.forEach(t => this.map.tokens.createToken(t)); + item.tokens.forEach(t => this.map.tokens.createToken(t.render.pos as any, + { uuid: t.uuid, ...t.meta }, t.render.appearance.sprite, t.render.appearance.index)); break; case 'modify_token': - for (let i = 0; i < item.tokens.pre.length; i++) { - const token = this.map.tokens.getToken(item.tokens.pre[i].uuid); - if (token) token.setToken(item.tokens.pre[i]); - else this.map.tokens.createToken(item.tokens.pre[i]); - } + for (let i = 0; i < item.tokens.pre.length; i++) + this.map.tokens.setRender(item.tokens.pre[i].uuid, item.tokens.pre[i]); break; } - this.evtHandler.dispatch({ event: 'prev', head: this.head, length: this.history.length }); + this.event.dispatch({ event: 'prev', head: this.head, length: this.history.length }); } next() { @@ -111,7 +116,8 @@ export default class ActionManager { break; case 'place_token': - item.tokens.forEach(t => this.map.tokens.createToken(t)); + item.tokens.forEach(t => this.map.tokens.createToken(t.render.pos as any, + { uuid: t.uuid, ...t.meta }, t.render.appearance.sprite, t.render.appearance.index)); break; case 'delete_token': @@ -119,15 +125,12 @@ export default class ActionManager { break; case 'modify_token': - for (let i = 0; i < item.tokens.post.length; i++) { - const token = this.map.tokens.getToken(item.tokens.post[i].uuid); - if (token) token.setToken(item.tokens.post[i]); - else this.map.tokens.createToken(item.tokens.post[i]); - } + for (let i = 0; i < item.tokens.post.length; i++) + this.map.tokens.setRender(item.tokens.post[i].uuid, item.tokens.post[i]); break; } - this.evtHandler.dispatch({ event: 'next', head: this.head, length: this.history.length }); + this.event.dispatch({ event: 'next', head: this.head, length: this.history.length }); } hasPrev(): boolean { @@ -137,12 +140,4 @@ export default class ActionManager { hasNext(): boolean { return this.head < this.history.length - 1; } - - bind(cb: (evt: ActionEvent) => boolean | void) { - this.evtHandler.bind(cb); - } - - unbind(cb: (evt: ActionEvent) => boolean | void) { - this.evtHandler.unbind(cb); - } } diff --git a/app/src/editor/interface/InterfaceRoot.ts b/app/src/editor/interface/InterfaceRoot.ts index 1025a09..c964e87 100644 --- a/app/src/editor/interface/InterfaceRoot.ts +++ b/app/src/editor/interface/InterfaceRoot.ts @@ -38,12 +38,11 @@ export default class InterfaceRoot { private tileSidebar: TileSidebar | null = null; private tokenSidebar: TokenSidebar | null = null; - init(scene: Phaser.Scene, display: 'edit' | 'view', input: InputManager, - mode: ModeMananger, actions: ActionManager, map: Map, assets: Asset[]) { + init(scene: Phaser.Scene, input: InputManager, mode: ModeMananger, + actions: ActionManager, map: Map, assets: Asset[]) { this.mode = mode; this.scene = scene; - // this.actions = action; this.inputManager = input; this.camera = this.scene.cameras.add(0, 0, undefined, undefined, undefined, 'ui_camera'); @@ -55,24 +54,17 @@ export default class InterfaceRoot { this.root.setName('root'); this.leftRoot = this.scene.add.container(0, 0); this.root.add(this.leftRoot); - // this.rightRoot = this.scene.add.container(this.camera.displayWidth, this.camera.displayHeight); - // this.root.add(this.rightRoot); - if (display === 'edit') { - this.leftRoot.add(new SidebarToggler(scene, 49, 1000, input, this)); + this.leftRoot.add(new SidebarToggler(scene, 49, 1000, input, this)); - // this.leftRoot.add(new ModeSwitcher(scene, 82, 1, input, mode)); - // this.leftRoot.add(new HistoryManipulator(scene, 124, 1, history, input)); + this.tokenSidebar = new TokenSidebar(scene, 0, 0, assets, input, mode); + this.leftRoot.add(this.tokenSidebar); - this.tokenSidebar = new TokenSidebar(scene, 0, 0, assets, input, mode); - this.leftRoot.add(this.tokenSidebar); + this.tileSidebar = new TileSidebar(scene, 0, 0, assets, input, mode, map); + this.leftRoot.add(this.tileSidebar); - this.tileSidebar = new TileSidebar(scene, 0, 0, assets, input, mode, map); - this.leftRoot.add(this.tileSidebar); - - this.root.add(new TokenCards(scene, { map, assets })); - this.root.add(new LayerManager(scene, { map })); - } + this.root.add(new TokenCards(scene, { map, assets })); + this.root.add(new LayerManager(scene, { map })); this.root.add(new Toolbar(scene, { mode, actions })); } diff --git a/app/src/editor/interface/components/TokenCard.sass b/app/src/editor/interface/components/TokenCard.sass new file mode 100644 index 0000000..8fee3fe --- /dev/null +++ b/app/src/editor/interface/components/TokenCard.sass @@ -0,0 +1,135 @@ +@use '../../../style/text' +@use '../../../style/slice' +@use '../../../style/def' as * + +.TokenCards + .TokenCard + @extend .slice_background + position: relative + width: 340px + height: 450px + top: 0px + + pointer-events: initial + transition: top $t-fast + + .TokenCard-Pin + position: absolute + top: 4px * 3 + right: -1px * 3 + width: 12px * 3 + height: 12px * 3 + padding: 0 + margin: 0 + + opacity: 0 + border: none + outline: none + background-size: cover + image-rendering: crisp-edges + image-rendering: pixelated + background-color: transparent + background-image: url(/app/static/icon/pin_up.png) + + &:hover, &:focus-within, &.Pinned + top: -406px + transition: top $t-med + + .TokenCard-Pin + opacity: .5 + + &:hover, &:focus-visible + opacity: 1 + + .TokenCard-Name + margin-top: 12px !important + + .TokenCard-Synopsis + opacity: 0 !important + + .TokenCard-Thumbnail + margin-top: 1px * 3 + + &.Pinned + .TokenCard-Pin + background-image: url(/app/static/icon/pin_down.png) + opacity: 1 + + &:hover, &:focus-visible + opacity: .5 + + .TokenCard-Inner + @include slice.slice_invert(3, 4px) + + .TokenCard-Top + display: grid + grid-gap: 9px + height: 19px * 3 + + overflow: hidden + margin: 2px * 3 0 + grid-template-columns: #{18px * 3} 1fr + + .TokenCard-Thumbnail + @extend .slice_highlight + + width: 18px * 3 + height: 18px * 3 + + transition: margin $t-fast + + .TokenCard-ThumbnailInner + @include slice.slice_invert + padding: 1px * 3 + + background-position: 1px * 3 1px * 3 + background-clip: content-box + image-rendering: crisp-edges + image-rendering: pixelated + + .TokenCard-TopDetails + width: 100% + overflow: hidden + + .TokenCard-Name + @include text.line_clamp + margin: -6px 0 0 0 + + color: $neutral-1000 + transition: margin $t-fast + + .TokenCard-Synopsis + @include text.line_clamp + + margin: 3px 0 + + opacity: 1 + color: $accent-200 + transition: opacity $t-fast + + .TokenCard-Sliders + display: grid + grid-gap: 6px + margin-top: 15px + + .TokenCard-NewSlider + @extend .slice_highlight + + span + display: block + margin: -10px + + + .TokenCard-Note + margin-top: 15px + padding: 6px + border-color: $accent-300 + background-color: transparent + + color: $neutral-1000 + + &:focus + border-color: $accent-050 + + &::placeholder + color: $accent-300 diff --git a/app/src/editor/interface/components/TokenCard.tsx b/app/src/editor/interface/components/TokenCard.tsx new file mode 100644 index 0000000..ca05a38 --- /dev/null +++ b/app/src/editor/interface/components/TokenCard.tsx @@ -0,0 +1,67 @@ +import * as Preact from 'preact'; + +import './TokenCard.sass'; + +import TokenSlider from './TokenSlider'; +import Text from '../../../components/input/fields/InputText'; +import { TokenSliderData, TokenMetaData } from '../../map/token/Token'; + +import { Asset } from '../../util/Asset'; + +interface TokenCardProps extends TokenMetaData { + assets: Asset[]; + pinned: boolean; + + setProps: (data: Partial) => void; + setPinned: (pinned: boolean) => void; +} + +export default function TokenCard(props: TokenCardProps) { + const icon: string = ''; + // props.assets.filter(a => a.identifier === props.appearance.sprite)[0]; + const scale = 4; + // icon.dimensions!.x / icon.tileSize; + + const handleAddSlider = () => { + props.setProps({ sliders: [ ...props.sliders, { name: 'Untitled', max: 10, current: 10, icon: 1 } ]}); + }; + + const handleUpdateSlider = (ind: number, data: Partial) => { + const newSliders: TokenSliderData[] = [ ...props.sliders ]; + newSliders[ind] = { ...newSliders[ind], ...data }; + props.setProps({ sliders: newSliders }); + }; + + const handleTogglePin = (e: MouseEvent) => { + props.setPinned(!props.pinned); + if (props.pinned) (e.target as any).blur(); + }; + + + return ( +
+
+ +
+ props.setProps({ note })} /> +
+ + ); +} diff --git a/app/src/editor/interface/components/TokenCards.sass b/app/src/editor/interface/components/TokenCards.sass index eeac014..ef7c662 100644 --- a/app/src/editor/interface/components/TokenCards.sass +++ b/app/src/editor/interface/components/TokenCards.sass @@ -1,5 +1,3 @@ -@use '../../../style/text' -@use '../../../style/slice' @use '../../../style/def' as * .TokenCards @@ -24,211 +22,3 @@ .TokenCard-Thumbnail margin-top: 1px * 3 - - .TokenCard - @extend .slice_background - position: relative - width: 340px - height: 450px - top: 0px - - pointer-events: initial - transition: top $t-fast - - .TokenCard-Pin - position: absolute - top: 4px * 3 - right: -1px * 3 - width: 12px * 3 - height: 12px * 3 - padding: 0 - margin: 0 - - opacity: 0 - border: none - outline: none - background-size: cover - image-rendering: crisp-edges - image-rendering: pixelated - background-color: transparent - background-image: url(/app/static/icon/pin_up.png) - - &:hover, &:focus-within, &.Pinned - top: -406px - transition: top $t-med - - .TokenCard-Pin - opacity: .5 - - &:hover, &:focus-visible - opacity: 1 - - .TokenCard-Name - margin-top: 12px !important - - .TokenCard-Synopsis - opacity: 0 !important - - .TokenCard-Thumbnail - margin-top: 1px * 3 - - &.Pinned - .TokenCard-Pin - background-image: url(/app/static/icon/pin_down.png) - opacity: 1 - - &:hover, &:focus-visible - opacity: .5 - - .TokenCard-Inner - @include slice.slice_invert(3, 4px) - - .TokenCard-Top - display: grid - grid-gap: 9px - height: 19px * 3 - - overflow: hidden - margin: 2px * 3 0 - grid-template-columns: #{18px * 3} 1fr - - .TokenCard-Thumbnail - @extend .slice_highlight - - width: 18px * 3 - height: 18px * 3 - - transition: margin $t-fast - - .TokenCard-ThumbnailInner - @include slice.slice_invert - padding: 1px * 3 - - background-position: 1px * 3 1px * 3 - background-clip: content-box - image-rendering: crisp-edges - image-rendering: pixelated - - .TokenCard-TopDetails - width: 100% - overflow: hidden - - .TokenCard-Name - @include text.line_clamp - margin: -6px 0 0 0 - - color: $neutral-1000 - transition: margin $t-fast - - .TokenCard-Synopsis - @include text.line_clamp - - margin: 3px 0 - - opacity: 1 - color: $accent-200 - transition: opacity $t-fast - - .TokenCard-Sliders - display: grid - grid-gap: 6px - margin-top: 15px - - .TokenCard-NewSlider - @extend .slice_highlight - - span - display: block - margin: -10px - - - .TokenCard-Note - margin-top: 15px - padding: 6px - border-color: $accent-300 - background-color: transparent - - color: $neutral-1000 - - &:focus - border-color: $accent-050 - - &::placeholder - color: $accent-300 - -.TokenSlider - display: flex - gap: 6px - - .TokenSlider-IconWrap - @extend .slice_highlight - - .TokenSlider-Icon - @include slice.slice_invert - width: 36px - height: 36px - - border-radius: 0 - background-size: 900% - image-rendering: crisp-edges - image-rendering: pixelated - background-image: url(/app/static/icon/slider_icons.png) - - .TokenSlider-Slider - @extend .slice_outline_white - flex-grow: 1 - - .TokenSlider-SliderInner - @include slice.slice_invert - border-radius: 0 - position: relative - - .TokenSlider-Bar - position: absolute - height: calc(100% - 6px) - clip-path: polygon(0 3px, 3px 3px, 3px 0, calc(100% - 3px) 0, calc(100% - 3px) 3px, 100% 3px, 100% calc(100% - 3px), calc(100% - 3px) calc(100% - 3px), calc(100% - 3px) 100%, 3px 100%, 3px calc(100% - 3px), 0 calc(100% - 3px)) - - .TokenSlider-BarContent - position: absolute - display: flex - justify-content: center - top: 0 - left: 0 - width: 100% - height: 100% - padding: 6px - - .TokenSlider-Input - padding: 0 2px - margin: 0 - outline: 0 - border: none - background: transparent - - height: 24px - - &:focus - background: transparentize(black, .8) - - .TokenSlider-Title - width: 100% - flex-shrink: 1 - min-width: 0 - - .TokenSlider-BarText - font-weight: 500 - color: $neutral-1000 - text-shadow: 0px 1px 2px $accent-800, 0px 1px 4px transparentize($accent-800, 0.8) - - .TokenSlider-Value, .TokenSlider-Max - width: min-content - - span - opacity: .6 - padding: 3px - -.SlideNumericInput-Tester - position: absolute !important - top: -10000 !important - left: -10000 !important - opacity: 0 !important diff --git a/app/src/editor/interface/components/TokenCards.tsx b/app/src/editor/interface/components/TokenCards.tsx index ae19634..935cbbe 100644 --- a/app/src/editor/interface/components/TokenCards.tsx +++ b/app/src/editor/interface/components/TokenCards.tsx @@ -1,144 +1,15 @@ import * as Preact from 'preact'; import { bind } from './PreactComponent'; -import { useState, useEffect, useLayoutEffect, useRef } from 'preact/hooks'; +import { useState, useEffect, useCallback } from 'preact/hooks'; import './TokenCards.sass'; import Map from '../../map/Map'; -import Text from '../../../components/input/fields/InputText'; -import { TokenSliderData, TokenData } from '../../map/token/Token'; + +import TokenCard from './TokenCard'; +import { TokenMetaData } from '../../map/token/Token'; import { Asset } from '../../util/Asset'; -import { clamp } from '../../util/Helpers'; - - -function SliderNumericInput(props: { min: number; max: number; - value: number; setValue: (val: number) => void; class?: string; }) { - - const [ value, setValue ] = useState(props.value + ''); - useEffect(() => { if ((value === '') !== (props.value === 0)) setValue(props.value + ''); }, [ props.value ]); - - const ref = useRef(null); - const [ width, setWidth ] = useState(0); - useLayoutEffect(() => setWidth(ref.current!.getBoundingClientRect().width), [ value ]); - - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { - const newValue = clamp(props.value + (e.key === 'ArrowUp' ? 1 : -1) * - (e.ctrlKey ? 5 : 1) * (e.shiftKey ? 10 : 1), props.min, props.max); - props.setValue(newValue); - setValue(newValue + ''); - - e.preventDefault(); - e.stopPropagation(); - } - }; - - const handleInput = (e: any) => { - setValue(e.target.value); - const numeric = Number.parseInt(e.target.value, 10); - props.setValue(Number.isNaN(numeric) ? 0 : numeric); - }; - - const handleBlur = () => setValue(props.value + ''); - - return ( - - - {value} - - ); -} - -interface TokenSliderProps extends TokenSliderData { - setProps: (data: Partial) => void; -} - -function TokenSlider(props: TokenSliderProps) { - const handleChangeName = (e: any) => { - const name: string = e.target.value; - props.setProps({ name }); - }; - - return ( -
-
-
-
-
-
-
-
- - - props.setProps({ current })} /> - / - props.setProps({ max })} /> -
-
-
-
- ); -} - -interface TokenCardProps extends TokenData { - assets: Asset[]; - - setProps: (data: Partial) => void; -} - -function TokenCard(props: TokenCardProps) { - const icon: Asset | undefined = props.assets.filter(a => a.identifier === props.appearance.sprite)[0]; - const scale = icon.dimensions!.x / icon.tileSize; - - const handleAddSlider = () => { - props.setProps({ sliders: [ ...props.sliders, { name: 'Untitled', max: 10, current: 10, icon: 1 } ]}); - }; - - const handleUpdateSlider = (ind: number, data: Partial) => { - const newSliders: TokenSliderData[] = [ ...props.sliders ]; - newSliders[ind] = { ...newSliders[ind], ...data }; - props.setProps({ sliders: newSliders }); - }; - - const handleTogglePin = (e: MouseEvent) => { - props.setProps({ pinned: !props.pinned }); - if (props.pinned) (e.target as any).blur(); - }; - - - return ( -
-
- -
- props.setProps({ note })} /> -
-
- ); -} interface Props { map: Map; @@ -146,26 +17,40 @@ interface Props { } export default bind(function TokenCards({ map, assets }: Props) { - const [ cards, setCards ] = useState(map.tokens.getTokenData()); + const [ cards, setCards ] = useState(map.tokens.getAllMeta()); + const [ pinned, setPinned ] = useState([]); useEffect(() => { const eventCb = () => { - setCards(JSON.parse(JSON.stringify(map.tokens.getTokenData()))); + setCards(JSON.parse(JSON.stringify(map.tokens.getAllMeta()))); }; - map.tokens.bind(eventCb); - return () => map.tokens.unbind(eventCb); + map.tokens.event.bind(eventCb); + return () => map.tokens.event.unbind(eventCb); }, []); - const handleSetProps = (ind: number, data: Partial) => { - map.tokens.setToken({ - uuid: cards[ind].uuid, - ...data - }); + const handleSetProps = (ind: number, data: Partial) => { + map.tokens.setMeta(cards[ind].uuid, data); }; + const handleSetPinned = useCallback((uuid: string, pin?: boolean) => { + setPinned(pinned => { + pin = pin ?? !pinned.includes(uuid); + if (pin) { + if (!pinned.includes(uuid)) return [ ...pinned, uuid ]; + return pinned; + } + else { + const ind = pinned.indexOf(uuid); + const n = [ ...pinned ]; + n.splice(ind, 1); + return n; + } + }); + }, []); + useEffect(() => { - const fnCallback = (evt: KeyboardEvent) => { + const fnKeyCallback = (evt: KeyboardEvent) => { if (evt.key[0] !== 'F' || evt.key.length !== 2 || !(evt.key.charCodeAt(1) >= '1'.charCodeAt(0) && evt.key.charCodeAt(1) <= '8'.charCodeAt(0))) return; @@ -173,16 +58,21 @@ export default bind(function TokenCards({ map, assets }: Props) { evt.stopPropagation(); const ind = Number.parseInt(evt.key.substr(1), 10); - if (cards.length >= ind) handleSetProps(ind - 1, { pinned: !cards[ind - 1].pinned }); + if (cards.length >= ind) handleSetPinned(cards[ind - 1].uuid); }; - window.addEventListener('keydown', fnCallback); - return () => window.removeEventListener('keydown', fnCallback); + window.addEventListener('keydown', fnKeyCallback); + return () => window.removeEventListener('keydown', fnKeyCallback); }, [ cards ]); return (
- {cards.map((c, i) => handleSetProps(i, u)} />)} + {cards.map((c, i) => handleSetProps(i, u)} + setPinned={p => handleSetPinned(c.uuid, p)} + />)}
); }); diff --git a/app/src/editor/interface/components/TokenSidebar.ts b/app/src/editor/interface/components/TokenSidebar.ts index b4587f8..265bd7f 100644 --- a/app/src/editor/interface/components/TokenSidebar.ts +++ b/app/src/editor/interface/components/TokenSidebar.ts @@ -7,6 +7,7 @@ import TokenMode from '../../mode/TokenMode'; import ModeManager from '../../mode/ModeManager'; import type InputManager from '../../InputManager'; +import { Vec2 } from '../../util/Vec'; import { Asset } from '../../util/Asset'; export default class TokenSidebar extends Sidebar { @@ -45,15 +46,15 @@ export default class TokenSidebar extends Sidebar { this.spinTimer++; if (this.spinTimer > 20) { - let index = hoveredToken.getFrame() + 1; + let index = hoveredToken.getFrameIndex() + 1; index %= hoveredToken.getFrameCount(); - hoveredToken.setToken({ appearance: { sprite: hoveredToken.getToken().appearance.sprite, index }}); + hoveredToken.setFrame(index); this.spinTimer = 0; } } elemUnhover(): void { - this.sprites.forEach(t => t.setToken({ appearance: { sprite: t.getToken().appearance.sprite, index: 0 }})); + this.sprites.forEach(t => t.setFrame(0)); } elemClick(x: number, y: number): void { @@ -70,12 +71,11 @@ export default class TokenSidebar extends Sidebar { if (x === 0) this.backgrounds[y].setFrame(0); - let token = new Token(this.scene, { appearance: { sprite, index: 0 }}); - token.setPosition(4 + x * 21, 4 + y * 21); - token.setScale(16); + let token = new Token(this.scene, {}, new Vec2(4 + x * 21, 4 + y * 21), sprite); + token.setScale(1); this.sprites.push(token); - this.list.push(token); + this.add(token); this.bringToTop(this.activeSpriteCursor); this.bringToTop(this.hoverSpriteCursor); diff --git a/app/src/editor/interface/components/TokenSlider.sass b/app/src/editor/interface/components/TokenSlider.sass new file mode 100644 index 0000000..11321d0 --- /dev/null +++ b/app/src/editor/interface/components/TokenSlider.sass @@ -0,0 +1,79 @@ +@use '../../../style/slice' +@use '../../../style/def' as * + +.TokenSlider + display: flex + gap: 6px + + .TokenSlider-IconWrap + @extend .slice_highlight + + .TokenSlider-Icon + @include slice.slice_invert + width: 36px + height: 36px + + border-radius: 0 + background-size: 900% + image-rendering: crisp-edges + image-rendering: pixelated + background-image: url(/app/static/icon/slider_icons.png) + + .TokenSlider-Slider + @extend .slice_outline_white + flex-grow: 11 + + .TokenSlider-SliderInner + @include slice.slice_invert + border-radius: 0 + position: relative + + .TokenSlider-Bar + position: absolute + height: calc(100% - 6px) + clip-path: polygon(0 3px, 3px 3px, 3px 0, calc(100% - 3px) 0, calc(100% - 3px) 3px, 100% 3px, 100% calc(100% - 3px), calc(100% - 3px) calc(100% - 3px), calc(100% - 3px) 100%, 3px 100%, 3px calc(100% - 3px), 0 calc(100% - 3px)) + + .TokenSlider-BarContent + position: absolute + display: flex + justify-content: center + top: 0 + left: 0 + width: 100% + height: 100% + padding: 6px + + .TokenSlider-Input + padding: 0 2px + margin: 0 + outline: 0 + border: none + background: transparent + + height: 24px + + &:focus + background: transparentize(black, .8) + + .TokenSlider-Title + width: 100% + flex-shrink: 1 + min-width: 0 + + .TokenSlider-BarText + font-weight: 500 + color: $neutral-1000 + text-shadow: 0px 1px 2px $accent-800, 0px 1px 4px transparentize($accent-800, 0.8) + + .TokenSlider-Value, .TokenSlider-Max + width: min-content + + span + opacity: .6 + padding: 3px + +.SlideNumericInput-Tester + opacity: 0 !important + top: -10000px !important + left: -10000px !important + position: absolute !important diff --git a/app/src/editor/interface/components/TokenSlider.tsx b/app/src/editor/interface/components/TokenSlider.tsx new file mode 100644 index 0000000..f3ad3cd --- /dev/null +++ b/app/src/editor/interface/components/TokenSlider.tsx @@ -0,0 +1,82 @@ +import * as Preact from 'preact'; +import { useState, useEffect, useLayoutEffect, useRef } from 'preact/hooks'; + +import './TokenSlider.sass'; + +import { TokenSliderData } from '../../map/token/Token'; + +import { clamp } from '../../util/Helpers'; + +function SliderNumericInput(props: { min: number; max: number; + value: number; setValue: (val: number) => void; class?: string; }) { + + const [ value, setValue ] = useState(props.value + ''); + useEffect(() => { if ((value === '') !== (props.value === 0)) setValue(props.value + ''); }, [ props.value ]); + + const ref = useRef(null); + const [ width, setWidth ] = useState(0); + useLayoutEffect(() => setWidth(ref.current!.getBoundingClientRect().width), [ value ]); + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { + const newValue = clamp(props.value + (e.key === 'ArrowUp' ? 1 : -1) * + (e.ctrlKey ? 5 : 1) * (e.shiftKey ? 10 : 1), props.min, props.max); + props.setValue(newValue); + setValue(newValue + ''); + + e.preventDefault(); + e.stopPropagation(); + } + }; + + const handleInput = (e: any) => { + setValue(e.target.value); + const numeric = Number.parseInt(e.target.value, 10); + props.setValue(Number.isNaN(numeric) ? 0 : numeric); + }; + + const handleBlur = () => setValue(props.value + ''); + + return ( + + + {value} + + ); +} + +interface Props extends TokenSliderData { + setProps: (data: Partial) => void; +} + +export default function TokenSlider(props: Props) { + const handleChangeName = (e: any) => { + const name: string = e.target.value; + props.setProps({ name }); + }; + + return ( +
+
+
+
+
+
+
+
+ + + props.setProps({ current })} /> + / + props.setProps({ max })} /> +
+
+
+
+ ); +} diff --git a/app/src/editor/interface/components/Toolbar.tsx b/app/src/editor/interface/components/Toolbar.tsx index 60b6011..079c1ef 100644 --- a/app/src/editor/interface/components/Toolbar.tsx +++ b/app/src/editor/interface/components/Toolbar.tsx @@ -29,8 +29,8 @@ export default bind(function LayerManager(props: Props) { setHasNext(props.actions.hasNext()); }; - props.actions.bind(actionCb); - return () => props.actions.unbind(actionCb); + props.actions.event.bind(actionCb); + return () => props.actions.event.unbind(actionCb); }, [ props.actions ]); const [ mode, setMode ] = useState(ArchitectModeKey); diff --git a/app/src/editor/map/Map.ts b/app/src/editor/map/Map.ts index 0c0a742..8c291e3 100755 --- a/app/src/editor/map/Map.ts +++ b/app/src/editor/map/Map.ts @@ -15,7 +15,8 @@ import { Asset } from '../util/Asset'; */ export default class Map { - size: Vec2 = new Vec2(0, 0); + identifier: string = ''; + size: Vec2 = new Vec2(2, 2); tileStore: TileStore = new TileStore(); tokens: TokenManager = new TokenManager(); @@ -26,9 +27,8 @@ export default class Map { private scene: Phaser.Scene = undefined as any; private chunks: MapChunk[][][] = []; - init(scene: Phaser.Scene, size: Vec2, assets: Asset[]) { + init(scene: Phaser.Scene, assets: Asset[]) { this.scene = scene; - this.size = size; this.tokens.init(scene); this.tileStore.init(scene.textures, assets); @@ -120,7 +120,7 @@ export default class Map { */ save(): string { - return MapSaver.save(this.size, this.layers); + return MapSaver.save(this.size, this.identifier, this.layers, this.tokens); } @@ -132,9 +132,10 @@ export default class Map { load(mapData: string) { const data = MapSaver.load(mapData); - this.size = data.size; + this.size = new Vec2(data.size); + this.identifier = data.identifier; - // this.chunks.forEach(cI => cI.forEach(cA => cA.forEach(c => c.destroy()))); + this.tokens.resetTokens(data.tokens); this.layers = data.layers; if (this.layers.length === 0) this.layers.push(new MapLayer(0, this.size)); diff --git a/app/src/editor/map/MapSaver.ts b/app/src/editor/map/MapSaver.ts index 97e84a6..19fbef1 100644 --- a/app/src/editor/map/MapSaver.ts +++ b/app/src/editor/map/MapSaver.ts @@ -1,11 +1,19 @@ +import TokenManager from './token/TokenManager'; import MapLayer, { LAYER_SERIALIZATION_ORDER } from './MapLayer'; import { Vec2 } from '../util/Vec'; import * as Buffer from '../util/Buffer'; -/** Data pretaining to a deserialized map. */ -export interface DeserializedMap { - size: Vec2; +/** JSON-serializable map data */ +export interface SerializedMap { + format: string; + identifier: string; + size: { x: number; y: number }; + tokens: any[]; +} + +/** Deserialized map data, including layer array. */ +export interface DeserializedMap extends SerializedMap { layers: MapLayer[]; } @@ -18,16 +26,19 @@ export interface DeserializedMap { * @returns {string} - a serialized map string. */ -export function save(size: Vec2, layers: MapLayer[]): string { +export function save(size: Vec2, identifier: string, layers: MapLayer[], tokens: TokenManager): string { let mapData = ''; - const mapMeta = { + const mapJson: SerializedMap = { format: '1.0.0', - size: size + + size, + identifier, + tokens: tokens.serializeAllTokens() }; - const mapMetaStr = JSON.stringify(mapMeta); - mapData += mapMetaStr.length + '|' + mapMetaStr; + const mapJsonStr = JSON.stringify(mapJson); + mapData += mapJsonStr.length + '|' + mapJsonStr; for (const layer of layers) { let layerStr = ''; @@ -71,17 +82,16 @@ export function load(mapData: string): DeserializedMap { const numEnd = mapData.indexOf('|'); const num = Number.parseInt(mapData.substr(0, numEnd), 10); - const mapMeta = JSON.parse(mapData.slice(numEnd + 1, numEnd + 1 + num)); + const mapJson = JSON.parse(mapData.slice(numEnd + 1, numEnd + 1 + num)); + const data: DeserializedMap = { ...mapJson, layers: [] }; mapData = mapData.substr(numEnd + num + 1); - const data: DeserializedMap = { ...mapMeta, layers: [] }; - let layerInd = 0; while (mapData.length) { const numEnd = mapData.indexOf('|'); const num = Number.parseInt(mapData.substr(0, numEnd), 10); - const layer = new MapLayer(layerInd++, data.size); + const layer = new MapLayer(layerInd++, new Vec2(data.size)); layer.load(mapData.slice(numEnd + 1, numEnd + 1 + num)); data.layers.push(layer); diff --git a/app/src/editor/map/token/Token.ts b/app/src/editor/map/token/Token.ts index e3984eb..45631a9 100755 --- a/app/src/editor/map/token/Token.ts +++ b/app/src/editor/map/token/Token.ts @@ -1,8 +1,13 @@ import EventHandler from '../../EventHandler'; +import { Vec2 } from '../../util/Vec'; import { generateId } from '../../util/Helpers'; -/** Data pretaining to a token slider. */ + +/** + * Represents a slider bar for a token. + */ + export interface TokenSliderData { name: string; color?: string; @@ -13,114 +18,192 @@ export interface TokenSliderData { current: number; } -/** Data pretaining to a token. */ -export interface TokenData { + +/** + * The meta information (name, sliders, note) for a token. + */ + +export interface TokenMetaData { uuid: string; - - pos: { x: number; y: number }; - appearance: { sprite: string; index: number }; name: string; note: string; sliders: TokenSliderData[]; - - pinned: boolean; } -/** Default token data, for raw assignment. */ -const DEFAULT_TOKEN_DATA: Omit = { - pos: { x: 0, y: 0 }, - appearance: { sprite: '', index: 0 }, - + +/** + * The render information for a token, such as position and sprite. + */ + +export interface TokenRenderData { + uuid: string; + + pos: { x: number; y: number }; + appearance: { sprite: string; index: number }; +} + + +/** + * Full token data, for serialization or info passing. + */ + +export interface TokenData { + uuid: string; + render: Omit; + meta: Omit; +} + + +/** + * Event object emitted when a token's meta data is modified. + */ + +export interface TokenMetaEvent { + token: Token; + pre: TokenMetaData; + post: TokenMetaData; +} + + +/** + * Event object emitted when a token's texture changes, or it is moved. + */ + +export interface TokenRenderEvent { + token: Token; + pos: { pre: Vec2; post: Vec2 }; + appearance: { pre: { sprite: string; index: number }; post: { sprite: string; index: number }}; +} + + +/** + * Default token meta data. + */ + +const DEFAULT_TOKEN_META: Omit = { name: '', - note: '', - sliders: [], - - pinned: false + sliders: [] }; -/** Event emitted when a token data is modified. */ -export interface TokenModifyEvent { - token: Token; - pre: TokenData; - post: TokenData; -} /** * Represents a token in the world, its properties are determined by its TokenData, * which can be set, updated, and retrieved through public methods. */ -export default class Token extends Phaser.GameObjects.Container { - readonly change = new EventHandler(); - - private sprite: Phaser.GameObjects.Sprite; +export default class Token extends Phaser.GameObjects.Sprite { + readonly on_meta = new EventHandler(); + readonly on_render = new EventHandler(); + private shadow: Phaser.GameObjects.Sprite; - private tokenData: TokenData; + private meta: TokenMetaData; private hovered: boolean = false; private selected: boolean = false; - constructor(scene: Phaser.Scene, tokenData?: Partial) { - super(scene, 0, 0); + constructor(scene: Phaser.Scene, tokenData?: Partial, pos?: Vec2, sprite?: string, index?: number) { + super(scene, 0, 0, sprite ?? '', index); + this.scene.add.existing(this); + this.setDepth(500); - this.tokenData = { ...DEFAULT_TOKEN_DATA, uuid: '' }; - this.setToken({ - ...DEFAULT_TOKEN_DATA, + this.meta = { ...DEFAULT_TOKEN_META, uuid: '' }; + this.setMeta({ + ...DEFAULT_TOKEN_META, ...tokenData ?? {}, uuid: tokenData?.uuid ?? generateId(32) }); - this.shadow = new Phaser.GameObjects.Sprite(this.scene, -1 / 16, -1 / 16, ''); - this.shadow.setOrigin(0, 0); + this.shadow = this.scene.add.sprite(this.x, this.y, sprite ?? '', index); + this.shadow.setOrigin(1 / 18, 1 / 18); this.shadow.setScale(18 / 16 / this.shadow.width, 18 / 16 / 4 / this.shadow.height); this.shadow.setAlpha(0.1, 0.1, 0.3, 0.3); this.shadow.setTint(0x000000); - this.list.push(this.shadow); + this.shadow.setDepth(this.depth - 1); - this.sprite = new Phaser.GameObjects.Sprite(this.scene, -1 / 16, -1 / 16, ''); - this.sprite.setOrigin(0, 0); - this.sprite.setScale(18 / 16 / this.sprite.width, 18 / 16 / this.sprite.height); - this.setPosition(this.x, this.y); - this.list.push(this.sprite); + this.on('removefromscene', () => this.shadow.destroy()); - this.updateAppearance(); + this.setOrigin(1 / 18, 1 / 18); + this.setScale(18 / 16 / this.width, 18 / 16 / this.height); + this.setPosition(pos?.x ?? 0, pos?.y ?? 0); } - setToken(data: Partial) { - if (!this.tokenData) return; + serialize(): TokenData { + const data = { + uuid: this.getUUID(), + meta: this.getMeta(), + render: this.getRender() + }; - const preSer = JSON.stringify(this.tokenData); - const postSer = JSON.stringify({ ...this.tokenData, ...data }); + delete (data.meta as any).uuid; + delete (data.render as any).uuid; + return data; + }; + + getMeta(): TokenMetaData { + return JSON.parse(JSON.stringify(this.meta)); + } + + setMeta(data: Partial) { + if (!this.meta) return; + + const preSer = JSON.stringify(this.meta); + const postSer = JSON.stringify({ ...this.meta, ...data }); if (preSer === postSer) return; const pre = JSON.parse(preSer); const post = JSON.parse(postSer); - this.tokenData = post; - this.updateAppearance(); - - this.change.dispatch({ token: this, pre, post }); + this.meta = post; + this.on_meta.dispatch({ token: this, pre, post }); } - getToken(): TokenData { - return JSON.parse(JSON.stringify(this.tokenData)); + getRender(): TokenRenderData { + return { + uuid: this.getUUID(), + pos: new Vec2(this.x, this.y), + appearance: { sprite: this.texture.key, index: this.frame.name as any } + }; + } + + setRender(render: Partial) { + if (render.pos) this.setPosition(render.pos.x, render.pos.y); + if (render.appearance) this.setTexture(render.appearance.sprite, render.appearance.index); } getUUID(): string { - return this.tokenData.uuid; + return this.meta.uuid; } - getFrame(): number { - return this.tokenData.appearance.index; + getFrameIndex(): number { + return this.frame.name as any; } getFrameCount(): number { - return Object.keys(this.sprite.texture.frames).length - 1; + return Object.keys(this.texture.frames).length - 1; + } + + setFrame(frame: number): this { + const pos = new Vec2(this.x, this.y); + const lastFrame = this.frame?.name as any || 0; + Phaser.GameObjects.Sprite.prototype.setFrame.call(this, frame); + + if (this.on_render) this.on_render.dispatch({ + token: this, + pos: { pre: pos, post: pos }, + appearance: { + pre: { sprite: this.texture.key, index: lastFrame }, + post: { sprite: this.texture.key, index: frame } + } + }); + + if (!this.shadow) return this; + this.shadow.setFrame(frame); + return this; } setSelected(selected: boolean) { @@ -134,35 +217,52 @@ export default class Token extends Phaser.GameObjects.Container { } setPosition(x?: number, y?: number): this { - if (!this.tokenData) return this; - this.setToken({ pos: { x: x ?? 0, y: y ?? x ?? 0 }}); + if (this.x === x && this.y === y) return this; + const pre = new Vec2(this.x, this.y); + const post = new Vec2(x, y); + + Phaser.GameObjects.Sprite.prototype.setPosition.call(this, x, y); + + if (this.on_render) this.on_render.dispatch({ + token: this, + pos: { pre, post }, + appearance: { + pre: { sprite: this.texture.key, index: this.getFrameIndex() }, + post: { sprite: this.texture.key, index: this.getFrameIndex() } + } + }); + + if (!this.shadow) return this; + this.shadow.setPosition(x, y! + this.displayHeight - this.shadow.displayHeight - 0.125); return this; } - private updateAppearance() { - // console.log(this.tokenData); - Phaser.GameObjects.Container.prototype.setPosition.call(this, this.tokenData.pos.x, this.tokenData.pos.y); - if (!this.sprite || !this.shadow) return; - - this.sprite.setTexture(this.tokenData.appearance.sprite); - this.sprite.setFrame(this.tokenData.appearance.index); - this.shadow.setTexture(this.tokenData.appearance.sprite); - this.shadow.setFrame(this.tokenData.appearance.index); + setTexture(key: string, index?: string | number): this { + Phaser.GameObjects.Sprite.prototype.setTexture.call(this, key, index); + this.setScale(18 / 16 / this.width, 18 / 16 / this.height); + if (!this.shadow) return this; + this.shadow.setTexture(key, index); this.shadow.setScale(18 / 16 / this.shadow.width, 18 / 16 / 4 / this.shadow.height); - this.sprite.setScale(18 / 16 / this.sprite.width, 18 / 16 / this.sprite.height); + this.shadow.y = this.y + this.displayHeight - this.shadow.displayHeight - 0.125; - this.shadow.y = this.sprite.displayHeight - this.shadow.displayHeight - 0.125; + return this; + } + + setVisible(visible: boolean): this { + Phaser.GameObjects.Sprite.prototype.setVisible.call(this, visible); + this.shadow.setVisible(visible); + return this; } private updateShader() { if (this.selected) { - this.sprite.setPipeline('outline'); - this.sprite.pipeline.set1f('tex_size', this.sprite.texture.source[0].width); + this.setPipeline('outline'); + this.pipeline.set1f('tex_size', this.texture.source[0].width); } else if (this.hovered) { - this.sprite.setPipeline('brighten'); + this.setPipeline('brighten'); } - else this.sprite.resetPipeline(); + else this.resetPipeline(); } } diff --git a/app/src/editor/map/token/TokenManager.tsx b/app/src/editor/map/token/TokenManager.tsx index d165bbe..2bd8334 100644 --- a/app/src/editor/map/token/TokenManager.tsx +++ b/app/src/editor/map/token/TokenManager.tsx @@ -1,12 +1,15 @@ import * as Phaser from 'phaser'; -import Token, { TokenData, TokenModifyEvent } from './Token'; import EventHandler from '../../EventHandler'; +import Token, { TokenData, TokenMetaData, TokenRenderData, TokenRenderEvent } from './Token'; +import { Vec2 } from '../../util/Vec'; + +/** Token Event emitted by the TokenManager */ export type TokenEvent = { uuid: string; } & (( - { type: 'modify' } & TokenModifyEvent + { type: 'modify' } & TokenRenderEvent ) | { type: 'create'; token: Token; @@ -15,12 +18,11 @@ export type TokenEvent = { }); export default class TokenManager { + readonly event = new EventHandler(); + private scene: Phaser.Scene = null as any; - private tokens: Token[] = []; - private evtHandler = new EventHandler(); - init(scene: Phaser.Scene) { this.scene = scene; } @@ -28,6 +30,9 @@ export default class TokenManager { /** * Gets a token based on it's UUID. + * + * @param {string} token - The token's UUID string. + * @returns the token instance if it exists, or undefined. */ getToken(token: string): Token | undefined { @@ -37,31 +42,32 @@ export default class TokenManager { return undefined; } - + /** - * Sets the data for a token, based on a UUID internally within the data structure. + * Gets a list of token instances stored within this manager. + * + * @returns the array of token game objects. */ - setToken(data: Partial): Token | undefined { - const token = this.tokens.filter(t => t.getUUID())[0]; - if (!token) return undefined; - token.setToken(data); - return token; + getAllTokens(): Token[] { + return this.tokens; } /** - * Creates a new token with the provided token - * data, and adds it to the token list. + * Creates a new token with the provided token data. + * + * @param {string} data - The TokenData to create the token from. + * @returns the new token instance. */ - createToken(data: Partial): Token { - const token = new Token(this.scene, data); - token.change.bind(this.onChange); + createToken(pos: Vec2, meta: Partial, sprite?: string, index?: number): Token { + const token = new Token(this.scene, meta, pos, sprite, index); + token.on_render.bind(this.onChange); this.scene.add.existing(token); this.tokens.push(token); - this.evtHandler.dispatch({ + this.event.dispatch({ type: 'create', uuid: token.getUUID(), token: token @@ -73,18 +79,21 @@ export default class TokenManager { /** * Deletes a token based on it's UUID or direct object. + * + * @param {string | Token} token - Either a token's UUID string, or the token instance itself. + * @returns the token's data prior to deletion, or undefined if the token was not found. */ deleteToken(token: string | Token): TokenData | undefined { for (let i = 0; i < this.tokens.length; i++) { if (typeof token === 'string' ? this.tokens[i].getUUID() === token : this.tokens[i] === token) { token = this.tokens[i]; - const data = token.getToken(); + const data = token.serialize(); - this.tokens[i].destroy(); + token.destroy(); this.tokens.splice(i, 1); - this.evtHandler.dispatch({ + this.event.dispatch({ type: 'destroy', uuid: token.getUUID() }); @@ -95,50 +104,80 @@ export default class TokenManager { return undefined; } - + /** - * Returns the array of token game objects. + * Deletes all tokens, and optionally deserializes new ones into the scene. + * + * @param {TokenData[]} to - New tokens to deserialize. + * @returns an array of the new token instances. */ - getTokens(): Token[] { - return this.tokens; + resetTokens(data?: TokenData[]): Token[] { + while (this.tokens.length > 0) this.deleteToken(this.tokens[this.tokens.length - 1]); + data?.forEach(d => this.createToken(new Vec2(d.render.pos as any), d.meta, + d.render.appearance.sprite, d.render.appearance.index)); + return this.getAllTokens(); } /** - * Gets an array of token data, frozen at the time of the function call. + * Returns the serialized form of all tokens. + * + * @returns a TokenData[] of all token instances. */ - getTokenData(): TokenData[] { - return this.tokens.map(t => t.getToken()); + serializeAllTokens(): TokenData[] { + return this.tokens.map(t => t.serialize()); } /** - * Bind a callback to token change events. + * Gets serialized token data for all the tokens within this manager. + * This data is cloned, and safe to modify. + * + * @returns an array of cloned TokenData objects corresponding to each token. */ - bind(cb: (evt: TokenEvent) => boolean | void) { - this.evtHandler.bind(cb); + getAllMeta(): TokenMetaData[] { + return this.tokens.map(t => t.getMeta()); } /** - * Unbind a callback from token change events. + * Updates a token with the specified token data. + * + * @param {Partial} data - The data to update the token with. + * @returns the token instance, if it exists. */ - unbind(cb: (evt: TokenEvent) => boolean | void) { - this.evtHandler.unbind(cb); + setMeta(uuid: string, data: Partial): Token | undefined { + const token = this.getToken(uuid); + if (!token) return undefined; + token.setMeta(data); + return token; } /** - * Callback to be called by Tokens when they change, - * triggers an event dispatch. + * */ - private onChange = (event: TokenModifyEvent) => { - this.evtHandler.dispatch({ + setRender(uuid: string, data: Partial): Token | undefined { + const token = this.getToken(uuid); + if (!token) return undefined; + token.setRender(data); + return token; + } + + + /** + * Called by Tokens upon changing, triggers an event dispatch. + * + * @param {TokenModifyEvent} event - The event object from the token's change. + */ + + private onChange = (event: TokenRenderEvent) => { + this.event.dispatch({ type: 'modify', uuid: event.token.getUUID(), ...event diff --git a/app/src/editor/mode/DrawMode.ts b/app/src/editor/mode/DrawMode.ts index 8673675..574a697 100644 --- a/app/src/editor/mode/DrawMode.ts +++ b/app/src/editor/mode/DrawMode.ts @@ -172,7 +172,7 @@ export default class DrawMode extends Mode { const tilePos = cursorPos.floor(); let found: Token | undefined = undefined; - this.map.tokens.getTokens().forEach(t => { + this.map.tokens.getAllTokens().forEach(t => { const isFound = t.x === tilePos.x && t.y === tilePos.y; t.setSelected(!!(isFound && highlight)); if (isFound) found = t; diff --git a/app/src/editor/mode/ModeManager.ts b/app/src/editor/mode/ModeManager.ts index 7eb96e3..0d46189 100644 --- a/app/src/editor/mode/ModeManager.ts +++ b/app/src/editor/mode/ModeManager.ts @@ -30,7 +30,7 @@ export default class ModeManager { private evtHandler = new EventHandler(); - init(scene: Phaser.Scene, _modes: 'edit' | 'view', map: Map, actions: ActionManager, assets: Asset[]) { + init(scene: Phaser.Scene, map: Map, actions: ActionManager, assets: Asset[]) { this.modes = { [ArchitectModeKey]: new ArchitectMode(scene, map, actions, assets), [TokenModeKey]: new TokenMode(scene, map, actions, assets), diff --git a/app/src/editor/mode/TokenMode.ts b/app/src/editor/mode/TokenMode.ts index 88a1e44..e680da1 100644 --- a/app/src/editor/mode/TokenMode.ts +++ b/app/src/editor/mode/TokenMode.ts @@ -4,7 +4,7 @@ import Mode from './Mode'; import Map from '../map/Map'; import InputManager from '../InputManager'; import ActionManager from '../action/ActionManager'; -import Token, { TokenData } from '../map/token/Token'; +import Token, { TokenData, TokenRenderData } from '../map/token/Token'; import { Vec2 } from '../util/Vec'; import { Asset } from '../util/Asset'; @@ -24,7 +24,7 @@ export default class TokenMode extends Mode { private selected: Set = new Set(); private startTilePos: Vec2 = new Vec2(); - private preMove: TokenData[] | null = null; + private preMove: TokenRenderData[] | null = null; private clickTestState: null | false | true = null; private cursor: Phaser.GameObjects.Sprite; @@ -50,10 +50,10 @@ export default class TokenMode extends Mode { input.bindScrollEvent((delta: number) => { if (this.editMode !== 'move') return false; this.selected.forEach(token => { - let index = token.getFrame() + delta; + let index = token.getFrameIndex() + delta; if (index < 0) index += token.getFrameCount(); index %= token.getFrameCount(); - token.setToken({ appearance: { sprite: token.getToken().appearance.sprite, index }}); + token.setFrame(index); }); return true; }); @@ -64,8 +64,7 @@ export default class TokenMode extends Mode { cursorPos = cursorPos.floor(); - if (this.preview.getToken().appearance.sprite !== this.placeTokenType) - this.preview.setToken({ appearance: { sprite: this.placeTokenType, index: 0 }}); + if (this.preview.texture.key !== this.placeTokenType) this.preview.setTexture(this.placeTokenType, 0); switch (this.editMode) { default: break; @@ -112,8 +111,8 @@ export default class TokenMode extends Mode { this.hovered?.setHovered(false); this.hovered = null; - for (let i = this.map.tokens.getTokens().length - 1; i >= 0; i--) { - let token = this.map.tokens.getTokens()[i]; + for (let i = this.map.tokens.getAllTokens().length - 1; i >= 0; i--) { + let token = this.map.tokens.getAllTokens()[i]; if (cursorPos.x === token.x && cursorPos.y === token.y) { this.hovered = token; this.hovered.setHovered(true); @@ -179,7 +178,7 @@ export default class TokenMode extends Mode { this.selected = new Set(); } - for (const token of this.map.tokens.getTokens()) { + for (const token of this.map.tokens.getAllTokens()) { if (token.x >= a.x && token.y >= a.y && token.x <= b.x && token.y <= b.y) { if (input.keyDown('CTRL')) { @@ -215,7 +214,7 @@ export default class TokenMode extends Mode { private handleMove(cursorPos: Vec2, input: InputManager) { this.cursor.setVisible(false); - if (!this.preMove) this.preMove = Array.from(this.selected).map(t => t.getToken()); + if (!this.preMove) this.preMove = Array.from(this.selected).map(t => t.getRender()); if (!this.selected.size) { this.editMode = 'place'; @@ -226,8 +225,8 @@ export default class TokenMode extends Mode { this.editMode = 'place'; if (this.clickTestState) { - const post: TokenData[] = []; - for (let t of this.selected) post.push(t.getToken()); + const post: TokenRenderData[] = []; + for (let t of this.selected) post.push(t.getRender()); this.actions.push({ type: 'modify_token', tokens: { pre: this.preMove!, post } }); this.preMove = null; } @@ -252,9 +251,8 @@ export default class TokenMode extends Mode { private placeToken(cursorPos: Vec2): Token { const asset = this.assets.filter(a => a.identifier === this.placeTokenType)[0]; - const token = this.map.tokens.createToken({ name: asset.name, pos: cursorPos, - appearance: { sprite: this.placeTokenType, index: 0 }}); - this.actions.push({ type: 'place_token', tokens: [ token.getToken() ] }); + const token = this.map.tokens.createToken(cursorPos, { name: asset.name }, this.placeTokenType); + this.actions.push({ type: 'place_token', tokens: [ token.serialize() ] }); return token; } @@ -272,17 +270,15 @@ export default class TokenMode extends Mode { } private moveToken(x: number, y: number, index: number): void { - let pre: TokenData[] = []; - let post: TokenData[] = []; + let pre: TokenRenderData[] = []; + let post: TokenRenderData[] = []; this.selected.forEach((token) => { - const old = token.getToken(); + const old = token.getRender(); pre.push(old); - token.setToken({ - pos: { x: old.pos.x + x, y: old.pos.y + y }, - appearance: { sprite: old.appearance.sprite, index } - }); - post.push(token.getToken()); + token.setPosition(old.pos.x + x, old.pos.y + y); + token.setTexture(old.appearance.sprite, index); + post.push(token.getRender()); }); this.actions.push({ type: 'modify_token', tokens: { pre, post } }); diff --git a/app/src/editor/scene/InitScene.ts b/app/src/editor/scene/InitScene.ts index 50bb789..6782d7b 100755 --- a/app/src/editor/scene/InitScene.ts +++ b/app/src/editor/scene/InitScene.ts @@ -9,32 +9,57 @@ async function emit(socket: IO.Socket, event: string, data?: any): Prom }); } +interface InitProps { + user: string; + identifier: string; + mapIdentifier?: string; + + onProgress: (progress: number) => void; +} + export default class InitScene extends Phaser.Scene { + private socket: IO.Socket = IO.io(); + constructor() { super({key: 'InitScene'}); } - async create({ user, onProgress, identifier, socket }: { - user: string; onProgress: (progress: number) => void; identifier: string; socket: IO.Socket; }) { + async create({ user, onProgress, identifier, mapIdentifier }: InitProps) { + this.socket.on('disconnect', this.onDisconnect); + this.game.events.addListener('destroy', this.onDestroy); - let res: { state: true; campaign: Campaign; assets: Asset[] } | { state: false; error?: string } - = await emit(socket, 'room_init', identifier); - let display = res.state ? 'edit' : 'view'; + const { res, map } = await this.onConnect(user, identifier, mapIdentifier); - if (!res.state) res = - await emit(socket, 'room_join', { user, identifier }) as { state: true; campaign: Campaign; assets: Asset[] }; - - socket.on('disconnect', () => console.log('Disconnected!!!')); - this.scene.start('LoadScene', { - user, - identifier, - onProgress, - socket, - display, - assets: res.assets, - campaign: res.campaign }); + socket: this.socket, + user, identifier, + ...res, map, + + onProgress + }); this.game.scene.stop('InitScene'); this.game.scene.swapPosition('LoadScene', 'InitScene'); } + + private onDestroy = async () => { + this.socket.off('disconnect', this.onDisconnect); + this.socket.disconnect(); + console.log('Destroyed!'); + }; + + private onDisconnect = async () => { + console.log('Disconnected!!!'); + }; + + private onConnect = async (user: string, identifier: string, mapIdentifier: string | undefined): + Promise<{ res: { campaign: Campaign; assets: Asset[] }; map: string | undefined }> => { + + let res: { state: true; campaign: Campaign; assets: Asset[] } | { state: false; error?: string } + = await emit(this.socket, 'room_init', identifier); + + let map: string | undefined; + if (res.state) map = (mapIdentifier ? res.campaign.maps.filter(m => m.identifier === mapIdentifier)[0] : res.campaign.maps[0]).data; + else res = await emit(this.socket, 'room_join', { user, identifier }) as { state: true; campaign: Campaign; assets: Asset[] }; + + return { res, map }; + }; } - diff --git a/app/src/editor/scene/LoadScene.ts b/app/src/editor/scene/LoadScene.ts index 94c9218..aafea02 100755 --- a/app/src/editor/scene/LoadScene.ts +++ b/app/src/editor/scene/LoadScene.ts @@ -60,12 +60,12 @@ export default class LoadScene extends Phaser.Scene { } } - create() { + async create() { const glRenderer = this.game.renderer as Phaser.Renderer.WebGL.WebGLRenderer; glRenderer.pipelines.add('brighten', new BrightenPipeline(this.game)); glRenderer.pipelines.add('outline', new OutlinePipeline(this.game)); - Promise.all(this.editorData!.assets.map(a => { + await Promise.all(this.editorData!.assets.map(a => { if (a.type === 'token') { const { width, height } = (this.textures.get(a.identifier).frames as any).__BASE; a.dimensions = new Vec2(width, height); @@ -76,13 +76,13 @@ export default class LoadScene extends Phaser.Scene { return Patch.tileset(this, a.identifier, a.tileSize); else return new Promise(resolve => resolve()); - })).then(() => { - this.editorData.onProgress(undefined); + })); - this.game.scene.start('MapScene', this.editorData); - this.game.scene.stop('LoadScene'); - this.game.scene.swapPosition('MapScene', 'LoadScene'); - }); + this.editorData.onProgress(undefined); + + this.game.scene.start('MapScene', this.editorData); + this.game.scene.stop('LoadScene'); + this.game.scene.swapPosition('MapScene', 'LoadScene'); } } diff --git a/app/src/editor/scene/MapScene.ts b/app/src/editor/scene/MapScene.ts index 8762ca9..37cecc3 100755 --- a/app/src/editor/scene/MapScene.ts +++ b/app/src/editor/scene/MapScene.ts @@ -7,8 +7,9 @@ import InterfaceRoot from '../interface/InterfaceRoot'; import Map from '../map/Map'; import CameraControl from '../CameraControl'; import ModeManager from '../mode/ModeManager'; +// import { SerializedMap } from '../map/MapSaver'; -import { Vec2 } from '../util/Vec'; +// import { Vec2 } from '../util/Vec'; import { Asset } from '../util/Asset'; import EditorData from '../EditorData'; @@ -22,13 +23,9 @@ export default class MapScene extends Phaser.Scene { mode: ModeManager = new ModeManager(); interface: InterfaceRoot = new InterfaceRoot(); - size: Vec2 = new Vec2(); - map: Map = new Map(); - saved: string = ''; - - constructor() { super({key: 'MapScene'}); } + constructor() { super({ key: 'MapScene' }); } create(data: EditorData): void { this.assets = data.assets; @@ -36,17 +33,12 @@ export default class MapScene extends Phaser.Scene { this.inputManager.init(); this.view.init(this.cameras.main, this.inputManager); - this.size = new Vec2(data.campaign.maps[0].size); - this.map.init(this, this.size, this.assets); + this.map.init(this, this.assets); + if (data.map) this.map.load(data.map); - const s = JSON.stringify({ size: new Vec2(32, 32) }); - this.map.load(s.length + '|' + s); - - this.mode.init(this, data.display, this.map, this.actions, this.assets); this.actions.init(this, this.map, data.socket); - this.interface.init(this, data.display, this.inputManager, this.mode, this.actions, this.map, this.assets); - - // this.sound.play('mystify', { loop: true, volume: .2 }); + this.mode.init(this, this.map, this.actions, this.assets); + this.interface.init(this, this.inputManager, this.mode, this.actions, this.map, this.assets); } update(): void { @@ -58,9 +50,6 @@ export default class MapScene extends Phaser.Scene { this.mode.update(this.view.cursorWorld, this.inputManager); this.map.update(); - - if (this.inputManager.keyPressed('S')) this.saved = this.map.save(); - if (this.inputManager.keyPressed('L')) this.map.load(this.saved); } } diff --git a/app/src/editor/shape/Shape.tsx b/app/src/editor/shape/Shape.tsx index 589eeab..6306f41 100644 --- a/app/src/editor/shape/Shape.tsx +++ b/app/src/editor/shape/Shape.tsx @@ -69,7 +69,7 @@ export default abstract class Shape extends Phaser.GameObjects.Container { this.attachedToken = token; this.attachedChangeEvent = () => this.setOrigin(new Vec2(token.x + offset.x, token.y + offset.y)); - token.change.bind(this.attachedChangeEvent); + token.on_render.bind(this.attachedChangeEvent); this.attachedDestroyEvent = () => this.detachFromToken(); token.on('destroy', this.attachedDestroyEvent); @@ -78,7 +78,7 @@ export default abstract class Shape extends Phaser.GameObjects.Container { detachFromToken() { if (!this.attachedToken) return; this.attachedToken.setSelected(false); - this.attachedToken.change.unbind(this.attachedChangeEvent!); + this.attachedToken.on_render.unbind(this.attachedChangeEvent!); this.attachedChangeEvent = undefined; this.attachedToken.off('destroy', this.attachedDestroyEvent!); this.attachedDestroyEvent = undefined; diff --git a/app/src/editor/util/Buffer.ts b/app/src/editor/util/Buffer.ts index 189803d..c53f9f1 100644 --- a/app/src/editor/util/Buffer.ts +++ b/app/src/editor/util/Buffer.ts @@ -1,5 +1,11 @@ +const CHUNK_SIZE = 100000; export function serialize(buf: ArrayBuffer): string { - return String.fromCharCode.apply(null, new Uint16Array(buf) as any); + let str = ''; + for (let i = 0; i < buf.byteLength / CHUNK_SIZE; i++) { + const slice = new Uint16Array(buf.slice(i * CHUNK_SIZE, (i + 1) * CHUNK_SIZE)); + str += String.fromCharCode.apply(undefined, new Uint16Array(slice) as any); + } + return str; } export function deserialize(str: string): ArrayBuffer { diff --git a/common/DBStructs.ts b/common/DBStructs.ts index 9199c5b..d9c799e 100755 --- a/common/DBStructs.ts +++ b/common/DBStructs.ts @@ -53,8 +53,7 @@ export interface Map { name: string; identifier: string; - size: {x: number, y: number}; - layers: string; + data: string; } export type AssetType = 'wall' | 'detail' | 'ground' | 'token'; diff --git a/server/src/Database.ts b/server/src/Database.ts index 0d9ea04..6fdc1a5 100755 --- a/server/src/Database.ts +++ b/server/src/Database.ts @@ -257,29 +257,49 @@ export default class Database { let mapIdentifier = this.sanitizeName(map); if (mapIdentifier.length < 3) 'Map name must contain at least 3 alphanumeric characters.'; - let campIdentifier = this.sanitizeName(campaign); + let identifier = this.sanitizeName(campaign); const collection = this.db!.collection('campaigns'); - let exists = await collection.findOne({user: user, identifier: campIdentifier}); + let exists = await collection.findOne({ user, identifier }); if (!exists) throw 'This campaign no longer exists.'; let mapExists = await collection.findOne({ - user: user, - identifier: campIdentifier, - maps: { - $elemMatch: { - identifier: mapIdentifier - } - } - }); + user, identifier, maps: { $elemMatch: { identifier: mapIdentifier }}}); if (mapExists) throw 'A map of this name already exists.'; - await collection.updateOne({user: user, identifier: campIdentifier}, { - $push: { maps: { name: map, identifier: mapIdentifier, size: { x: 64, y: 64 }, layers: '' }}}); + console.log(mapIdentifier); + + const stub = JSON.stringify({ format: '1.0.0', identifier: mapIdentifier, size: { x: 32, y: 32 }, tokens: [] }); + const data = stub.length + '|' + stub; + + await collection.updateOne({ user, identifier }, { $push: { maps: { name: map, identifier: mapIdentifier, data }}}); return mapIdentifier; } + /** + * Updates a map data to the serialized string provided. + * Throws if the campaign or map doesn't exist. + * + * @param {string} user - The user identifier. + * @param {string} campaign - The campaign identifier. + * @param {string} map - The map identifier. + * @param {string} data - The new map data string. + * ...wow, all strings :p + */ + + async setMap(user: string, identifier: string, map: string, data: string) { + const maps: DB.Map[] | null = (await this.db!.collection('campaigns').findOne({ user, identifier })).maps; + if (!maps) throw 'Campaign doesn\'t exist'; + + const mapObj: DB.Map | undefined = maps.filter(m => m.identifier === map)[0]; + if (!mapObj) throw 'Map doesn\'t exist.'; + mapObj.data = data; + + await this.db!.collection('campaigns').updateOne({ user, identifier }, { $set: { maps } }); + } + + /** * Get a map. * Throws if the map or the campaign doesn't exist. @@ -291,7 +311,7 @@ export default class Database { async getMap(user: string, campaign: string, map: string) { const collection = this.db!.collection('campaigns'); - let exists = await collection.findOne({user: user, identifier: campaign, maps: { $elemMatch: {identifier: map}}}); + let exists = await collection.findOne({ user: user, identifier: campaign, maps: { $elemMatch: { identifier: map }}}); if (!exists) throw 'This map no longer exists.'; let mapObj = null; for (let i of exists.maps) { diff --git a/server/src/MapController.ts b/server/src/MapController.ts index f05d911..f561f0b 100644 --- a/server/src/MapController.ts +++ b/server/src/MapController.ts @@ -101,6 +101,7 @@ export default class MapController { // socket.on('map_load', this.mapLoad.bind(this, user)); socket.on('action', this.onAction.bind(this, socket, room)); + socket.on('serialize', this.onSerialize.bind(this, user, campaign)); // socket.on('get_campaign_assets', this.onGetCampaignAssets.bind(this, user)); } @@ -108,6 +109,10 @@ export default class MapController { socket.in(room).emit('action', event); } + private onSerialize(user: string, campaign: string, map: string, data: string) { + this.db.setMap(user, campaign, map, data); + } + // private async mapLoad(user: string, identifier: { campaign: string, map: string }, res: (data: string) => void) { // if (typeof res !== 'function' || typeof identifier !== 'object' || // typeof identifier.map !== 'string' || typeof identifier.campaign !== 'string') return;