I updated the tileset format two times in one commit help

- Entirely new interface wow!
- Asset upload form
- (Now deprecated) tileset previewer in asset upload form
- Token previewer in asset upload form
- New Database format for assets
- New tileset format that allows better auto-patching
- Auto-patching
- (Partially) fix tokens
- Help me
master
Auri 2021-01-05 00:02:13 -08:00
parent 0634951fae
commit ca2ec263f6
145 changed files with 3409 additions and 1739 deletions

View File

@ -109,7 +109,6 @@ module.exports = {
"code": 150
}
],
"no-bitwise": "error",
"no-caller": "error",
"no-console": [
"error",

View File

Before

Width:  |  Height:  |  Size: 156 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 970 B

BIN
app/res/token/8 slice.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

BIN
app/res/ui/icon/assets.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 704 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
app/res/ui/icon/ground.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 654 B

BIN
app/res/ui/icon/home.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
app/res/ui/icon/map.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 668 B

BIN
app/res/ui/icon/map_new.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

BIN
app/res/ui/icon/token.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 642 B

BIN
app/res/ui/icon/token_1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 591 B

BIN
app/res/ui/icon/token_4.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 605 B

BIN
app/res/ui/icon/token_8.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 B

BIN
app/res/ui/icon/wall.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 635 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@ -6,9 +6,9 @@
height: 100%
overflow: auto
position: relative
grid-template-rows: auto 1fr
color: $accent-050
font-weight: 400
color: $neutral-950
font-family: $font-main
background-color: $neutral-1000
@ -32,4 +32,8 @@
background-color: rgba(87, 87, 87, .6)
.App-Main
height: 100%
display: grid
grid-template-columns: 80px 1fr
background-color: $neutral-050

View File

@ -5,7 +5,7 @@ import { BrowserRouter as Router, Switch, Route, Redirect } from 'react-router-d
import './App.sass';
import AppHeader from './AppHeader';
import AppSidebar from './AppSidebar';
import * as Routes from './route/Routes';
import { AppData } from '../../../common/AppData';
@ -42,22 +42,24 @@ export default function App() {
}
{state === 'AUTH' &&
<div class='App'>
<div class='App-Main'>
<Router basename='/app'>
<AppHeader />
<Switch>
<Route exact path='/:campaign/' component={Routes.Home as any} />
<Route exact path='/:campaign/details' component={Routes.Campaign as any} />
<Route exact path='/:campaign/:map' component={Routes.Map as any} />
<Route exact path='/:campaign/:map/edit' component={Routes.Editor as any} />
{/* <Route exact path='/:campaign/play' component={Route.Play as any} />*/}
<Route exact path='/' component={Routes.Home as any} />
<Redirect to='/' />
</Switch>
</Router>
</div>
<Router basename='/app'>
<Switch>
<Route path='/edit/:campaign/:map' component={Routes.Editor} />
<Route>
<div class='App-Main'>
<AppSidebar />
<Switch>
<Route path='/assets' component={Routes.Assets} />
<Route path='/campaigns' component={Routes.Campaigns} />
<Route path='/campaign/:id?' component={Routes.Campaign} />
<Redirect to='/campaigns' />
</Switch>
</div>
</Route>
</Switch>
</Router>
</div>
}
</AppDataContext.Provider>

View File

@ -1,56 +0,0 @@
.AppHeader
display: flex
flex-direction: row
align-items: center
justify-content: space-between
height: 64px
width: 100%
max-width: 1000px
margin: 0 auto
padding: 12px 16px
user-select: none
h1
margin: 0
color: white
font-size: 39px
a
text-decoration: none
&:hover, &:focus-visible
text-decoration: underline
.AppHeader-Buttons
display: flex
flex-direction: row
gap: 6px
button
display: block
width: 54px
height: 54px
margin: 0
padding: 0
outline: 0
background: transparent
border: 3px solid transparent
&:hover, &:focus-visible
background-color: transparentize(white, 0.9)
&:focus-visible
border-color: white
&:active
background-color: transparentize(white, 0.8)
img
width: 100%
height: 100%
image-rendering: pixelated

View File

@ -1,17 +0,0 @@
import * as Preact from 'preact';
import { NavLink as Link } from 'react-router-dom';
import './AppHeader.sass';
export default function AppHeader() {
return (
<div class='AppHeader'>
<h1><Link to='/'>Virtual Dungeon</Link></h1>
<div class='AppHeader-Buttons'>
<button><img src='/app/static/ui/icon/settings.png' alt='Settings'/></button>
<button><img src='/app/static/ui/icon/profile.png' alt='Profile'/></button>
<button><img src='/app/static/ui/icon/logout.png' alt='Logout'/></button>
</div>
</div>
);
}

View File

@ -0,0 +1,49 @@
@use '../style/def' as *
.AppSidebar
position: relative
z-index: 1
display: flex
flex-direction: column
align-items: center
justify-content: space-between
box-shadow: 0px 0px 12px 0px transparentize($neutral-000, 0.9)
background: linear-gradient(-75deg, $accent-900, $accent-600)
.AppSidebar-Top
width: 100%
.AppSidebar-Rule
margin: 16px 12px 12px 12px
border: none
border-bottom: 2px solid white
border-radius: 1px
.AppSidebar-Link
width: 100%
height: 72px
padding: 12px
display: block
position: relative
img
display: block
width: 100%
&.Pixel
padding: 12px 16px
outline: 0
opacity: 0.5
image-rendering: pixelated
transition: opacity 0.075s, background-color 0.075s
&:hover, &:focus-visible
opacity: 0.85
&.Active
opacity: 1
background-color: transparentize(white, 0.9)

View File

@ -0,0 +1,29 @@
import * as Preact from 'preact';
import { NavLink as Link } from 'react-router-dom';
import './AppSidebar.sass';
export default function AppHeader() {
return (
<div class='AppSidebar'>
<nav>
<Link className='AppSidebar-Link' to='/'><img role='heading' aria-level='1' aria-label='Virtual Dungeon'
src='/app/static/logo_small.png' alt='Virtual Dungeon' /></Link>
<hr class='AppSidebar-Rule' />
<Link className='AppSidebar-Link Pixel' activeClassName='Active' exact to='/'>
<img src='/app/static/ui/icon/home.png' alt='Home' /></Link>
<Link className='AppSidebar-Link Pixel' activeClassName='Active' to='/campaigns'>
<img src='/app/static/ui/icon/campaign.png' alt='Campaigns' /></Link>
<Link className='AppSidebar-Link Pixel' activeClassName='Active' to='/assets'>
<img src='/app/static/ui/icon/assets.png' alt='Assets' /></Link>
</nav>
<Link className='AppSidebar-Link Pixel' activeClassName='Active' to='/settings'>
<img src='/app/static/ui/icon/settings_large.png' alt='Settings' /></Link>
</div>
);
}

View File

@ -23,12 +23,14 @@
&:disabled, &.Disabled
pointer-events: none
.Button-Icon
filter: brightness(45%) sepia(1) contrast(2.25) hue-rotate(185deg)
.Button-Label
color: $accent-200
&:disabled, &.Disabled, &.Inactive
&:not(:active):not(:focus-visible)
.Button-Icon
filter: brightness(45%) sepia(1) contrast(2.25) hue-rotate(185deg)
.Button-Label
color: $accent-200
.Button-Label
display: inline-block

View File

@ -8,6 +8,7 @@ interface Props {
icon?: string;
alt?: string;
inactive?: boolean;
disabled?: boolean;
to?: string;
@ -27,7 +28,8 @@ export default function Button(props: Props) {
class='Button-Icon' alt={props.alt ?? (props.label ? '' : undefined)} />;
const label = props.label && <span class='Button-Label'>{props.label}</span>;
const classes = ('Button ' + (props.class ?? '') + (props.disabled ? ' Disabled' : '')).trim();
const classes = ('Button ' + (props.class ?? '') + (props.disabled ? ' Disabled' : '') +
(props.inactive ? ' Inactive' : '')).trim();
if (props.to) return <Link className={classes} style={props.style} to={props.disabled ? '' : props.to}
tabIndex={props.disabled ? -1 : undefined}>{icon}{label}</Link>;

View File

@ -5,15 +5,15 @@
position: relative
@include slice.slice('button_group_' + $sect)
&:disabled, &.Disabled, &.Inactive
@include slice.slice('button_group_' + $sect + '_disabled')
z-index: 1
&:active, &:focus-visible
@include slice.slice('button_group_' + $sect + '_active')
z-index: 2
&:disabled, &.Disabled
@include slice.slice('button_group_' + $sect + '_disabled')
z-index: 1
%button_group_left
@include button_group('left')

View File

@ -1,5 +1,6 @@
.Editor
position: relative
overflow: hidden
width: 100%
height: 100%

View File

@ -1,9 +1,12 @@
import * as Preact from 'preact';
import type Phaser from 'phaser';
import { useRef, useEffect } from 'preact/hooks';
import { useParams } from 'react-router-dom';
import { useEffect, useRef } from 'preact/hooks';
import './Editor.sass';
import { ExternalData } from '../editor/EditorData';
// // Prevent scrolling hotkeys as the app implements its own scrolling.
// document.addEventListener('keydown', (e: KeyboardEvent) => {
// if (e.ctrlKey
@ -23,6 +26,8 @@ import './Editor.sass';
export default function Editor() {
const rootRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<Phaser.Game | null>(null);
const { campaign, map } = useParams<{ campaign: string; map: string }>();
/**
* Lazy-load the editor, display it when ready,
@ -33,7 +38,21 @@ export default function Editor() {
let ignore = false;
import('../editor/Main').then(({ default: create }) => {
if (ignore || !rootRef.current) return;
editorRef.current = create(rootRef.current);
const data: ExternalData = {
campaign,
map
};
editorRef.current = create(rootRef.current, data);
const resizeCallback = () => {
const { width, height } = rootRef.current.getBoundingClientRect();
editorRef.current!.scale.resize(width, height);
};
window.addEventListener('resize', resizeCallback);
return () => window.removeEventListener('resize', resizeCallback);
});
return () => {
@ -61,6 +80,6 @@ export default function Editor() {
}, []);
return (
<div ref={rootRef} class='Editor'></div>
<div ref={rootRef} class='Editor' />
);
}

View File

@ -1,76 +0,0 @@
@use '../../style/def' as *
@use '../../style/text'
@use '../../style/slice'
.CampaignDetails
.CampaignDetails-Split
display: grid
grid-gap: 12px
padding-top: 6px
grid-template-columns: 300px 1fr
.CampaignDetails-ImageWrap
@extend .slice_highlight
display: grid
height: 200px
.CampaignDetails-Image
@include slice.slice_invert
img
width: 100%
height: 100%
display: block
object-fit: cover
user-select: none
pointer-events: none
.CampaignDetails-Right
display: flex
overflow: hidden
flex-direction: column
justify-content: space-between
.CampaignDetails-Details
.CampaignDetails-Title
@include text.line_clamp
margin-top: 0
margin-bottom: 8px
padding-right: 6px
font-weight: 500
font-size: 34px
font-family: $font-header
.CampaignDetails-Description
margin-top: 0
color: $accent-200
.CampaignDetails-Actions
float: right
.CampaignDetails-CharactersWrap
@extend .slice_highlight
height: 64px
.CampaignDetails-Characters
@include slice.slice_invert
display: flex
padding: 6px
.CampaignDetails-Character
width: #{18px * 3}
height: #{18px * 3}
margin-right: 16px
background-size: #{18px * 3 * 2} #{18px * 3 * 2}
background-position: top left
image-rendering: pixelated

View File

@ -1,43 +0,0 @@
import * as Preact from 'preact';
import './CampaignDetails.sass';
// import Button from '../Button';
// import ButtonGroup from '../ButtonGroup';
import MapList from './MapList';
import { Campaign } from '../../../../common/DBStructs';
interface Props {
campaign: Campaign;
}
export default function CampaignDetails({ campaign }: Props) {
return (
<div class='CampaignDetails'>
<div class='CampaignDetails-Split'>
<div class='CampaignDetails-ImageWrap'>
<div class='CampaignDetails-Image'>
<img src='https://placekitten.com/400/300' alt='' />
</div>
</div>
<div class='CampaignDetails-Right'>
<div class='CampaignDetails-Details'>
{/* <div class='CampaignDetails-Actions'>
<ButtonGroup>
<Button to={`${campaign.identifier}/details`} icon='edit' alt='Edit'/>
<Button to={`${campaign.identifier}/play`} icon='play' label='Start' disabled={campaign.maps.length === 0}/>
</ButtonGroup>
</div>*/}
<h2 class='CampaignDetails-Title'>{campaign.title || 'Untitled'}</h2>
<p class='CampaignDetails-Description'>{campaign.description || 'Lorem ipsum dolor sit amet...'}</p>
</div>
</div>
</div>
<MapList maps={campaign.maps} allowNew={true}/>
</div>
);
}

View File

@ -1,46 +0,0 @@
@use '../../style/grid'
@use '../../style/slice'
@use '../../style/text'
.CampaignList
& > h2
margin-top: 8px
margin-bottom: 16px
font-size: 34px
user-select: none
.CampaignList-Grid
@include grid.auto_width(220px)
.CampaignList-CampaignWrap
.CampaignList-Campaign
@extend .slice_outline_button
display: grid
user-select: none
text-decoration: none
.CampaignList-CampaignInner
@include slice.slice_invert
display: grid
grid-template-rows: 120px auto
.CampaignList-CampaignPreview
width: 100%
height: 100%
object-fit: cover
user-select: none
pointer-events: none
.CampaignList-CampaignTitle
@include text.line_clamp
margin: 0
padding: 9px 6px
.CampaignList-Actions
display: flex
margin-top: 12px
justify-content: space-between

View File

@ -1,63 +0,0 @@
import * as Preact from 'preact';
import { useState } from 'preact/hooks';
import { useAppData } from '../../Hooks';
import { NavLink as Link } from 'react-router-dom';
import './CampaignList.sass';
import Button from '../Button';
import ButtonGroup from '../ButtonGroup';
interface Props {
allowNew?: boolean;
}
export default function CampaignsCard({ allowNew }: Props) {
const [ { campaigns } ] = useAppData('campaigns');
const [ page, setPage ] = useState<number>(0);
const currentCampaigns = (campaigns || []).slice(page * 4, (page + 1) * 4);
return (
<div class='CampaignList'>
<h2>Campaigns</h2>
{campaigns === undefined && <p>Loading Campaigns...</p>}
{campaigns !== undefined &&
<Preact.Fragment>
<ul class='CampaignList-Grid'>
{currentCampaigns.map(c => <li class='CampaignList-CampaignWrap'>
<Link className='CampaignList-Campaign' to={`/${c.identifier}`}>
<div class='CampaignList-CampaignInner'>
<img class='CampaignList-CampaignPreview' src='https://placekitten.com/400/300' alt='' />
<p class='CampaignList-CampaignTitle'>{c.title || 'Untitled'}</p>
</div>
</Link>
</li>)}
</ul>
<div class='CampaignList-Actions'>
<Button icon='add' alt='New Campaign' to='/new' disabled={!allowNew} />
{(campaigns || []).length > 4 &&
<ButtonGroup class='HomeView-Pagination'>
<Button icon='nav_left' alt='Next Page'
onClick={() => setPage(page - 1)} disabled={page === 0}/>
{(() => {
let elems: Preact.VNode[] = [];
for (let i = 0; i < campaigns.length / 4; i++) elems.push(
<Button label={(i + 1).toString()} onClick={() => setPage(i)} disabled={page === i} />);
return elems;
})()}
<Button icon='nav_right' alt='Next Page'
onClick={() => setPage(page + 1)} disabled={page + 1 >= campaigns.length / 4} />
</ButtonGroup>
}
</div>
</Preact.Fragment>
}
</div>
);
}

View File

@ -1,72 +0,0 @@
@use '../../style/def' as *
@use '../../style/text'
@use '../../style/slice'
.CampaignOverview
display: grid
grid-gap: 12px
padding-top: 6px
grid-template-columns: 300px 1fr
.CampaignOverview-ImageWrap
@extend .slice_highlight
display: grid
height: 200px
.CampaignOverview-Image
@include slice.slice_invert
img
width: 100%
height: 100%
display: block
object-fit: cover
user-select: none
pointer-events: none
.CampaignOverview-Right
display: flex
overflow: hidden
flex-direction: column
justify-content: space-between
.CampaignOverview-Details
.CampaignOverview-Title
@include text.line_clamp
margin-top: 0
margin-bottom: 8px
padding-right: 6px
font-weight: 500
font-size: 34px
font-family: $font-header
.CampaignOverview-Description
margin-top: 0
color: $accent-200
.CampaignOverview-Actions
float: right
.CampaignOverview-CharactersWrap
@extend .slice_highlight
height: 64px
.CampaignOverview-Characters
@include slice.slice_invert
display: flex
padding: 6px
.CampaignOverview-Character
width: #{18px * 3}
height: #{18px * 3}
margin-right: 16px
background-size: #{18px * 3 * 2} #{18px * 3 * 2}
background-position: top left
image-rendering: pixelated

View File

@ -1,46 +0,0 @@
import * as Preact from 'preact';
import './CampaignOverview.sass';
import Button from '../Button';
import ButtonGroup from '../ButtonGroup';
import { Campaign } from '../../../../common/DBStructs';
interface Props {
campaign: Campaign;
}
export default function CampaignOverview({ campaign }: Props) {
return (
<div class='CampaignOverview'>
<div class='CampaignOverview-ImageWrap'>
<div class='CampaignOverview-Image'>
<img src='https://placekitten.com/400/300' alt='' />
</div>
</div>
<div class='CampaignOverview-Right'>
<div class='CampaignOverview-Details'>
<div class='CampaignOverview-Actions'>
<ButtonGroup>
<Button to={`${campaign.identifier}/details`} icon='edit' alt='Edit'/>
<Button to={`${campaign.identifier}/play`} icon='play' label='Start' disabled={campaign.maps.length === 0}/>
</ButtonGroup>
</div>
<h3 class='CampaignOverview-Title'>{campaign.title || 'Untitled'}</h3>
<p class='CampaignOverview-Description'>{campaign.description || 'Lorem ipsum dolor sit amet...'}</p>
</div>
<div class='CampaignOverview-CharactersWrap'>
<div class='CampaignOverview-Characters'>
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/baby_blue_dragon.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/cadin_1.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/dragonfolk_1.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/druid_male.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/naexi_human_yklwa.png)' }} />
</div>
</div>
</div>
</div>
);
}

