feat: Initial commit
This commit is contained in:
commit
bf11761bc8
37 changed files with 6853 additions and 0 deletions
24
.gitignore
vendored
Normal file
24
.gitignore
vendored
Normal file
|
@ -0,0 +1,24 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
32
.prettierignore
Normal file
32
.prettierignore
Normal file
|
@ -0,0 +1,32 @@
|
||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
# lockfile
|
||||||
|
pnpm-lock.yaml
|
||||||
|
|
||||||
|
# CI
|
||||||
|
.pnpm-store
|
||||||
|
.drone.yml
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.next/
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
8
.prettierrc
Normal file
8
.prettierrc
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"jsxSingleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"tabWidth": 4,
|
||||||
|
"useTabs": false,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
3
.vscode/extensions.json
vendored
Normal file
3
.vscode/extensions.json
vendored
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
{
|
||||||
|
"recommendations": ["svelte.svelte-vscode"]
|
||||||
|
}
|
3
README.md
Normal file
3
README.md
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
# Doodly
|
||||||
|
|
||||||
|
A skribbl.io clone that doesn't include builtin drawing.
|
16
index.html
Normal file
16
index.html
Normal file
|
@ -0,0 +1,16 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<meta name="description" content="Draw with friends">
|
||||||
|
<title>Doodly</title>
|
||||||
|
<link rel="icon" href="/favicon.png">
|
||||||
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" sizes="180x180">
|
||||||
|
<meta name="theme-color" content="#ffffff">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
34
package.json
Normal file
34
package.json
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
{
|
||||||
|
"name": "doodly",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite --host 0.0.0.0",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"check": "svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"dev:server": "node --loader ts-node/esm src/server/main.ts",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
|
"format": "prettier --write ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^1.0.1",
|
||||||
|
"@tsconfig/svelte": "^3.0.0",
|
||||||
|
"prettier": "^2.7.1",
|
||||||
|
"svelte": "^3.49.0",
|
||||||
|
"svelte-check": "^2.8.0",
|
||||||
|
"svelte-preprocess": "^4.10.7",
|
||||||
|
"ts-node": "^10.9.1",
|
||||||
|
"tslib": "^2.4.0",
|
||||||
|
"typescript": "^4.6.4",
|
||||||
|
"vite": "^3.0.6",
|
||||||
|
"vite-plugin-pwa": "^0.12.3"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@fontsource/noto-sans": "^4.5.11",
|
||||||
|
"bulma": "^0.9.4",
|
||||||
|
"socket.io": "^4.5.1",
|
||||||
|
"socket.io-client": "^4.5.1"
|
||||||
|
}
|
||||||
|
}
|
3522
pnpm-lock.yaml
Normal file
3522
pnpm-lock.yaml
Normal file
File diff suppressed because it is too large
Load diff
BIN
public/apple-touch-icon.png
Normal file
BIN
public/apple-touch-icon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
BIN
public/favicon-192.png
Normal file
BIN
public/favicon-192.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
BIN
public/favicon.png
Normal file
BIN
public/favicon.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
14
squircle.svg
Normal file
14
squircle.svg
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
<svg viewbox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"><path d="
|
||||||
|
M 0, 75
|
||||||
|
C 0, 18.75 18.75, 0 75, 0
|
||||||
|
S 150, 18.75 150, 75
|
||||||
|
131.25, 150 75, 150
|
||||||
|
0, 131.25 0, 75
|
||||||
|
" fill="#FADB5F" transform="rotate(
|
||||||
|
0,
|
||||||
|
100,
|
||||||
|
100
|
||||||
|
) translate(
|
||||||
|
25
|
||||||
|
25
|
||||||
|
)"></path></svg>
|
After Width: | Height: | Size: 451 B |
171
src/App.svelte
Normal file
171
src/App.svelte
Normal file
|
@ -0,0 +1,171 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { io, type Socket } from "socket.io-client";
|
||||||
|
import { onDestroy } from "svelte";
|
||||||
|
|
||||||
|
import WaitingLobby from "./client/WaitingLobby.svelte";
|
||||||
|
import JoinPage from "./client/JoinPage.svelte";
|
||||||
|
import type { ChatMessage, ClientToServerEvents, GameState, Player, ServerToClientEvents, GameOver as GameOverData } from "./lib/socket";
|
||||||
|
import SelectWord from "./client/SelectWord.svelte";
|
||||||
|
import Card from "./components/Card.svelte";
|
||||||
|
import ChatWindow from "./client/ChatWindow.svelte";
|
||||||
|
import GuessPage from "./client/GuessPage.svelte";
|
||||||
|
import GameOver from "./client/GameOver.svelte";
|
||||||
|
|
||||||
|
let socket: Socket<ServerToClientEvents, ClientToServerEvents> | null = null;
|
||||||
|
let connected = false;
|
||||||
|
|
||||||
|
let username = '';
|
||||||
|
let gameId = '';
|
||||||
|
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
let host = false;
|
||||||
|
|
||||||
|
let players: Player[] = [];
|
||||||
|
|
||||||
|
let gameState: GameState = 'waiting';
|
||||||
|
let wordOptions: string[] = null;
|
||||||
|
|
||||||
|
let chat: ChatMessage[] = [];
|
||||||
|
let gameOver: GameOverData = null;
|
||||||
|
|
||||||
|
function joinGame(uname: string, gameId: string) {
|
||||||
|
players = [];
|
||||||
|
socket = io(`http://${window.location.hostname}:3000/`);
|
||||||
|
socket.on('connect', () => {
|
||||||
|
socket.emit('joinGame', gameId, uname, (result) => {
|
||||||
|
if (result.error === false) {
|
||||||
|
const { id, isHost, players: p } = result.data;
|
||||||
|
players = p;
|
||||||
|
host = isHost;
|
||||||
|
connected = true;
|
||||||
|
username = uname;
|
||||||
|
gameState = 'waiting';
|
||||||
|
chat = [];
|
||||||
|
gameOver = null;
|
||||||
|
} else {
|
||||||
|
console.log('failed to join', result.message);
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
connected = false;
|
||||||
|
error = result.message;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnect', (reason) => {
|
||||||
|
connected = false;
|
||||||
|
error = 'lost connection: ' + reason;
|
||||||
|
socket.disconnect();
|
||||||
|
socket = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playerJoined', (username) => {
|
||||||
|
players.push({
|
||||||
|
username,
|
||||||
|
isHost: false,
|
||||||
|
});
|
||||||
|
players = players;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('playerLeft', (username) => {
|
||||||
|
players = players.filter(p => p.username !== username);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('becameHost', (newHost) => {
|
||||||
|
if (newHost === username) {
|
||||||
|
host = true;
|
||||||
|
}
|
||||||
|
players = players.map(player => {
|
||||||
|
return {
|
||||||
|
...player,
|
||||||
|
isHost: player.username === newHost,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('waitForWordPick', () => {
|
||||||
|
gameState = 'waitingForWord';
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('pickWord', (words) => {
|
||||||
|
console.log('pickWord', gameState, words);
|
||||||
|
gameState = 'selectingWord';
|
||||||
|
wordOptions = words;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('startDrawing', () => {
|
||||||
|
gameState = 'drawing';
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('startGuessing', () => {
|
||||||
|
gameState = 'guessing';
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('chatMessage', (author, message) => {
|
||||||
|
chat.reverse();
|
||||||
|
chat.push({ author, message });
|
||||||
|
chat.reverse();
|
||||||
|
chat = chat;
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('gameOver', (result) => {
|
||||||
|
gameState = 'gameOver';
|
||||||
|
gameOver = result;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
if (socket) {
|
||||||
|
socket.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function startGame() {
|
||||||
|
socket.emit('startGame');
|
||||||
|
chat = [];
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
{#if !connected}
|
||||||
|
<JoinPage joinGame={joinGame} error={error} loading={socket !== null} on:dismiss-error={() => error = ''}></JoinPage>
|
||||||
|
{:else}
|
||||||
|
{#if gameState === 'waiting'}
|
||||||
|
<WaitingLobby username={username} gameId={gameId} host={host} socket={socket} players={players}></WaitingLobby>
|
||||||
|
{:else if gameState === 'waitingForWord'}
|
||||||
|
<Card>
|
||||||
|
<p>
|
||||||
|
Waiting for a word to be picked...
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
{:else if gameState === 'selectingWord'}
|
||||||
|
<SelectWord words={wordOptions} on:pick-word={(event) => socket.emit('pickWord', event.detail)} />
|
||||||
|
{:else if gameState === 'guessing'}
|
||||||
|
<GuessPage on:guess={(e) => socket.emit('guessWord', e.detail, (correct) => {
|
||||||
|
if (correct) {
|
||||||
|
gameState = 'guessedCorrectly';
|
||||||
|
}
|
||||||
|
})} />
|
||||||
|
<ChatWindow messages={chat} />
|
||||||
|
{:else if gameState === 'guessedCorrectly'}
|
||||||
|
<Card>
|
||||||
|
<h1>Correct!</h1>
|
||||||
|
<p>
|
||||||
|
Waiting for the other players to finish...
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
<ChatWindow messages={chat} />
|
||||||
|
{:else if gameState === 'drawing'}
|
||||||
|
<Card>
|
||||||
|
<h1>
|
||||||
|
Start drawing!
|
||||||
|
</h1>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<ChatWindow messages={chat} />
|
||||||
|
{:else if gameState === 'gameOver'}
|
||||||
|
<GameOver gameOver={gameOver} isHost={host} {username} on:play-again={startGame} />
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</main>
|
15
src/app.css
Normal file
15
src/app.css
Normal file
|
@ -0,0 +1,15 @@
|
||||||
|
body {
|
||||||
|
font-family: 'Noto Sans', sans-serif;
|
||||||
|
/* background-color: #212;
|
||||||
|
color: #ded;
|
||||||
|
|
||||||
|
box-sizing: border-box; */
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* input, button {
|
||||||
|
font-family: inherit;
|
||||||
|
} */
|
2299
src/assets/wordlist.json
Normal file
2299
src/assets/wordlist.json
Normal file
File diff suppressed because it is too large
Load diff
18
src/client/ChatWindow.svelte
Normal file
18
src/client/ChatWindow.svelte
Normal file
|
@ -0,0 +1,18 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Card from "../components/Card.svelte";
|
||||||
|
import type { ChatMessage } from "../lib/socket";
|
||||||
|
|
||||||
|
export let messages: ChatMessage[];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2>Chat</h2>
|
||||||
|
<div aria-live="polite">
|
||||||
|
{#each messages as message}
|
||||||
|
<p>
|
||||||
|
<span class="has-text-info">{message.author}</span>:
|
||||||
|
{message.message}
|
||||||
|
</p>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
80
src/client/GameOver.svelte
Normal file
80
src/client/GameOver.svelte
Normal file
|
@ -0,0 +1,80 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from "../components/Button.svelte";
|
||||||
|
import Card from "../components/Card.svelte";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import type { GameOver } from "../lib/socket";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let gameOver: GameOver;
|
||||||
|
export let username: string;
|
||||||
|
export let isHost: boolean;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h1>Game over</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>Details</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
<li>Drawing: <code>{gameOver.drawer}</code></li>
|
||||||
|
<li>Word: <code>{gameOver.word}</code></li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rankings">
|
||||||
|
<h2>Rankings</h2>
|
||||||
|
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr class="is-flex is-flex-direction-row">
|
||||||
|
<th><abbr title="Position">Pos</abbr></th>
|
||||||
|
<th class="is-flex-grow-1">Player</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{#each gameOver.leaderboard as entry, i}
|
||||||
|
<tr class="is-flex is-flex-direction-row">
|
||||||
|
<th>{i + 1}</th>
|
||||||
|
<td class="is-flex-grow-1">
|
||||||
|
<span class:has-text-primary={entry.username === username}>
|
||||||
|
{entry.username}
|
||||||
|
</span>
|
||||||
|
{#if entry.username === username}
|
||||||
|
<span class="tag is-primary">
|
||||||
|
You
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{(entry.time / 1000).toFixed(1)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if isHost}
|
||||||
|
<section>
|
||||||
|
<h1>
|
||||||
|
Host options
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
<Button title="Play again" on:click={() => dispatch('play-again')} colour="primary">
|
||||||
|
Play again
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.rankings {
|
||||||
|
max-width: fit-content;
|
||||||
|
}
|
||||||
|
</style>
|
26
src/client/GuessPage.svelte
Normal file
26
src/client/GuessPage.svelte
Normal file
|
@ -0,0 +1,26 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Card from "../components/Card.svelte";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import TextInput from "../components/TextInput.svelte";
|
||||||
|
import Button from "../components/Button.svelte";
|
||||||
|
import ChatWindow from "./ChatWindow.svelte";
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher<{ guess: string }>();
|
||||||
|
|
||||||
|
let composeMessage = '';
|
||||||
|
|
||||||
|
function submit(m?: string) {
|
||||||
|
const guess = m ?? composeMessage;
|
||||||
|
if (guess !== '') {
|
||||||
|
dispatch('guess', guess);
|
||||||
|
}
|
||||||
|
composeMessage = '';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h1>Guess!</h1>
|
||||||
|
|
||||||
|
<TextInput placeholder="Guess a word!" autofocus bind:value={composeMessage} on:submit={(e) => submit(e.detail)} />
|
||||||
|
<Button title="Guess" colour="primary" on:click={() => submit()}>Guess</Button>
|
||||||
|
</Card>
|
31
src/client/JoinPage.svelte
Normal file
31
src/client/JoinPage.svelte
Normal file
|
@ -0,0 +1,31 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Card from "../components/Card.svelte";
|
||||||
|
|
||||||
|
import Button from "../components/Button.svelte";
|
||||||
|
import TextInput from "../components/TextInput.svelte";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
export let joinGame: (username: string, gameId: string) => void;
|
||||||
|
export let error: string;
|
||||||
|
export let loading = false;
|
||||||
|
let username = '';
|
||||||
|
let gameId = '';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
{#if error !== ''}
|
||||||
|
<div class="notification is-danger is-light">
|
||||||
|
<button class="delete" on:click={() => dispatch('dismiss-error')} />
|
||||||
|
Error: {error}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<TextInput bind:value={username} placeholder="Username" maxlength={32} disabled={loading} />
|
||||||
|
<TextInput bind:value={gameId} placeholder="Game ID" maxlength={32} disabled={loading} />
|
||||||
|
|
||||||
|
<Button title="Join game" on:click={() => joinGame(username, gameId)} colour="primary" {loading}>
|
||||||
|
Join game
|
||||||
|
</Button>
|
||||||
|
</Card>
|
22
src/client/SelectWord.svelte
Normal file
22
src/client/SelectWord.svelte
Normal file
|
@ -0,0 +1,22 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import Button from "../components/Button.svelte";
|
||||||
|
import Card from "../components/Card.svelte";
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
export let words: string[];
|
||||||
|
const dispatch = createEventDispatcher<{ 'pick-word': string }>();
|
||||||
|
|
||||||
|
function pickWord(word: string) {
|
||||||
|
dispatch('pick-word', word);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h1>Pick a word!</h1>
|
||||||
|
|
||||||
|
<div class="buttons">
|
||||||
|
{#each words as word}
|
||||||
|
<Button title={word} on:click={() => pickWord(word)} colour="primary">{word}</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</Card>
|
65
src/client/WaitingLobby.svelte
Normal file
65
src/client/WaitingLobby.svelte
Normal file
|
@ -0,0 +1,65 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import type { Socket } from "socket.io-client";
|
||||||
|
import Button from "../components/Button.svelte";
|
||||||
|
import Card from "../components/Card.svelte";
|
||||||
|
|
||||||
|
import type { ClientToServerEvents, Player, ServerToClientEvents } from "../lib/socket";
|
||||||
|
|
||||||
|
export let players: Player[];
|
||||||
|
export let username: string;
|
||||||
|
export let gameId: string;
|
||||||
|
export let host: boolean;
|
||||||
|
export let socket: Socket<ServerToClientEvents, ClientToServerEvents>;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h1>
|
||||||
|
Waiting for
|
||||||
|
{#if host}
|
||||||
|
players
|
||||||
|
{:else}
|
||||||
|
the host
|
||||||
|
{/if}
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2>
|
||||||
|
Players
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<ul>
|
||||||
|
{#each players as player}
|
||||||
|
<li>
|
||||||
|
<span class:has-text-info={player.username === username}>
|
||||||
|
{player.username}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{#if player.isHost}
|
||||||
|
<span class="tag is-warning">
|
||||||
|
Host
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{#if host}
|
||||||
|
<section>
|
||||||
|
<h2>Game</h2>
|
||||||
|
<Button title="Start game" colour="primary" on:click={() => socket.emit('startGame')} disabled={players.length < 2}>Start game</Button>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<h2>How to play</h2>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
A random player will be chosen to pick a word, which they will then have to draw it in front of everyone else.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
All incorrect guesses are broadcast to the chat window for every player though, so be careful!
|
||||||
|
</p>
|
||||||
|
</Card>
|
19
src/components/Button.svelte
Normal file
19
src/components/Button.svelte
Normal file
|
@ -0,0 +1,19 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import type { BulmaColour } from "./utils";
|
||||||
|
|
||||||
|
export let title: string;
|
||||||
|
export let disabled: boolean = false;
|
||||||
|
export let colour: BulmaColour = undefined;
|
||||||
|
export let loading: boolean = false;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function dispatchClick() {
|
||||||
|
if (!disabled) dispatch('click');
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<button class={`button is-${colour}`} class:is-loading={loading} {disabled} {title} on:click={dispatchClick}>
|
||||||
|
<slot />
|
||||||
|
</button>
|
7
src/components/Card.svelte
Normal file
7
src/components/Card.svelte
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
<div class="container">
|
||||||
|
<div class="card mt-4">
|
||||||
|
<div class="card-content content">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
23
src/components/TextInput.svelte
Normal file
23
src/components/TextInput.svelte
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
<script lang="ts">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
|
||||||
|
|
||||||
|
export let placeholder: string;
|
||||||
|
export let value: string;
|
||||||
|
export let id: string = undefined;
|
||||||
|
export let autofocus = false;
|
||||||
|
export let maxlength: number = undefined;
|
||||||
|
export let disabled: boolean = false;
|
||||||
|
const dispatch = createEventDispatcher<{ submit: string }>();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- svelte-ignore a11y-autofocus -->
|
||||||
|
<div class="field">
|
||||||
|
<div class="control">
|
||||||
|
<input type="text" {id} {placeholder} {autofocus} {maxlength} {disabled} bind:value class="input" on:keyup={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
dispatch('submit', value);
|
||||||
|
}
|
||||||
|
}}>
|
||||||
|
</div>
|
||||||
|
</div>
|
1
src/components/utils.ts
Normal file
1
src/components/utils.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export type BulmaColour = 'primary' | 'link' | 'success' | 'warning' | 'danger';
|
43
src/lib/socket.ts
Normal file
43
src/lib/socket.ts
Normal file
|
@ -0,0 +1,43 @@
|
||||||
|
export interface ServerToClientEvents {
|
||||||
|
playerJoined: (username: string) => void;
|
||||||
|
playerLeft: (username: string) => void;
|
||||||
|
becameHost: (username: string) => void;
|
||||||
|
waitForWordPick: () => void;
|
||||||
|
pickWord: (words: string[]) => void;
|
||||||
|
startGuessing: () => void;
|
||||||
|
startDrawing: () => void;
|
||||||
|
chatMessage: (author: string, message: string) => void;
|
||||||
|
gameOver: (result: GameOver) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GameOver {
|
||||||
|
leaderboard: (Player & { time: number })[];
|
||||||
|
drawer: string;
|
||||||
|
word: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Result<T> = { error: false; data: T } | { error: true; message: string };
|
||||||
|
|
||||||
|
export interface ClientToServerEvents {
|
||||||
|
joinGame: (id: string, username: string, callback: (result: Result<{id: string, isHost: boolean, players: Player[]}>) => void) => void;
|
||||||
|
startGame: () => void;
|
||||||
|
|
||||||
|
pickWord: (word: string) => void;
|
||||||
|
guessWord: (word: string, callback: (success: boolean) => void) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SocketData {
|
||||||
|
gameId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Player {
|
||||||
|
username: string;
|
||||||
|
isHost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type GameState = 'waiting' | 'selectingWord' | 'waitingForWord' | 'drawing' | 'guessing' | 'guessedCorrectly' | 'gameOver';
|
||||||
|
|
||||||
|
export interface ChatMessage {
|
||||||
|
author: string;
|
||||||
|
message: string;
|
||||||
|
}
|
27
src/lib/util.ts
Normal file
27
src/lib/util.ts
Normal file
|
@ -0,0 +1,27 @@
|
||||||
|
export function sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
// https://www.tutorialspoint.com/levenshtein-distance-in-javascript
|
||||||
|
export function levenshteinDistance(a: string, b: string): number {
|
||||||
|
const track: number[][] = Array(b.length + 1)
|
||||||
|
.fill(null)
|
||||||
|
.map(() => Array(a.length + 1).fill(null));
|
||||||
|
for (let i = 0; i <= a.length; i += 1) {
|
||||||
|
track[0][i] = i;
|
||||||
|
}
|
||||||
|
for (let j = 0; j <= b.length; j += 1) {
|
||||||
|
track[j][0] = j;
|
||||||
|
}
|
||||||
|
for (let j = 1; j <= b.length; j += 1) {
|
||||||
|
for (let i = 1; i <= a.length; i += 1) {
|
||||||
|
const indicator = a[i - 1] === b[j - 1] ? 0 : 1;
|
||||||
|
track[j][i] = Math.min(
|
||||||
|
track[j][i - 1] + 1, // deletion
|
||||||
|
track[j - 1][i] + 1, // insertion
|
||||||
|
track[j - 1][i - 1] + indicator // substitution
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return track[b.length][a.length];
|
||||||
|
}
|
10
src/main.ts
Normal file
10
src/main.ts
Normal file
|
@ -0,0 +1,10 @@
|
||||||
|
import './app.css';
|
||||||
|
import App from './App.svelte';
|
||||||
|
import '@fontsource/noto-sans';
|
||||||
|
import 'bulma/css/bulma.css';
|
||||||
|
|
||||||
|
const app = new App({
|
||||||
|
target: document.getElementById('app'),
|
||||||
|
});
|
||||||
|
|
||||||
|
export default app;
|
147
src/server/game.ts
Normal file
147
src/server/game.ts
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import type { Server, Socket } from "socket.io";
|
||||||
|
import type { ClientToServerEvents, Player, ServerToClientEvents, SocketData } from "src/lib/socket";
|
||||||
|
import { levenshteinDistance } from "../lib/util.js";
|
||||||
|
import randomWords from "./words.js";
|
||||||
|
|
||||||
|
export class Game {
|
||||||
|
io: Server<ClientToServerEvents, ServerToClientEvents, {}, SocketData>;
|
||||||
|
gameId: string;
|
||||||
|
players: Map<string, Player>;
|
||||||
|
host: string;
|
||||||
|
|
||||||
|
startTime: number | null;
|
||||||
|
currentPlayer: string | null;
|
||||||
|
currentWord: string | null;
|
||||||
|
successfulPlayers: [string, number][];
|
||||||
|
|
||||||
|
constructor(gameId: string, io: Server<ClientToServerEvents, ServerToClientEvents, {}, SocketData>) {
|
||||||
|
this.io = io;
|
||||||
|
this.gameId = gameId;
|
||||||
|
this.players = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
playerJoin(socket: Socket<ClientToServerEvents, ServerToClientEvents, {}, SocketData>, username: string): string | Player {
|
||||||
|
for (const player of this.players.values()) {
|
||||||
|
if (player.username === username) {
|
||||||
|
return 'username taken';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isHost = this.players.size === 0;
|
||||||
|
|
||||||
|
if (isHost) {
|
||||||
|
this.host = socket.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
const player: Player = {
|
||||||
|
isHost,
|
||||||
|
username,
|
||||||
|
};
|
||||||
|
this.players.set(socket.id, player);
|
||||||
|
|
||||||
|
return player;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a client socket disconnects.
|
||||||
|
*
|
||||||
|
* @param socket Socket that has just disconnected
|
||||||
|
* @returns true if the game is empty, false otherwise
|
||||||
|
*/
|
||||||
|
playerLeft(socket: Socket<ClientToServerEvents, ServerToClientEvents, {}, SocketData>): boolean {
|
||||||
|
const player = this.players.get(socket.id);
|
||||||
|
this.players.delete(socket.id);
|
||||||
|
if (this.players.size === 0) return true;
|
||||||
|
if (player.isHost) {
|
||||||
|
// we need to assign a new host for this room
|
||||||
|
const playerIds = Array.from(this.players.keys());
|
||||||
|
const newHostId = playerIds[Math.floor(Math.random() * playerIds.length)];
|
||||||
|
const newHost = this.players.get(newHostId);
|
||||||
|
newHost.isHost = true;
|
||||||
|
this.io.to(this.gameId).emit('becameHost', newHost.username);
|
||||||
|
this.host = newHostId;
|
||||||
|
}
|
||||||
|
this.io.to(this.gameId).emit('playerLeft', player.username);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when the host presses the 'Start game' button.
|
||||||
|
* Will pick a random player to choose a word, and then
|
||||||
|
* broadcast the start to the other players.
|
||||||
|
*/
|
||||||
|
gameStart(socket: Socket<ClientToServerEvents, ServerToClientEvents, {}, SocketData>) {
|
||||||
|
if (!this.players.get(socket.id).isHost) return;
|
||||||
|
|
||||||
|
const playerIds = Array.from(this.players.keys());
|
||||||
|
const player = playerIds[Math.floor(Math.random() * playerIds.length)];
|
||||||
|
const words = randomWords(3);
|
||||||
|
this.currentPlayer = player;
|
||||||
|
this.successfulPlayers = [];
|
||||||
|
this.io.to(this.gameId).except(player).emit('waitForWordPick');
|
||||||
|
this.io.to(player).emit('pickWord', words);
|
||||||
|
}
|
||||||
|
|
||||||
|
pickWord(socket: Socket<ClientToServerEvents, ServerToClientEvents, {}, SocketData>, word: string) {
|
||||||
|
if (socket.id !== this.currentPlayer) return;
|
||||||
|
this.currentWord = word;
|
||||||
|
this.io.to(this.gameId).except(socket.id).emit('startGuessing');
|
||||||
|
this.io.to(socket.id).emit('startDrawing');
|
||||||
|
this.startTime = new Date().getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called when a player makes a guess during a game.
|
||||||
|
*
|
||||||
|
* @param socket The socket of the player that made the guess
|
||||||
|
* @param guess The word that the player guessed
|
||||||
|
* @returns true if the word was correct, false otherwise
|
||||||
|
*/
|
||||||
|
guessWord(socket: Socket<ClientToServerEvents, ServerToClientEvents, {}, SocketData>, guess: string): boolean {
|
||||||
|
if (!this.currentWord || this.successfulPlayers.find(([id, _]) => id === socket.id) !== undefined) return;
|
||||||
|
|
||||||
|
const player = this.players.get(socket.id);
|
||||||
|
|
||||||
|
if (guess.toLocaleLowerCase() === this.currentWord.toLocaleLowerCase()) {
|
||||||
|
// Correct answer!
|
||||||
|
this.io.to(this.gameId).except(socket.id).emit('chatMessage', 'game', `${player.username} guessed the correct word`);
|
||||||
|
this.successfulPlayers.push([socket.id, new Date().getTime() - this.startTime]);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} else if (levenshteinDistance(guess.toLocaleLowerCase(), this.currentWord.toLocaleLowerCase()) <= 2) {
|
||||||
|
// close guess
|
||||||
|
this.io.to(socket.id).emit('chatMessage', 'game', `${guess} is close!`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Broadcast incorrect guesses to all players
|
||||||
|
this.io.to(this.gameId).emit('chatMessage', player.username, guess);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
checkGameOver() {
|
||||||
|
if (this.isGameOver()) {
|
||||||
|
const leaderboard = this.successfulPlayers.map(([id, time]) => {
|
||||||
|
const p = this.players.get(id);
|
||||||
|
return {
|
||||||
|
...p,
|
||||||
|
time,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
this.io.to(this.gameId).emit('gameOver', {
|
||||||
|
leaderboard,
|
||||||
|
drawer: this.players.get(this.currentPlayer).username,
|
||||||
|
word: this.currentWord,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isGameOver(): boolean {
|
||||||
|
for (const id of this.players.keys()) {
|
||||||
|
if (id !== this.currentPlayer && this.successfulPlayers.find(([i, _]) => id === i) === undefined) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
109
src/server/main.ts
Normal file
109
src/server/main.ts
Normal file
|
@ -0,0 +1,109 @@
|
||||||
|
import { Server } from 'socket.io';
|
||||||
|
import type {
|
||||||
|
ClientToServerEvents,
|
||||||
|
Player,
|
||||||
|
ServerToClientEvents,
|
||||||
|
SocketData,
|
||||||
|
} from 'src/lib/socket';
|
||||||
|
import { Game } from './game.js';
|
||||||
|
import randomWords from './words.js';
|
||||||
|
|
||||||
|
const io = new Server<
|
||||||
|
ClientToServerEvents,
|
||||||
|
ServerToClientEvents,
|
||||||
|
{},
|
||||||
|
SocketData
|
||||||
|
>({
|
||||||
|
cors: {
|
||||||
|
origin: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const games = new Map<string, Game>();
|
||||||
|
|
||||||
|
io.on('connection', (socket) => {
|
||||||
|
socket.on('joinGame', (gameId, username, callback) => {
|
||||||
|
if (gameId.length < 1 || gameId.length > 32) {
|
||||||
|
callback({
|
||||||
|
error: true,
|
||||||
|
message: 'invalid game id',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (username.length === 0 || !/^[a-zA-Z0-9]+$/.test(username)) {
|
||||||
|
callback({
|
||||||
|
error: true,
|
||||||
|
message: 'invalid username',
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!games.has(gameId)) {
|
||||||
|
games.set(gameId, new Game(gameId, io));
|
||||||
|
}
|
||||||
|
const room = games.get(gameId);
|
||||||
|
|
||||||
|
const player = room.playerJoin(socket, username);
|
||||||
|
if (typeof player === 'string') {
|
||||||
|
return callback({
|
||||||
|
error: true,
|
||||||
|
message: player,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
io.to(gameId).emit('playerJoined', username);
|
||||||
|
|
||||||
|
socket.data.gameId = gameId;
|
||||||
|
socket.rooms.forEach(room => room !== socket.id && socket.leave(room));
|
||||||
|
socket.join(gameId);
|
||||||
|
|
||||||
|
const players = Array.from(room.players.values());
|
||||||
|
|
||||||
|
players.sort((a, b) => a.username.localeCompare(b.username));
|
||||||
|
|
||||||
|
callback({
|
||||||
|
error: false,
|
||||||
|
data: {
|
||||||
|
id: gameId,
|
||||||
|
isHost: player.isHost,
|
||||||
|
players,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('startGame', () => {
|
||||||
|
if (!socket.data.gameId) return;
|
||||||
|
const game = games.get(socket.data.gameId);
|
||||||
|
|
||||||
|
game.gameStart(socket);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('pickWord', (word) => {
|
||||||
|
if (!socket.data.gameId) return;
|
||||||
|
const game = games.get(socket.data.gameId);
|
||||||
|
|
||||||
|
game.pickWord(socket, word);
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('guessWord', (word, callback) => {
|
||||||
|
if (!socket.data.gameId) return;
|
||||||
|
const game = games.get(socket.data.gameId);
|
||||||
|
|
||||||
|
const result = game.guessWord(socket, word);
|
||||||
|
callback(result);
|
||||||
|
game.checkGameOver();
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('disconnecting', () => {
|
||||||
|
socket.leave(socket.id);
|
||||||
|
if (socket.data.gameId) {
|
||||||
|
if (games.get(socket.data.gameId).playerLeft(socket)) {
|
||||||
|
games.delete(socket.data.gameId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('listening on port :3000');
|
||||||
|
io.listen(3000);
|
14
src/server/words.ts
Normal file
14
src/server/words.ts
Normal file
|
@ -0,0 +1,14 @@
|
||||||
|
import words from '../assets/wordlist.json' assert {type: 'json'};
|
||||||
|
|
||||||
|
export default function randomWords(count = 3): string[] {
|
||||||
|
const randomWords: string[] = [];
|
||||||
|
|
||||||
|
while (randomWords.length < count) {
|
||||||
|
const word = words[Math.floor(Math.random() * words.length)];
|
||||||
|
if (!randomWords.includes(word)) {
|
||||||
|
randomWords.push(word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return randomWords;
|
||||||
|
}
|
2
src/vite-env.d.ts
vendored
Normal file
2
src/vite-env.d.ts
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference types="svelte" />
|
||||||
|
/// <reference types="vite/client" />
|
7
svelte.config.js
Normal file
7
svelte.config.js
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import sveltePreprocess from 'svelte-preprocess'
|
||||||
|
|
||||||
|
export default {
|
||||||
|
// Consult https://github.com/sveltejs/svelte-preprocess
|
||||||
|
// for more information about preprocessors
|
||||||
|
preprocess: sveltePreprocess()
|
||||||
|
}
|
21
tsconfig.json
Normal file
21
tsconfig.json
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/svelte/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ESNext",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"baseUrl": ".",
|
||||||
|
/**
|
||||||
|
* Typecheck JS in `.svelte` and `.js` files by default.
|
||||||
|
* Disable checkJs if you'd like to use dynamic types in JS.
|
||||||
|
* Note that setting allowJs false does not prevent the use
|
||||||
|
* of JS in `.svelte` files.
|
||||||
|
*/
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"isolatedModules": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.d.ts", "src/**/*.ts", "src/**/*.js", "src/**/*.svelte"],
|
||||||
|
"references": [{ "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
8
tsconfig.node.json
Normal file
8
tsconfig.node.json
Normal file
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"composite": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Node"
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
30
vite.config.ts
Normal file
30
vite.config.ts
Normal file
|
@ -0,0 +1,30 @@
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import { svelte } from '@sveltejs/vite-plugin-svelte'
|
||||||
|
import { VitePWA } from 'vite-plugin-pwa';
|
||||||
|
|
||||||
|
// https://vitejs.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
svelte(),
|
||||||
|
VitePWA({
|
||||||
|
includeAssets: ['favicon.png', 'apple-touch-icon.png'],
|
||||||
|
manifest: {
|
||||||
|
name: 'Doodly',
|
||||||
|
short_name: 'Doodly',
|
||||||
|
description: 'Drawing game with friends',
|
||||||
|
theme_color: '#ffffff',
|
||||||
|
icons: [
|
||||||
|
{
|
||||||
|
src: 'favicon.png',
|
||||||
|
sizes: '256x256',
|
||||||
|
type: 'image/png',
|
||||||
|
},{
|
||||||
|
src: 'favicon-192.png',
|
||||||
|
sizes: '192x192',
|
||||||
|
type: 'image/png',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
Loading…
Reference in a new issue