diff --git a/.github/workflows/tauri-publish.yml b/.github/workflows/tauri-publish.yml new file mode 100644 index 0000000..bd13e7c --- /dev/null +++ b/.github/workflows/tauri-publish.yml @@ -0,0 +1,41 @@ +name: 'tauri-publish' +on: + push: + branches: + - release + +jobs: + publish-tauri: + permissions: + contents: write + strategy: + fail-fast: false + matrix: + platform: [macos-latest, ubuntu-20.04, windows-latest] + + runs-on: ${{ matrix.platform }} + steps: + - uses: actions/checkout@v3 + - name: setup node + uses: actions/setup-node@v3 + with: + node-version: 16 + - name: install Rust stable + uses: dtolnay/rust-toolchain@stable + - name: install dependencies (ubuntu only) + if: matrix.platform == 'ubuntu-20.04' + run: | + sudo apt-get update + sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.0-dev libappindicator3-dev librsvg2-dev patchelf + - name: install frontend dependencies + run: cd client && npm install # change this to npm or pnpm depending on which one you use + - uses: tauri-apps/tauri-action@v0 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tagName: app-v__VERSION__ # the action automatically replaces \_\_VERSION\_\_ with the app version + releaseName: 'Twwe v__VERSION__' + releaseBody: 'See the assets to download this version and install.' + releaseDraft: true + prerelease: false + projectPath: desktop diff --git a/README.md b/README.md index 6050e56..c49e65d 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,10 @@ Teeworlds / DDraceNetwork map editor. Online and collaborative, just like the game. -A demo server is hosted at [tw.thissma.fr](https://tw.thissma.fr). +A demo server is hosted at [tw.thissma.fr](https://tw.thissma.fr). A DDNet server is hosted with the name `twwe -- tw.thissma.fr` (ip: `82.64.235.33:8303`). -## Development Status (Sep. 2023) +## Development Status (Oct. 2023) The app is now mostly compatible with ddnet editor. Bugs are expected. It is advised to save regularly and if a bug happens, log out and back in to roll back to the previous save. @@ -40,13 +40,13 @@ The table below shows the feature parity with ddnet's in-game map editor. ### Roadmap to 1.0 - * Desktop client - * Server bridging - * sync with ddnet server / reload-on-save (#21) - * Map passwords and permissions - * Undo / Redo history (#31) - * More tools: Proof, Quad tools - * Bug squashing + [*] Desktop client + [*] Server bridging + [] sync with ddnet server / reload-on-save (#21) + [] Map passwords and permissions + [*] Undo / Redo history (#31) + [] More tools: Proof, Quad tools + [] Bug squashing ## Usage @@ -85,6 +85,15 @@ Use the `--cert` and `--key` flags to enable TLS support for websocket. They mus Use the `--rpp ` flat to enable Rules++ support (experimental). `` must be the **absolute** path to a directory containing: `rpp` (the rpp executable), `base.r` and `base.p`. +#### Server bridging + +With the desktop client, it is possible to connect a "bridge" to a remote server (e.g. pi.thissma.fr:16900), such that other users can access and edit a map on your hard drive from the internet. This feature has security implications for both the server and the client, so make sure you understand them before enabling bridging. + + * For the client, enabling bridge essentially gives the internet a direct access to the map file on your computer. Anyone who has access to the passphrase can do damage to your map file. Make sure you make a backup and trust the people with whom you share the passphrase. + * For the server, bridging initiates a connection to a websocket url chosen by the client. Make sure your server may not leak the local network or connect to unwanted networks. Do to not use this feature aside from the desktop client's server. + +The `bridge_out` and `bridge_in` feature flags guard this feature and are disabled by default. You can enable them with `cargo run --feature bridge_in -- ...`. + ### Client Copy the `env.example` file to `.env` or `.env.production` and configure the websocket server url. For a TLS-encrypted websocket, the url schemes are `wss://` and `https://`. Otherwise, use `ws://` and `http://`. @@ -94,6 +103,10 @@ Have [npm](https://www.npmjs.com/) installed and run `npm install` in the client Note: the client is written in non-strict Typescript. Typescript is only used for IDE hints and documentation, but ignored by the [Vite](https://vitejs.dev/guide/features.html#typescript) bundler. Use `npm run check` to run Typescript checks on the project. +### Desktop + +The desktop client is a [Tauri](https://tauri.app/) web-app that you can install and enables editing your local map files like the default editor. You can also enable sharing your maps over the internet (read [Server bridging](#server-bridging)). Binaries are can be found in the [releases page](./releases). + ## License This work is licensed under the GNU Affero General Public License v3.0 (agpl). You are free to use and modify the code and executables under some conditions. Please contact me if the license doesn't fit your needs. diff --git a/client/assets/icon/fr.thissma.tw.svg b/client/assets/icon/fr.thissma.tw.svg deleted file mode 100644 index 1acd954..0000000 --- a/client/assets/icon/fr.thissma.tw.svg +++ /dev/null @@ -1,55 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/assets/icon/icon.svg b/client/assets/icon/icon.svg deleted file mode 100644 index a1c45af..0000000 --- a/client/assets/icon/icon.svg +++ /dev/null @@ -1,642 +0,0 @@ - - - -Adwaita Icon Templateimage/svg+xmlGNOME Design TeamAdwaita Icon TemplateHicolorSymbolic diff --git a/client/env.example b/client/env.example index 9f2da47..6e8754f 100644 --- a/client/env.example +++ b/client/env.example @@ -1,5 +1,5 @@ -# copy this file to .env.production and update the values +# copy this file to .env and update the values. You can also create a different .env.prodution file for your release builds. -VITE_WEBSOCKET_URL=ws://localhost:16800/ws -VITE_HTTP_URL=http://localhost:16800 +# VITE_SERVER_URLS: comma-separated list of display_name:host:port:ssl describing the default servers in the frontend. +VITE_SERVER_URLS=Local files:localhost:16800:0,Default server:pi.thissma.fr:16900:1 diff --git a/client/index.html b/client/index.html index f6b56f0..f816ce6 100644 --- a/client/index.html +++ b/client/index.html @@ -1,21 +1,24 @@ - - - - - DDNet Map Editor + + + + - - - - + DDNet Map Editor + + + + - - - - - + + + + + + + + \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index 5abd9d5..2ad4b0d 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -25,6 +25,7 @@ "pako": "^2.1.0", "prettier": "^3.0.3", "prettier-plugin-svelte": "^3.0.3", + "random-words": "^2.0.0", "sass": "^1.66.1", "svelte": "^4.2.0", "svelte-check": "^3.5.1", @@ -2067,9 +2068,9 @@ } }, "node_modules/postcss": { - "version": "8.4.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz", - "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "funding": [ { @@ -2237,6 +2238,15 @@ "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", "dev": true }, + "node_modules/random-words": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/random-words/-/random-words-2.0.0.tgz", + "integrity": "sha512-uqpnDqFnYrZajgmvgjmBrSZL2V1UA/9bNPGrilo12CmBeBszoff/avElutUlwWxG12gvmCk/8dUhvHefYxzYjw==", + "dev": true, + "dependencies": { + "seedrandom": "^3.0.5" + } + }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -2409,6 +2419,12 @@ "node": ">=14.0.0" } }, + "node_modules/seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -4464,9 +4480,9 @@ "dev": true }, "postcss": { - "version": "8.4.29", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.29.tgz", - "integrity": "sha512-cbI+jaqIeu/VGqXEarWkRCCffhjgXc0qjBtXpqJhTBohMUjUQnbBr0xqX3vEKudc4iviTewcJo5ajcec5+wdJw==", + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", "dev": true, "requires": { "nanoid": "^3.3.6", @@ -4578,6 +4594,15 @@ "integrity": "sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==", "dev": true }, + "random-words": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/random-words/-/random-words-2.0.0.tgz", + "integrity": "sha512-uqpnDqFnYrZajgmvgjmBrSZL2V1UA/9bNPGrilo12CmBeBszoff/avElutUlwWxG12gvmCk/8dUhvHefYxzYjw==", + "dev": true, + "requires": { + "seedrandom": "^3.0.5" + } + }, "rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -4687,6 +4712,12 @@ "source-map-js": ">=0.6.2 <2.0.0" } }, + "seedrandom": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/seedrandom/-/seedrandom-3.0.5.tgz", + "integrity": "sha512-8OwmbklUNzwezjGInmZ+2clQmExQPvomqjL7LFqOYqtmuxRgQYqOD3mHaU+MvZn5FLUeVxVfQjwLZW/n/JFuqg==", + "dev": true + }, "semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", diff --git a/client/package.json b/client/package.json index 6257acd..775b0fb 100644 --- a/client/package.json +++ b/client/package.json @@ -1,7 +1,7 @@ { "name": "twwe-client", "version": "1.0.0", - "description": "Teeworlds collaborative map editor online", + "description": "Teeworlds Web Editor Client", "type": "module", "scripts": { "dev": "vite", @@ -38,6 +38,7 @@ "pako": "^2.1.0", "prettier": "^3.0.3", "prettier-plugin-svelte": "^3.0.3", + "random-words": "^2.0.0", "sass": "^1.66.1", "svelte": "^4.2.0", "svelte-check": "^3.5.1", diff --git a/client/public/entities/ddnet.png b/client/public/entities/DDNet-comfort.png similarity index 100% rename from client/public/entities/ddnet.png rename to client/public/entities/DDNet-comfort.png diff --git a/client/public/entities/FNG.png b/client/public/entities/FNG.png new file mode 100644 index 0000000..6657c94 Binary files /dev/null and b/client/public/entities/FNG.png differ diff --git a/client/public/entities/Race.png b/client/public/entities/Race.png new file mode 100644 index 0000000..c63c734 Binary files /dev/null and b/client/public/entities/Race.png differ diff --git a/client/public/entities/Vanilla.png b/client/public/entities/Vanilla.png new file mode 100644 index 0000000..8013d6a Binary files /dev/null and b/client/public/entities/Vanilla.png differ diff --git a/client/public/entities/blockworlds.png b/client/public/entities/blockworlds.png new file mode 100644 index 0000000..7cca8e1 Binary files /dev/null and b/client/public/entities/blockworlds.png differ diff --git a/client/src/gl/renderMap.ts b/client/src/gl/renderMap.ts index 58dafe3..28d1a22 100644 --- a/client/src/gl/renderMap.ts +++ b/client/src/gl/renderMap.ts @@ -386,13 +386,15 @@ export class RenderMap { if (part.name !== undefined) rlayer.layer.name = part.name + if (rlayer instanceof RenderAnyTilesLayer) { + if ('width' in part) + this.setLayerWidth(rgroup, rlayer, part.width) + if ('height' in part) + this.setLayerHeight(rgroup, rlayer, part.height) + } if (rlayer instanceof RenderTilesLayer && part.type === MapDir.LayerKind.Tiles) { if (part.color !== undefined) rlayer.layer.color = part.color - if (part.width !== undefined) - this.setLayerWidth(rgroup, rlayer, part.width) - if (part.height !== undefined) - this.setLayerHeight(rgroup, rlayer, part.height) if (part.color_env !== undefined) rlayer.layer.colorEnv = part.color_env === null ? null : this.map.envelopes[stringToResIndex(part.color_env)[0]] as ColorEnvelope if (part.color_env_offset !== undefined) diff --git a/client/src/gl/viewport.ts b/client/src/gl/viewport.ts index ad74cdb..59fc214 100644 --- a/client/src/gl/viewport.ts +++ b/client/src/gl/viewport.ts @@ -6,11 +6,11 @@ export class Viewport { // note on the coordinates systems: // - all origins are top-left corner, x grows to the left and y down. - // - the pixel space correspond to on-screen pixels and may be equal - // to canvas space, depending on the canvas resolution. + // - the pixel space correspond to on-screen pixels, origin is the + // window viewport (top-left) // - the canvas space is the coordinate system of the canvas context. // - the world space is indexed by tiles i.e. 1 unit = 1 tile. - // - the tile space world space but with integers. + // - the tile space is world space but with integers (tile indices). // following variables are in world space. pos: Vec2 // pos of the view top-left corner @@ -49,7 +49,7 @@ export class Viewport { this.createListeners() } - // return the screen dimensions in the world space. + // return the canvas window in world space. screen() { const [x1, y1] = this.canvasToWorld(0, 0) const [x2, y2] = this.canvasToWorld(this.canvas.width, this.canvas.height) @@ -67,18 +67,19 @@ export class Viewport { this.cont.addEventListener('keydown', this.onkeydown.bind(this)) } - // this is probably a noop, because canvas is sized to window size pixelToCanvas(x: number, y: number) { + const rect = this.canvas.getBoundingClientRect() return [ - (x / this.canvas.clientWidth) * this.canvas.width, - (y / this.canvas.clientHeight) * this.canvas.height, + ((x - rect.left) / this.canvas.clientWidth) * this.canvas.width, + ((y - rect.top) / this.canvas.clientHeight) * this.canvas.height, ] } canvasToPixel(x: number, y: number) { + const rect = this.canvas.getBoundingClientRect() return [ - (x / this.canvas.width) * this.canvas.clientWidth, - (y / this.canvas.height) * this.canvas.clientHeight, + ((x - rect.left) / this.canvas.width) * this.canvas.clientWidth, + ((y - rect.top) / this.canvas.height) * this.canvas.clientHeight, ] } @@ -102,13 +103,13 @@ export class Viewport { // ------------ desktop events -------------------------------- private onmousedown(e: MouseEvent) { - const [canvasX, canvasY] = this.pixelToCanvas(e.offsetX, e.offsetY) + const [canvasX, canvasY] = this.pixelToCanvas(e.clientX, e.clientY) this.onDragStart(canvasX, canvasY) } private onmousemove(e: MouseEvent) { - const [canvasX, canvasY] = this.pixelToCanvas(e.offsetX, e.offsetY) + const [canvasX, canvasY] = this.pixelToCanvas(e.clientX, e.clientY) const [worldX, worldY] = this.canvasToWorld(canvasX, canvasY) this.mousePos.x = worldX this.mousePos.y = worldY @@ -122,7 +123,7 @@ export class Viewport { private onwheel(e: WheelEvent) { const direction = e.deltaY < 0 ? 1 : -1 - this.onZoom(0.1 * direction, e.offsetX, e.offsetY) + this.onZoom(0.1 * direction, e.clientX, e.clientY) } private onkeydown(e: KeyboardEvent) { @@ -163,13 +164,13 @@ export class Viewport { } // ------------------------------------------------------------ - private onZoom(factor: number, offsetX: number, offsetY: number) { + private onZoom(factor: number, clientX: number, clientY: number) { let delta = factor * this.scale if (this.scale + delta > this.maxScale) delta = this.maxScale - this.scale if (this.scale + delta < this.minScale) delta = this.minScale - this.scale - const [canvasX, canvasY] = this.pixelToCanvas(offsetX, offsetY) + const [canvasX, canvasY] = this.pixelToCanvas(clientX, clientY) const zoom = (this.scale + delta) / this.scale diff --git a/client/src/server/util.ts b/client/src/server/util.ts index 3a4b941..dc2af93 100644 --- a/client/src/server/util.ts +++ b/client/src/server/util.ts @@ -1,7 +1,16 @@ +import type { ServerConfig } from "../storage" import type { Readable, Writable } from "svelte/store" import type { Recv, RecvKey, Send, SendKey } from "./protocol" import type { Server } from "./server" +export function serverHttpUrl(conf: ServerConfig) { + return `http${conf.encrypted ? 's' : ''}://${conf.host}:${conf.port}${conf.path ?? ''}` +} + +export function serverWsUrl(conf: ServerConfig) { + return `ws${conf.encrypted ? 's' : ''}://${conf.host}:${conf.port}${conf.path ?? ''}/ws` +} + export const skip: unique symbol = Symbol() type Skip = typeof skip @@ -12,7 +21,6 @@ export const cont: unique symbol = Symbol() type Cont = typeof cont export const _: Cont = cont // alias for placeholder - function patternValue(pat: any, val: any): any { if (pat === pick) { return val diff --git a/client/src/storage.ts b/client/src/storage.ts index 80a1fa2..fb50cf0 100644 --- a/client/src/storage.ts +++ b/client/src/storage.ts @@ -1,9 +1,11 @@ -const { VITE_WEBSOCKET_URL, VITE_HTTP_URL } = import.meta.env +const { VITE_SERVER_URLS } = import.meta.env export interface ServerConfig { name: string - wsUrl: string - httpUrl: string + host: string + port: number + encrypted: boolean + path?: string } interface StorageSpec { @@ -14,46 +16,73 @@ interface StorageSpec { interface StorageEntry { clone: (inst: T) => T default: T + sanitize: (entry: any) => boolean } type StorageEntries = { [K in keyof StorageSpec]: StorageEntry } -function cloneServerConf(conf: ServerConfig) { - const { wsUrl, httpUrl, name } = conf - return { wsUrl, httpUrl, name } +function isServerConfig(entry: any): entry is ServerConfig { + return typeof entry === 'object' && ['name', 'host', 'port', 'encrypted'].every(k => entry.hasOwnProperty(k)) } const entries: StorageEntries = { servers: { - clone: function (confs: ServerConfig[]) { - return confs.map(cloneServerConf) - }, - default: [{ name: 'Default Server', wsUrl: VITE_WEBSOCKET_URL, httpUrl: VITE_HTTP_URL }], + clone: (confs: ServerConfig[]) => confs.map(c => ({ ...c })), + default: VITE_SERVER_URLS + .split(',') + .map(url => url.split(':')) + .map(([name, host, port, ssl]) => ({ name, host, port: parseInt(port), encrypted: ssl === '1' })), + sanitize: (entry: any) => Array.isArray(entry) && entry.every(e => isServerConfig(e)) }, currentServer: { clone: x => x, default: 0, + sanitize: (entry: any) => typeof entry === 'number' && entry < storage.load('servers').length }, } const storage = { - version: 2, + version: 3, + reset: function () { + sessionStorage.clear() + localStorage.clear() + localStorage.setItem('version', '' + storage.version) + for (const [key, entry] of Object.entries(entries)) { + localStorage.setItem(key, JSON.stringify(entry.default)) + } + }, init: function () { const storedVersion = parseInt(localStorage.getItem('version') ?? '0') if (storedVersion !== storage.version) { - localStorage.clear() - localStorage.setItem('version', '' + storage.version) - for (const [key, entry] of Object.entries(entries)) { - localStorage.setItem(key, JSON.stringify(entry.default)) - } + storage.reset() } }, load: function (key: K): StorageSpec[K] { - return JSON.parse(localStorage.getItem(key) ?? '') + let item = sessionStorage.getItem(key) ?? localStorage.getItem(key) + try { + const res = JSON.parse(item) + if (entries[key].sanitize(res)) { + return res + } + else { + throw 'sanitization failed' + } + } + catch (e) { + console.error('localstorage failure:', e) + storage.reset() + return storage.load(key) + } }, - save: function (key: K, val: StorageSpec[K]) { - localStorage.setItem(key, JSON.stringify(val)) + save: function (key: K, val: StorageSpec[K], { persistent } = { persistent: true }) { + if (persistent) { + localStorage.setItem(key, JSON.stringify(val)) + sessionStorage.removeItem(key) + } + else { + sessionStorage.setItem(key, JSON.stringify(val)) + } }, } diff --git a/client/src/ui/actions.ts b/client/src/ui/actions.ts index 4fabf65..a598b30 100644 --- a/client/src/ui/actions.ts +++ b/client/src/ui/actions.ts @@ -1,8 +1,9 @@ import { clearDialog, showDialog, showError, showInfo } from "./lib/dialog" -import { server, serverConfig, rmap, peers } from "./global" +import { server, serverCfg, rmap, peers } from "./global" import { get } from "svelte/store" import { navigate } from 'svelte-routing' import { download } from "./lib/util" +import { serverHttpUrl } from "../server/util" export async function saveMap() { const server_ = get(server) @@ -17,10 +18,12 @@ export async function saveMap() { } export async function downloadMap() { - const serverConf_ = get(serverConfig) + const serverConf_ = get(serverCfg) const rmap_ = get(rmap) - download(`${serverConf_.httpUrl}/maps/${rmap_.map.name}`, `${rmap_.map.name}.map`) + const httpUrl = serverHttpUrl(serverConf_) + + download(`${httpUrl}/maps/${rmap_.map.name}`, `${rmap_.map.name}.map`) } export async function deleteMap() { diff --git a/client/src/ui/global.ts b/client/src/ui/global.ts index dfff106..c44c21a 100644 --- a/client/src/ui/global.ts +++ b/client/src/ui/global.ts @@ -10,7 +10,7 @@ export enum View { } export const server: Writable = writable(null) -export const serverConfig: Writable = writable(null) +export const serverCfg: Writable = writable(null) export const view: Writable = writable(View.Layers) // map diff --git a/client/src/ui/index.svelte b/client/src/ui/index.svelte index 60c5ef3..17f35d1 100644 --- a/client/src/ui/index.svelte +++ b/client/src/ui/index.svelte @@ -5,16 +5,18 @@ import Fence from './lib/fence.svelte' import storage from '../storage' import { WebSocketServer } from '../server/server' - import { server, serverConfig } from './global' + import { server, serverCfg } from './global' + import { serverWsUrl } from '../server/util' export let url = '' function joinServer() { - const serverConfs = storage.load('servers') + const serverCfgs = storage.load('servers') const serverId = storage.load('currentServer') - $serverConfig = serverConfs[serverId] - $server = new WebSocketServer($serverConfig.wsUrl) + $serverCfg = serverCfgs[serverId] + const wsUrl = serverWsUrl($serverCfg) + $server = new WebSocketServer(wsUrl) return new Promise((resolve, reject) => { $server.socket.addEventListener('open', resolve, { once: true }) diff --git a/client/src/ui/lib/dialog.svelte b/client/src/ui/lib/dialog.svelte index d0971f4..b4cbfc2 100644 --- a/client/src/ui/lib/dialog.svelte +++ b/client/src/ui/lib/dialog.svelte @@ -24,6 +24,10 @@ function onNo(id: number) { dispatch('close', [id, false]) } + + function title(type: string) { + return type[0].toUpperCase() + type.slice(1) + } @@ -48,7 +52,8 @@ {:else} onClose(id)} hideCloseButton={controls !== 'closable'} > diff --git a/client/src/ui/lib/dialog.ts b/client/src/ui/lib/dialog.ts index b6e1dfa..b62df88 100644 --- a/client/src/ui/lib/dialog.ts +++ b/client/src/ui/lib/dialog.ts @@ -27,7 +27,8 @@ export function clearDialog(id: number | 'all' = 'all') { export function showDialog( type: DialogType, message: string, - controls: DialogControls = 'none' + controls: DialogControls = 'none', + timeout: number = 5000 ): Promise | number { const id = Math.random() dialog.$set({ @@ -43,10 +44,19 @@ export function showDialog( return id else return new Promise(resolve => { + let timeout_id = 0 dialog.$on('close', e => { - if (e.detail[0] === id) + if (e.detail[0] === id) { + window.clearTimeout(timeout_id) resolve(e.detail[1]) + } }) + if (timeout && controls === 'closable') { + timeout_id = window.setTimeout(() => { + clearDialog(id) + resolve(false) + }, timeout) + } }) } diff --git a/client/src/ui/lib/editAutomapper.svelte b/client/src/ui/lib/editAutomapper.svelte index ae2c32c..607709c 100644 --- a/client/src/ui/lib/editAutomapper.svelte +++ b/client/src/ui/lib/editAutomapper.svelte @@ -265,7 +265,7 @@
- +
{#each Object.entries($automappers).sort(([f1], [f2]) => f1.localeCompare(f2)) as [file, am]}
- +
{changed ? '*' : ''}{selected ?? ''} @@ -305,7 +305,7 @@
- +
diff --git a/client/src/ui/lib/editBrush.svelte b/client/src/ui/lib/editBrush.svelte index 2659505..f49d2a7 100644 --- a/client/src/ui/lib/editBrush.svelte +++ b/client/src/ui/lib/editBrush.svelte @@ -17,6 +17,7 @@ import FlipH from '../../../assets/flip-h.svg?component' import RotateCW from '../../../assets/rotate-cw.svg?component' import RotateCCW from '../../../assets/rotate-ccw.svg?component' + import { LayerKind } from '../../twmap/mapdir' export let brush: Editor.Brush @@ -32,6 +33,13 @@ Editor.off('keypress', onKeyPress) }) + function clamp(cur: number, min: number, max: number) { + if (isNaN(cur)) + return min + else + return Math.min(Math.max(min, cur), max) + } + function brushRotateCW(sel: Info.AnyTile[][]) { return Array.from({ length: sel[0].length }, (_, j) => Array.from({ length: sel.length }, (_, i) => sel[sel.length - 1 - i][j]) @@ -252,6 +260,34 @@ else if (e.key === 'h' || e.key === 'n') onFlipH() } + function tilesProperty(tiles: Info.AnyTile[][], prop: string): number | undefined { + let res: number + + for (const row of tiles) { + for (const tile of row) { + if (prop in tile) { + if (res === undefined) { + res = tile[prop] + } + else if (tile[prop] !== res) { + return undefined + } + } + } + } + + return res + } + + function onSetProperty(prop: string, val: number) { + brush.layers.forEach(l => l.tiles.forEach(r => r.forEach(t => { + if (prop in t) { + t[prop] = val + } + }))) + dispatch('change', brush) + } +
@@ -268,6 +304,48 @@ + {#if brush.layers.length === 1} + {@const layer = brush.layers[0]} + {#if layer.kind === LayerKind.Tele} + + {:else if layer.kind === LayerKind.Speedup} + + + + {:else if layer.kind === LayerKind.Switch} + + + {:else if layer.kind === LayerKind.Tune} + + {/if} + {/if}
diff --git a/client/src/ui/lib/editLayer.svelte b/client/src/ui/lib/editLayer.svelte index 5b0d0be..405ee09 100644 --- a/client/src/ui/lib/editLayer.svelte +++ b/client/src/ui/lib/editLayer.svelte @@ -133,7 +133,7 @@ if (!layer) return try { - // await uploadImage($serverConfig.httpUrl, $rmap.map.name, name, file) // TODO return index + // await uploadImage($serverCfg.httpUrl, $rmap.map.name, name, file) // TODO return index const buf = new Uint8Array(await file.arrayBuffer()) await $server.query('create/image', [name, bytesToBase64(buf)]) const index = $rmap.map.images.length - 1 diff --git a/client/src/ui/lib/editSharing.svelte b/client/src/ui/lib/editSharing.svelte new file mode 100644 index 0000000..808a760 --- /dev/null +++ b/client/src/ui/lib/editSharing.svelte @@ -0,0 +1,89 @@ + + + +
+

In this dialog you can open access to your map on the internet, so other tees can connect and edit with you.

+ + + + {#if shareCfg} + + + {/if} +
+ diff --git a/client/src/ui/lib/editor.svelte b/client/src/ui/lib/editor.svelte index ad24999..53edff5 100644 --- a/client/src/ui/lib/editor.svelte +++ b/client/src/ui/lib/editor.svelte @@ -23,12 +23,12 @@ import * as Actions from '../actions' import { viewport } from '../../gl/global' import Fence from './fence.svelte' - import type { AutomapperKind, Recv, Resp, Tiles } from '../../server/protocol' + import type { AutomapperKind, Recv, Tiles } from '../../server/protocol' // split panes - let layerPaneSize = px2vw(rem2px(15)) - let propsPaneSize = px2vw(rem2px(20)) - let envPaneSize = px2vw(rem2px(20)) + let layerPaneSize = px2vw(rem2px(10)) + let propsPaneSize = px2vw(rem2px(10)) + let envPaneSize = px2vw(rem2px(10)) let lastLayerPaneSize = layerPaneSize let lastPropsPaneSize = propsPaneSize let lastEnvPaneSize = 20 @@ -273,7 +273,7 @@
- + diff --git a/client/src/ui/lib/editor.ts b/client/src/ui/lib/editor.ts index aae1aca..4a5b30f 100644 --- a/client/src/ui/lib/editor.ts +++ b/client/src/ui/lib/editor.ts @@ -13,6 +13,7 @@ export type Brush = { group: number, layers: { layer: number, + kind: MapDir.LayerKind, tiles: Info.AnyTile[][], }[] } @@ -106,7 +107,11 @@ export function makeBoxSelection(map: Map, g: number, ll: number[], sel: Range): tiles.push(row) } - res.layers.push({ layer: l, tiles }) + res.layers.push({ + layer: l, + kind: layerKind(layer), + tiles + }) } return res @@ -132,7 +137,11 @@ export function makeEmptySelection(map: Map, g: number, ll: number[], sel: Range tiles.push(row) } - res.layers.push({ layer: l, tiles }) + res.layers.push({ + layer: l, + kind: layerKind(layer), + tiles + }) } return res @@ -196,27 +205,26 @@ function adaptTile(tile: Info.AnyTile, kind: MapDir.LayerKind): Info.AnyTile { } } -export function adaptTilesToLayer(map: Map, g: number, l: number, tiles: Info.AnyTile[][]): Info.AnyTile[][] { - const layer = map.groups[g].layers[l] - const kind = layerKind(layer) - +export function adaptTiles(tiles: Info.AnyTile[][], kind: MapDir.LayerKind): Info.AnyTile[][] { return tiles.map(row => row.map(tile => adaptTile(tile, kind)) ) } -export function adaptBrushToLayers(map: Map, brush: Brush, ll: number[]): Brush { +export function adaptBrushToLayers(map: Map, brush: Brush, g: number, ll: number[]): Brush { return { - group: brush.group, + group: g, layers: ll.map(l => { - const layer = brush.layers.find(x => x.layer === l) + const layer = brush.layers.find(x => brush.group === g && x.layer === l) if (layer) { return layer } else { + const kind = layerKind(map.groups[g].layers[l]) return { layer: l, - tiles: adaptTilesToLayer(map, brush.group, l, brush.layers[0].tiles) + kind, + tiles: adaptTiles(brush.layers[0].tiles, kind) } } }) @@ -307,6 +315,7 @@ export function fill( group: brush.group, layers: brush.layers.map(l => ({ layer: l.layer, + kind: l.kind, tiles: repeat(l.tiles, size), })) } diff --git a/client/src/ui/lib/headerbar.svelte b/client/src/ui/lib/headerbar.svelte index 5d0cca6..5f320f3 100644 --- a/client/src/ui/lib/headerbar.svelte +++ b/client/src/ui/lib/headerbar.svelte @@ -8,6 +8,7 @@ Image as ImagesIcon, Music as SoundsIcon, Code as AutomapperIcon, + Share as ShareIcon, } from 'carbon-icons-svelte' import { OverflowMenu, @@ -19,9 +20,11 @@ import { peers, map, anim, view, View } from '../global' import * as Actions from '../actions' import InfoEditor from './editInfo.svelte' + import SharingEditor from './editSharing.svelte' import TeesIcon from '../../../assets/ddnet/tees_symbolic.svg?component' let infoEditorVisible = false + let shareVisible = false function onToggleLayers() { $view = View.Layers @@ -51,10 +54,6 @@ infoEditorVisible = !infoEditorVisible } - async function onInfoClose() { - infoEditorVisible = false - } - function onRenameMap() { alert("TODO renaming maps is not implemented yet.") } @@ -70,6 +69,10 @@ function onDeleteMap() { Actions.deleteMap() } + + function onShareMap() { + shareVisible = true + }
- {$map.name} +
{$map.name}
{$peers}
+ {#if '__TAURI__' in window || import.meta.env.MODE === 'development'} + + {/if}
infoEditorVisible = false} selectorPrimaryFocus=".bx--modal-close" > @@ -132,4 +140,16 @@ + shareVisible = false} + selectorPrimaryFocus=".bx--modal-close" + > + + + + + +
diff --git a/client/src/ui/lib/mapEditor.svelte b/client/src/ui/lib/mapEditor.svelte index 5300a2d..0bf2909 100644 --- a/client/src/ui/lib/mapEditor.svelte +++ b/client/src/ui/lib/mapEditor.svelte @@ -2,8 +2,8 @@ @@ -574,6 +584,7 @@ {#if rlayer instanceof RenderAnyTilesLayer} diff --git a/client/src/ui/lib/mapView.svelte b/client/src/ui/lib/mapView.svelte index f2a5bf3..0e78027 100644 --- a/client/src/ui/lib/mapView.svelte +++ b/client/src/ui/lib/mapView.svelte @@ -24,12 +24,14 @@ let destroyed = false let resized = false - let resizeObserver = new ResizeObserver(() => resized = true) + let resizeObserver = new ResizeObserver(() => { + resized = true + }) onMount(() => { - canvas.width = cont.clientWidth - canvas.height = cont.clientHeight - resizeObserver.observe(cont) + canvas.width = canvas.clientWidth + canvas.height = canvas.clientHeight + resizeObserver.observe(canvas) renderer = new Renderer(canvas) viewport = new Viewport(cont, canvas) @@ -50,8 +52,8 @@ return if (resized) { - canvas.width = cont.clientWidth - canvas.height = cont.clientHeight + canvas.width = canvas.clientWidth + canvas.height = canvas.clientHeight resized = false } diff --git a/client/src/ui/lib/quadsView.svelte b/client/src/ui/lib/quadsView.svelte index 58727d6..4657566 100644 --- a/client/src/ui/lib/quadsView.svelte +++ b/client/src/ui/lib/quadsView.svelte @@ -35,7 +35,9 @@ } function makeViewBox() { - const { x1, y1, x2, y2 } = viewport.screen() + const rect = viewport.cont.getBoundingClientRect() + const [x1, y1] = viewport.pixelToWorld(rect.left, rect.top) + const [x2, y2] = viewport.pixelToWorld(rect.right, rect.bottom) const rgroup = $rmap.groups[g] const [offX, offY] = rgroup.offset() return [x1 - offX, y1 - offY, x2 - x1, y2 - y1].map(x => x * 32).join(' ') diff --git a/client/src/ui/lib/tilePicker.svelte b/client/src/ui/lib/tilePicker.svelte index e811615..f59409b 100644 --- a/client/src/ui/lib/tilePicker.svelte +++ b/client/src/ui/lib/tilePicker.svelte @@ -2,9 +2,9 @@ import { createEventDispatcher } from 'svelte' import type * as Info from '../../twmap/types' import * as MapDir from '../../twmap/mapdir' - import type { Image } from '../../twmap/image' import type { AnyTilesLayer } from '../../twmap/tilesLayer' import type { RenderAnyTilesLayer } from '../../gl/renderTilesLayer' + import type { Image } from '../../twmap/image' import type { Tile, Tele, Switch, Speedup, Tune, Coord } from '../../twmap/types' import { TileFlags } from '../../twmap/types' import { @@ -29,6 +29,7 @@ const tileCount = 16 export let rlayer: RenderAnyTilesLayer> + export let image: Image export let selected: Info.AnyTile[][] = [] const dispatch = createEventDispatcher<{ @@ -59,14 +60,15 @@ let currentSpeedup: { kind: MapDir.LayerKind.Speedup } & Speedup = { kind: MapDir.LayerKind.Speedup, ...SpeedupLayer.defaultTile() } let currentTune: { kind: MapDir.LayerKind.Tune } & Tune = { kind: MapDir.LayerKind.Tune, ...TuneLayer.defaultTile() } - $: currentTele.number = clamp(currentTele.number, 0, 255) - $: currentSwitch.delay = clamp(currentSwitch.delay, 0, 255) - $: currentSwitch.number = clamp(currentSwitch.number, 0, 255) - $: currentSpeedup.angle = clamp(currentSpeedup.angle, 0, 359) - $: currentSpeedup.maxSpeed = clamp(currentSpeedup.maxSpeed, 0, 255) - $: currentSpeedup.force = clamp(currentSpeedup.force, 0, 255) - $: currentTune.number = clamp(currentTune.number, 0, 255) - + function onInputChanged() { + currentTele.number = clamp(currentTele.number, 0, 255) + currentSwitch.delay = clamp(currentSwitch.delay, 0, 255) + currentSwitch.number = clamp(currentSwitch.number, 0, 255) + currentSpeedup.angle = clamp(currentSpeedup.angle, 0, 359) + currentSpeedup.maxSpeed = clamp(currentSpeedup.maxSpeed, 0, 255) + currentSpeedup.force = clamp(currentSpeedup.force, 0, 255) + currentTune.number = clamp(currentTune.number, 0, 255) + } let current: Info.AnyTile | null let boxSelect = false @@ -90,7 +92,6 @@ let mounted = false onMount(() => { ctx = canvas.getContext('2d')! - drawLayer() Editor.on('keydown', onKeyDown) Editor.on('keyup', onKeyUp) mounted = true @@ -102,12 +103,12 @@ mounted = false }) - $: if (mounted && rlayer) { - drawLayer() + $: if (mounted) { + drawLayer(image) } - async function drawLayer() { - const img = await getCanvasImage(rlayer.texture.image) + async function drawLayer(image: Image) { + const img = await getCanvasImage(image) if (!mounted) return canvas.width = img.width as number @@ -250,6 +251,61 @@
+ +
+
+ +
+ {#if rlayer.layer instanceof TeleLayer} + + {:else if rlayer.layer instanceof SpeedupLayer} + + + + {:else if rlayer.layer instanceof SwitchLayer} + + + {:else if rlayer.layer instanceof TuneLayer} + + {:else} + Select tiles to place on the map. + {/if} +
+ +
+ + +
+
+ +
+
+
-
-
- {#if rlayer.layer instanceof TeleLayer} - - {:else if rlayer.layer instanceof SwitchLayer} - - - {:else if rlayer.layer instanceof SpeedupLayer} - - - - {:else if rlayer.layer instanceof TuneLayer} - - {:else} - Select tiles to place on the map. - {/if} -
-
- -
-
-
+
diff --git a/client/src/ui/lib/util.ts b/client/src/ui/lib/util.ts index b12f932..93bfbe8 100644 --- a/client/src/ui/lib/util.ts +++ b/client/src/ui/lib/util.ts @@ -7,6 +7,7 @@ import type { WebSocketServer } from '../../server/server' import type { MapCreation, MapDetail } from '../../server/protocol' import * as MapDir from '../../twmap/mapdir' import { QuadsLayer } from '../../twmap/quadsLayer' +import { clearDialog, showInfo } from './dialog' export type Ctor = new (...args: any[]) => T @@ -14,17 +15,24 @@ export type FormEvent = Event & { currentTarget: EventTarget & T } export type FormInputEvent = FormEvent export async function download(file: string, name: string) { - const resp = await fetch(file) - const data = await resp.blob() - const url = URL.createObjectURL(data) - - const link = document.createElement('a') - link.href = url - link.download = name - - document.body.append(link) - link.click() - link.remove() + const id = showInfo(`Downloading '${name}'…`, 'none') + try { + const resp = await fetch(file) + const data = await resp.blob() + const url = URL.createObjectURL(data) + + const link = document.createElement('a') + link.href = url + link.download = name + + document.body.append(link) + link.click() + link.remove() + showInfo(`Downloaded '${name}'.`) + } + finally { + clearDialog(id) + } } export async function uploadMap(httpRoot: string, name: string, file: Blob) { diff --git a/client/src/ui/routes/edit.svelte b/client/src/ui/routes/edit.svelte index 2172a02..1f4dc10 100644 --- a/client/src/ui/routes/edit.svelte +++ b/client/src/ui/routes/edit.svelte @@ -1,5 +1,5 @@