View File

@ -1,46 +0,0 @@
@use '../../style/grid'
@use '../../style/slice'
@use '../../style/text'
.MapList
& > h2
margin-top: 8px
margin-bottom: 16px
font-size: 34px
user-select: none
.MapList-Grid
@include grid.auto_width(220px)
.MapList-MapWrap
.MapList-Map
@extend .slice_outline_button
display: grid
user-select: none
text-decoration: none
.MapList-MapInner
@include slice.slice_invert
display: grid
grid-template-rows: 120px auto
.MapList-MapPreview
width: 100%
height: 100%
object-fit: cover
user-select: none
pointer-events: none
.MapList-MapTitle
@include text.line_clamp
margin: 0
padding: 9px 6px
.MapList-Actions
display: flex
margin-top: 12px
justify-content: space-between

View File

@ -1,63 +0,0 @@
import * as Preact from 'preact';
import { useState } from 'preact/hooks';
import { NavLink as Link, useRouteMatch } from 'react-router-dom';
import './MapList.sass';
import Button from '../Button';
import ButtonGroup from '../ButtonGroup';
import { Map } from '../../../../common/DBStructs';
const ITEMS_PER_PAGE = 8;
interface Props {
maps: Map[];
allowNew?: boolean;
}
export default function CampaignsCard({ maps, allowNew }: Props) {
const match = useRouteMatch();
const [ page, setPage ] = useState<number>(0);
const currentMaps = maps.slice(page * ITEMS_PER_PAGE, (page + 1) * ITEMS_PER_PAGE);
return (
<div class='MapList'>
<h3>Maps</h3>
<ul class='MapList-Grid'>
{currentMaps.map(m => <li class='MapList-MapWrap'>
<Link className='MapList-Map' to={`/${m.identifier}`}>
<div class='MapList-MapInner'>
<img class='MapList-MapPreview' src='https://placekitten.com/400/300' alt='' />
<p class='MapList-MapTitle'>{m.name || 'Untitled'}</p>
</div>
</Link>
</li>)}
</ul>
<div class='MapList-Actions'>
<Button icon='add' alt='New Map' to={`${match.url}/new`} disabled={!allowNew} />
{maps.length > ITEMS_PER_PAGE &&
<ButtonGroup class='HomeView-Pagination'>
<Button icon='nav_left' alt='Next Page'
onClick={() => setPage(page - 1)} disabled={page === 0}/>
{(() => {
let elems: Preact.VNode[] = [];
for (let i = 0; i < maps.length / ITEMS_PER_PAGE; i++) elems.push(
<Button label={(i + 1).toString()} onClick={() => setPage(i)} disabled={page === i} />);
return elems;
})()}
<Button icon='nav_right' alt='Next Page'
onClick={() => setPage(page + 1)} disabled={page + 1 >= maps.length / ITEMS_PER_PAGE} />
</ButtonGroup>
}
</div>
</div>
);
}

View File

@ -1,32 +0,0 @@
@use '../../style/def' as *
.NewCampaignForm
label
span
display: inline-block
margin-top: 6px
color: $accent-100
input, textarea
width: 100%
padding: 6px
display: block
margin: 6px 0
outline: 0
border: 3px solid $accent-500
background-color: transparent
&::placeholder
color: $accent-400
&:focus, &:focus-within
border-color: $accent-300
.NewCampaignForm-Title
font-family: $font-header
font-size: 27px
.NewCampaignForm-Submit
margin-top: 12px

View File

