Separate render props and meta props of tokens, fix IO not closing on soft redirect.

master
Auri 2021-01-24 21:13:56 -08:00
parent 504250dee3
commit c87e9214a1
31 changed files with 865 additions and 646 deletions

View File

@ -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<HTMLDivElement>(null);
const editorRef = useRef<Phaser.Game | null>(null);
const [ loadPercent, setLoadPercent ] = useState<number | undefined>(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);
};

View File

@ -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 (
<div class='EditorPage'>
<Editor user={user} identifier={campaign} />
<Editor user={user} identifier={campaign} mapIdentifier={map} />
</div>
);
}

View File

@ -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`);
}
};

View File

@ -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;

View File

@ -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;
}

View File

@ -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 {

View File

@ -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<ActionEvent>();
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<ActionEvent>();
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);
}
}

View File

@ -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 }));
}

View File

@ -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

View File

@ -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<TokenMetaData>) => 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<TokenSliderData>) => {
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 (
<div class={('TokenCard ' + (props.pinned ? 'Pinned' : '')).trim()}>
<div class='TokenCard-Inner'>
<button class='TokenCard-Pin'
onClick={handleTogglePin} />
<div class='TokenCard-Top'>
<div class='TokenCard-Thumbnail'>
<div class='TokenCard-ThumbnailInner' style={{
backgroundImage: `url("/app/asset/${icon}")`,
backgroundSize: (48 * scale) + 'px'
}} />
</div>
<div class='TokenCard-TopDetails'>
<h1 class='TokenCard-Name'>{props.name}</h1>
<p class='TokenCard-Synopsis'>{props.note}</p>
</div>
</div>
<div class='TokenCard-Sliders'>
{props.sliders.map((s, i) => <TokenSlider {...s} setProps={(props) => handleUpdateSlider(i, props)} />)}
<button class='TokenCard-NewSlider' onClick={handleAddSlider} ><span>New Slider</span></button>
</div>
<Text class='TokenCard-Note' long={true} minRows={2} placeholder='Enter notes here...'
value={props.note} setValue={note => props.setProps({ note })} />
</div>
</div>
);
}

View File

@ -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

View File

@ -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<string>(props.value + '');
useEffect(() => { if ((value === '') !== (props.value === 0)) setValue(props.value + ''); }, [ props.value ]);
const ref = useRef<HTMLSpanElement>(null);
const [ width, setWidth ] = useState<number>(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 (
<Preact.Fragment>
<input type='text' class={props.class} style={{ width: width + 'px' }} value={value}
onKeyDown={handleKeyDown} onInput={handleInput} onChange={handleInput} onBlur={handleBlur} />
<span ref={ref} class={('SlideNumericInput-Tester ' + (props.class ?? '')).trim()}>{value}</span>
</Preact.Fragment>
);
}
interface TokenSliderProps extends TokenSliderData {
setProps: (data: Partial<TokenSliderData>) => void;
}
function TokenSlider(props: TokenSliderProps) {
const handleChangeName = (e: any) => {
const name: string = e.target.value;
props.setProps({ name });
};
return (
<div class='TokenSlider'>
<div class='TokenSlider-IconWrap'>
<div class='TokenSlider-Icon' style={{ backgroundPosition: `${(props.icon || 0) * (100 / 8)}% 0`}} />
</div>
<div class='TokenSlider-Slider'>
<div class='TokenSlider-SliderInner'>
<div class='TokenSlider-Bar' style={{ backgroundColor: props.color ?? '#f06292',
width: 'calc(' + ((props.current - (props.min || 0)) / props.max) * 100 + '% - 6px)'}}/>
<div class='TokenSlider-BarContent'>
<input class='TokenSlider-Input TokenSlider-BarText TokenSlider-Title'
value={props.name} onChange={handleChangeName}/>
<SliderNumericInput class='TokenSlider-Input TokenSlider-BarText' value={props.current}
min={props.min ?? 0} max={props.max} setValue={current => props.setProps({ current })} />
<span class='TokenSlider-BarText'>/</span>
<SliderNumericInput class='TokenSlider-Input TokenSlider-BarText' value={props.max}
min={0} max={Number.POSITIVE_INFINITY} setValue={max => props.setProps({ max })} />
</div>
</div>
</div>
</div>
);
}
interface TokenCardProps extends TokenData {
assets: Asset[];
setProps: (data: Partial<TokenData>) => 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<TokenSliderData>) => {
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 (
<div class={('TokenCard ' + (props.pinned ? 'Pinned' : '')).trim()}>
<div class='TokenCard-Inner'>
<button class='TokenCard-Pin'
onClick={handleTogglePin} />
<div class='TokenCard-Top'>
<div class='TokenCard-Thumbnail'>
<div class='TokenCard-ThumbnailInner' style={{
backgroundImage: `url("/app/asset/${icon.path}")`,
backgroundSize: (48 * scale) + 'px'
}} />
</div>
<div class='TokenCard-TopDetails'>
<h1 class='TokenCard-Name'>{props.name}</h1>
<p class='TokenCard-Synopsis'>{props.note}</p>
</div>
</div>
<div class='TokenCard-Sliders'>
{props.sliders.map((s, i) => <TokenSlider {...s} setProps={(props) => handleUpdateSlider(i, props)} />)}
<button class='TokenCard-NewSlider' onClick={handleAddSlider} ><span>New Slider</span></button>
</div>
<Text class='TokenCard-Note' long={true} minRows={2} placeholder='Enter notes here...'
value={props.note} setValue={note => props.setProps({ note })} />
</div>
</div>
);
}
interface Props {
map: Map;
@ -146,26 +17,40 @@ interface Props {
}
export default bind<Props>(function TokenCards({ map, assets }: Props) {
const [ cards, setCards ] = useState<TokenData[]>(map.tokens.getTokenData());
const [ cards, setCards ] = useState<TokenMetaData[]>(map.tokens.getAllMeta());
const [ pinned, setPinned ] = useState<string[]>([]);
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<TokenData>) => {
map.tokens.setToken({
uuid: cards[ind].uuid,
...data
});
const handleSetProps = (ind: number, data: Partial<TokenMetaData>) => {
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<Props>(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 (
<div class='TokenCards'>
{cards.map((c, i) => <TokenCard {...c} assets={assets} setProps={u => handleSetProps(i, u)} />)}
{cards.map((c, i) => <TokenCard {...c}
assets={assets}
pinned={pinned.includes(c.uuid)}
setProps={u => handleSetProps(i, u)}
setPinned={p => handleSetPinned(c.uuid, p)}
/>)}
</div>
);
});

View File

@ -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);

View File

@ -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

View File

@ -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<string>(props.value + '');
useEffect(() => { if ((value === '') !== (props.value === 0)) setValue(props.value + ''); }, [ props.value ]);
const ref = useRef<HTMLSpanElement>(null);
const [ width, setWidth ] = useState<number>(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 (
<Preact.Fragment>
<input type='text' class={props.class} style={{ width: width + 'px' }} value={value}
onKeyDown={handleKeyDown} onInput={handleInput} onChange={handleInput} onBlur={handleBlur} />
<span ref={ref} class={('SlideNumericInput-Tester ' + (props.class ?? '')).trim()}>{value}</span>
</Preact.Fragment>
);
}
interface Props extends TokenSliderData {
setProps: (data: Partial<TokenSliderData>) => void;
}
export default function TokenSlider(props: Props) {
const handleChangeName = (e: any) => {
const name: string = e.target.value;
props.setProps({ name });
};
return (
<div class='TokenSlider'>
<div class='TokenSlider-IconWrap'>
<div class='TokenSlider-Icon' style={{ backgroundPosition: `${(props.icon || 0) * (100 / 8)}% 0`}} />
</div>
<div class='TokenSlider-Slider'>
<div class='TokenSlider-SliderInner'>
<div class='TokenSlider-Bar' style={{ backgroundColor: props.color ?? '#f06292',
width: 'calc(' + ((props.current - (props.min || 0)) / props.max) * 100 + '% - 6px)'}}/>
<div class='TokenSlider-BarContent'>
<input class='TokenSlider-Input TokenSlider-BarText TokenSlider-Title'
value={props.name} onChange={handleChangeName}/>
<SliderNumericInput class='TokenSlider-Input TokenSlider-BarText' value={props.current}
min={props.min ?? 0} max={props.max} setValue={current => props.setProps({ current })} />
<span class='TokenSlider-BarText'>/</span>
<SliderNumericInput class='TokenSlider-Input TokenSlider-BarText' value={props.max}
min={0} max={Number.POSITIVE_INFINITY} setValue={max => props.setProps({ max })} />
</div>
</div>
</div>
</div>
);
}

View File

@ -29,8 +29,8 @@ export default bind<Props>(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<string>(ArchitectModeKey);

View File

@ -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));

View File

@ -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);

View File

@ -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<TokenData, 'uuid'> = {
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<TokenRenderData, 'uuid'>;
meta: Omit<TokenMetaData, 'uuid'>;
}
/**
* 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<TokenMetaData, 'uuid'> = {
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<TokenModifyEvent>();
private sprite: Phaser.GameObjects.Sprite;
export default class Token extends Phaser.GameObjects.Sprite {
readonly on_meta = new EventHandler<TokenMetaEvent>();
readonly on_render = new EventHandler<TokenRenderEvent>();
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<TokenData>) {
super(scene, 0, 0);
constructor(scene: Phaser.Scene, tokenData?: Partial<TokenMetaData>, 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<TokenData>) {
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<TokenMetaData>) {
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<TokenRenderData>) {
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();
}
}

View File

@ -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<TokenEvent>();
private scene: Phaser.Scene = null as any;
private tokens: Token[] = [];
private evtHandler = new EventHandler<TokenEvent>();
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<TokenData>): 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<TokenData>): Token {
const token = new Token(this.scene, data);
token.change.bind(this.onChange);
createToken(pos: Vec2, meta: Partial<TokenMetaData>, 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<TokenMetaData>} 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<TokenMetaData>): 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<TokenRenderData>): 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

View File

@ -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;

View File

@ -30,7 +30,7 @@ export default class ModeManager {
private evtHandler = new EventHandler<ModeSwitchEvent>();
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),

View File

@ -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<Token> = 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 } });

View File

@ -9,32 +9,57 @@ async function emit<R = any>(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 };
};
}

View File

@ -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<void>(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');
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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 {

View File

@ -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';

View File

@ -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) {

View File

@ -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;