@ -0,0 +1,85 @@
@import '../../partial/Ext'
.ColorPicker
width: 300px
height: 200px
display: grid
user-select: none
grid-template-rows: 1fr 12px 28px
border-radius: 8px
background-color: white
box-shadow: 0px 2px 8px 0px transparentize($neutral-950, 0.9)
&.Write
height: 220px
grid-template-rows: 1fr 12px 28px 44px
&.Absolute
z-index: 5
position: absolute
transform: translateX(-50%)
box-shadow: 0px 2px 12px 0px transparentize($neutral-950, 0.8)
.ColorPicker-Separator
background-color: #000
.ColorPicker-SatVal
@extend %center_wrap
position: relative
cursor: pointer
border-radius: 8px 8px 0 0
background-image: linear-gradient(0deg, #000, transparent), linear-gradient(90deg, #fff, hsla(0, 0%, 100%, 0))
.ColorPicker-Hex
margin: 0
margin-top: 16px
font-size: 24px
font-family: monospace
color: rgba(255, 255, 255, 0.6)
text-shadow: 0 0 4px rgba(0, 0, 0, 0.2)
.ColorPicker-Hue
position: relative
cursor: pointer
border-radius: 0 0 8px 8px
background-image: linear-gradient(90deg, red 0, #ff0 17%, #0f0 33%, #0ff 50%, #00f 67%, #f0f 83%, red)
.ColorPicker-Indicator
top: 50%
.ColorPicker-Indicator
position: absolute
z-index: 1
width: 24px
height: 24px
border-radius: 50%
border: 2px solid white
transform: translate(-50%, -50%)
box-shadow: 0px 1px 4px 0px rgba(0, 0, 0, 0.4)
.ColorPicker-Details
display: grid
padding: 8px
grid-gap: 8px
grid-template-columns: 32px 1fr
.ColorPicker-ColorBlock
@extend %material_border
border-color: $neutral-200
border-radius: 4px
.ColorPicker-ColorInput
@extend %material_input
padding-top: 3px
padding-left: 6px
font-family: monospace

View File

@ -0,0 +1,105 @@
// import * as Preact from 'preact';
// import { Color } from 'auriserve-api';
// import { forwardRef } from 'preact/compat';
// import { useEffect, useRef } from 'preact/hooks';
// import './ColorPicker.sass';
// import { WidgetProps } from './Input';
// interface Props {
// parent?: HTMLElement;
// writable?: boolean;
// displayHex?: boolean;
// }
// const ColorPicker = forwardRef<HTMLDivElement, WidgetProps & Props>((props, ref) => {
// const color = props.value ? typeof props.value === 'string' ? Color.hexToHSV(props.value) : props.value : { h: 0, s: 0, v : 0};
// const mouseTarget = useRef<string>('');
// const satValElem = useRef<HTMLDivElement>(null);
// const hueElem = useRef<HTMLDivElement>(null);
// const inputHex = (evt: any) => {
// const val = evt.target.value;
// if (val.length !== 7) return;
// props.setValue(Color.hexToHSV(val));
// };
// const handleHueMove = (evt: MouseEvent) => {
// const bounds = hueElem.current.getBoundingClientRect();
// const hue = Math.max(Math.min((evt.clientX - bounds.left) / bounds.width, 1), 0);
// props.setValue({ ...color, h: hue });
// };
// const handleSatValMove = (evt: MouseEvent) => {
// const bounds = satValElem.current.getBoundingClientRect();
// const sat = Math.max(Math.min((evt.clientX - bounds.left) / bounds.width, 1), 0);
// const val = Math.max(Math.min((bounds.bottom - evt.clientY) / bounds.height, 1), 0);
// props.setValue({ ...color, s: sat, v: val });
// };
// const handleMouseMove = (evt: MouseEvent) => {
// switch (mouseTarget.current) {
// default: return;
// case 'hue': return handleHueMove(evt);
// case 'satval': return handleSatValMove(evt);
// }
// };
// const handleMouseClick = (evt: MouseEvent, target: string) => {
// evt.stopImmediatePropagation();
// evt.preventDefault();
// mouseTarget.current = target;
// handleMouseMove(evt);
// };
// useEffect(() => {
// const clearMouse = () => mouseTarget.current = '';
// document.body.addEventListener('mouseup', clearMouse);
// document.body.addEventListener('mousemove', handleMouseMove);
// return () => {
// document.body.removeEventListener('mouseup', clearMouse);
// document.body.removeEventListener('mousemove', handleMouseMove);
// };
// }, [ handleMouseMove ]);
// const hueHex = Color.HSVToHex({ h: color.h, s: 1, v: 1 });
// const fullHex = Color.HSVToHex(color);
// const style: any = {};
// if (props.parent) {
// style.top = props.parent.getBoundingClientRect().bottom + 'px';
// style.left = ((props.parent.getBoundingClientRect().left +
// props.parent.getBoundingClientRect().right) / 2) + 'px';
// }
// return (
// <div class={('ColorPicker ' + (props.writable ? 'Write ' : '' + (props.parent ? 'Absolute' : ''))).trim()}
// ref={ref} style={style}>
// <div class='ColorPicker-SatVal' ref={satValElem}
// onMouseDown={(evt) => handleMouseClick(evt, 'satval')}
// style={{ backgroundColor: hueHex }}>
// {props.displayHex && <p class='ColorPicker-Hex'>{fullHex}</p>}
// <div class='ColorPicker-Indicator' style={{ left: (color.s * 100) + '%',
// top: ((1 - color.v) * 100) + '%', backgroundColor: fullHex }} />
// </div>
// <div class='ColorPicker-Separator' />
// <div class='ColorPicker-Hue' ref={hueElem}
// onMouseDown={(evt) => handleMouseClick(evt, 'hue')}>
// <div class='ColorPicker-Indicator' style={{ left: (color.h * 100) + '%', backgroundColor: hueHex }} />
// </div>
// {props.writable && <div class='ColorPicker-Details'>
// <div class='ColorPicker-ColorBlock' style={{ backgroundColor: fullHex }} />
// <input class='ColorPicker-ColorInput' value={fullHex} onChange={inputHex} onInput={inputHex} maxLength={7} />
// </div>}
// </div>
// );
// });
// export default ColorPicker;

View File

@ -0,0 +1,86 @@
@use '../../style/def' as *
.DatePicker
width: 280px
height: 250px
display: grid
grid-template-rows: 48px 1fr
user-select: none
overflow: hidden
border-radius: 8px
background-color: $neutral-000
box-shadow: 0px 2px 8px 0px transparentize($neutral-950, 0.9)
&.Absolute
z-index: 5
position: absolute
transform: translateX(-50%)
box-shadow: 0px 2px 12px 0px transparentize($neutral-950, 0.8)
.DatePicker-Header
display: grid
grid-template-columns: 32px 32px 1fr 32px
grid-gap: 4px
text-align: center
border-bottom: 1px solid $neutral-100
padding: 8px
button
@extend %material_button
height: 32px
border-color: $neutral-100
p
margin: 0
padding: 4px 0
line-height: 1.6
color: $neutral-600
.DatePicker-Grid
display: grid
grid-template-columns: repeat(7, 1fr)
padding: 6px
.DatePicker-Date
@extend %material_button
display: flex
align-items: center
justify-content: center
color: $neutral-500
background-color: $neutral-050
border-radius: 0
transition: background-color $t-fast
&:hover, &:focus
border-radius: 4px
&:nth-of-type(1)
border-top-left-radius: 4px
&:nth-of-type(7)
border-top-right-radius: 4px
&:nth-last-of-type(1)
border-bottom-right-radius: 4px
&:nth-last-of-type(7)
border-bottom-left-radius: 4px
&.CurrentMonth
border-radius: 4px
color: $neutral-700
background-color: $neutral-000
&.CurrentDay
color: $accent-800
background-color: $accent-100
&.Selected
background-color: $neutral-500
border-color: $neutral-500
color: $neutral-000

View File

@ -0,0 +1,112 @@
import * as Preact from 'preact';
import { forwardRef } from 'preact/compat';
import { useState, useEffect } from 'preact/hooks';
import './DatePicker.sass';
import { WidgetProps } from './Input';
interface Props {
parent?: HTMLElement;
writable?: boolean;
displayHex?: boolean;
}
const monthNames = [
'January', 'February', 'March', 'April', 'May', 'June',
'July', 'August', 'September', 'October', 'November', 'December'];
const DatePicker = forwardRef<HTMLDivElement, WidgetProps & Props>((props, ref) => {
const [ month, setMonth ] = useState<number>(() => new Date().getMonth());
const [ year, setYear ] = useState<number>(() => new Date().getFullYear());
useEffect(() => {
setMonth(props.value.getMonth());
setYear(props.value.getFullYear());
}, [ props.value ]);
const handleNavigate = (months: number): void => {
setMonth((((month + months) % 12) + 12) % 12);
setYear(year + Math.floor((month + months) / 12));
};
const handleSetNow = (): void => {
const date = new Date(props.value.getTime());
const now = new Date();
date.setFullYear(now.getFullYear(), now.getMonth(), now.getDate());
props.setValue(date);
};
const setDate = (day: number, month: number, year: number) => {
const date = new Date(props.value.getTime());
date.setFullYear(year, month, day);
props.setValue(date);
};
const style: any = {};
if (props.parent) {
style.top = props.parent.getBoundingClientRect().bottom + 'px';
style.left = ((props.parent.getBoundingClientRect().left +
props.parent.getBoundingClientRect().right) / 2) + 'px';
}
let monthDate = new Date();
monthDate.setMonth(month, 1);
monthDate.setFullYear(year);
let days: Preact.VNode[] = [];
const firstDayOfMonth = monthDate.getDay();
for (let i = 1; true; i += 7) {
// Create a date at Sunday of the `i`th week shown on the calendar.
const day = i - firstDayOfMonth;
let date = new Date();
date.setMonth(month, 1);
date.setFullYear(year);
date.setDate(day);
// Break when we go past the last week of the month.
if ((date.getMonth() > month && date.getFullYear() >= year) || date.getFullYear() > year) break;
const currentDate = new Date();
for (let j = 0; j < 7; j++) {
const isCurrentMonth = date.getMonth() === month;
const isCurrentDate =
date.getDate() === currentDate.getDate() &&
date.getMonth() === currentDate.getMonth() &&
date.getFullYear() === currentDate.getFullYear();
const isSelectedDate =
date.getDate() === props.value.getDate() &&
date.getMonth() === props.value.getMonth() &&
date.getFullYear() === props.value.getFullYear();
const dayClasses = 'DatePicker-Date ' +
(isCurrentDate ? ' CurrentDay' : '') +
(isCurrentMonth ? ' CurrentMonth' : '') +
(isSelectedDate ? ' Selected' : '');
const rep: [ number, number, number ] = [ date.getDate(), date.getMonth(), date.getFullYear() ];
days.push(<button tabIndex={-1} class={dayClasses} onClick={() => setDate(...rep)}>{date.getDate()}</button>);
date.setDate(date.getDate() + 1);
}
}
return (
<div class={('DatePicker ' + (props.parent ? 'Absolute' : '')).trim()}
ref={ref} style={style}>
<div class='DatePicker-Header'>
<button tabIndex={-1} onClick={() => handleNavigate(-1)}>&lt;</button>
<button tabIndex={-1} onClick={() => handleSetNow()}>🕒</button>
<p>{monthNames[month]} {year}</p>
<button tabIndex={-1} onClick={() => handleNavigate(1)}>&gt;</button>
</div>
<div class='DatePicker-Grid'>{days}</div>
</div>
);
});
export default DatePicker;

View File

@ -0,0 +1,23 @@
export interface WidgetProps {
value: any;
setValue: (newValue: any) => any;
disabled?: boolean;
placeholder?: string;
onFocusChange?: (focus: boolean) => any;
style?: any;
class?: string;
}
export { default as Label } from './InputLabel';
export { default as Divider } from './InputDivider';
export { default as Annotation } from './InputAnnotation';
export { default as Text } from './fields/InputText';
export { default as Color } from './fields/InputColor';
// export { default as Select } from './fields/InputSelect';
export { default as Numeric } from './fields/InputNumeric';
export { default as Checkbox } from './fields/InputCheckbox';
export { default as DateTime } from './fields/InputDateTime';

View File

@ -0,0 +1,29 @@
@use '../../style/ext'
@use '../../style/def' as *
.InputAnnotation
display: block
position: relative
overflow: auto
user-select: none
.InputAnnotation-Title
padding-right: 3em
margin: 16px 0 8px 0
cursor: pointer
.InputAnnotation-Description
margin: 8px 0
font-size: 14px
font-weight: 400
line-height: 1.4
color: $neutral-500
.InputCheckbox
@extend %material_checkbox
position: absolute
top: 14px
right: 0

View File

@ -0,0 +1,21 @@
import * as Preact from 'preact';
import './InputAnnotation.sass';
interface Props {
title: string;
description?: string;
children?: Preact.ComponentChildren;
}
export default function InputAnnotation(props: Props) {
return (
<label class='InputAnnotation'>
<p class='InputAnnotation-Title'>{props.title}</p>
{props.description && <p class='InputAnnotation-Description'>{props.description}</p>}
{props.children}
</label>
);
}

View File

@ -0,0 +1,7 @@
@use '../../style/def' as *
.InputDivider
border: none
border-bottom: 1px solid $neutral-300
margin: 20px 0 16px 0
padding: 0

View File

@ -0,0 +1,9 @@
import * as Preact from 'preact';
import './InputDivider.sass';
export default function InputLabel() {
return (
<hr class='InputDivider' />
);
}

View File

@ -0,0 +1,13 @@
@use '../../style/ext'
@use '../../style/def' as *
@use '../../style/text'
.InputLabel
display: block
overflow: auto
position: relative
.InputLabel-Label
@include text.line_clamp
@extend %material_label
margin: 0

View File

@ -0,0 +1,20 @@
import * as Preact from 'preact';
import './InputLabel.sass';
interface Props {
label: string;
class?: string;
style?: any;
children?: Preact.ComponentChildren;
}
export default function InputLabel(props: Props) {
return (
<label class={('InputLabel ' + (props.class ?? '')).trim()} style={props.style}>
<p class='InputLabel-Label'>{props.label}</p>
{props.children}
</label>
);
}

View File

@ -0,0 +1,67 @@
@import "../../partial/Ext"
.SearchableOptionPicker
z-index: 5
overflow: auto
position: absolute
height: min-content
min-height: 48px
max-height: 300px
border-radius: 4px
background-color: $neutral-000
border: 1px solid $neutral-100
// box-shadow: 0px 2px 12px 0px transparentize($neutral-950, 0.8)
.SearchableOptionPicker-Empty
padding: 15px 8px 14px 8px
text-align: center
background-color: $neutral-050
p
margin: 0
color: $neutral-400
font-weight: normal
.InputLabel-Label
padding-left: 8px
.SearchableOptionPicker-Items
list-style-type: none
padding: 0
margin: 0
.SearchableOptionPicker-Item
display: grid
min-height: 48px
padding: 0
margin: 0
&.Focused
background-color: mix($neutral-050, $neutral-100, 50%)
&:hover
background-color: mix($neutral-050, $neutral-100, 25%)
.SearchableOptionPicker-Button
display: grid
padding: 0
margin: 0
background-color: transparent
border: none
.SearchableOptionPicker-DefaultItem
display: flex
padding: 8px
height: 100%
align-items: center
border: none
cursor: pointer
text-align: left
background-color: transparent
p
margin: 0
font-weight: normal

View File

@ -0,0 +1,88 @@
import * as Preact from 'preact';
import { useState, useEffect, useMemo } from 'preact/hooks';
import './SearchableOptionPicker.sass';
import Label from './InputLabel';
interface SearchProps {
parent: HTMLElement;
query: string;
multi?: boolean;
options: string[] | {[key: string]: string };
renderOption?: Preact.FunctionalComponent<{ option: string; ind: number; value?: any }>;
setSelected: (identifier: string | string[]) => any;
}
function sortOptions(query: string, a: string, b: string) {
let off = a.indexOf(query) - b.indexOf(query);
if (off < 0) return -1;
if (off > 0) return 1;
return a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' });
}
export default function SearchableOptionPicker(props: SearchProps) {
const [ index, setIndex ] = useState<number>(0);
const optionsArray = useMemo(() => Array.isArray(props.options) ?
props.options : Object.keys(props.options), [ props.options ]);
const query = props.query.toLowerCase().trim();
const filteredOptions = optionsArray.filter(o => o.toLowerCase().includes(query))
.sort((a, b) => sortOptions(query, a, b)).filter((_, i) => i < 5);
useEffect(() => setIndex(0), [ query ]);
// Interactions
useEffect(() => {
const handleArrowSelect = (evt: KeyboardEvent) => {
if (evt.key !== 'ArrowUp' && evt.key !== 'ArrowDown' && evt.key !== 'Enter') return;
evt.preventDefault();
evt.stopPropagation();
if (filteredOptions.length === 0) return;
if (evt.key === 'ArrowUp') setIndex((index) => (index <= 0 ? filteredOptions.length : index) - 1);
else if (evt.key === 'ArrowDown') setIndex((index) => (index + 1) % filteredOptions.length);
else if (evt.key === 'Enter') props.setSelected(filteredOptions[index]);
};
window.addEventListener('keydown', handleArrowSelect);
return () => window.removeEventListener('keydown', handleArrowSelect);
});
const style: any = {
top: props.parent.getBoundingClientRect().bottom + 'px',
left: (props.parent.getBoundingClientRect().left +
props.parent.getBoundingClientRect().width / 2) + 'px',
width: props.parent.getBoundingClientRect().width + 'px'
};
return (
<div class='SearchableOptionPicker' style={style} onMouseDown={evt => { evt.preventDefault(); evt.stopPropagation(); }}>
{query && <Label label={`Search results for '${query}'`} />}
<ul class='SearchableOptionPicker-Items'>
{filteredOptions.map((option, ind) => <li
class={('SearchableOptionPicker-Item ' + (index === ind ? 'Focused' : '')).trim()}
key={ind} onClick={() => props.setSelected(option)}>
<button class='SearchableOptionPicker-Button'>
{props.renderOption ? props.renderOption({ option, ind,
value: !Array.isArray(props.options) && props.options[option] }) :
<span class='SearchableOptionPicker-DefaultItem'>
{Array.isArray(props.options) ? option : props.options[option] || option}
</span>
}
</button>
</li>)}
{filteredOptions.length === 0 &&
<div class='SearchableOptionPicker-Empty'>
<p>No results found.</p>
</div>
}
</ul>
</div>
);
}

View File

@ -0,0 +1,9 @@
@use '../../../style/ext'
.InputCheckbox
@extend %material_checkbox
&.AlignRight
position: absolute
top: 16px
right: 1px

View File

@ -0,0 +1,33 @@
import * as Preact from 'preact';
import { forwardRef } from 'preact/compat';
import './InputCheckbox.sass';
import { WidgetProps } from '../Input';
interface Props extends WidgetProps {
alignRight?: boolean;
}
/**
* A two-state checkbox input widget.
*/
const InputCheckbox = forwardRef<HTMLInputElement, Props>((props, fRef) => {
const cb = () => props.setValue(!props.value);
return (
<input
class={('InputCheckbox ' + (props.alignRight ? 'AlignRight ' : '') + (props.class ?? '')).trim()}
style={props.style}
checked={props.value}
onChange={cb}
ref={fRef as any}
type='checkbox'
/>
);
});
export default InputCheckbox;

View File

@ -0,0 +1,61 @@
@use '../../../style/ext'
@use '../../../style/def' as *
.InputColor
position: relative
.InputText
font-family: monospace
padding-left: 48px
.InputColor-ColorIndicator
position: absolute
display: block
top: 8px
bottom: 8px
left: 8px
width: 32px
border-radius: 4px
pointer-events: none
box-shadow: 0px 1px 4px 0px transparentize($neutral-950, 0.7)
&.Full
.InputText
position: absolute
opacity: 0
font-size: 0
cursor: pointer
top: 0
left: 0
right: 0
bottom: 0
.InputColor-ColorIndicator
top: 0
left: 0
right: 0
bottom: 0
width: unset
box-shadow: none
.InputColor-ColorPickerWrap
position: fixed
z-index: 5
top: 0
left: 0
.ColorPicker.ColorPicker
transform: translate(-50%, 0px) scale(0.95)
opacity: 0
transition: opacity $t-ufast, transform $t-ufast
&.Animate-enter-active, &.Animate-enter-done
.ColorPicker.ColorPicker
opacity: 1
transform: translate(-50%, 12px) scale(1)
&.Animate-enter-active:not(.Animate-enter-done), &.Animate-exit-active:not(.Animate-exit-done)
.ColorPicker
will-change: transform

View File

@ -0,0 +1,44 @@
import * as Preact from 'preact';
// import { Color } from 'auriserve-api';
import { forwardRef } from 'preact/compat';
import { useState, useRef } from 'preact/hooks';
// import { usePopupCancel } from '../../../Hooks';
import './InputColor.sass';
// import Popup from '../../Popup';
// import InputText from './InputText';
// import ColorPicker from '../ColorPicker';
import { WidgetProps } from '../Input';
interface Props {
writable?: boolean;
displayHex?: boolean;
full?: boolean;
}
const InputColor = forwardRef<HTMLInputElement, Props & WidgetProps>((props, _fRef) => {
const inputRef = useRef<HTMLDivElement>(null);
const [ /* pickerActive */, setPickerActive ] = useState(false);
// usePopupCancel(inputRef, () => setPickerActive(false));
return (
<div class={('InputColor ' + (props.full ? 'Full ' : '') + (props.class ?? '')).trim()}
style={props.style} onFocusCapture={() => setPickerActive(true)} ref={inputRef}>
{/* <InputText
ref={fRef}
value={Color.HSVToHex(props.value)}
setValue={hex => props.setValue(Color.hexToHSV(hex))}
max={7}
/>
<div class='InputColor-ColorIndicator' style={{ backgroundColor: Color.HSVToHex(props.value) }}/>
<Popup active={pickerActive} defaultAnimation={true}>
<ColorPicker {...props} parent={inputRef.current} />
</Popup>*/}
</div>
);
});
export default InputColor;

View File

@ -0,0 +1,43 @@
@use '../../../style/ext'
@use '../../../style/def' as *
.InputDateTime
@function space($chars)
@return calc(#{$chars + 'ch'} + 6px * 2 + #{$chars + 'px'})
@extend %material_border
display: grid
height: 48px
overflow: hidden
padding: 0 6px
grid-template-columns: space(2) 0.5ch space(2) 0.5ch space(4) 1fr space(2) 0.5ch space(2)
border-color: $neutral-100
background-color: $neutral-050
&:hover
border-color: $neutral-200
&:focus, &:focus-within
border-color: $neutral-400
&::placeholder
font-weight: 400
color: $neutral-400
.InputText
padding: 12px 6px
text-align: right
overflow: hidden
border: none
font-family: monospace
background-color: transparentize(red, 0.8)
background-color: transparent
.InputDateTime-Divider
padding-top: 12px
line-height: 1.4
color: $neutral-300

View File

@ -0,0 +1,164 @@
import * as Preact from 'preact';
import { forwardRef } from 'preact/compat';
import { useState, useRef, useEffect, useReducer } from 'preact/hooks';
// import { usePopupCancel } from '../../../Hooks';
import './InputDateTime.sass';
// import Popup from '../../Popup';
import InputText from './InputText';
// import DatePicker from '../DatePicker';
import { WidgetProps as Props } from '../Input';
type DateIdentifier = 'date' | 'month' | 'year' | 'hour' | 'minute';
function zeroPad(num: number, pad: number = 2): string {
let str = '' + num;
while (str.length < pad) str = '0' + str;
return str;
}
const InputDateTime = forwardRef<HTMLInputElement, Props>((props) => {
const dateRef = useRef<HTMLInputElement>(null);
const monthRef = useRef<HTMLInputElement>(null);
const yearRef = useRef<HTMLInputElement>(null);
const hourRef = useRef<HTMLInputElement>(null);
const minuteRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<HTMLDivElement>(null);
// const portalRef = useRef<HTMLDivElement>(null);
const [ /* pickerActive */, setPickerActive ] = useState(false);
// usePopupCancel([ inputRef, portalRef ], () => setPickerActive(false));
const [ editedDate, setEditedDate ] =
useReducer((date, newDate: { date: string; month: string; year: string; hour: string; minute: string }) =>
({...date, ...newDate}), { date: '00', month: '00', year: '0000', hour: '00', minute: '00' });
const handleResetEditedDate = () => {
const newDate: any = {};
if (parseInt(editedDate.date, 10) !== props.value.getDate()) newDate.date = zeroPad(props.value.getDate());
if (parseInt(editedDate.month, 10) !== props.value.getMonth() + 1) newDate.month = zeroPad(props.value.getMonth() + 1);
if (parseInt(editedDate.year, 10) !== props.value.getFullYear()) newDate.year = zeroPad(props.value.getFullYear());
if (parseInt(editedDate.hour, 10) !== props.value.getHours()) newDate.hour = zeroPad(props.value.getHours());
if (parseInt(editedDate.minute, 10) !== props.value.getMinutes()) newDate.minute = zeroPad(props.value.getMinutes());
setEditedDate(newDate);
};
useEffect(() => handleResetEditedDate(), [ props.value ]);
const setValue = (type: DateIdentifier, val: number) => {
const newDate = new Date(props.value.getTime());
if (type === 'date' ) newDate.setDate(val);
else if (type === 'month') newDate.setMonth(val - 1);
else if (type === 'year' ) newDate.setFullYear(val);
else if (type === 'hour' ) newDate.setHours(val);
else if (type === 'minute') newDate.setMinutes(val);
props.setValue(newDate);
};
const handleSet = (val: string, type: DateIdentifier, pad: number, min: number | undefined,
max: number | undefined, next?: Preact.RefObject<HTMLInputElement>) => {
setEditedDate({ [type]: val } as any);
val = val.replace(/\D/g, '');
setEditedDate({ [type]: val } as any);
if (!isNaN(parseInt(val, 10)) && (!max || parseInt(val, 10) <= max)) setValue(type, parseInt(val, 10));
if (val.length >= pad) {
let numeric = parseInt(val, 10);
if (isNaN(numeric) || numeric < (min ?? 1)) numeric = 1;
if (max && numeric > max) numeric = max;
setValue(type, numeric);
setEditedDate({ [type]: zeroPad(numeric, pad) } as any);
if (next?.current) window.requestAnimationFrame(() => next.current!.focus());
};
};
// const handleDatePickerSet = (newDate: Date) => {
// props.setValue(newDate);
// };
const handleFocus = (ref: Preact.RefObject<HTMLInputElement>, ..._: any) => {
ref.current!.select();
};
const handleBlur = (_: any, type: DateIdentifier, min: number | undefined,
max: number | undefined, pad: number) => {
let numeric = parseInt(editedDate[type], 10);
if (isNaN(numeric) || numeric < (min ?? 1)) numeric = 1;
if (max && numeric > max) numeric = max;
setValue(type, numeric);
setEditedDate({ [type]: zeroPad(numeric, pad) } as any);
};
const handleFocusChange = (state: boolean, ...other: [ Preact.RefObject<HTMLInputElement>,
DateIdentifier, number | undefined, number | undefined, number ]) => {
if (state) handleFocus(...other);
else handleBlur(...other);
};
const nextDate = new Date(props.value.getTime());
nextDate.setMonth(nextDate.getMonth() + 1);
nextDate.setDate(0);
const maxMonth = nextDate.getDate();
return (
<div
ref={inputRef}
style={props.style}
class={('InputDateTime ' + (props.class ?? '')).trim()}
onFocusCapture={() => setPickerActive(true)}>
<InputText
value={editedDate.date} max={2} ref={dateRef}
setValue={date => handleSet(date, 'date', 2, 1, maxMonth, monthRef)}
onFocusChange={state => handleFocusChange(state, dateRef, 'date', 1, maxMonth, 2)} />
<span class='InputDateTime-Divider'>/</span>
<InputText
value={editedDate.month} max={2} ref={monthRef}
setValue={month => handleSet(month, 'month', 2, 1, 12, yearRef)}
onFocusChange={state => handleFocusChange(state, monthRef, 'month', 1, 12, 2)} />
<span class='InputDateTime-Divider'>/</span>
<InputText
value={editedDate.year} max={4} ref={yearRef}
setValue={year => handleSet(year, 'year', 4, undefined, undefined, hourRef)}
onFocusChange={state => handleFocusChange(state, yearRef, 'year', undefined, undefined, 4)} />
<div/>
<InputText
value={editedDate.hour} max={2} ref={hourRef}
setValue={hour => handleSet(hour, 'hour', 2, 0, 23, minuteRef)}
onFocusChange={state => handleFocusChange(state, hourRef, 'hour', 0, 23, 2)} />
<span class='InputDateTime-Divider'>:</span>
<InputText
value={editedDate.minute} max={2} ref={minuteRef}
setValue={minute => handleSet(minute, 'minute', 2, 0, 59, undefined)}
onFocusChange={state => handleFocusChange(state, minuteRef, 'minute', 0, 59, 2)} />
{/* <Popup active={pickerActive} defaultAnimation={true} ref={portalRef}>
<DatePicker value={props.value} setValue={handleDatePickerSet} parent={inputRef.current} />
</Popup>*/}
</div>
);
});
export default InputDateTime;

View File

@ -0,0 +1,7 @@
@use '../../../style/ext'
.InputNumeric
@extend %material_input
width: 100%
padding: 12px

View File

@ -0,0 +1,36 @@
import * as Preact from 'preact';
import { forwardRef } from 'preact/compat';
import './InputNumeric.sass';
import { WidgetProps as Props } from '../Input';
/**
* A numeric input widget.
*/
const InputNumeric = forwardRef<HTMLInputElement, Props>((props, fRef) => {
const cb = (evt: any) => props.setValue(evt.target.value);
return (
<input
value={props.value}
onInput={cb}
onChange={cb}
class={('InputNumeric ' + (props.class ?? '')).trim()}
style={props.style}
type='number'
ref={fRef as any}
disabled={props.disabled}
placeholder={props.placeholder}
onFocus={props.onFocusChange ? () => props.onFocusChange!(true) : undefined}
onBlur={props.onFocusChange ? () => props.onFocusChange!(false) : undefined}
/>
);
});
export default InputNumeric;

View File

@ -0,0 +1,38 @@
@use '../../../style/ext'
.InputSelect
@extend %material_border
display: grid
height: 48px
padding: 0
overflow: hidden
border-color: $neutral-100
background-color: $neutral-050
&:hover
border-color: $neutral-200
&:focus, &:focus-within
border-color: $neutral-400
&::placeholder
font-weight: 400
color: $neutral-400
.InputSelect-Input
padding: 8px
width: 100%
height: 100%
order: 1
border: none
outline: none
background: transparent
&::placeholder
font-weight: 400
color: $neutral-400
&.Selected:not(:focus)::placeholder
color: $neutral-700

View File

@ -0,0 +1,67 @@
import * as Preact from 'preact';
import { forwardRef } from 'preact/compat';
import { useState, useRef } from 'preact/hooks';
import './InputSelect.sass';
// import Popup from '../../Popup';
import { WidgetProps } from '../Input';
// import SearchableOptionPicker from '../SearchableOptionPicker';
interface Props extends WidgetProps {
options: { [value: string]: string };
multi?: boolean;
}
/**
* A select / multiselect widget.
*/
const InputSelect = forwardRef<HTMLDivElement, Props>((props, fRef) => {
const wrapRef = useRef<HTMLDivElement>(null);
const [ search, setSearch ] = useState<string>('');
const [ /* focused*/, setFocused ] = useState<boolean>(false);
const handleSearch = (evt: any) => {
setSearch(evt.target.value ?? '');
};
// const handleSet = (identifier: string) => {
// props.setValue(identifier);
// setSearch('');
// };
const handleFocus = (focused: boolean) => {
setFocused(focused);
if (props.onFocusChange) props.onFocusChange(focused);
};
return (
<div class={('InputSelect ' + (props.class ?? '')).trim()} style={props.style} ref={e => {
if (!e) return; wrapRef.current = e; if (fRef) fRef.current = e;
}}>
<input
value={search}
onInput={handleSearch}
onChange={handleSearch}
type='text'
disabled={props.disabled}
placeholder={props.options[props.value] || props.placeholder}
class={('InputSelect-Input ' + (props.value?.length ? 'Selected' : '')).trim()}
onFocus={() => handleFocus(true)}
onBlur={() => handleFocus(false)}
/>
{/* <Popup active={focused} defaultAnimation={true}>
<SearchableOptionPicker query={search} parent={wrapRef.current}
options={props.options}
setSelected={selected => handleSet(selected as string)} />
</Popup>*/}
</div>
);
});
export default InputSelect;

View File

@ -0,0 +1,13 @@
@use '../../../style/ext'
.InputText
@extend %material_input
width: 100%
padding: 12px
&.Long
resize: none
&.Code
font-family: monospace

View File

@ -0,0 +1,83 @@
import * as Preact from 'preact';
import { forwardRef } from 'preact/compat';
import { useRef, useLayoutEffect } from 'preact/hooks';
import './InputText.sass';
import { WidgetProps } from '../Input';
/**
* Automatically scales a HTML TextArea element to the height of its content,
* or the specified max-height, whichever is smaller. Returns a ref to attach to
* the TextArea which should be scaled.
*
* @param {number} maxHeight - An optional maximum height, defaults to Infinity.
* @param {any[]} dependents - A list of dependent variables for the TextArea's content.
* @return {RefObject} - A RefObject to attach to the targeted TextArea.
*/
function useAutoTextArea(maxHeight?: number, dependents?: any[]): Preact.RefObject<HTMLTextAreaElement> {
const ref = useRef<HTMLTextAreaElement>(null);
useLayoutEffect(() => {
if (!ref.current) return;
ref.current.style.height = '';
ref.current.style.height = Math.min(ref.current.scrollHeight + 2, maxHeight ?? Infinity) + 'px';
}, [ ref.current, ...dependents || [] ]);
return ref;
}
interface Props extends WidgetProps {
long?: boolean;
code?: boolean;
maxHeight?: number;
min?: number;
max?: number;
}
/**
* A line text input widget, either a single-line input form
* or an autoscaling textarea depending on a prop.
*/
const InputText = forwardRef<HTMLInputElement | HTMLTextAreaElement, Props>((props, fRef) => {
const ref = useAutoTextArea(props.maxHeight ?? 420, [ props.value ]);
const cb = (evt: any) => props.setValue(evt.target.value);
const sharedProps = {
value: props.value,
onInput: cb,
onChange: cb,
onFocus: props.onFocusChange ? () => props.onFocusChange!(true) : undefined,
onBlur: props.onFocusChange ? () => props.onFocusChange!(false) : undefined,
minLength: props.min,
maxLength: props.max,
disabled: props.disabled,
placeholder: props.placeholder,
style: props.style
};
return (
props.long ?
<textarea
{...sharedProps}
class={('InputText Long ' + (props.class ?? '') + (props.code ? ' Code' : '')).trim()}
rows={1}
ref={(newRef) => {
ref.current = newRef;
if (fRef) fRef.current = newRef as any;
}} />
:
<input
{...sharedProps}
class={('InputText Short ' + (props.class ?? '') + (props.code ? ' Code' : '')).trim()}
type='text'
ref={fRef as any} />
);
});
export default InputText;

View File

@ -0,0 +1,32 @@
import * as Preact from 'preact';
import { Switch, Route, Redirect, NavLink as Link } from 'react-router-dom';
import NewAssetForm from '../view/NewAssetForm';
import MyAssetsList from '../view/MyAssetsList';
export default function AssetsRoute() {
return (
<div class='AssetsRoute Page'>
<aside class='Page-Sidebar'>
<h2 class='Page-SidebarTitle'>Assets</h2>
{/* <Link className='Page-SidebarCategory' activeClassName='Active' exact to='/assets/'>Featured Assets</Link>
<Link className='Page-SidebarCategory' activeClassName='Active' exact to='/assets/subscribed'>Subscribed Assets</Link>*/}
<Link className='Page-SidebarCategory' activeClassName='Active' to='/assets/uploaded'>Uploaded Assets</Link>
</aside>
<main class='AssetsRoute-Main'>
<Switch>
{/* <Route exact path='/assets/'>
<h1>Storefront</h1>
</Route>
<Route exact path='/assets/subscribed'>
<h1>Subscribed</h1>
</Route>*/}
<Route exact path='/assets/uploaded'><MyAssetsList/></Route>
<Route exact path='/assets/new'><NewAssetForm/></Route>
<Redirect to='/assets/uploaded' />
</Switch>
</main>
</div>
);
}

View File

@ -1,15 +0,0 @@
@use '../../style/slice'
.CampaignRoute
width: 100%
max-width: 1000px
margin: 0 auto
.CampaignRoute-Card
@extend .slice_background
width: auto
margin: 32px 16px
&:first-of-type
margin-top: 8px

View File

@ -1,25 +1,50 @@
import * as Preact from 'preact';
import { useAppData } from '../../Hooks';
import { Redirect, useParams } from 'react-router-dom';
import { Switch, Route, Redirect, NavLink as Link, useParams } from 'react-router-dom';
import './CampaignRoute.sass';
import CampaignDetails from '../card/CampaignDetails';
import MapList from '../view/MapList';
import NewMapForm from '../view/NewMapForm';
import PlayerList from '../view/PlayerList';
import CampaignOverview from '../view/CampaignOverview';
export default function CampaignRoute() {
const [ { campaigns } ] = useAppData('campaigns');
if (!campaigns) return null;
const { campaign: identifier } = useParams<{ campaign: string }>();
const currentCampaign = (campaigns ?? []).filter(c => c.identifier === identifier)[0];
const { id } = useParams<{ id: string }>();
const currentCampaign = (campaigns ?? []).filter(c => c.identifier === id)[0];
if (!currentCampaign) return <Redirect to='/campaigns/' />;
if (!currentCampaign) return <Redirect to='/' />;
return (
<div class='CampaignRoute'>
<div class='CampaignRoute-Card'>
<CampaignDetails campaign={currentCampaign} />
</div>
<div class='CampaignRoute Page'>
<aside class='Page-Sidebar'>
<h2 class='Page-SidebarTitle'>{currentCampaign.title}</h2>
<Link className='Page-SidebarCategory' activeClassName='Active' exact to={`/campaign/${id}`}>Overview</Link>
<Link className='Page-SidebarCategory' activeClassName='Active' to={`/campaign/${id}/players`}>Players</Link>
<Link className='Page-SidebarCategory' activeClassName='Active' to={`/campaign/${id}/maps`}>Maps</Link>
<Link className='Page-SidebarCategory' activeClassName='Active' to={`/campaign/${id}/assets`}>Assets</Link>
</aside>
<main class='CampaignRoute-Main'>
<Switch>
<Route exact path='/campaign/:id'><CampaignOverview campaign={currentCampaign} /></Route>
<Route exact path='/campaign/:id/players'>
<PlayerList players={[
{ name: 'Player', sprite: '/app/static/token/baby_blue_dragon.png' },
{ name: 'Player', sprite: '/app/static/token/cadin_1.png' },
{ name: 'Player', sprite: '/app/static/token/dragonfolk_1.png' },
{ name: 'Player', sprite: '/app/static/token/druid_male.png' },
{ name: 'Player', sprite: '/app/static/token/naexi_human_yklwa.png' }
]}/>
</Route>
<Route exact path='/campaign/:id/maps'><MapList maps={currentCampaign.maps} /></Route>
<Route exact path='/campaign/:id/maps/new'><NewMapForm /></Route>
<Route exact path='/campaign/:id/assets'></Route>
<Redirect to={`/campaign/${id}`} />
</Switch>
</main>
</div>
);
}

View File

@ -0,0 +1,14 @@
@use '../../style/ext'
@use '../../style/def' as *
.CampaignsRoute-New
padding: 16px
padding-top: 72px
.CampaignsRoute-NewCard
@extend %card
width: 100%
max-width: 1000px
display: block
margin: 0 auto

View File

@ -0,0 +1,46 @@
import * as Preact from 'preact';
import { NavLink as Link, Switch, Route, Redirect } from 'react-router-dom';
import './CampaignsRoute.sass';
import CampaignList from '../view/CampaignList';
import NewCampaignForm from '../view/NewCampaignForm';
function testActive(match: any, desire: string) {
return match.pathname.match(/\/campaigns\/?$/g) && match.search === desire;
}
export default function CampaignsRoute() {
return (
<div class='CampaignsRoute Page'>
<aside class='Page-Sidebar'>
<h2 class='Page-SidebarTitle'>Campaigns</h2>
<Link className='Page-SidebarCategory' activeClassName='Active'
to='/campaigns' isActive={(_, loc) => testActive(loc, '')}>All Campaigns</Link>
<Link className='Page-SidebarCategory' activeClassName='Active'
to='/campaigns?owner=self' isActive={(_, loc) => testActive(loc, '?owner=self')}>My Campaigns</Link>
<Link className='Page-SidebarCategory' activeClassName='Active'
to='/campaigns?owner=other' isActive={(_, loc) => testActive(loc, '?owner=other')}>Joined Campaigns</Link>
</aside>
<Switch>
<Route path='/campaigns/new'>
<main class='CampaignsRoute-New'>
<div class='CampaignsRoute-NewCard'>
<NewCampaignForm />
</div>
</main>
</Route>
<Route exact path='/campaigns'>
<main class='CampaignsRoute-Main Page-Main'>
<CampaignList />
</main>
</Route>
<Redirect to='/campaigns' />
</Switch>
</div>
);
}

View File

@ -5,11 +5,11 @@
max-width: 1000px
margin: 0 auto
.HomeRoute-Card
@extend .slice_background
.HomeRoute-Card
@extend .slice_background
width: auto
margin: 32px 16px
width: auto
margin: 32px 16px
&:first-of-type
margin-top: 8px
&:first-of-type
margin-top: 8px

View File

@ -1,29 +1,13 @@
import * as Preact from 'preact';
import { useAppData } from '../../Hooks';
import { useParams } from 'react-router-dom';
import './HomeRoute.sass';
import CampaignList from '../card/CampaignList';
import NewCampaignForm from '../card/NewCampaignForm';
import CampaignOverview from '../card/CampaignOverview';
export default function HomeRoute() {
const [ { campaigns } ] = useAppData('campaigns');
const { campaign: identifier } = useParams<{ campaign: string }>();
const currentCampaign = (campaigns ?? []).filter(c => c.identifier === identifier)[0];
return (
<div class='HomeRoute'>
<div class='HomeRoute-Card'>
<CampaignList allowNew={identifier !== 'new'} />
<h1>Home (unused)</h1>
</div>
{(currentCampaign || identifier === 'new') && <div class='HomeRoute-Card'>
{identifier === 'new' && <NewCampaignForm />}
{currentCampaign && <CampaignOverview campaign={currentCampaign} />}
</div>}
</div>
);
}

View File

@ -0,0 +1,40 @@
@use '../../style/def' as *
@use '../../style/text'
@use '../../style/slice'
.Page
width: 100%
height: 100%
display: grid
grid-template-columns: 280px 1fr
.Page-Sidebar
background: $neutral-200
padding: 0 16px
.Page-SidebarTitle
@include text.line_clamp
padding: 16px 0 20px 0
margin: 0
font-size: 34px
.Page-SidebarCategory
display: block
padding: 12px
margin-bottom: 8px
font-size: 18px
border-radius: 4px
text-decoration: none
background: $neutral-250
&:hover, &:focus-visible
background: $neutral-300
&.Active
background: linear-gradient(130deg, $accent-600, $accent-800)
.Page-Main
padding: 16px

View File

@ -1,7 +1,13 @@
import './Route.sass';
export { default as Login } from './LoginRoute';
export { default as Home } from './HomeRoute';
export { default as Map } from './MapRoute';
export { default as Campaign } from './CampaignRoute';
export { default as Campaigns } from './CampaignsRoute';
export { default as Assets } from './AssetsRoute';
export { default as Editor } from './EditorRoute';

View File

@ -0,0 +1,77 @@
@use '../../style/ext'
@use '../../style/grid'
@use '../../style/text'
@use '../../style/slice'
@use '../../style/def' as *
.CampaignList
max-width: 1000px
display: block
margin: 0 auto
margin-top: 56px
.CampaignList-Grid
@include grid.auto_width(300px, 16px)
.CampaignList-CampaignWrap
.CampaignList-Campaign
display: grid
user-select: none
overflow: hidden
border-radius: 4px
text-decoration: none
background-color: $neutral-200
box-shadow: 0px 2px 12px 0px transparentize($neutral-000, 0.9)
.CampaignList-CampaignInner
.CampaignList-CampaignPreview
position: relative
width: 100%
height: 0
padding-bottom: 56.25%
img
position: absolute
top: 0
left: 0
width: 100%
height: 100%
object-fit: cover
user-select: none
pointer-events: none
.CampaignList-CampaignTitle
@include text.line_clamp
margin: 0
font-size: 18px
padding: 16px 12px
.CampaignList-NewCampaign
display: flex
user-select: none
flex-direction: column
justify-content: center
align-items: center
overflow: hidden
border-radius: 4px
background-color: $neutral-100
box-shadow: 0px 2px 12px 0px transparentize($neutral-000, 0.9)
border: 3px dotted $neutral-300
min-height: 220px
height: 100%
img
width: 80px
height: 80px
image-rendering: pixelated
p
font-size: 18px
margin: 0
margin-top: 4px

View File

@ -0,0 +1,37 @@
import * as Preact from 'preact';
import { useAppData } from '../../Hooks';
import { NavLink as Link } from 'react-router-dom';
import './CampaignList.sass';
export default function CampaignList() {
const [ { campaigns } ] = useAppData('campaigns');
return (
<div class='CampaignList'>
{campaigns === undefined && <p>Loading Campaigns...</p>}
{campaigns !== undefined &&
<Preact.Fragment>
<ul class='CampaignList-Grid'>
{campaigns.map(c => <li class='CampaignList-CampaignWrap'>
<Link className='CampaignList-Campaign' to={`/campaign/${c.identifier}`}>
<div class='CampaignList-CampaignInner'>
<div class='CampaignList-CampaignPreview'>
<img src='https://placekitten.com/400/300' alt='' />
</div>
<p class='CampaignList-CampaignTitle'>{c.title || 'Untitled'}</p>
</div>
</Link>
</li>)}
<li class='CampaignList-CampaignWrap'>
<Link className='CampaignList-NewCampaign' to='/campaigns/new'>
<img src='/app/static/ui/icon/campaign_new.png' alt=''/>
<p>Create Campaign</p>
</Link>
</li>
</ul>
</Preact.Fragment>
}
</div>
);
}

View File

@ -0,0 +1,91 @@
@use '../../style/ext'
@use '../../style/text'
@use '../../style/slice'
@use '../../style/def' as *
.CampaignOverview
.CampaignOverview-Carousel
position: relative
height: 0
width: 100%
margin-top: 16px
margin-bottom: 32px
padding-bottom: min(360px, 33.33%)
display: flex
align-items: center
.CampaignOverview-CarouselInner
display: grid
grid-gap: 24px
position: absolute
grid-template-columns: 1fr min(80%, 1000px) 1fr
top: 0
left: 0
width: 100%
height: 100%
justify-content: center
.CampaignOverview-CarouselImage
width: 100%
height: 100%
border-radius: 4px
object-fit: cover
&.Unfocused
filter: saturate(0%) contrast(0.5) brightness(0.25) sepia(1) saturate(150%) hue-rotate(185deg)
&:first-child
border-radius: 0 4px 4px 0
&:last-child
border-radius: 4px 0 0 4px
.CampaignOverview-Details
@extend %card
margin: 0 auto
max-width: 1000px
.CampaignOverview-Title
@include text.line_clamp
margin-top: 0
margin-bottom: 8px
padding-right: 6px
font-weight: 500
font-size: 34px
font-family: $font-header
.CampaignOverview-Description
margin-top: 20px
min-height: 6em
color: $neutral-800
.CampaignOverview-Actions
float: right
.CampaignOverview-CharactersWrap
@extend .slice_highlight_neutral
height: 64px
width: max-content
.CampaignOverview-Characters
@include slice.slice_invert
display: flex
padding: 6px 12px
width: max-content
gap: 12px
.CampaignOverview-Character
width: #{18px * 3}
height: #{18px * 3}
background-size: #{18px * 3 * 2} #{18px * 3 * 2}
background-position: top left
image-rendering: pixelated

View File

@ -0,0 +1,51 @@
import * as Preact from 'preact';
import { Link } from 'react-router-dom';
import './CampaignOverview.sass';
import Button from '../Button';
import ButtonGroup from '../ButtonGroup';
import { Campaign } from '../../../../common/DBStructs';
interface Props {
campaign: Campaign;
}
export default function CampaignOverview({ campaign }: Props) {
return (
<div class='CampaignOverview'>
<div class='CampaignOverview-Carousel'>
<div class='CampaignOverview-CarouselInner'>
<img class='CampaignOverview-CarouselImage Unfocused' src='https://placekitten.com/100/300' alt='' />
<img class='CampaignOverview-CarouselImage' src='https://placekitten.com/800/300' alt='' />
<img class='CampaignOverview-CarouselImage Unfocused' src='https://placekitten.com/100/300' alt='' />
</div>
</div>
<div class='CampaignOverview-Details'>
<div class='CampaignOverview-Actions'>
<ButtonGroup>
<Button to={`${campaign.identifier}/details`} icon='edit' alt='Edit'/>
<Button to={`${campaign.identifier}/play`} icon='play' label='Start' disabled={campaign.maps.length === 0}/>
</ButtonGroup>
</div>
<h3 class='CampaignOverview-Title'>{campaign.title || 'Untitled'}</h3>
<p class='CampaignOverview-Description'>{campaign.description || 'Lorem ipsum dolor sit amet...'}</p>
<Link to={`${campaign.identifier}/players`}>
<div class='CampaignOverview-CharactersWrap'>
<div class='CampaignOverview-Characters'>
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/baby_blue_dragon.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/cadin_1.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/dragonfolk_1.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/druid_male.png)' }} />
<div class='CampaignOverview-Character' style={{ backgroundImage: 'url(/app/static/token/naexi_human_yklwa.png)' }} />
</div>
</div>
</Link>
</div>
</div>
);
}

View File

@ -0,0 +1,80 @@
@use '../../style/ext'
@use '../../style/grid'
@use '../../style/text'
@use '../../style/def' as *
.MapList
// @extend %card
max-width: 1000px
margin: 56px auto
.MapList-Grid
@include grid.auto_width(160px, 16px)
.MapList-MapWrap
.MapList-Map
height: 0
display: grid
user-select: none
position: relative
padding-bottom: 100%
overflow: hidden
border-radius: 4px
text-decoration: none
background-color: $neutral-250
box-shadow: 0px 2px 12px 0px transparentize($neutral-000, 0.9)
.MapList-MapInner
position: absolute
top: 0
left: 0
width: 100%
height: 100%
display: grid
grid-template-rows: 1fr auto
.MapList-MapPreview
width: 100%
img
width: 100%
height: 100%
object-fit: cover
user-select: none
pointer-events: none
.MapList-MapTitle
@include text.line_clamp
margin: 0
display: block
font-size: 18px
padding: 10px 12px
.MapList-NewMap
display: flex
user-select: none
flex-direction: column
justify-content: center
align-items: center
overflow: hidden
border-radius: 4px
background-color: $neutral-100
box-shadow: 0px 2px 12px 0px transparentize($neutral-000, 0.9)
border: 3px dotted $neutral-300
min-height: 180px
height: 100%
img
width: 80px
height: 80px
image-rendering: pixelated
p
font-size: 18px
margin: 0
margin-top: 4px

View File

@ -0,0 +1,42 @@
import * as Preact from 'preact';
import { NavLink as Link, useParams } from 'react-router-dom';
import './MapList.sass';
import { Map } from '../../../../common/DBStructs';
interface Props {
maps: Map[];
}
export default function MapList({ maps }: Props) {
const { id: campaign } = useParams<{ id: string }>();
return (
<div class='MapList'>
{maps === undefined && <p>Loading Maps...</p>}
{maps !== undefined &&
<Preact.Fragment>
<ul class='MapList-Grid'>
{maps.map(m => <li class='MapList-MapWrap'>
<Link className='MapList-Map' to={`/edit/${campaign}/${m.identifier}`}>
<div class='MapList-MapInner'>
<div class='MapList-MapPreview'>
<img src='https://placekitten.com/400/300' alt='' />
</div>
<p class='MapList-MapTitle'>{m.name || 'Untitled'}</p>
</div>
</Link>
</li>)}
<li class='MapList-MapWrap'>
<Link className='MapList-NewMap' to={`/campaign/${campaign}/maps/new`}>
<img src='/app/static/ui/icon/map_new.png' alt=''/>
<p>Create Map</p>
</Link>
</li>
</ul>
</Preact.Fragment>
}
</div>
);
}

View File

@ -0,0 +1,84 @@
@use '../../style/text'
@use '../../style/grid'
@use '../../style/def' as *
.AssetsList
max-width: 1000px
display: block
margin: 0 auto
margin-top: 56px
.AssetsList-Grid
@include grid.auto_width(160px, 16px)
.AssetsList-AssetWrap
.AssetsList-Asset
height: 0
display: grid
user-select: none
position: relative
padding-bottom: 100%
overflow: hidden
border-radius: 4px
text-decoration: none
background-color: $neutral-200
box-shadow: 0px 2px 12px 0px transparentize($neutral-000, 0.9)
.AssetsList-AssetInner
position: absolute
top: 0
left: 0
width: 100%
height: 100%
display: grid
grid-template-rows: 1fr auto
.AssetsList-AssetPreview
position: relative
padding: 8px
overflow: hidden
background-color: $neutral-100
img
width: 100%
height: 100%
user-select: none
object-fit: contain
pointer-events: none
image-rendering: pixelated
.AssetsList-AssetTitle
@include text.line_clamp
margin: 0
font-size: 18px
padding: 12px
.AssetsList-NewAsset
display: flex
user-select: none
flex-direction: column
justify-content: center
align-items: center
overflow: hidden
border-radius: 4px
background-color: $neutral-100
box-shadow: 0px 2px 12px 0px transparentize($neutral-000, 0.9)
border: 3px dotted $neutral-300
min-height: 180px
height: 100%
img
width: 80px
height: 80px
image-rendering: pixelated
p
font-size: 18px
margin: 0
margin-top: 4px

View File

@ -0,0 +1,37 @@
import * as Preact from 'preact';
import { useAppData } from '../../Hooks';
import { NavLink as Link } from 'react-router-dom';
import './MyAssetsList.sass';
export default function MyAssetsList() {
const [ { assets } ] = useAppData('assets');
return (
<div class='AssetsList'>
{assets === undefined && <p>Loading Assets...</p>}
{assets !== undefined &&
<Preact.Fragment>
<ul class='AssetsList-Grid'>
{assets.map(a => <li class='AssetsList-AssetWrap'>
<Link className='AssetsList-Asset' to={`/campaign/${a.identifier}`}>
<div class='AssetsList-AssetInner'>
<div class='AssetsList-AssetPreview'>
<img src={'/app/asset/' + a.path} alt='' />
</div>
<p class='AssetsList-AssetTitle'>{a.name || 'Untitled'}</p>
</div>
</Link>
</li>)}
<li class='AssetsList-AssetWrap'>
<Link className='AssetsList-NewAsset' to='/assets/new'>
<img src='/app/static/ui/icon/asset_new.png' alt=''/>
<p>Upload Asset</p>
</Link>
</li>
</ul>
</Preact.Fragment>
}
</div>
);
}

View File

@ -0,0 +1,200 @@
@use '../../style/ext'
@use '../../style/text'
@use '../../style/slice'
@use '../../style/def' as *
.NewAssetForm
@extend %card
max-width: 1000px
display: block
margin: 0 auto
margin-top: 56px
.NewAssetForm-Title
@include text.line_clamp
margin-top: 0
margin-bottom: 8px
padding-right: 6px
font-weight: 500
font-size: 34px
font-family: $font-header
.NewAssetForm-Col2
display: grid
grid-gap: 16px
grid-template-columns: 1fr 1fr
.NewAssetForm-UploadWrap
@extend %material_button
position: relative
height: 100px
cursor: pointer
border-color: $neutral-300
.NewAssetForm-Upload
width: 100%
height: 100%
outline: 0
opacity: 0
.NewAssetForm-UploadTitle
position: absolute
padding: 0
margin: 0
top: 50%
left: 0
right: 0
transform: translateY(-50%)
text-align: center
color: $neutral-600
.NewAssetForm-AssetPreview
display: flex
gap: 12px
margin-top: 12px
height: 16px * 3 * 6
flex-direction: row
.NewAssetForm-ImagePreviewWrap
@extend .slice_highlight_neutral
flex-grow: 1
.NewAssetForm-ImagePreview
@include slice.slice_invert
img
width: 100%
height: 100%
object-fit: contain
image-rendering: pixelated
.NewAssetForm-TilePreviewWrap, .NewAssetForm-TokenPreviewWrap
@extend .slice_highlight_neutral
display: inline-block
width: max-content
flex-shrink: 0
.NewAssetForm-TilePreview, .NewAssetForm-TokenPreview
@include slice.slice_invert
display: grid
width: 16px * 3 * 6
height: 16px * 3 * 6
grid-template-rows: 1fr 1fr 1fr
grid-template-columns: 1fr 1fr 1fr
background-size: 0px
& > div
image-rendering: pixelated
background-image: inherit
background-repeat: no-repeat
background-size: #{9 * 100%} #{7 * 100%}
&.wall > div
&:nth-child(1)
background-position: #{100% * (7/8)} 0
&:nth-child(2)
background-position: #{100% * (4/8)} #{100% * (3/6)}
&:nth-child(3)
background-position: #{100% * (8/8)} 0
&:nth-child(4)
background-position: #{100% * (4/8)} #{100% * (2/6)}
&:nth-child(5)
background-position: 0 #{100% * (6/6)}
&:nth-child(6)
background-position: #{100% * (5/8)} #{100% * (2/6)}
&:nth-child(7)
background-position: #{100% * (7/8)} #{100% * (1/6)}
&:nth-child(8)
background-position: #{100% * (5/8)} #{100% * (3/6)}
&:nth-child(9)
background-position: #{100% * (8/8)} #{100% * (1/6)}
&.ground > div
&:nth-child(1)
background-position: #{100% * (3/8)} 0
&:nth-child(2)
background-position: #{100% * (1/8)} #{100% * (2/6)}
&:nth-child(3)
background-position: #{100% * (5/8)} 0
&:nth-child(4)
background-position: #{100% * (2/8)} #{100% * (1/6)}
&:nth-child(5)
background-position: 0 #{100% * (6/6)}
&:nth-child(6)
background-position: 0 #{100% * (1/6)}
&:nth-child(7)
background-position: #{100% * (3/8)} #{100% * (2/6)}
&:nth-child(8)
background-position: #{100% * (1/8)} 0
&:nth-child(9)
background-position: #{100% * (5/8)} #{100% * (2/6)}
.NewAssetForm-TokenPreview
background-repeat: no-repeat
background-size: 100% 100%
image-rendering: pixelated
&.Slice4
animation: Token-Anim-4 2.5s infinite
background-size: 200% 200%
@keyframes Token-Anim-4
0%, 24.99999%
background-position: 0% 0%
25%, 49.99999%
background-position: 100% 0%
50%, 74.99999%
background-position: 0% 100%
75%, 100%
background-position: 100% 100%
&.Slice8
animation: Token-Anim-8 5s infinite
background-size: 300% 300%
@keyframes Token-Anim-8
0%, 12.49999%
background-position: 0% 0%
12.5%, 24.99999%
background-position: 50% 0%
25%, 37.49999%
background-position: 100% 0%
37.5%, 49.99999%
background-position: 100% 50%
50%, 62.49999%
background-position: 100% 100%
62.5%, 74.99999%
background-position: 50% 100%
75%, 87.49999%
background-position: 0% 100%
87.5%, 100%
background-position: 0% 50%
.NewAssetForm-AssetDisclaimer
margin: 0
padding: 0
color: $neutral-700
margin-top: 16px
margin-bottom: 36px
.NewAssetForm-Description
min-height: 100px
.NewAssetForm-Submit
margin-top: 20px

View File

@ -0,0 +1,148 @@
import * as Preact from 'preact';
import { useHistory } from 'react-router-dom';
import { useState, useEffect } from 'preact/hooks';
import './NewAssetForm.sass';
import { Label, Text } from '../input/Input';
import Button from '../Button';
import ButtonGroup from '../ButtonGroup';
import * as Format from '../../../../common/Format';
export default function NewAssetForm() {
const history = useHistory();
const [ queryState, setQueryState ] = useState<'idle' | 'querying'>('idle');
const [ type, setType ] = useState<'wall' | 'ground' | 'token'>('token');
const [ tokenType, setTokenType ] = useState<1 | 4 | 8>(4);
const [ file, setFile ] = useState<File | null>(null);
const [ filePreview, setFilePreview ] = useState<string | null>(null);
const [ name, setName ] = useState<string>('');
const [ identifier, setIdentifier ] = useState<string>('');
const handleSetType = (type: 'wall' | 'ground' | 'token') => {
setType(type);
};
const handleFileSet = (evt: any) => {
const input = evt.target as HTMLInputElement;
const newFile: File | undefined = input.files?.[0];
if (!newFile || newFile.type !== 'image/png' || !newFile.name.endsWith('.png')) return setFile(null);
setFile(newFile ?? null);
setName(Format.name(newFile.name));
setIdentifier(Format.identifier(Format.name(newFile.name)));
};
useEffect(() => {
if (!file) return;
let set = true;
const reader = new FileReader();
reader.onload = e => { if (set) setFilePreview((e.target as any).result); };
reader.readAsDataURL(file);
return () => set = false;
}, [ file ]);
const createCampaign = async () => {
if (queryState !== 'idle' || !file) return;
setQueryState('querying');
let data = new FormData();
data.append('file', file);
data.append('type', type);
if (type === 'token') data.append('tokenType', tokenType.toString());
data.append('name', name);
data.append('identifier', identifier);
const res = await fetch('/data/asset/upload', {
method: 'POST', cache: 'no-cache',
body: data
});
if (res.status === 202) {
console.log('hellyea!');
history.push('/assets');
}
else {
console.log('hellnah', await res.text());
setQueryState('idle');
}
};
return (
<div class='NewAssetForm'>
<h2 class='NewAssetForm-Title'>New Asset</h2>
<div class='NewAssetForm-Col2'>
<div>
<Label label='Asset Type' />
<ButtonGroup>
<Button icon='token' label='Token' inactive={type !== 'token'} onClick={() => handleSetType('token')}/>
<Button icon='ground' label='Ground' inactive={type !== 'ground'} onClick={() => handleSetType('ground')}/>
<Button icon='wall' label='Wall' inactive={type !== 'wall'} onClick={() => handleSetType('wall')}/>
</ButtonGroup>
</div>
{type === 'token' && <div>
<Label label='Token Type' />
<ButtonGroup>
<Button icon='token_1' label='Single' inactive={tokenType !== 1} onClick={() => setTokenType(1)}/>
<Button icon='token_4' label='4 Slice' inactive={tokenType !== 4} onClick={() => setTokenType(4)}/>
<Button icon='token_8' label='8 Slice' inactive={tokenType !== 8} onClick={() => setTokenType(8)}/>
</ButtonGroup>
</div>}
</div>
<Label label='Asset'>
<div class='NewAssetForm-UploadWrap'>
<input type='file' class='NewAssetForm-Upload' accept='.png' onChange={handleFileSet} />
<p class='NewAssetForm-UploadTitle'>Drag file here or click to Select</p>
</div>
</Label>
{file && <Preact.Fragment>
<div class='NewAssetForm-AssetPreview'>
<div class='NewAssetForm-ImagePreviewWrap'>
<div class='NewAssetForm-ImagePreview'>
<img src={filePreview ?? undefined} alt='' />
</div>
</div>
{type !== 'token' && <div class='NewAssetForm-TilePreviewWrap'>
<div class={'NewAssetForm-TilePreview ' + type} style={{ backgroundImage: `url(${filePreview})` }}>
<div /><div /><div />
<div /><div /><div />
<div /><div /><div />
</div>
</div>}
{type === 'token' && <div class='NewAssetForm-TokenPreviewWrap'>
<div class={'NewAssetForm-TokenPreview Slice' + tokenType.toString()} style={{ backgroundImage: `url(${filePreview})` }} />
</div>}
</div>
<p class='NewAssetForm-AssetDisclaimer'>If the preview doesn't look correct,
try changing the asset {type === 'token' ? 'or token' : ''} type!</p>
<div class='NewAssetForm-Col2'>
<Label label='Asset Name'>
<Text value={name} setValue={setName} />
</Label>
<Label label='Asset Identifier'>
<Text class='NewAssetForm-Identifier' long={true} value={identifier} setValue={setIdentifier} />
</Label>
</div>
<Button class='NewAssetForm-Submit' onClick={createCampaign} icon='add' label={`Create ${Format.name(type)} Asset`}
disabled={!(name.length > 3 && identifier.length > 3)} />
</Preact.Fragment>}
</div>
);
}

View File

@ -0,0 +1,21 @@
@use '../../style/def' as *
@use '../../style/text'
.NewCampaignForm
.NewCampaignForm-Title
@include text.line_clamp
margin-top: 0
margin-bottom: 8px
padding-right: 6px
font-weight: 500
font-size: 34px
font-family: $font-header
.NewCampaignForm-Description
min-height: 100px
.NewCampaignForm-Submit
margin-top: 20px

View File

@ -5,6 +5,8 @@ import { useHistory } from 'react-router-dom';
import './NewCampaignForm.sass';
import { Label, Text } from '../input/Input';
import Button from '../Button';
import * as Format from '../../../../common/Format';
@ -40,20 +42,17 @@ export default function NewCampaignForm() {
return (
<div class='NewCampaignForm'>
<label>
<span>Campaign Title</span>
<input class='NewCampaignForm-Title' value={title}
onChange={(e: any) => setTitle(e.target.value )} />
</label>
<label>
<span>Campaign Description</span>
<textarea
rows={3} placeholder='Lorem ipsum dolor sit amet...'
class='NewCampaignForm-Description' value={description}
onChange={(e: any) => setDescription(e.target.value)} />
</label>
<h2 class='NewCampaignForm-Title'>New Campaign</h2>
<Button class='NewCampaignForm-Submit' onClick={createCampaign} icon='add' label='Create Campaign' disabled={!title} />
<Label label='Campaign Title'>
<Text value={title} setValue={setTitle} />
</Label>
<Label label='Campaign Description'>
<Text class='NewCampaignForm-Description' long={true} value={description} setValue={setDescription} />
</Label>
<Button class='NewCampaignForm-Submit' onClick={createCampaign} icon='add' label='Create' disabled={!title} />
</div>
);
}

View File

@ -0,0 +1,33 @@
@use '../../style/ext'
@use '../../style/text'
@use '../../style/def' as *
.NewMapForm
padding: 16px
padding-top: 72px
.NewMapForm-Title
@include text.line_clamp
margin-top: 0
margin-bottom: 8px
padding-right: 6px
font-weight: 500
font-size: 34px
font-family: $font-header
.NewMapForm-Card
@extend %card
width: 100%
max-width: 1000px
display: block
margin: 0 auto
.NewMapForm-Description
min-height: 100px
.NewMapForm-Submit
margin-top: 20px

View File

@ -0,0 +1,59 @@
import * as Preact from 'preact';
import { useState } from 'preact/hooks';
import { useAppData } from '../../Hooks';
import { useParams, useHistory } from 'react-router-dom';
import './NewMapForm.sass';
import { Label, Text } from '../input/Input';
import Button from '../Button';
export default function NewMapForm() {
const [ ,, mergeData ] = useAppData();
const history = useHistory();
const { id: campaign } = useParams<{ id: string }>();
const [ queryState, setQueryState ] = useState<'idle' | 'querying'>('idle');
const [ name, setName ] = useState<string>('');
const [ description, setDescription ] = useState<string>('');
const createMap = async () => {
if (queryState !== 'idle') return;
setQueryState('querying');
const res = await fetch('/data/map/new', {
method: 'POST', cache: 'no-cache',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ campaign, name, description })
});
setQueryState('idle');
if (res.status !== 200) console.error(await res.text());
else {
const data = await res.json();
await mergeData(data);
history.push(`/campaign/${campaign}/maps`);
}
};
return (
<div class='NewMapForm'>
<div class='NewMapForm-Card'>
<h2 class='NewMapForm-Title'>New Map</h2>
<Label label='Map Title'>
<Text value={name} setValue={setName} />
</Label>
<Label label='Map Description'>
<Text class='NewMapForm-Description' long={true} value={description} setValue={setDescription} />
</Label>
<Button class='NewMapForm-Submit' onClick={createMap} icon='add' label='Create' disabled={!name} />
</div>
</div>
);
}

View File

@ -0,0 +1,41 @@
@use '../../style/ext'
@use '../../style/slice'
@use '../../style/def' as *
.PlayerList
@extend %card
max-width: 1000px
margin: 48px auto
.PlayerList-List
list-style-type: none
padding: 0
margin: 0
.PlayerList-PlayerWrap
@extend .slice_outline_neutral
margin-bottom: 6px
.PlayerList-Player
@include slice.slice_invert
padding: 9px
display: grid
grid-gap: 12px
grid-template-columns: #{18px * 5} 1fr
.PlayerList-PlayerImageWrap
@extend .slice_highlight_neutral
width: 18px * 5
height: 18px * 5
.PlayerList-PlayerImage
@include slice.slice_invert
background-size: #{18px * 5 * 2} #{18px * 5 * 2}
background-position: top left
image-rendering: pixelated
.PlayerList-PlayerName
font-size: 30px
margin: 8px 0

View File

@ -0,0 +1,33 @@
import * as Preact from 'preact';
import './PlayerList.sass';
interface Player {
name: string;
sprite: string;
}
interface Props {
players: Player[];
}
export default function PlayerList({ players }: Props) {
return (
<div class='PlayerList'>
<ul class='PlayerList-List'>
{players.map(p =>
<li class='PlayerList-PlayerWrap'>
<div class='PlayerList-Player'>
<div class='PlayerList-PlayerImageWrap'>
<div class='PlayerList-PlayerImage' style={{ backgroundImage: `url('${p.sprite}')` }}/>
</div>
<div class='PlayerList-PlayerDetails'>
<h3 class='PlayerList-PlayerName'>{p.name}</h3>
</div>
</div>
</li>
)}
</ul>
</div>
);
}

View File

@ -0,0 +1,12 @@
import { Asset } from './util/Asset';
import * as DB from '../../../common/DBStructs';
export interface ExternalData {
map: string;
campaign: string;
}
export default interface EditorData extends ExternalData {
assets: Asset[];
data: DB.Map;
}

View File

@ -1,11 +1,12 @@
import Phaser from 'phaser';
import { ExternalData } from './EditorData';
import * as Scene from './scene/Scenes';
export default function create(root: HTMLElement) {
export default function create(root: HTMLElement, data: ExternalData) {
const bounds = root.getBoundingClientRect();
return new Phaser.Game({
const game = new Phaser.Game({
title: 'Editor',
parent: root,
@ -25,4 +26,7 @@ export default function create(root: HTMLElement) {
}
}
});
game.scene.start('InitScene', data);
return game;
}

View File

@ -78,16 +78,16 @@ export default class MapChunk {
let wallTile = this.map.getTile(Layer.wall, mX, mY);
if (this.map.getTileset(Layer.wall, mX, mY) === -1 || (wallTile < 54 || wallTile > 60)) {
this.canvas.drawFrame(this.map.manager.groundLocations[this.map.getTileset(Layer.floor, mX, mY)].key,
this.canvas.drawFrame(this.map.manager.groundLocations[this.map.getTileset(Layer.floor, mX, mY)].identifier,
this.map.getTile(Layer.floor, mX, mY), x * MapChunk.TILE_SIZE, y * MapChunk.TILE_SIZE);
if (this.map.getTileset(Layer.overlay, mX, mY) !== -1)
this.canvas.drawFrame(this.map.manager.overlayLocations[this.map.getTileset(Layer.overlay, mX, mY)].key,
this.canvas.drawFrame(this.map.manager.overlayLocations[this.map.getTileset(Layer.overlay, mX, mY)].identifier,
this.map.getTile(Layer.overlay, mX, mY), x * MapChunk.TILE_SIZE, y * MapChunk.TILE_SIZE);
}
if (this.map.getTileset(Layer.wall, mX, mY) !== -1)
this.canvas.drawFrame(this.map.manager.wallLocations[this.map.getTileset(Layer.wall, mX, mY)].key,
this.canvas.drawFrame(this.map.manager.wallLocations[this.map.getTileset(Layer.wall, mX, mY)].identifier,
this.map.getTile(Layer.wall, mX, mY), x * MapChunk.TILE_SIZE, y * MapChunk.TILE_SIZE);
if ((x % 2 === 0 && y % 2 === 0) || (x % 2 !== 0 && y % 2 !== 0))

View File

@ -91,6 +91,9 @@ export default class MapData {
for (let j = clamp(y - 1, this.size.y - 1, 0); j <= clamp(y + 1, this.size.y - 1, 0); j++) {
const solids = this.getTilesetsAt(Layer.wall, i, j).map(i => i !== -1);
// const bits = SmartTiler.bitsToIndices(solids);
// if (i === x && j === y) console.log(bits, Math.floor(bits / 16), bits % 16);
const wall = SmartTiler.wall(solids, this.getTile(Layer.wall, i, j));
if (wall !== -1) this.setTileRaw(Layer.wall, i, j, wall);

View File

@ -1,323 +1,68 @@
const wallField = [
33, 33, 6, 6, 33, 33, 6, 6, 5, 5, 10, 17, 5, 5, 10, 17, 15, 15, 9, 9, 15, 15, 16, 16, 2, 2, 4, 50, 2, 2, 49, 32,
33, 33, 6, 6, 33, 33, 6, 6, 5, 5, 10, 17, 5, 5, 10, 17, 15, 15, 9, 9, 15, 15, 16, 16, 2, 2, 4, 50, 2, 2, 49, 32,
14, 14, 11, 11, 14, 14, 11, 11, 1, 1, 13, 48, 1, 1, 13, 48, 0, 0, 12, 12, 0, 0, 47, 47, 3, 3, 25, 46, 3, 3, 45, 20,
14, 14, 11, 11, 14, 14, 11, 11, 8, 8, 39, 23, 8, 8, 39, 23, 0, 0, 12, 12, 0, 0, 47, 47, 41, 41, 37, 29, 41, 41, 51, 18,
33, 33, 6, 6, 33, 33, 6, 6, 5, 5, 10, 17, 5, 5, 10, 17, 15, 15, 9, 9, 15, 15, 16, 16, 2, 2, 4, 50, 2, 2, 49, 32,
33, 33, 6, 6, 33, 33, 6, 6, 5, 5, 10, 17, 5, 5, 10, 17, 15, 15, 9, 9, 15, 15, 16, 16, 2, 2, 4, 50, 2, 2, 49, 32,
14, 14, 11, 11, 14, 14, 11, 11, 1, 1, 13, 48, 1, 1, 13, 48, 7, 7, 38, 38, 7, 7, 22, 22, 40, 40, 36, 42, 40, 40, 30, 19,
14, 14, 11, 11, 14, 14, 11, 11, 8, 8, 39, 23, 8, 8, 39, 23, 7, 7, 38, 38, 7, 7, 22, 22, 31, 31, 21, 27, 31, 31, 28, 54
];
const floorField = [
54, 20, 19, 19, 18, 4, 19, 19, 11, 11, 3, 3, 51, 51, 3, 3, 9, 52, 5, 5, 9, 52, 5, 5, 39, 39, 30, 30, 39, 39, 30, 30,
2, 12, 32, 32, 34, 6, 32, 32, 11, 11, 3, 3, 51, 51, 3, 3, 43, 38, 29, 29, 43, 38, 29, 29, 39, 39, 30, 30, 39, 39, 30, 30,
1, 41, 17, 17, 40, 46, 17, 17, 21, 21, 8, 8, 45, 45, 8, 8, 23, 47, 26, 26, 23, 47, 26, 26, 48, 48, 49, 49, 48, 48, 49, 49,
1, 41, 17, 17, 40, 46, 17, 17, 21, 21, 8, 8, 45, 45, 8, 8, 23, 47, 26, 26, 23, 47, 26, 26, 48, 48, 49, 49, 48, 48, 49, 49,
0, 33, 31, 31, 14, 7, 31, 31, 42, 42, 27, 27, 36, 36, 27, 27, 9, 52, 5, 5, 9, 52, 5, 5, 39, 39, 30, 30, 39, 39, 30, 30,
22, 15, 28, 28, 16, 37, 28, 28, 42, 42, 27, 27, 36, 36, 27, 27, 43, 38, 29, 29, 43, 38, 29, 29, 39, 39, 30, 30, 39, 39, 30, 30,
1, 41, 17, 17, 40, 46, 17, 17, 21, 21, 8, 8, 45, 45, 8, 8, 23, 47, 26, 26, 23, 47, 26, 26, 48, 48, 49, 49, 48, 48, 49, 49,
1, 41, 17, 17, 40, 46, 17, 17, 21, 21, 8, 8, 45, 45, 8, 8, 23, 47, 26, 26, 23, 47, 26, 26, 48, 48, 49, 49, 48, 48, 49, 49
];
export function bitsToIndices(walls: boolean[]) {
return (
(+walls[0] << 0) +
(+walls[1] << 1) +
(+walls[2] << 2) +
(+walls[3] << 3) +
(+walls[5] << 4) +
(+walls[6] << 5) +
(+walls[7] << 6) +
(+walls[8] << 7));
}
export function wall(walls: boolean[], current: number): number {
const TL = 0, T = 1, TR = 2, L = 3, R = 5, BL = 6, B = 7, BR = 8;
if (current === -1) return -1;
let empty = walls.map(b => !b);
let tile = 54;
if (empty[T]) {
if (empty[B]) {
if (empty[L]) {
if (empty[R]) tile = 33;
else tile = 15;
}
else if (empty[R]) tile = 5;
else tile = 2;
}
else if (empty[L]) {
if (empty[R]) tile = 14;
else if (empty[BR]) tile = 0;
else tile = 7;
}
else if (empty[R]) {
if (empty[BL]) tile = 1;
else tile = 8;
}
else {
if (empty[BL]) {
if (empty[BR]) tile = 3;
else tile = 40;
}
else if (empty[BR]) tile = 41;
else tile = 31;
}
}
else if (empty[B]) {
if (empty[L]) {
if (empty[R]) tile = 6;
else if (empty[TR]) tile = 9;
else tile = 16;
}
else if (empty[R]) {
if (empty[TL]) tile = 10;
else tile = 17;
}
else {
if (empty[TL]) {
if (empty[TR]) tile = 4;
else tile = 49;
}
else if (empty[TR]) tile = 50;
else tile = 32;
}
}
else if (empty[L]) {
if (empty[R]) tile = 11;
else {
if (empty[TR]) {
if (empty[BR]) tile = 12;
else tile = 38;
}
else if (empty[BR]) tile = 47;
else tile = 22;
}
}
else if (empty[R]) {
if (empty[TL]) {
if (empty[BL]) tile = 13;
else tile = 39;
}
else if (empty[BL]) tile = 48;
else tile = 23;
}
else if (empty[TL]) {
if (empty[TR]) {
if (empty[BL]) {
if (empty[BR]) tile = 25;
else tile = 36;
}
else if (empty[BR]) tile = 37;
else tile = 21;
}
else if (empty[BL]) {
if (empty[BR]) tile = 45;
else tile = 30;
}
else if (empty[BR]) tile = 51;
else tile = 28;
}
else if (empty[TR]) {
if (empty[BL]) {
if (empty[BR]) tile = 46;
else tile = 42;
}
else if (empty[BR]) tile = 29;
else tile = 27;
}
else if (empty[BL]) {
if (empty[BR]) tile = 20;
else tile = 19;
}
else if (empty[BR]) tile = 18;
else {
if (current >= 54 && current <= 60) return -1;
tile = 54 + Math.floor(Math.random() * 6);
}
return tile;
const ind = wallField[bitsToIndices(walls)];
if (ind < 54) return ind;
return 54 + Math.floor(Math.random() * 6);
}
export function overlay(overlays: boolean[], current: number): number {
const TL = 0, T = 1, TR = 2, L = 3, R = 5, BL = 6, B = 7, BR = 8;
if (current === -1) return -1;
let empty = overlays.map(b => !b);
let tile = 54;
if (empty[T]) {
if (empty[B]) {
if (empty[L]) {
if (empty[R]) tile = 33;
else tile = 15;
}
else if (empty[R]) tile = 5;
else tile = 2;
}
else if (empty[L]) {
if (empty[R]) tile = 14;
else if (empty[BR]) tile = 0;
else tile = 7;
}
else if (empty[R]) {
if (empty[BL]) tile = 1;
else tile = 8;
}
else {
if (empty[BL]) {
if (empty[BR]) tile = 3;
else tile = 40;
}
else if (empty[BR]) tile = 41;
else tile = 31;
}
}
else if (empty[B]) {
if (empty[L]) {
if (empty[R]) tile = 6;
else if (empty[TR]) tile = 9;
else tile = 16;
}
else if (empty[R]) {
if (empty[TL]) tile = 10;
else tile = 17;
}
else {
if (empty[TL]) {
if (empty[TR]) tile = 4;
else tile = 49;
}
else if (empty[TR]) tile = 50;
else tile = 32;
}
}
else if (empty[L]) {
if (empty[R]) tile = 11;
else {
if (empty[TR]) {
if (empty[BR]) tile = 12;
else tile = 38;
}
else if (empty[BR]) tile = 47;
else tile = 22;
}
}
else if (empty[R]) {
if (empty[TL]) {
if (empty[BL]) tile = 13;
else tile = 39;
}
else if (empty[BL]) tile = 48;
else tile = 23;
}
else if (empty[TL]) {
if (empty[TR]) {
if (empty[BL]) {
if (empty[BR]) tile = 25;
else tile = 36;
}
else if (empty[BR]) tile = 37;
else tile = 21;
}
else if (empty[BL]) {
if (empty[BR]) tile = 45;
else tile = 30;
}
else if (empty[BR]) tile = 51;
else tile = 28;
}
else if (empty[TR]) {
if (empty[BL]) {
if (empty[BR]) tile = 46;
else tile = 42;
}
else if (empty[BR]) tile = 29;
else tile = 27;
}
else if (empty[BL]) {
if (empty[BR]) tile = 20;
else tile = 19;
}
else if (empty[BR]) tile = 18;
else {
if (current >= 54 && current <= 60) return -1;
tile = 54 + Math.floor(Math.random() * 6);
}
return tile;
const ind = floorField[bitsToIndices(overlays)];
if (ind < 54) return ind;
return 54 + Math.floor(Math.random() * 6);
}
export function floor(walls: boolean[], current: number): number {
const TL = 0, T = 1, TR = 2, L = 3, C = 4, R = 5, BL = 6, B = 7, BR = 8;
if (current === -1) return -1;
let tile = 10;
if (walls[C]) tile = 10;
else if (walls[B]) {
if (walls[T]) {
if (walls[R]) {
if (walls[L]) tile = 49;
else tile = 26;
}
else if (walls[L]) tile = 8;
else tile = 17;
}
else if (walls[L]) {
if (walls[R]) tile = 48;
else if (walls[TR]) tile = 45;
else tile = 21;
}
else if (walls[R]) {
if (walls[TL]) tile = 47;
else tile = 23;
}
else if (walls[TL]) {
if (walls[TR]) tile = 46;
else tile = 41;
}
else if (walls[TR]) tile = 40;
else tile = 1;
}
else if (walls[T]) {
if (walls[L]) {
if (walls[R]) tile = 30;
else if (walls[BR]) tile = 27;
else tile = 3;
}
else if (walls[R]) {
if (walls[BL]) tile = 29;
else tile = 5;
}
else if (walls[BL]) {
if (walls[BR]) tile = 28;
else tile = 32;
}
else if (walls[BR]) tile = 31;
else tile = 19;
}
else if (walls[L]) {
if (walls[R]) tile = 39;
else if (walls[TR]) {
if (walls[BR]) tile = 36;
else tile = 51;
}
else if (walls[BR]) tile = 42;
else tile = 11;
}
else if (walls[R]) {
if (walls[TL]) {
if (walls[BL]) tile = 38;
else tile = 52;
}
else if (walls[BL]) tile = 43;
else tile = 9;
}
else if (walls[TL]) {
if (walls[TR]) {
if (walls[BL]) {
if (walls[BR]) tile = 37;
else tile = 6;
}
else if (walls[BR]) tile = 7;
else tile = 4;
}
else if (walls[BL]) {
if (walls[BR]) tile = 15;
else tile = 12;
}
else if (walls[BR]) tile = 33;
else tile = 20;
}
else if (walls[TR]) {
if (walls[BL]) {
if (walls[BR]) tile = 16;
else tile = 34;
}
else if (walls[BR]) tile = 14;
else tile = 18;
}
else if (walls[BL]) {
if (walls[BR]) tile = 22;
else tile = 2;
}
else if (walls[BR]) tile = 0;
else {
if (current >= 54 && current <= 60) return -1;
tile = 54 + Math.floor(Math.random() * 6);
}
return tile;
const ind = floorField[bitsToIndices(walls)];
if (ind < 54) return ind;
return 54 + Math.floor(Math.random() * 6);
}
// const l = [];
// for (let i = 0; i < (1 << 8); ++i ) {
// let arr = [ i & 0x001, i & 0x002, i & 0x004, i & 0x008, 0,
// i & 0x010, i & 0x020, i & 0x040, i & 0x080 ].map(a => !!a);
// l.push(overlay(arr, 1));
// }
// let lines = [];
// for (let i = 0; i < 16; i++) {
// lines.push(l.slice(i * 16, (i + 1) * 16).map(n => n < 10 ? n + ', ' : n + ',').join(' '));
// }
// console.log(lines.join('\n'));

Some files were not shown because too many files have changed in this diff Show More