Compare commits
1 commit
main
...
old-next.j
Author | SHA1 | Date | |
---|---|---|---|
ef6ec3b9b4 |
145 changed files with 11603 additions and 11463 deletions
8
.eslintignore
Normal file
8
.eslintignore
Normal file
|
@ -0,0 +1,8 @@
|
|||
# Ignore all config files in the root folder
|
||||
leaky-ships/*.config.js
|
||||
|
||||
# Ignore the whole __tests__ folder
|
||||
leaky-ships/__tests__
|
||||
|
||||
# Allow all other files in folders
|
||||
leaky-ships/!*/
|
11
.github/workflows/playwright.yml
vendored
11
.github/workflows/playwright.yml
vendored
|
@ -18,12 +18,12 @@ jobs:
|
|||
- name: Install Node.js
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
node-version: 18
|
||||
|
||||
- uses: pnpm/action-setup@v2
|
||||
name: Install pnpm
|
||||
with:
|
||||
version: 8.7.4
|
||||
version: 8.6.10
|
||||
run_install: false
|
||||
|
||||
- name: Get pnpm store directory
|
||||
|
@ -41,8 +41,8 @@ jobs:
|
|||
|
||||
- name: Add FA token
|
||||
run: |
|
||||
npm config set "@fortawesome:registry" "https://npm.fontawesome.com/"
|
||||
npm config set "//npm.fontawesome.com/:_authToken" "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}"
|
||||
echo "@fortawesome:registry=https://npm.fontawesome.com/" > .npmrc
|
||||
npm config set '//npm.fontawesome.com/:_authToken' "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
@ -53,7 +53,8 @@ jobs:
|
|||
- name: 'Compiling page'
|
||||
run: |
|
||||
echo "${{ secrets.ENV_FILE }}" > .env
|
||||
pnpm build
|
||||
pnpm prisma generate
|
||||
pnpm run build
|
||||
|
||||
- name: Run Playwright tests
|
||||
run: pnpm playwright test
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
# leaky-ships
|
||||
|
||||
Battleship web app made with SolidJS using solid-start.
|
||||
Battleship web app with react frontend and ASP.NET Core backend
|
||||
|
||||
## Bluetooth
|
||||
|
||||
|
|
|
@ -1,11 +1,6 @@
|
|||
{
|
||||
"parser": "@typescript-eslint/parser",
|
||||
"env": {
|
||||
"node": true
|
||||
},
|
||||
"plugins": ["solid"],
|
||||
"extends": [
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:solid/typescript"
|
||||
]
|
||||
"extends": ["next/core-web-vitals", "prettier"],
|
||||
"rules": {
|
||||
"@next/next/no-img-element": "off"
|
||||
}
|
||||
}
|
||||
|
|
57
leaky-ships/.gitignore
vendored
57
leaky-ships/.gitignore
vendored
|
@ -1,35 +1,48 @@
|
|||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# logs
|
||||
/log
|
||||
src/drizzle/migrations
|
||||
|
||||
dist
|
||||
.vinxi
|
||||
.output
|
||||
.vercel
|
||||
.netlify
|
||||
netlify
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env*.local
|
||||
# prisma
|
||||
/prisma/migrations
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# IDEs and editors
|
||||
/.idea
|
||||
.project
|
||||
.classpath
|
||||
*.launch
|
||||
.settings/
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# Temp
|
||||
gitignore
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# System Files
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
.env
|
||||
.env*.local
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
|
||||
# playwright
|
||||
/test-results/
|
||||
/playwright-report/
|
||||
/playwright/.cache/
|
||||
/playwright/.cache/
|
||||
|
|
|
@ -1,8 +1,8 @@
|
|||
import { createSignal } from "solid-js"
|
||||
import { useState } from "react"
|
||||
|
||||
function Bluetooth() {
|
||||
const [startDisabled, setStartDisabled] = createSignal(true)
|
||||
const [stopDisabled, setStopDisabled] = createSignal(true)
|
||||
const [startDisabled, setStartDisabled] = useState(true)
|
||||
const [stopDisabled, setStopDisabled] = useState(true)
|
||||
|
||||
const deviceName = "Chromecast Remote"
|
||||
// ble UV Index
|
||||
|
@ -48,7 +48,7 @@ function Bluetooth() {
|
|||
if (!isWebBluetoothEnabled()) return
|
||||
return getDeviceInfo()
|
||||
.then(connectGatt)
|
||||
.then(() => {
|
||||
.then((_) => {
|
||||
console.log("Reading UV Index...")
|
||||
return gattCharacteristic.readValue()
|
||||
})
|
||||
|
@ -108,7 +108,7 @@ function Bluetooth() {
|
|||
if (!isWebBluetoothEnabled()) return
|
||||
gattCharacteristic
|
||||
.startNotifications()
|
||||
.then(() => {
|
||||
.then((_) => {
|
||||
console.log("Start reading...")
|
||||
setStartDisabled(true)
|
||||
setStopDisabled(false)
|
||||
|
@ -119,7 +119,7 @@ function Bluetooth() {
|
|||
if (!isWebBluetoothEnabled()) return
|
||||
gattCharacteristic
|
||||
.stopNotifications()
|
||||
.then(() => {
|
||||
.then((_) => {
|
||||
console.log("Stop reading...")
|
||||
setStartDisabled(false)
|
||||
setStopDisabled(true)
|
||||
|
@ -135,28 +135,28 @@ function Bluetooth() {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<button id="read" class="bluetooth" onClick={read}>
|
||||
<button id="read" className="bluetooth" onClick={read}>
|
||||
Connect with BLE device
|
||||
</button>
|
||||
<button
|
||||
id="start"
|
||||
class="bluetooth"
|
||||
disabled={startDisabled()}
|
||||
className="bluetooth"
|
||||
disabled={startDisabled}
|
||||
onClick={start}
|
||||
>
|
||||
Start
|
||||
</button>
|
||||
<button
|
||||
id="stop"
|
||||
class="bluetooth"
|
||||
disabled={stopDisabled()}
|
||||
className="bluetooth"
|
||||
disabled={stopDisabled}
|
||||
onClick={stop}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
<p>
|
||||
<span
|
||||
class="App-link"
|
||||
className="App-link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"chrome://flags/#enable-experimental-web-platform-features",
|
||||
|
@ -169,7 +169,7 @@ function Bluetooth() {
|
|||
Step 1
|
||||
</span>{" "}
|
||||
<span
|
||||
class="App-link"
|
||||
className="App-link"
|
||||
onClick={() => {
|
||||
navigator.clipboard.writeText(
|
||||
"chrome://flags/#enable-web-bluetooth-new-permissions-backend",
|
|
@ -1,17 +1,23 @@
|
|||
import classNames from "classnames"
|
||||
|
||||
function BurgerMenu(props: { onClick?: () => void; blur?: boolean }) {
|
||||
function BurgerMenu({
|
||||
onClick,
|
||||
blur,
|
||||
}: {
|
||||
onClick?: () => void
|
||||
blur?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
id="menu"
|
||||
class={classNames(
|
||||
className={classNames(
|
||||
"absolute left-4 top-4 flex h-16 w-16 items-center justify-center rounded-lg border-b-2 border-shield-gray bg-grayish shadow-lg duration-100 active:border-b-0 active:border-t-2 md:left-6 md:top-6 md:h-20 md:w-20 md:rounded-xl md:border-b-4 md:active:border-t-4 lg:left-8 lg:top-8 xl:left-12 xl:top-12 xl:h-24 xl:w-24",
|
||||
{ "blur-sm": props.blur },
|
||||
{ "blur-sm": blur },
|
||||
)}
|
||||
onClick={() => props.onClick && setTimeout(props.onClick, 200)}
|
||||
onClick={() => onClick && setTimeout(onClick, 200)}
|
||||
>
|
||||
<img
|
||||
class="pixelart h-12 w-12 md:h-16 md:w-16 xl:h-20 xl:w-20"
|
||||
className="pixelart h-12 w-12 md:h-16 md:w-16 xl:h-20 xl:w-20"
|
||||
src="/assets/burger-menu.png"
|
||||
alt="Burger Menu"
|
||||
/>
|
151
leaky-ships/components/Gamefield/BorderTiles.tsx
Normal file
151
leaky-ships/components/Gamefield/BorderTiles.tsx
Normal file
|
@ -0,0 +1,151 @@
|
|||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import useIndex from "@hooks/useIndex"
|
||||
import useShips from "@hooks/useShips"
|
||||
import {
|
||||
borderCN,
|
||||
cornerCN,
|
||||
fieldIndex,
|
||||
intersectingShip,
|
||||
isAlreadyHit,
|
||||
overlapsWithAnyBorder,
|
||||
shipProps,
|
||||
targetList,
|
||||
} from "@lib/utils/helpers"
|
||||
import { CSSProperties, useCallback } from "react"
|
||||
import { count } from "./Gamefield"
|
||||
|
||||
type TilesType = {
|
||||
key: number
|
||||
isGameTile: boolean
|
||||
className: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function BorderTiles() {
|
||||
const { activeUser } = useIndex()
|
||||
const {
|
||||
payload,
|
||||
mode,
|
||||
targetPreview,
|
||||
mouseCursor,
|
||||
setTarget,
|
||||
setMouseCursor,
|
||||
} = useGameProps()
|
||||
const { ships, setShips, removeShip } = useShips()
|
||||
|
||||
const settingTarget = useCallback(
|
||||
(isGameTile: boolean, x: number, y: number) => {
|
||||
if (payload?.game?.state === "running") {
|
||||
const list = targetList(targetPreview, mode)
|
||||
if (
|
||||
!isGameTile ||
|
||||
!list.filter(
|
||||
({ x, y }) => !isAlreadyHit(x, y, activeUser?.hits ?? []),
|
||||
).length
|
||||
)
|
||||
return
|
||||
if (!overlapsWithAnyBorder(targetPreview, mode))
|
||||
setTarget({
|
||||
show: true,
|
||||
x,
|
||||
y,
|
||||
orientation: targetPreview.orientation,
|
||||
})
|
||||
} else if (
|
||||
payload?.game?.state === "starting" &&
|
||||
targetPreview.show &&
|
||||
!intersectingShip(ships, shipProps(ships, mode, targetPreview)).score
|
||||
) {
|
||||
setMouseCursor((e) => ({ ...e, shouldShow: false }))
|
||||
setShips([...ships, shipProps(ships, mode, targetPreview)])
|
||||
}
|
||||
},
|
||||
[
|
||||
activeUser?.hits,
|
||||
mode,
|
||||
payload?.game?.state,
|
||||
setMouseCursor,
|
||||
setShips,
|
||||
setTarget,
|
||||
ships,
|
||||
targetPreview,
|
||||
],
|
||||
)
|
||||
let tilesProperties: TilesType[] = []
|
||||
|
||||
for (let y = 0; y < count + 2; y++) {
|
||||
for (let x = 0; x < count + 2; x++) {
|
||||
const key = fieldIndex(count, x, y)
|
||||
const cornerReslt = cornerCN(count, x, y)
|
||||
const borderType = cornerReslt ? cornerReslt : borderCN(count, x, y)
|
||||
const isGameTile = x > 0 && x < count + 1 && y > 0 && y < count + 1
|
||||
const classNames = ["border-tile"]
|
||||
if (borderType) classNames.push("edge", borderType)
|
||||
if (isGameTile) classNames.push("game-tile")
|
||||
const className = classNames.join(" ")
|
||||
tilesProperties.push({
|
||||
key,
|
||||
className,
|
||||
isGameTile,
|
||||
x: x + 1,
|
||||
y: y + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{tilesProperties.map(({ key, className, isGameTile, x, y }) => {
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={className}
|
||||
style={{ "--x": x, "--y": y } as CSSProperties}
|
||||
onClick={() => {
|
||||
if (payload?.game?.state === "running") {
|
||||
settingTarget(isGameTile, x, y)
|
||||
} else if (payload?.game?.state === "starting") {
|
||||
const { index } = intersectingShip(ships, {
|
||||
...mouseCursor,
|
||||
size: 1,
|
||||
variant: 0,
|
||||
orientation: "h",
|
||||
})
|
||||
if (typeof index === "undefined")
|
||||
settingTarget(isGameTile, x, y)
|
||||
else {
|
||||
const ship = ships[index]
|
||||
useGameProps.setState({ mode: ship.size - 2 })
|
||||
removeShip(ship)
|
||||
setMouseCursor((e) => ({ ...e, shouldShow: true }))
|
||||
}
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() =>
|
||||
setMouseCursor({
|
||||
x,
|
||||
y,
|
||||
shouldShow:
|
||||
isGameTile &&
|
||||
(payload?.game?.state === "starting"
|
||||
? intersectingShip(
|
||||
ships,
|
||||
shipProps(ships, mode, {
|
||||
x,
|
||||
y,
|
||||
orientation: targetPreview.orientation,
|
||||
}),
|
||||
true,
|
||||
).score < 2
|
||||
: true),
|
||||
})
|
||||
}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default BorderTiles
|
395
leaky-ships/components/Gamefield/EventBar.tsx
Normal file
395
leaky-ships/components/Gamefield/EventBar.tsx
Normal file
|
@ -0,0 +1,395 @@
|
|||
import {
|
||||
faSquare2,
|
||||
faSquare3,
|
||||
faSquare4,
|
||||
} from "@fortawesome/pro-regular-svg-icons"
|
||||
import {
|
||||
faBroomWide,
|
||||
faCheck,
|
||||
faComments,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFlag,
|
||||
faGlasses,
|
||||
faLock,
|
||||
faPalette,
|
||||
faReply,
|
||||
faRotate,
|
||||
faScribble,
|
||||
faShip,
|
||||
faSparkles,
|
||||
faSwords,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { useDrawProps } from "@hooks/useDrawProps"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import useIndex from "@hooks/useIndex"
|
||||
import useShips from "@hooks/useShips"
|
||||
import { socket } from "@lib/socket"
|
||||
import { modes } from "@lib/utils/helpers"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import { useRouter } from "next/router"
|
||||
import { useCallback, useEffect, useMemo } from "react"
|
||||
import { Icons, toast } from "react-toastify"
|
||||
import { EventBarModes, GameSettings } from "../../interfaces/frontend"
|
||||
import Item from "./Item"
|
||||
|
||||
export function setGameSetting(
|
||||
payload: GameSettings,
|
||||
setSetting: (settings: GameSettings) => string | null,
|
||||
full: (payload: GamePropsSchema) => void,
|
||||
) {
|
||||
return () => {
|
||||
const hash = setSetting(payload)
|
||||
socket.emit("gameSetting", payload, (newHash) => {
|
||||
if (newHash === hash) return
|
||||
console.log("hash", hash, newHash)
|
||||
socket.emit("update", full)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function EventBar({ clear }: { clear: () => void }) {
|
||||
const { shouldHide, color } = useDrawProps()
|
||||
const { selfIndex, isActiveIndex, selfUser } = useIndex()
|
||||
const { ships } = useShips()
|
||||
const router = useRouter()
|
||||
const {
|
||||
payload,
|
||||
userStates,
|
||||
menu,
|
||||
mode,
|
||||
setSetting,
|
||||
full,
|
||||
target,
|
||||
setTarget,
|
||||
setTargetPreview,
|
||||
setIsReady,
|
||||
reset,
|
||||
} = useGameProps()
|
||||
const gameSetting = useCallback(
|
||||
(payload: GameSettings) => setGameSetting(payload, setSetting, full),
|
||||
[full, setSetting],
|
||||
)
|
||||
|
||||
const items = useMemo<EventBarModes>(
|
||||
() => ({
|
||||
main: [
|
||||
{
|
||||
icon: "burger-menu",
|
||||
text: "Menu",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "menu" })
|
||||
},
|
||||
},
|
||||
payload?.game?.state === "running"
|
||||
? {
|
||||
icon: faSwords,
|
||||
text: "Attack",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "moves" })
|
||||
},
|
||||
}
|
||||
: {
|
||||
icon: faShip,
|
||||
text: "Ships",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "moves" })
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "pen",
|
||||
text: "Draw",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "draw" })
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "gear",
|
||||
text: "Settings",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "settings" })
|
||||
},
|
||||
},
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
icon: faFlag,
|
||||
text: "Surrender",
|
||||
iconColor: "darkred",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "surrender" })
|
||||
},
|
||||
},
|
||||
],
|
||||
moves:
|
||||
payload?.game?.state === "running"
|
||||
? [
|
||||
{
|
||||
icon: "scope",
|
||||
text: "Fire missile",
|
||||
enabled: mode === 0,
|
||||
callback: () => {
|
||||
useGameProps.setState({ mode: 0 })
|
||||
setTarget((e) => ({ ...e, show: false }))
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "torpedo",
|
||||
text: "Fire torpedo",
|
||||
enabled: mode === 1 || mode === 2,
|
||||
amount:
|
||||
2 -
|
||||
((selfUser?.moves ?? []).filter(
|
||||
(e) => e.type === "htorpedo" || e.type === "vtorpedo",
|
||||
).length ?? 0),
|
||||
callback: () => {
|
||||
useGameProps.setState({ mode: 1 })
|
||||
setTarget((e) => ({ ...e, show: false }))
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "radar",
|
||||
text: "Radar scan",
|
||||
enabled: mode === 3,
|
||||
amount:
|
||||
1 -
|
||||
((selfUser?.moves ?? []).filter((e) => e.type === "radar")
|
||||
.length ?? 0),
|
||||
callback: () => {
|
||||
useGameProps.setState({ mode: 3 })
|
||||
setTarget((e) => ({ ...e, show: false }))
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: faSquare2,
|
||||
text: "Minensucher",
|
||||
amount: 1 - ships.filter((e) => e.size === 2).length,
|
||||
callback: () => {
|
||||
if (1 - ships.filter((e) => e.size === 2).length === 0) return
|
||||
useGameProps.setState({ mode: 0 })
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faSquare3,
|
||||
text: "Kreuzer",
|
||||
amount: 3 - ships.filter((e) => e.size === 3).length,
|
||||
callback: () => {
|
||||
if (3 - ships.filter((e) => e.size === 3).length === 0) return
|
||||
useGameProps.setState({ mode: 1 })
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faSquare4,
|
||||
text: "Schlachtschiff",
|
||||
amount: 2 - ships.filter((e) => e.size === 4).length,
|
||||
callback: () => {
|
||||
if (2 - ships.filter((e) => e.size === 4).length === 0) return
|
||||
useGameProps.setState({ mode: 2 })
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faRotate,
|
||||
text: "Rotate",
|
||||
callback: () => {
|
||||
setTargetPreview((t) => ({
|
||||
...t,
|
||||
orientation: t.orientation === "h" ? "v" : "h",
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
{ icon: faBroomWide, text: "Clear", callback: clear },
|
||||
{ icon: faPalette, text: "Color", iconColor: color },
|
||||
{
|
||||
icon: shouldHide ? faEye : faEyeSlash,
|
||||
text: shouldHide ? "Show" : "Hide",
|
||||
callback: () => {
|
||||
useDrawProps.setState({ shouldHide: !shouldHide })
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: [
|
||||
{
|
||||
icon: faGlasses,
|
||||
text: "Spectators",
|
||||
disabled: !payload?.game?.allowSpectators,
|
||||
callback: gameSetting({
|
||||
allowSpectators: !payload?.game?.allowSpectators,
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: faSparkles,
|
||||
text: "Specials",
|
||||
disabled: !payload?.game?.allowSpecials,
|
||||
callback: gameSetting({
|
||||
allowSpecials: !payload?.game?.allowSpecials,
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: faComments,
|
||||
text: "Chat",
|
||||
disabled: !payload?.game?.allowChat,
|
||||
callback: gameSetting({ allowChat: !payload?.game?.allowChat }),
|
||||
},
|
||||
{
|
||||
icon: faScribble,
|
||||
text: "Mark/Draw",
|
||||
disabled: !payload?.game?.allowMarkDraw,
|
||||
callback: gameSetting({
|
||||
allowMarkDraw: !payload?.game?.allowMarkDraw,
|
||||
}),
|
||||
},
|
||||
],
|
||||
surrender: [
|
||||
{
|
||||
icon: faCheck,
|
||||
text: "Yes",
|
||||
iconColor: "green",
|
||||
callback: async () => {
|
||||
socket.emit("gameState", "aborted")
|
||||
await router.push("/")
|
||||
reset()
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faXmark,
|
||||
text: "No",
|
||||
iconColor: "red",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "main" })
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
[
|
||||
payload?.game?.state,
|
||||
payload?.game?.allowSpectators,
|
||||
payload?.game?.allowSpecials,
|
||||
payload?.game?.allowChat,
|
||||
payload?.game?.allowMarkDraw,
|
||||
mode,
|
||||
selfUser?.moves,
|
||||
ships,
|
||||
clear,
|
||||
color,
|
||||
shouldHide,
|
||||
gameSetting,
|
||||
setTarget,
|
||||
setTargetPreview,
|
||||
router,
|
||||
reset,
|
||||
],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
menu !== "moves" ||
|
||||
payload?.game?.state !== "starting" ||
|
||||
mode < 0 ||
|
||||
items.moves[mode].amount
|
||||
)
|
||||
return
|
||||
const index = items.moves.findIndex((e) => e.amount)
|
||||
useGameProps.setState({ mode: index })
|
||||
}, [items.moves, menu, mode, payload?.game?.state])
|
||||
|
||||
useEffect(() => {
|
||||
useDrawProps.setState({ enable: menu === "draw" })
|
||||
}, [menu])
|
||||
|
||||
useEffect(() => {
|
||||
if (payload?.game?.state !== "running") return
|
||||
|
||||
let toastId = "otherPlayer"
|
||||
if (isActiveIndex) toast.dismiss(toastId)
|
||||
else
|
||||
toast.info("Waiting for other player...", {
|
||||
toastId,
|
||||
position: "top-right",
|
||||
icon: Icons.spinner(),
|
||||
autoClose: false,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
})
|
||||
|
||||
// toastId = "connect_error"
|
||||
// const isActive = toast.isActive(toastId)
|
||||
// console.log(toastId, isActive)
|
||||
// if (isActive)
|
||||
// toast.update(toastId, {
|
||||
// autoClose: 5000,
|
||||
// })
|
||||
// else
|
||||
// toast.warn("Spie", { toastId })
|
||||
}, [isActiveIndex, menu, payload?.game?.state])
|
||||
|
||||
return (
|
||||
<div className="event-bar">
|
||||
{menu !== "main" ? (
|
||||
<Item
|
||||
props={{
|
||||
icon: faReply,
|
||||
text: "Return",
|
||||
iconColor: "#555",
|
||||
callback: () => {
|
||||
useGameProps.setState({ menu: "main" })
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{items[menu].map((e, i) => {
|
||||
if (!isActiveIndex && menu === "main" && i === 1) return
|
||||
return <Item key={i} props={e} />
|
||||
})}
|
||||
{menu === "moves" ? (
|
||||
<Item
|
||||
props={{
|
||||
icon:
|
||||
selfIndex >= 0 && userStates[selfIndex].isReady
|
||||
? faLock
|
||||
: faCheck,
|
||||
text:
|
||||
selfIndex >= 0 && userStates[selfIndex].isReady
|
||||
? "unready"
|
||||
: "Done",
|
||||
disabled:
|
||||
payload?.game?.state === "starting" ? mode >= 0 : undefined,
|
||||
enabled:
|
||||
payload?.game?.state === "running" && mode >= 0 && target.show,
|
||||
callback: () => {
|
||||
if (selfIndex < 0) return
|
||||
switch (payload?.game?.state) {
|
||||
case "starting":
|
||||
const isReady = !userStates[selfIndex].isReady
|
||||
setIsReady({ isReady, i: selfIndex })
|
||||
socket.emit("isReady", isReady)
|
||||
break
|
||||
|
||||
case "running":
|
||||
const i = (selfUser?.moves ?? [])
|
||||
.map((e) => e.index)
|
||||
.reduce((prev, curr) => (curr > prev ? curr : prev), 0)
|
||||
const props = {
|
||||
type: modes[mode].type,
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
orientation: target.orientation,
|
||||
index: (selfUser?.moves ?? []).length ? i + 1 : 0,
|
||||
}
|
||||
socket.emit("dispatchMove", props)
|
||||
setTarget((t) => ({ ...t, show: false }))
|
||||
break
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventBar
|
13
leaky-ships/components/Gamefield/FogImages.tsx
Normal file
13
leaky-ships/components/Gamefield/FogImages.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
function FogImages() {
|
||||
return (
|
||||
<>
|
||||
<img className="fog left" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img className="fog right" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img className="fog top" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img className="fog bottom" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img className="fog middle" src={`/fog/fog4.png`} alt={`fog4.png`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FogImages
|
145
leaky-ships/components/Gamefield/Gamefield.tsx
Normal file
145
leaky-ships/components/Gamefield/Gamefield.tsx
Normal file
|
@ -0,0 +1,145 @@
|
|||
// import Bluetooth from "./Bluetooth"
|
||||
// import FogImages from "./FogImages"
|
||||
import BorderTiles from "@components/Gamefield/BorderTiles"
|
||||
import EventBar from "@components/Gamefield/EventBar"
|
||||
import HitElems from "@components/Gamefield/HitElems"
|
||||
import Targets from "@components/Gamefield/Targets"
|
||||
import { useDraw } from "@hooks/useDraw"
|
||||
import { useDrawProps } from "@hooks/useDrawProps"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import useIndex from "@hooks/useIndex"
|
||||
import useSocket from "@hooks/useSocket"
|
||||
import { socket } from "@lib/socket"
|
||||
import { overlapsWithAnyBorder } from "@lib/utils/helpers"
|
||||
import { useRouter } from "next/router"
|
||||
import { CSSProperties, useEffect } from "react"
|
||||
import { toast } from "react-toastify"
|
||||
import Labeling from "./Labeling"
|
||||
import Ships from "./Ships"
|
||||
|
||||
export const count = 12
|
||||
|
||||
function Gamefield() {
|
||||
const { selfUser } = useIndex()
|
||||
const router = useRouter()
|
||||
const {
|
||||
userStates,
|
||||
mode,
|
||||
target,
|
||||
mouseCursor,
|
||||
setMouseCursor,
|
||||
payload,
|
||||
setTargetPreview,
|
||||
full,
|
||||
reset,
|
||||
} = useGameProps()
|
||||
const { isConnected } = useSocket()
|
||||
|
||||
const { canvasRef, onMouseDown, clear } = useDraw()
|
||||
const { enable, color, shouldHide } = useDrawProps()
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
payload?.game?.state !== "starting" ||
|
||||
userStates.reduce((prev, curr) => prev || !curr.isReady, false)
|
||||
)
|
||||
return
|
||||
socket.emit("ships", selfUser?.ships ?? [])
|
||||
socket.emit("gameState", "running")
|
||||
}, [payload?.game?.state, selfUser?.ships, userStates])
|
||||
|
||||
useEffect(() => {
|
||||
if (payload?.game?.id || !isConnected) return
|
||||
socket.emit("update", full)
|
||||
}, [full, payload?.game?.id, isConnected])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode < 0) return
|
||||
const { x, y, show } = target
|
||||
const { shouldShow, ...position } = mouseCursor
|
||||
if (
|
||||
!shouldShow ||
|
||||
(payload?.game?.state === "running" &&
|
||||
overlapsWithAnyBorder(position, mode))
|
||||
)
|
||||
setTargetPreview((t) => ({ ...t, show: false }))
|
||||
else {
|
||||
setTargetPreview((t) => ({
|
||||
...t,
|
||||
...position,
|
||||
show: !show || x !== position.x || y !== position.y,
|
||||
}))
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key !== "r") return
|
||||
if (payload?.game?.state === "starting") {
|
||||
setTargetPreview((t) => ({
|
||||
...t,
|
||||
orientation: t.orientation === "h" ? "v" : "h",
|
||||
}))
|
||||
}
|
||||
if (payload?.game?.state === "running" && (mode === 1 || mode === 2))
|
||||
useGameProps.setState({ mode: mode === 1 ? 2 : 1 })
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyPress)
|
||||
return () => {
|
||||
document.removeEventListener("keydown", handleKeyPress)
|
||||
}
|
||||
}
|
||||
}, [mode, mouseCursor, payload?.game?.state, setTargetPreview, target])
|
||||
|
||||
useEffect(() => {
|
||||
if (payload?.game?.state !== "aborted") return
|
||||
toast.info("Enemy gave up!")
|
||||
router.push("/")
|
||||
reset()
|
||||
}, [payload?.game?.state, reset, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (payload?.game?.id) return
|
||||
const timeout = setTimeout(() => router.push("/"), 5000)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [payload?.game?.id, router])
|
||||
|
||||
return (
|
||||
<div id="gamefield">
|
||||
{/* <Bluetooth /> */}
|
||||
<div
|
||||
id="game-frame"
|
||||
style={{ "--i": count } as CSSProperties}
|
||||
onMouseLeave={() =>
|
||||
setMouseCursor((e) => ({ ...e, shouldShow: false }))
|
||||
}
|
||||
>
|
||||
<BorderTiles />
|
||||
|
||||
{/* Collumn lettes and row numbers */}
|
||||
<Labeling />
|
||||
|
||||
<Ships />
|
||||
|
||||
<HitElems />
|
||||
|
||||
{/* <FogImages /> */}
|
||||
|
||||
<Targets />
|
||||
|
||||
<canvas
|
||||
style={
|
||||
{
|
||||
opacity: shouldHide ? 0 : 1,
|
||||
boxShadow: enable ? "inset 0 0 0 2px " + color : "none",
|
||||
pointerEvents: enable && !shouldHide ? "auto" : "none",
|
||||
} as CSSProperties
|
||||
}
|
||||
ref={canvasRef}
|
||||
onMouseDown={onMouseDown}
|
||||
width="648"
|
||||
height="648"
|
||||
/>
|
||||
</div>
|
||||
<EventBar clear={clear} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Gamefield
|
33
leaky-ships/components/Gamefield/GamefieldPointer.tsx
Normal file
33
leaky-ships/components/Gamefield/GamefieldPointer.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import { faCrosshairs } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { faRadar } from "@fortawesome/pro-thin-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import classNames from "classnames"
|
||||
import { CSSProperties } from "react"
|
||||
import { PointerProps } from "../../interfaces/frontend"
|
||||
|
||||
function GamefieldPointer({
|
||||
props: { x, y, show, type, edges, imply },
|
||||
preview,
|
||||
}: {
|
||||
props: PointerProps
|
||||
preview?: boolean
|
||||
}) {
|
||||
const isRadar = type === "radar"
|
||||
const style = !(isRadar && !edges.filter((s) => s).length)
|
||||
? { "--x": x, "--y": y }
|
||||
: { "--x1": x - 1, "--x2": x + 2, "--y1": y - 1, "--y2": y + 2 }
|
||||
return (
|
||||
<div
|
||||
className={classNames("hit-svg", "target", type, ...edges, {
|
||||
preview: preview,
|
||||
show: show,
|
||||
imply: imply,
|
||||
})}
|
||||
style={style as CSSProperties}
|
||||
>
|
||||
<FontAwesomeIcon icon={!isRadar ? faCrosshairs : faRadar} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GamefieldPointer
|
36
leaky-ships/components/Gamefield/HitElems.tsx
Normal file
36
leaky-ships/components/Gamefield/HitElems.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import { faBurst, faXmark } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import useIndex from "@hooks/useIndex"
|
||||
import { CSSProperties } from "react"
|
||||
import { Hit } from "../../interfaces/frontend"
|
||||
|
||||
function HitElems({
|
||||
props,
|
||||
}: {
|
||||
props?: { hits: Hit[]; colorOverride?: string }
|
||||
}) {
|
||||
const { activeUser } = useIndex()
|
||||
|
||||
return (
|
||||
<>
|
||||
{(props?.hits ?? activeUser?.hits ?? []).map(({ hit, x, y }, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="hit-svg"
|
||||
style={{ "--x": x, "--y": y } as CSSProperties}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
icon={hit ? faBurst : faXmark}
|
||||
style={
|
||||
{
|
||||
color: props?.colorOverride || (hit ? "red" : undefined),
|
||||
} as CSSProperties
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default HitElems
|
73
leaky-ships/components/Gamefield/Item.tsx
Normal file
73
leaky-ships/components/Gamefield/Item.tsx
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { useDrawProps } from "@hooks/useDrawProps"
|
||||
import classNames from "classnames"
|
||||
import { CSSProperties, useEffect, useRef, useState } from "react"
|
||||
import { HexColorPicker } from "react-colorful"
|
||||
import { ItemProps } from "../../interfaces/frontend"
|
||||
|
||||
function Item({
|
||||
props: { icon, text, amount, iconColor, disabled, enabled, callback },
|
||||
}: {
|
||||
props: ItemProps
|
||||
}) {
|
||||
const isColor = text === "Color"
|
||||
const { color, setColor } = useDrawProps()
|
||||
const [active, setActive] = useState(false)
|
||||
const cpRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const inActive = (e: MouseEvent) => {
|
||||
if (cpRef.current && !cpRef.current.contains(e.target as Node))
|
||||
setActive(false)
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
if (!isColor) return
|
||||
setTimeout(() => window.addEventListener("click", inActive), 200)
|
||||
|
||||
// Remove event listeners
|
||||
return () => {
|
||||
window.removeEventListener("click", inActive)
|
||||
}
|
||||
}, [active, isColor])
|
||||
|
||||
return (
|
||||
<div className="item" onClick={isColor ? () => setActive(true) : callback}>
|
||||
{isColor ? (
|
||||
<div
|
||||
ref={cpRef}
|
||||
className={classNames("react-colorful-wrapper", { active: active })}
|
||||
>
|
||||
<HexColorPicker color={color} onChange={setColor} />
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames("container", {
|
||||
amount: typeof amount !== "undefined",
|
||||
disabled: disabled || amount === 0,
|
||||
enabled: disabled === false || enabled,
|
||||
})}
|
||||
style={
|
||||
typeof amount !== "undefined"
|
||||
? ({
|
||||
"--amount": JSON.stringify(amount.toString()),
|
||||
} as CSSProperties)
|
||||
: {}
|
||||
}
|
||||
>
|
||||
{typeof icon === "string" ? (
|
||||
<img
|
||||
src={`/assets/${icon}.png`}
|
||||
alt={`${icon}.png`}
|
||||
className="pixelart"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={icon} color={iconColor ?? "#444"} />
|
||||
)}
|
||||
</div>
|
||||
<span>{text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
|
@ -1,6 +1,6 @@
|
|||
import { fieldIndex } from "@lib/utils/helpers"
|
||||
import classNames from "classnames"
|
||||
import { For } from "solid-js"
|
||||
import { fieldIndex } from "~/lib/utils/helpers"
|
||||
import { CSSProperties } from "react"
|
||||
import { Field } from "../../interfaces/frontend"
|
||||
import { count } from "./Gamefield"
|
||||
|
||||
|
@ -34,16 +34,17 @@ function Labeling() {
|
|||
(a, b) => fieldIndex(count, a.x, a.y) - fieldIndex(count, b.x, b.y),
|
||||
)
|
||||
return (
|
||||
<For each={elems}>
|
||||
{(props) => (
|
||||
<>
|
||||
{elems.map(({ field, x, y, orientation }, i) => (
|
||||
<span
|
||||
class={classNames("label", props.orientation, props.field)}
|
||||
style={{ "--x": props.x, "--y": props.y }}
|
||||
key={i}
|
||||
className={classNames("label", orientation, field)}
|
||||
style={{ "--x": x, "--y": y } as CSSProperties}
|
||||
>
|
||||
{props.field}
|
||||
{field}
|
||||
</span>
|
||||
)}
|
||||
</For>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
62
leaky-ships/components/Gamefield/Ship.tsx
Normal file
62
leaky-ships/components/Gamefield/Ship.tsx
Normal file
|
@ -0,0 +1,62 @@
|
|||
import classNames from "classnames"
|
||||
import { CSSProperties, useEffect, useRef } from "react"
|
||||
import { ShipProps } from "../../interfaces/frontend"
|
||||
|
||||
const sizes: { [n: number]: number } = {
|
||||
2: 96,
|
||||
3: 144,
|
||||
4: 196,
|
||||
}
|
||||
|
||||
function Ship({
|
||||
props: { size, variant, x, y, orientation },
|
||||
preview,
|
||||
warn,
|
||||
color,
|
||||
}: {
|
||||
props: ShipProps
|
||||
preview?: boolean
|
||||
warn?: boolean
|
||||
color?: string
|
||||
}) {
|
||||
const filename = `ship_blue_${size}x_${variant}.gif`
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
const ctx = canvas?.getContext("2d")
|
||||
if (!canvas || !ctx) return
|
||||
const gif = new Image()
|
||||
gif.src = "/assets/" + filename
|
||||
|
||||
// Load the GIF and start rendering
|
||||
gif.onload = function () {
|
||||
// Set the canvas size to match the GIF dimensions
|
||||
canvas.width = orientation === "h" ? sizes[size] : 48
|
||||
canvas.height = orientation === "v" ? sizes[size] : 48
|
||||
|
||||
if (orientation === "v")
|
||||
// Rotate the canvas by 90 degrees
|
||||
ctx.rotate((90 * Math.PI) / 180)
|
||||
|
||||
// Draw the rotated GIF
|
||||
ctx.drawImage(gif, 0, orientation === "h" ? 0 : -48, sizes[size], 48)
|
||||
}
|
||||
}, [filename, orientation, size, x, y])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames("ship", "s" + size, orientation, {
|
||||
preview: preview,
|
||||
warn: warn,
|
||||
})}
|
||||
style={
|
||||
{ "--x": x, "--y": y, "--color": color ?? "limegreen" } as CSSProperties
|
||||
}
|
||||
>
|
||||
<canvas ref={canvasRef} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ship
|
16
leaky-ships/components/Gamefield/Ships.tsx
Normal file
16
leaky-ships/components/Gamefield/Ships.tsx
Normal file
|
@ -0,0 +1,16 @@
|
|||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import useIndex from "@hooks/useIndex"
|
||||
import Ship from "./Ship"
|
||||
|
||||
function Ships() {
|
||||
const { payload } = useGameProps()
|
||||
const { isActiveIndex, selfUser } = useIndex()
|
||||
|
||||
if (payload?.game?.state === "running" && isActiveIndex) return null
|
||||
|
||||
return (
|
||||
<>{selfUser?.ships.map((props, i) => <Ship key={i} props={props} />)}</>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ships
|
71
leaky-ships/components/Gamefield/Targets.tsx
Normal file
71
leaky-ships/components/Gamefield/Targets.tsx
Normal file
|
@ -0,0 +1,71 @@
|
|||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import useIndex from "@hooks/useIndex"
|
||||
import useShips from "@hooks/useShips"
|
||||
import {
|
||||
composeTargetTiles,
|
||||
intersectingShip,
|
||||
shipProps,
|
||||
} from "@lib/utils/helpers"
|
||||
import GamefieldPointer from "./GamefieldPointer"
|
||||
import HitElems from "./HitElems"
|
||||
import Ship from "./Ship"
|
||||
|
||||
function Targets() {
|
||||
const { activeUser } = useIndex()
|
||||
const { payload, target, targetPreview, mode } = useGameProps()
|
||||
const { ships } = useShips()
|
||||
|
||||
switch (payload?.game?.state) {
|
||||
case "running":
|
||||
return (
|
||||
<>
|
||||
{[
|
||||
...composeTargetTiles(target, mode, activeUser?.hits ?? []).map(
|
||||
(props, i) => <GamefieldPointer key={"t" + i} props={props} />,
|
||||
),
|
||||
...composeTargetTiles(
|
||||
targetPreview,
|
||||
mode,
|
||||
activeUser?.hits ?? [],
|
||||
).map((props, i) => (
|
||||
<GamefieldPointer key={"p" + i} props={props} preview />
|
||||
)),
|
||||
]}
|
||||
</>
|
||||
)
|
||||
|
||||
case "starting":
|
||||
if (mode < 0 && !targetPreview.show) return null
|
||||
const ship = shipProps(ships, mode, targetPreview)
|
||||
const { fields, borders, score } = intersectingShip(ships, ship)
|
||||
return (
|
||||
<>
|
||||
<Ship
|
||||
preview
|
||||
warn={score > 0}
|
||||
color={
|
||||
fields.length ? "red" : borders.length ? "orange" : undefined
|
||||
}
|
||||
key={targetPreview.orientation}
|
||||
props={ship}
|
||||
/>
|
||||
<HitElems
|
||||
props={{
|
||||
hits: fields.map((e, i) => ({ ...e, i, hit: true })),
|
||||
}}
|
||||
/>
|
||||
<HitElems
|
||||
props={{
|
||||
hits: borders.map((e, i) => ({ ...e, i, hit: true })),
|
||||
colorOverride: "orange",
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export default Targets
|
115
leaky-ships/components/Grid.tsx
Normal file
115
leaky-ships/components/Grid.tsx
Normal file
|
@ -0,0 +1,115 @@
|
|||
import classNames from "classnames"
|
||||
import { CSSProperties, useEffect, useMemo, useState } from "react"
|
||||
|
||||
function Grid() {
|
||||
function floorClient(number: number) {
|
||||
return Math.floor(number / 50)
|
||||
}
|
||||
|
||||
const [columns, setColumns] = useState(0)
|
||||
const [rows, setRows] = useState(0)
|
||||
const [params, setParams] = useState({
|
||||
columns,
|
||||
rows,
|
||||
quantity: columns * rows,
|
||||
})
|
||||
const [position, setPosition] = useState([0, 0])
|
||||
const [active, setActve] = useState(false)
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setColumns(floorClient(document.body.clientWidth))
|
||||
setRows(floorClient(document.body.clientHeight))
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setParams({ columns, rows, quantity: columns * rows })
|
||||
}, 500)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [columns, rows])
|
||||
|
||||
const createTiles = useMemo(() => {
|
||||
const colors = [
|
||||
"rgb(229, 57, 53)",
|
||||
"rgb(253, 216, 53)",
|
||||
"rgb(244, 81, 30)",
|
||||
"rgb(76, 175, 80)",
|
||||
"rgb(33, 150, 243)",
|
||||
"rgb(156, 39, 176)",
|
||||
]
|
||||
|
||||
function createTile(index: number) {
|
||||
const x = index % params.columns
|
||||
const y = Math.floor(index / params.columns)
|
||||
const xDiff = (x - position[0]) / 20
|
||||
const yDiff = (y - position[1]) / 20
|
||||
const pos = Math.sqrt(xDiff * xDiff + yDiff * yDiff).toFixed(2)
|
||||
|
||||
function doEffect(posX: number, posY: number) {
|
||||
if (active) return
|
||||
setPosition([posX, posY])
|
||||
setActve(true)
|
||||
|
||||
function xDiff(x: number) {
|
||||
return (x - posX) / 20
|
||||
}
|
||||
function yDiff(y: number) {
|
||||
return (y - posY) / 20
|
||||
}
|
||||
function pos(x: number, y: number) {
|
||||
return Math.sqrt(xDiff(x) * xDiff(x) + yDiff(y) * yDiff(y))
|
||||
}
|
||||
const diagonals = [
|
||||
pos(0, 0),
|
||||
pos(params.columns, 0),
|
||||
pos(0, params.rows),
|
||||
pos(params.columns, params.rows),
|
||||
]
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
setActve(false)
|
||||
setCount((e) => e + 1)
|
||||
},
|
||||
Math.max(...diagonals) * 1000 + 300,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames("tile", { active: active })}
|
||||
style={{ "--delay": pos + "s" } as CSSProperties}
|
||||
onClick={() => doEffect(x, y)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tiles"
|
||||
style={
|
||||
{
|
||||
"--columns": params.columns,
|
||||
"--rows": params.rows,
|
||||
"--bg-color-1": colors[count % colors.length],
|
||||
"--bg-color-2": colors[(count + 1) % colors.length],
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
{Array.from(Array(params.quantity), (_tile, index) =>
|
||||
createTile(index),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [params, position, active, count])
|
||||
|
||||
return createTiles
|
||||
}
|
||||
|
||||
export default Grid
|
121
leaky-ships/components/Grid2.tsx
Normal file
121
leaky-ships/components/Grid2.tsx
Normal file
|
@ -0,0 +1,121 @@
|
|||
import classNames from "classnames"
|
||||
import { CSSProperties, useEffect, useMemo, useState } from "react"
|
||||
|
||||
function Grid2() {
|
||||
function floorClient(number: number) {
|
||||
return Math.floor(number / 50)
|
||||
}
|
||||
|
||||
const [columns, setColumns] = useState(0)
|
||||
const [rows, setRows] = useState(0)
|
||||
const [params, setParams] = useState({
|
||||
columns,
|
||||
rows,
|
||||
quantity: columns * rows,
|
||||
})
|
||||
const [position, setPosition] = useState([0, 0])
|
||||
const [active, setActve] = useState(false)
|
||||
const [action, setAction] = useState(false)
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
function handleResize() {
|
||||
setColumns(floorClient(document.body.clientWidth))
|
||||
setRows(floorClient(document.body.clientHeight))
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(() => {
|
||||
setParams({ columns, rows, quantity: columns * rows })
|
||||
}, 500)
|
||||
return () => clearTimeout(timeout)
|
||||
}, [columns, rows])
|
||||
|
||||
const createTiles = useMemo(() => {
|
||||
const sentences = [
|
||||
"Ethem ...",
|
||||
"hat ...",
|
||||
"lange ...",
|
||||
"Hörner 🐂",
|
||||
"Grüße von Mallorca 🌊 🦦 ☀️",
|
||||
]
|
||||
|
||||
function createTile(index: number) {
|
||||
const x = index % params.columns
|
||||
const y = Math.floor(index / params.columns)
|
||||
const xDiff = (x - position[0]) / 20
|
||||
const yDiff = (y - position[1]) / 20
|
||||
const pos = Math.sqrt(xDiff * xDiff + yDiff * yDiff).toFixed(2)
|
||||
|
||||
function doEffect(posX: number, posY: number) {
|
||||
if (action) return
|
||||
setPosition([posX, posY])
|
||||
setActve((e) => !e)
|
||||
setAction(true)
|
||||
|
||||
function xDiff(x: number) {
|
||||
return (x - posX) / 20
|
||||
}
|
||||
function yDiff(y: number) {
|
||||
return (y - posY) / 20
|
||||
}
|
||||
function pos(x: number, y: number) {
|
||||
return Math.sqrt(xDiff(x) * xDiff(x) + yDiff(y) * yDiff(y))
|
||||
}
|
||||
const diagonals = [
|
||||
pos(0, 0),
|
||||
pos(params.columns, 0),
|
||||
pos(0, params.rows),
|
||||
pos(params.columns, params.rows),
|
||||
]
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
setAction(false)
|
||||
if (active) setCount((e) => e + 1)
|
||||
},
|
||||
Math.max(...diagonals) * 1000 + 1000,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={classNames("tile", active ? "active" : "inactive")}
|
||||
style={{ "--delay": pos + "s" } as CSSProperties}
|
||||
onClick={() => doEffect(x, y)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tiles"
|
||||
style={
|
||||
{
|
||||
"--columns": params.columns,
|
||||
"--rows": params.rows,
|
||||
} as CSSProperties
|
||||
}
|
||||
>
|
||||
<div className="center-div">
|
||||
<h1
|
||||
className={classNames("headline", !active ? "active" : "inactive")}
|
||||
>
|
||||
{sentences[count % sentences.length]}
|
||||
</h1>
|
||||
</div>
|
||||
{Array.from(Array(params.quantity), (_tile, index) =>
|
||||
createTile(index),
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}, [params, position, active, action, count])
|
||||
|
||||
return createTiles
|
||||
}
|
||||
|
||||
export default Grid2
|
|
@ -1,36 +1,43 @@
|
|||
import classNames from "classnames"
|
||||
import { JSX } from "solid-js"
|
||||
import { ReactNode } from "react"
|
||||
|
||||
function Button(props: {
|
||||
function Button({
|
||||
type,
|
||||
disabled,
|
||||
onClick,
|
||||
children,
|
||||
latching,
|
||||
isLatched,
|
||||
}: {
|
||||
type: "red" | "orange" | "green" | "gray"
|
||||
disabled?: boolean
|
||||
onClick: () => void
|
||||
children: JSX.Element
|
||||
children: ReactNode
|
||||
latching?: boolean
|
||||
isLatched?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
disabled={props.disabled}
|
||||
class={classNames(
|
||||
disabled={disabled}
|
||||
className={classNames(
|
||||
"font-farro rounded-xl px-8 py-4 text-5xl font-medium duration-100",
|
||||
props.disabled
|
||||
disabled
|
||||
? "border-4 border-dashed"
|
||||
: props.latching
|
||||
? props.isLatched
|
||||
: latching
|
||||
? isLatched
|
||||
? "mx-1 my-0.5 border-t-4"
|
||||
: "mx-1 my-0.5 border-b-4"
|
||||
: "mx-1 my-0.5 border-b-4 active:border-b-0 active:border-t-4",
|
||||
{
|
||||
"border-red-600 bg-red-500": props.type === "red",
|
||||
"border-orange-400 bg-warn": props.type === "orange",
|
||||
"border-green-600 bg-green-500": props.type === "green",
|
||||
"border-gray-600 bg-gray-500": props.type === "gray",
|
||||
"border-red-600 bg-red-500": type === "red",
|
||||
"border-orange-400 bg-warn": type === "orange",
|
||||
"border-green-600 bg-green-500": type === "green",
|
||||
"border-gray-600 bg-gray-500": type === "gray",
|
||||
},
|
||||
)}
|
||||
onClick={() => props.onClick()}
|
||||
onClick={onClick}
|
||||
>
|
||||
{props.children}
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
27
leaky-ships/components/Lobby/Icon.tsx
Normal file
27
leaky-ships/components/Lobby/Icon.tsx
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { ReactNode } from "react"
|
||||
|
||||
function Icon({
|
||||
src,
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
src: string
|
||||
children: ReactNode
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="mx-4 mt-4 flex flex-col items-center border-none"
|
||||
onClick={onClick}
|
||||
>
|
||||
<img
|
||||
className="pixelart mb-1 box-content w-16 rounded-xl bg-white p-1"
|
||||
src={"/assets/" + src}
|
||||
alt={src}
|
||||
/>
|
||||
<span className="font-semibold">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
143
leaky-ships/components/Lobby/LobbyFrame.tsx
Normal file
143
leaky-ships/components/Lobby/LobbyFrame.tsx
Normal file
|
@ -0,0 +1,143 @@
|
|||
import {
|
||||
faRightFromBracket,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import useSocket from "@hooks/useSocket"
|
||||
import { socket } from "@lib/socket"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useRouter } from "next/router"
|
||||
import { Fragment, ReactNode, useEffect, useMemo, useState } from "react"
|
||||
import Button from "./Button"
|
||||
import Icon from "./Icon"
|
||||
import Player from "./Player"
|
||||
|
||||
function WithDots({ children }: { children: ReactNode }) {
|
||||
const [dots, setDots] = useState(1)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setDots((e) => (e % 3) + 1), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
{children + " "}
|
||||
{Array.from(Array(dots), () => ".").join("")}
|
||||
{Array.from(Array(3 - dots), (_, i) => (
|
||||
<Fragment key={i}> </Fragment>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LobbyFrame({ openSettings }: { openSettings: () => void }) {
|
||||
const { payload, userStates, full, leave, reset } = useGameProps()
|
||||
const { isConnected } = useSocket()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
const [launchTime, setLaunchTime] = useState(3)
|
||||
|
||||
const launching = useMemo(
|
||||
() =>
|
||||
payload?.users.length === 2 &&
|
||||
!userStates.filter((user) => !user.isReady).length,
|
||||
[payload?.users.length, userStates],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!launching || launchTime > 0) return
|
||||
socket.emit("gameState", "starting")
|
||||
}, [launching, launchTime, router])
|
||||
|
||||
useEffect(() => {
|
||||
if (!launching) return setLaunchTime(3)
|
||||
if (launchTime < 0) return
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setLaunchTime((e) => e - 1)
|
||||
}, 1000)
|
||||
|
||||
return () => clearTimeout(timeout)
|
||||
}, [launching, launchTime])
|
||||
|
||||
useEffect(() => {
|
||||
if (payload?.game?.id || !isConnected) return
|
||||
socket.emit("update", full)
|
||||
}, [full, payload?.game?.id, isConnected])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
typeof payload?.game?.state !== "string" ||
|
||||
payload?.game?.state === "lobby"
|
||||
)
|
||||
return
|
||||
router.push("/gamefield")
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="mx-32 flex flex-col self-stretch rounded-3xl bg-gray-400">
|
||||
<div className="flex items-center justify-between border-b-2 border-slate-900">
|
||||
<Icon src="speech_bubble.png">Chat</Icon>
|
||||
<h1 className="font-farro text-5xl font-medium">
|
||||
{launching ? (
|
||||
<WithDots>
|
||||
{launchTime < 0
|
||||
? "Game starts"
|
||||
: "Game is starting in " + launchTime}
|
||||
</WithDots>
|
||||
) : (
|
||||
<>
|
||||
{"Game-PIN: "}
|
||||
{isConnected ? (
|
||||
<span className="underline">{payload?.gamePin ?? "----"}</span>
|
||||
) : (
|
||||
<FontAwesomeIcon icon={faSpinnerThird} spin={true} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</h1>
|
||||
<Icon src="gear.png" onClick={openSettings}>
|
||||
Settings
|
||||
</Icon>
|
||||
</div>
|
||||
<div className="flex items-center justify-around">
|
||||
{isConnected ? (
|
||||
<>
|
||||
<Player src="player_blue.png" i={0} userId={session?.user.id} />
|
||||
<p className="font-farro m-4 text-6xl font-semibold">VS</p>
|
||||
{payload?.users[1] ? (
|
||||
<Player src="player_red.png" i={1} userId={session?.user.id} />
|
||||
) : (
|
||||
<p className="font-farro w-96 text-center text-4xl font-medium">
|
||||
<WithDots>Warte auf Spieler 2</WithDots>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="font-farro m-48 text-center text-6xl font-medium">
|
||||
Warte auf Verbindung
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center justify-around border-t-2 border-slate-900 p-4">
|
||||
<Button
|
||||
type={launching ? "gray" : "red"}
|
||||
disabled={launching}
|
||||
onClick={() => {
|
||||
leave(async () => {
|
||||
reset()
|
||||
await router.push("/")
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span>LEAVE</span>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} className="ml-4 w-12" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LobbyFrame
|
125
leaky-ships/components/Lobby/Player.tsx
Normal file
125
leaky-ships/components/Lobby/Player.tsx
Normal file
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
faCheck,
|
||||
faHandPointer,
|
||||
faHourglass1,
|
||||
faHourglass2,
|
||||
faHourglass3,
|
||||
faHourglassClock,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { faCaretDown } from "@fortawesome/sharp-solid-svg-icons"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import { socket } from "@lib/socket"
|
||||
import classNames from "classnames"
|
||||
import { CSSProperties, useEffect, useMemo, useState } from "react"
|
||||
import Button from "./Button"
|
||||
|
||||
function HourGlass() {
|
||||
const [count, setCount] = useState(3)
|
||||
|
||||
useEffect(() => {
|
||||
const interval = setInterval(() => setCount((e) => (e + 1) % 4), 1000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const icon = useMemo(() => {
|
||||
switch (count) {
|
||||
case 0:
|
||||
return faHourglass3
|
||||
case 1:
|
||||
return faHourglass1
|
||||
case 2:
|
||||
return faHourglass2
|
||||
case 3:
|
||||
return faHourglass3
|
||||
default:
|
||||
return faHourglassClock
|
||||
}
|
||||
}, [count])
|
||||
|
||||
return (
|
||||
<FontAwesomeIcon icon={icon} className="ml-4 w-12" spin={count === 0} />
|
||||
)
|
||||
}
|
||||
|
||||
function Player({
|
||||
src,
|
||||
i,
|
||||
userId,
|
||||
}: {
|
||||
src: string
|
||||
i: number
|
||||
userId?: string
|
||||
}) {
|
||||
const { payload, userStates, setIsReady } = useGameProps()
|
||||
const player = useMemo(() => payload?.users[i], [i, payload?.users])
|
||||
const { isReady, isConnected } = useMemo(() => userStates[i], [i, userStates])
|
||||
const primary = useMemo(
|
||||
() => userId && userId === payload?.users[i]?.id,
|
||||
[i, payload?.users, userId],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex w-96 flex-col items-center gap-4 p-4">
|
||||
<p
|
||||
className={classNames(
|
||||
"font-farro w-max text-5xl",
|
||||
primary ? "font-semibold" : "font-normal",
|
||||
)}
|
||||
>
|
||||
{player?.name ?? "Spieler " + (player?.index === 2 ? "2" : "1")}
|
||||
</p>
|
||||
<div className="relative">
|
||||
<img className="pixelart w-64" src={"/assets/" + src} alt={src} />
|
||||
{primary ? (
|
||||
<button className="absolute right-4 top-4 h-14 w-14 rounded-lg border-2 border-dashed border-warn bg-gray-800 bg-opacity-90">
|
||||
<FontAwesomeIcon
|
||||
className="h-full w-full text-warn"
|
||||
icon={faCaretDown}
|
||||
/>
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
type={isConnected ? (isReady ? "green" : "orange") : "gray"}
|
||||
latching
|
||||
isLatched={!!isReady}
|
||||
onClick={() => {
|
||||
if (!player) return
|
||||
setIsReady({
|
||||
i,
|
||||
isReady: !isReady,
|
||||
})
|
||||
socket.emit("isReady", !isReady)
|
||||
}}
|
||||
disabled={!primary}
|
||||
>
|
||||
Ready
|
||||
{isReady && isConnected ? (
|
||||
<FontAwesomeIcon icon={faCheck} className="ml-4 w-12" />
|
||||
) : primary ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faHandPointer}
|
||||
className="ml-4 w-12"
|
||||
style={
|
||||
{
|
||||
"--fa-bounce-start-scale-x": 1.05,
|
||||
"--fa-bounce-start-scale-y": 0.95,
|
||||
"--fa-bounce-jump-scale-x": 0.95,
|
||||
"--fa-bounce-jump-scale-y": 1.05,
|
||||
"--fa-bounce-land-scale-x": 1.025,
|
||||
"--fa-bounce-land-scale-y": 0.975,
|
||||
"--fa-bounce-height": "-0.125em",
|
||||
} as CSSProperties
|
||||
}
|
||||
bounce
|
||||
/>
|
||||
) : (
|
||||
<HourGlass />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
58
leaky-ships/components/Lobby/SettingsFrame/Setting.tsx
Normal file
58
leaky-ships/components/Lobby/SettingsFrame/Setting.tsx
Normal file
|
@ -0,0 +1,58 @@
|
|||
import { setGameSetting } from "@components/Gamefield/EventBar"
|
||||
import {
|
||||
faToggleLargeOff,
|
||||
faToggleLargeOn,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import classNames from "classnames"
|
||||
import { ReactNode, useMemo } from "react"
|
||||
import { GameSettingKeys } from "../../../interfaces/frontend"
|
||||
|
||||
function Setting({
|
||||
children,
|
||||
prop,
|
||||
}: {
|
||||
children: ReactNode
|
||||
prop: GameSettingKeys
|
||||
}) {
|
||||
const { payload, setSetting, full } = useGameProps()
|
||||
const state = useMemo(() => payload?.game?.[prop], [payload?.game, prop])
|
||||
|
||||
return (
|
||||
<label className="flex items-center justify-between" htmlFor={prop}>
|
||||
<span className="col-span-2 w-96 select-none text-5xl text-white drop-shadow-md">
|
||||
{children}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
className={classNames(
|
||||
"text-md mx-auto rounded-full px-4 drop-shadow-md transition-all",
|
||||
state ? "text-blue-500" : "text-gray-800",
|
||||
{
|
||||
"bg-gray-300 ": state,
|
||||
},
|
||||
)}
|
||||
size="3x"
|
||||
icon={state ? faToggleLargeOn : faToggleLargeOff}
|
||||
/>
|
||||
<input
|
||||
className="bg-none"
|
||||
checked={state}
|
||||
type="checkbox"
|
||||
id={prop}
|
||||
onChange={() =>
|
||||
setGameSetting(
|
||||
{
|
||||
[prop]: !state,
|
||||
},
|
||||
setSetting,
|
||||
full,
|
||||
)
|
||||
}
|
||||
hidden={true}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default Setting
|
77
leaky-ships/components/Lobby/SettingsFrame/Settings.tsx
Normal file
77
leaky-ships/components/Lobby/SettingsFrame/Settings.tsx
Normal file
|
@ -0,0 +1,77 @@
|
|||
import { faRotateLeft } from "@fortawesome/pro-regular-svg-icons"
|
||||
import { faXmark } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import { socket } from "@lib/socket"
|
||||
import { useCallback } from "react"
|
||||
import { GameSettings } from "../../../interfaces/frontend"
|
||||
import Setting from "./Setting"
|
||||
|
||||
function Settings({ closeSettings }: { closeSettings: () => void }) {
|
||||
const { setSetting, full } = useGameProps()
|
||||
|
||||
const gameSetting = useCallback(
|
||||
(payload: GameSettings) => {
|
||||
const hash = setSetting(payload)
|
||||
socket.emit("gameSetting", payload, (newHash) => {
|
||||
if (newHash === hash) return
|
||||
console.log("hash", hash, newHash)
|
||||
socket.emit("update", full)
|
||||
})
|
||||
},
|
||||
[full, setSetting],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center bg-black/40">
|
||||
<div className="w-full max-w-screen-lg">
|
||||
<div className="mx-16 flex flex-col rounded-3xl border-4 border-slate-800 bg-zinc-500 p-8">
|
||||
<div className="flex items-center justify-center">
|
||||
<h1 className="font-farro ml-auto pl-14 text-center text-6xl font-semibold text-white shadow-black drop-shadow-lg">
|
||||
Settings
|
||||
</h1>
|
||||
<button
|
||||
className="right-6 top-6 ml-auto h-14 w-14"
|
||||
onClick={closeSettings}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="h-full w-full text-gray-800 drop-shadow-md"
|
||||
size="3x"
|
||||
icon={faXmark}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-8 rounded-xl bg-zinc-600 p-8">
|
||||
<div className="flex items-center justify-end">
|
||||
<button
|
||||
className="right-12 top-8 h-14 w-14"
|
||||
onClick={() =>
|
||||
gameSetting({
|
||||
allowSpectators: true,
|
||||
allowSpecials: true,
|
||||
allowChat: true,
|
||||
allowMarkDraw: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
className="h-full w-full text-gray-800 drop-shadow-md"
|
||||
size="3x"
|
||||
icon={faRotateLeft}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex flex-col gap-8">
|
||||
<Setting prop="allowSpectators">Erlaube Zuschauer</Setting>
|
||||
<Setting prop="allowSpecials">Erlaube spezial Items</Setting>
|
||||
<Setting prop="allowChat">Erlaube den Chat</Setting>
|
||||
<Setting prop="allowMarkDraw">Erlaube zeichen/makieren</Setting>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
83
leaky-ships/components/Logo.tsx
Normal file
83
leaky-ships/components/Logo.tsx
Normal file
|
@ -0,0 +1,83 @@
|
|||
import classNames from "classnames"
|
||||
|
||||
function Logo({ small }: { small?: boolean }) {
|
||||
return (
|
||||
<div className="relative flex flex-col items-center rounded-sm border-x-4 border-y-2 border-shield-gray bg-shield-lightgray md:border-x-8 md:border-y-4">
|
||||
<h1
|
||||
className={classNames(
|
||||
"font-checkpoint mx-16 my-2 flex flex-col gap-2 border-y-2 border-slate-700 text-center text-2xl leading-tight tracking-widest sm:mx-24 sm:my-3 sm:gap-3 sm:border-y-[3px] sm:text-4xl md:mx-36 md:my-4 md:gap-4 md:border-y-4 md:text-5xl",
|
||||
{ "xl:gap-6 xl:py-2 xl:text-6xl": !small },
|
||||
)}
|
||||
>
|
||||
<span>Leaky</span>
|
||||
<span>Ships</span>
|
||||
</h1>
|
||||
<Screws small={small} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Screws({ small }: { small?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Screw
|
||||
small={small}
|
||||
orientation={classNames("top-1 left-1 sm:top-2 sm:left-2", {
|
||||
"xl:top-4 xl:left-4": !small,
|
||||
})}
|
||||
rotation="rotate-[135deg]"
|
||||
/>
|
||||
<Screw
|
||||
small={small}
|
||||
orientation={classNames("top-1 right-1 sm:top-2 sm:right-2", {
|
||||
"xl:top-4 xl:right-4": !small,
|
||||
})}
|
||||
rotation="rotate-[5deg]"
|
||||
/>
|
||||
<Screw
|
||||
small={small}
|
||||
orientation={classNames("bottom-1 right-1 sm:bottom-2 sm:right-2", {
|
||||
"xl:bottom-4 xl:right-4": !small,
|
||||
})}
|
||||
rotation="rotate-[150deg]"
|
||||
/>
|
||||
<Screw
|
||||
small={small}
|
||||
orientation={classNames("bottom-1 left-1 sm:bottom-2 sm:left-2", {
|
||||
"xl:bottom-4 xl:left-4": !small,
|
||||
})}
|
||||
rotation="rotate-[20deg]"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Screw({
|
||||
orientation,
|
||||
rotation,
|
||||
small,
|
||||
}: {
|
||||
orientation: string
|
||||
rotation: string
|
||||
small?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"absolute flex h-3 w-3 flex-col items-center justify-center rounded-full border-[1px] border-neutral-700 bg-neutral-400 sm:h-5 sm:w-5 sm:border-2 md:h-6 md:w-6",
|
||||
{ "xl:h-8 xl:w-8": !small },
|
||||
orientation,
|
||||
)}
|
||||
>
|
||||
<hr
|
||||
className={classNames(
|
||||
"color w-full border-neutral-500 sm:border-t-2",
|
||||
{ "xl:border-t-4": !small },
|
||||
rotation,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logo
|
41
leaky-ships/components/OptionButton.tsx
Normal file
41
leaky-ships/components/OptionButton.tsx
Normal file
|
@ -0,0 +1,41 @@
|
|||
import {
|
||||
FontAwesomeIcon,
|
||||
FontAwesomeIconProps,
|
||||
} from "@fortawesome/react-fontawesome"
|
||||
import classNames from "classnames"
|
||||
|
||||
function OptionButton({
|
||||
id,
|
||||
icon,
|
||||
callback,
|
||||
node,
|
||||
disabled,
|
||||
}: {
|
||||
id: string
|
||||
icon: FontAwesomeIconProps["icon"]
|
||||
callback?: () => void
|
||||
node?: JSX.Element
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className={classNames(
|
||||
"flex w-full flex-row items-center justify-between rounded-xl py-2 pl-8 pr-4 text-lg text-grayish duration-100 first:mt-4 last:mt-4 sm:py-4 sm:pl-16 sm:pr-8 sm:text-4xl sm:first:mt-8 sm:last:mt-8",
|
||||
!disabled
|
||||
? "border-b-4 border-shield-gray bg-voidDark active:border-b-0 active:border-t-4"
|
||||
: "border-4 border-dashed border-slate-600 bg-red-950",
|
||||
)}
|
||||
onClick={() => callback && setTimeout(callback, 200)}
|
||||
disabled={disabled}
|
||||
title={!disabled ? "" : "Please login"}
|
||||
>
|
||||
<span className="mx-auto">{node ? node : id}</span>
|
||||
<FontAwesomeIcon
|
||||
className="ml-2 w-10 text-xl sm:ml-12 sm:text-4xl"
|
||||
icon={icon}
|
||||
/>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default OptionButton
|
|
@ -1,7 +1,7 @@
|
|||
function profileImg(src: string) {
|
||||
return (
|
||||
<img
|
||||
style={{ transform: "scale(1.5)", "border-radius": "100%" }}
|
||||
style={{ transform: "scale(1.5)", borderRadius: "100%" }}
|
||||
src={src}
|
||||
alt="profile picture"
|
||||
/>
|
|
@ -1,11 +0,0 @@
|
|||
import "dotenv/config"
|
||||
import type { Config } from "drizzle-kit"
|
||||
|
||||
export default {
|
||||
schema: "./src/drizzle/schemas/Tables.ts",
|
||||
out: "./src/drizzle/migrations",
|
||||
driver: "pg",
|
||||
dbCredentials: {
|
||||
connectionString: process.env.DATABASE_URL ?? "",
|
||||
},
|
||||
} satisfies Config
|
|
@ -1,5 +1,6 @@
|
|||
import { expect, test, type BrowserContext, type Page } from "@playwright/test"
|
||||
|
||||
const callbackUrl = process.env.NEXTAUTH_URL + "/"
|
||||
let context: BrowserContext
|
||||
let page: Page
|
||||
|
||||
|
@ -15,36 +16,36 @@ test.describe.serial("Check Azure AD auth", () => {
|
|||
})
|
||||
|
||||
test("Login process...", async () => {
|
||||
await page.goto("/signin")
|
||||
await page.goto(callbackUrl + "signin")
|
||||
|
||||
await page.locator("button#microsoft").click()
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
|
||||
await page.click("button#microsoft")
|
||||
|
||||
// Indicates email can be filled in
|
||||
await page.waitForSelector("a#cantAccessAccount")
|
||||
// Fill email input
|
||||
await page.locator("input#i0116").fill(process.env.AUTH_EMAIL!)
|
||||
await page.fill("input#i0116", process.env.AUTH_EMAIL ?? "")
|
||||
|
||||
// Click the "Next" button
|
||||
await page.locator("input#idSIButton9").click()
|
||||
await page.click("input#idSIButton9")
|
||||
|
||||
// Indicates password can be filled in
|
||||
await page.waitForSelector("a#idA_PWD_ForgotPassword")
|
||||
// Fill password input
|
||||
await page.locator("input#i0118").fill(process.env.AUTH_PW!)
|
||||
await page.fill("input#i0118", process.env.AUTH_PW ?? "")
|
||||
|
||||
// Click the "Sign in" button
|
||||
await page.locator("input#idSIButton9").click()
|
||||
await page.click("input#idSIButton9")
|
||||
|
||||
// Click the "No" button
|
||||
await page.locator("input#idBtn_Back").click()
|
||||
await page.click("input#idBtn_Back")
|
||||
|
||||
await page.waitForSelector("#start")
|
||||
})
|
||||
|
||||
test("Is logged in", async () => {
|
||||
await page.goto("/signin")
|
||||
await page.waitForSelector("button#signout")
|
||||
await page.goto("/")
|
||||
await page.goto(callbackUrl + "signin")
|
||||
await page.waitForSelector("#start")
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
|
||||
|
@ -54,9 +55,11 @@ test.describe.serial("Check Azure AD auth", () => {
|
|||
})
|
||||
|
||||
test("Is logged out", async () => {
|
||||
await page.goto("/signout")
|
||||
await page.goto(callbackUrl + "signout")
|
||||
|
||||
await page.locator("button#signout").click()
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
|
||||
await page.click("button#signout")
|
||||
|
||||
await page.waitForSelector("#start")
|
||||
|
||||
|
|
|
@ -5,10 +5,9 @@ import {
|
|||
type Page,
|
||||
} from "@playwright/test"
|
||||
import { createHash, randomBytes } from "crypto"
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import db from "~/drizzle"
|
||||
import { verificationTokens } from "~/drizzle/schemas/Tables"
|
||||
import prisma from "../lib/prisma"
|
||||
|
||||
const callbackUrl = process.env.NEXTAUTH_URL + "/"
|
||||
const player1Email = (browser: Browser) =>
|
||||
browser.browserType().name() + "-player-1@example.com"
|
||||
|
||||
|
@ -26,51 +25,51 @@ test.describe.serial("Check Email auth", () => {
|
|||
})
|
||||
|
||||
test("Email login process...", async ({ browser }) => {
|
||||
await page.goto("/signin")
|
||||
await page.goto(callbackUrl + "signin")
|
||||
|
||||
await page.locator("input#email").fill(player1Email(browser))
|
||||
await page.locator("button#email-submit").click()
|
||||
await page.waitForSelector("input#email")
|
||||
await page.fill("input#email", player1Email(browser))
|
||||
await page.click("button#email-submit")
|
||||
|
||||
await page.waitForURL("/api/auth/verify-request?provider=email&type=email")
|
||||
await page.waitForURL(
|
||||
callbackUrl + "api/auth/verify-request?provider=email&type=email",
|
||||
)
|
||||
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
})
|
||||
|
||||
test("Verify Email...", async ({ browser }) => {
|
||||
const token = randomBytes(32).toString("hex")
|
||||
|
||||
const hash = createHash("sha256")
|
||||
// Prefer provider specific secret, but use default secret if none specified
|
||||
.update(`${token}${process.env.AUTH_SECRET}`)
|
||||
.update(`${token}${process.env.NEXTAUTH_SECRET}`)
|
||||
.digest("hex")
|
||||
|
||||
// Use drizzle to fetch the latest token for the email
|
||||
const latestToken = await db.query.verificationTokens.findFirst({
|
||||
where: eq(verificationTokens.identifier, player1Email(browser)),
|
||||
orderBy: [desc(verificationTokens.expires)],
|
||||
// Use Prisma to fetch the latest token for the email
|
||||
const latestToken = await prisma.verificationToken.findFirst({
|
||||
where: { identifier: player1Email(browser) },
|
||||
orderBy: { expires: "desc" },
|
||||
})
|
||||
await prisma.verificationToken.update({
|
||||
where: {
|
||||
identifier_token: {
|
||||
identifier: player1Email(browser),
|
||||
token: latestToken?.token ?? "",
|
||||
},
|
||||
},
|
||||
data: { token: hash },
|
||||
})
|
||||
await db
|
||||
.update(verificationTokens)
|
||||
.set({ token: hash })
|
||||
.where(
|
||||
and(
|
||||
eq(verificationTokens.identifier, player1Email(browser)),
|
||||
eq(verificationTokens.token, latestToken?.token ?? ""),
|
||||
),
|
||||
)
|
||||
|
||||
const params = new URLSearchParams({
|
||||
callbackUrl: process.env.AUTH_URL!,
|
||||
callbackUrl,
|
||||
token,
|
||||
email: player1Email(browser),
|
||||
})
|
||||
const url = callbackUrl + "api/auth/callback/email?" + params
|
||||
|
||||
await page.goto("/api/auth/callback/email?" + params)
|
||||
})
|
||||
await page.goto(url)
|
||||
|
||||
test("Verify Logged in...", async () => {
|
||||
await page.goto("/signin")
|
||||
await page.waitForSelector("button#signout")
|
||||
})
|
||||
|
||||
test("Logging out...", async () => {
|
||||
await page.locator("button#signout").click()
|
||||
await page.waitForSelector("#start")
|
||||
await page.waitForLoadState("domcontentloaded")
|
||||
})
|
||||
})
|
||||
|
|
5
leaky-ships/global.d.ts
vendored
Normal file
5
leaky-ships/global.d.ts
vendored
Normal file
|
@ -0,0 +1,5 @@
|
|||
import "@total-typescript/ts-reset"
|
||||
|
||||
declare global {
|
||||
var prismaClient: PrismaClient
|
||||
}
|
126
leaky-ships/hooks/useDraw.ts
Normal file
126
leaky-ships/hooks/useDraw.ts
Normal file
|
@ -0,0 +1,126 @@
|
|||
import { socket } from "@lib/socket"
|
||||
import { useEffect, useRef, useState } from "react"
|
||||
import { Draw, DrawLineProps, PlayerEvent, Point } from "../interfaces/frontend"
|
||||
import { useDrawProps } from "./useDrawProps"
|
||||
|
||||
function drawLine({ prevPoint, currentPoint, ctx, color }: Draw) {
|
||||
const { x: currX, y: currY } = currentPoint
|
||||
const lineColor = color
|
||||
const lineWidth = 5
|
||||
|
||||
let startPoint = prevPoint ?? currentPoint
|
||||
ctx.beginPath()
|
||||
ctx.lineWidth = lineWidth
|
||||
ctx.strokeStyle = lineColor
|
||||
ctx.moveTo(startPoint.x, startPoint.y)
|
||||
ctx.lineTo(currX, currY)
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = lineColor
|
||||
ctx.beginPath()
|
||||
ctx.arc(startPoint.x, startPoint.y, 2, 0, 2 * Math.PI)
|
||||
ctx.fill()
|
||||
}
|
||||
|
||||
export const useDraw = () => {
|
||||
const [mouseDown, setMouseDown] = useState(false)
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null)
|
||||
const prevPoint = useRef<null | Point>(null)
|
||||
|
||||
const { color } = useDrawProps()
|
||||
|
||||
const onMouseDown = () => setMouseDown(true)
|
||||
|
||||
const clear = () => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (!mouseDown) return
|
||||
const currentPoint = computePointInCanvas(e)
|
||||
|
||||
const ctx = canvasRef.current?.getContext("2d")
|
||||
if (!ctx || !currentPoint) return
|
||||
|
||||
drawLine({ ctx, currentPoint, prevPoint: prevPoint.current, color })
|
||||
prevPoint.current = currentPoint
|
||||
}
|
||||
|
||||
const computePointInCanvas = (e: MouseEvent) => {
|
||||
const rect = canvas.getBoundingClientRect()
|
||||
const x = e.clientX - rect.left
|
||||
const y = e.clientY - rect.top
|
||||
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
const mouseUpHandler = () => {
|
||||
setMouseDown(false)
|
||||
prevPoint.current = null
|
||||
}
|
||||
|
||||
// Add event listeners
|
||||
canvas.addEventListener("mousemove", handler)
|
||||
window.addEventListener("mouseup", mouseUpHandler)
|
||||
|
||||
// Remove event listeners
|
||||
return () => {
|
||||
canvas.removeEventListener("mousemove", handler)
|
||||
window.removeEventListener("mouseup", mouseUpHandler)
|
||||
}
|
||||
}, [color, mouseDown])
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current
|
||||
if (!canvas) return
|
||||
|
||||
const ctx = canvas.getContext("2d")
|
||||
if (!ctx) return
|
||||
|
||||
const playerEvent = (event: PlayerEvent) => {
|
||||
if (!canvasRef.current?.toDataURL() || event.type !== "connect") return
|
||||
console.log("sending canvas state")
|
||||
socket.emit("canvas-state", canvasRef.current.toDataURL())
|
||||
}
|
||||
|
||||
const canvasStateFromServer = (state: string, index: number) => {
|
||||
console.log("I received the state")
|
||||
const img = new Image()
|
||||
img.src = state
|
||||
img.onload = () => {
|
||||
ctx?.drawImage(img, 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
const socketDrawLine = (props: DrawLineProps, index: number) => {
|
||||
const { prevPoint, currentPoint, color } = props
|
||||
if (!ctx) return console.log("no ctx here")
|
||||
drawLine({ prevPoint, currentPoint, ctx, color })
|
||||
}
|
||||
|
||||
socket.on("playerEvent", playerEvent)
|
||||
socket.on("canvas-state-from-server", canvasStateFromServer)
|
||||
socket.on("draw-line", socketDrawLine)
|
||||
socket.on("canvas-clear", clear)
|
||||
|
||||
return () => {
|
||||
socket.off("playerEvent", playerEvent)
|
||||
socket.off("canvas-state-from-server", canvasStateFromServer)
|
||||
socket.off("draw-line", socketDrawLine)
|
||||
socket.off("canvas-clear", clear)
|
||||
}
|
||||
})
|
||||
|
||||
return { canvasRef, onMouseDown, clear }
|
||||
}
|
40
leaky-ships/hooks/useDrawProps.ts
Normal file
40
leaky-ships/hooks/useDrawProps.ts
Normal file
|
@ -0,0 +1,40 @@
|
|||
import { produce } from "immer"
|
||||
import { create } from "zustand"
|
||||
import { devtools } from "zustand/middleware"
|
||||
|
||||
const initialState: {
|
||||
enable: boolean
|
||||
shouldHide: boolean
|
||||
color: string
|
||||
} = {
|
||||
enable: false,
|
||||
shouldHide: false,
|
||||
color: "#b32aa9",
|
||||
}
|
||||
|
||||
export type State = typeof initialState
|
||||
|
||||
export type Action = {
|
||||
setColor: (color: string) => void
|
||||
reset: () => void
|
||||
}
|
||||
|
||||
export const useDrawProps = create<State & Action>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
setColor: (color) =>
|
||||
set(
|
||||
produce((state) => {
|
||||
state.color = color
|
||||
}),
|
||||
),
|
||||
reset: () => {
|
||||
set(initialState)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "gameState",
|
||||
},
|
||||
),
|
||||
)
|
274
leaky-ships/hooks/useGameProps.ts
Normal file
274
leaky-ships/hooks/useGameProps.ts
Normal file
|
@ -0,0 +1,274 @@
|
|||
import { getPayloadwithChecksum } from "@lib/getPayloadwithChecksum"
|
||||
import { socket } from "@lib/socket"
|
||||
import {
|
||||
initlialMouseCursor,
|
||||
initlialTarget,
|
||||
initlialTargetPreview,
|
||||
intersectingShip,
|
||||
targetList,
|
||||
} from "@lib/utils/helpers"
|
||||
import {
|
||||
GamePropsSchema,
|
||||
optionalGamePropsSchema,
|
||||
PlayerSchema,
|
||||
} from "@lib/zodSchemas"
|
||||
import { GameState, MoveType } from "@prisma/client"
|
||||
import { produce } from "immer"
|
||||
import { SetStateAction } from "react"
|
||||
import { toast } from "react-toastify"
|
||||
import { create } from "zustand"
|
||||
import { devtools } from "zustand/middleware"
|
||||
import {
|
||||
EventBarModes,
|
||||
GameSettings,
|
||||
MouseCursor,
|
||||
MoveDispatchProps,
|
||||
ShipProps,
|
||||
Target,
|
||||
TargetPreview,
|
||||
} from "../interfaces/frontend"
|
||||
|
||||
const initialState: optionalGamePropsSchema & {
|
||||
userStates: {
|
||||
isReady: boolean
|
||||
isConnected: boolean
|
||||
}[]
|
||||
menu: keyof EventBarModes
|
||||
mode: number
|
||||
target: Target
|
||||
targetPreview: TargetPreview
|
||||
mouseCursor: MouseCursor
|
||||
} = {
|
||||
menu: "moves",
|
||||
mode: 0,
|
||||
payload: null,
|
||||
hash: null,
|
||||
target: initlialTarget,
|
||||
targetPreview: initlialTargetPreview,
|
||||
mouseCursor: initlialMouseCursor,
|
||||
userStates: Array.from(Array(2), () => ({
|
||||
isReady: false,
|
||||
isConnected: false,
|
||||
})),
|
||||
}
|
||||
|
||||
export type State = typeof initialState
|
||||
|
||||
export type Action = {
|
||||
DispatchMove: (props: MoveDispatchProps, i: number) => void
|
||||
setTarget: (target: SetStateAction<Target>) => void
|
||||
setTargetPreview: (targetPreview: SetStateAction<TargetPreview>) => void
|
||||
setMouseCursor: (mouseCursor: SetStateAction<MouseCursor>) => void
|
||||
setPlayer: (payload: { users: PlayerSchema[] }) => string | null
|
||||
setSetting: (settings: GameSettings) => string | null
|
||||
full: (newProps: GamePropsSchema) => void
|
||||
leave: (cb: () => void) => void
|
||||
setIsReady: (payload: { i: number; isReady: boolean }) => void
|
||||
gameState: (newState: GameState) => void
|
||||
setShips: (ships: ShipProps[], index: number) => void
|
||||
removeShip: (props: ShipProps, index: number) => void
|
||||
setIsConnected: (payload: { i: number; isConnected: boolean }) => void
|
||||
reset: () => void
|
||||
setActiveIndex: (i: number, selfIndex: number) => void
|
||||
}
|
||||
|
||||
export const useGameProps = create<State & Action>()(
|
||||
devtools(
|
||||
(set) => ({
|
||||
...initialState,
|
||||
setActiveIndex: (i, selfIndex) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (!state.payload) return
|
||||
state.payload.activeIndex = i
|
||||
if (i === selfIndex) {
|
||||
state.menu = "moves"
|
||||
state.mode = 0
|
||||
} else {
|
||||
state.menu = "main"
|
||||
state.mode = -1
|
||||
}
|
||||
}),
|
||||
),
|
||||
DispatchMove: (move, i) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (!state.payload) return
|
||||
const list = targetList(move, move.type)
|
||||
state.payload.users.map((e) => {
|
||||
if (!e) return e
|
||||
if (i === e.index) e.moves.push(move)
|
||||
else if (move.type !== MoveType.radar)
|
||||
e.hits.push(
|
||||
...list.map(({ x, y }) => ({
|
||||
hit: !!intersectingShip(e.ships, {
|
||||
...move,
|
||||
size: 1,
|
||||
variant: 0,
|
||||
}).fields.length,
|
||||
x,
|
||||
y,
|
||||
})),
|
||||
)
|
||||
|
||||
return e
|
||||
})
|
||||
}),
|
||||
),
|
||||
setTarget: (dispatch) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (typeof dispatch === "function")
|
||||
state.target = dispatch(state.target)
|
||||
else state.target = dispatch
|
||||
}),
|
||||
),
|
||||
setTargetPreview: (dispatch) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (typeof dispatch === "function")
|
||||
state.targetPreview = dispatch(state.targetPreview)
|
||||
else state.targetPreview = dispatch
|
||||
}),
|
||||
),
|
||||
setMouseCursor: (dispatch) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (typeof dispatch === "function")
|
||||
state.mouseCursor = dispatch(state.mouseCursor)
|
||||
else state.mouseCursor = dispatch
|
||||
}),
|
||||
),
|
||||
setShips: (ships, index) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (!state.payload) return
|
||||
state.payload.users = state.payload.users.map((e) => {
|
||||
if (!e || e.index !== index) return e
|
||||
e.ships = ships
|
||||
return e
|
||||
})
|
||||
}),
|
||||
),
|
||||
removeShip: ({ size, variant, x, y }, index) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
state.payload?.users.map((e) => {
|
||||
if (!e || e.index !== index) return
|
||||
const indexToRemove = e.ships.findIndex(
|
||||
(ship) =>
|
||||
ship.size === size &&
|
||||
ship.variant === variant &&
|
||||
ship.x === x &&
|
||||
ship.y === y,
|
||||
)
|
||||
e.ships.splice(indexToRemove, 1)
|
||||
return e
|
||||
})
|
||||
}),
|
||||
),
|
||||
setPlayer: (payload) => {
|
||||
let hash: string | null = null
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (!state.payload) return
|
||||
state.payload.users = payload.users
|
||||
const body = getPayloadwithChecksum(state.payload)
|
||||
if (!body.hash) {
|
||||
toast.warn("Something is wrong... ", {
|
||||
toastId: "st_wrong",
|
||||
theme: "colored",
|
||||
})
|
||||
return
|
||||
}
|
||||
hash = body.hash
|
||||
state.hash = hash
|
||||
}),
|
||||
)
|
||||
return hash
|
||||
},
|
||||
setSetting: (settings) => {
|
||||
let hash: string | null = null
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (!state.payload?.game) return
|
||||
Object.assign(state.payload.game, settings)
|
||||
const body = getPayloadwithChecksum(state.payload)
|
||||
if (!body.hash) {
|
||||
toast.warn("Something is wrong... ", {
|
||||
toastId: "st_wrong",
|
||||
theme: "colored",
|
||||
})
|
||||
return
|
||||
}
|
||||
hash = body.hash
|
||||
state.hash = hash
|
||||
}),
|
||||
)
|
||||
return hash
|
||||
},
|
||||
full: (newGameProps) =>
|
||||
set((state) => {
|
||||
if (state.hash === newGameProps.hash) {
|
||||
console.log("Everything up to date.")
|
||||
} else {
|
||||
console.log("Update was needed.", state.hash, newGameProps.hash)
|
||||
|
||||
if (
|
||||
state.payload?.game?.id &&
|
||||
state.payload?.game?.id !== newGameProps.payload?.game?.id
|
||||
) {
|
||||
console.warn(
|
||||
"Different gameId detected on update: ",
|
||||
state.payload?.game?.id,
|
||||
newGameProps.payload?.game?.id,
|
||||
)
|
||||
}
|
||||
|
||||
return newGameProps
|
||||
}
|
||||
return state
|
||||
}),
|
||||
leave: (cb) => {
|
||||
socket.emit("leave", (ack) => {
|
||||
if (!ack) {
|
||||
toast.error("Something is wrong...")
|
||||
}
|
||||
cb()
|
||||
})
|
||||
},
|
||||
setIsReady: ({ i, isReady }) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
state.userStates[i].isReady = isReady
|
||||
state.userStates[i].isConnected = true
|
||||
}),
|
||||
),
|
||||
gameState: (newState: GameState) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
if (!state.payload?.game) return
|
||||
state.payload.game.state = newState
|
||||
state.userStates = state.userStates.map((e) => ({
|
||||
...e,
|
||||
isReady: false,
|
||||
}))
|
||||
}),
|
||||
),
|
||||
setIsConnected: ({ i, isConnected }) =>
|
||||
set(
|
||||
produce((state: State) => {
|
||||
state.userStates[i].isConnected = isConnected
|
||||
if (isConnected) return
|
||||
state.userStates[i].isReady = false
|
||||
}),
|
||||
),
|
||||
reset: () => {
|
||||
set(initialState)
|
||||
},
|
||||
}),
|
||||
{
|
||||
name: "gameState",
|
||||
},
|
||||
),
|
||||
)
|
24
leaky-ships/hooks/useIndex.ts
Normal file
24
leaky-ships/hooks/useIndex.ts
Normal file
|
@ -0,0 +1,24 @@
|
|||
import { useSession } from "next-auth/react"
|
||||
import { useGameProps } from "./useGameProps"
|
||||
|
||||
function useIndex() {
|
||||
const { payload } = useGameProps()
|
||||
const { data: session } = useSession()
|
||||
|
||||
const selfIndex =
|
||||
payload?.users.findIndex((e) => e?.id === session?.user.id) ?? -1
|
||||
const activeIndex = payload?.activeIndex ?? -1
|
||||
const isActiveIndex = selfIndex >= 0 && payload?.activeIndex === selfIndex
|
||||
const selfUser = payload?.users[selfIndex]
|
||||
const activeUser = payload?.users[activeIndex === 0 ? 1 : 0]
|
||||
|
||||
return {
|
||||
selfIndex,
|
||||
activeIndex,
|
||||
isActiveIndex,
|
||||
selfUser,
|
||||
activeUser,
|
||||
}
|
||||
}
|
||||
|
||||
export default useIndex
|
27
leaky-ships/hooks/useShips.ts
Normal file
27
leaky-ships/hooks/useShips.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
import { useCallback, useMemo } from "react"
|
||||
import { ShipProps } from "../interfaces/frontend"
|
||||
import { useGameProps } from "./useGameProps"
|
||||
import useIndex from "./useIndex"
|
||||
|
||||
function useShips() {
|
||||
const gameProps = useGameProps()
|
||||
const { selfIndex } = useIndex()
|
||||
|
||||
const ships = useMemo(
|
||||
() =>
|
||||
gameProps.payload?.users.find((e) => e?.index === selfIndex)?.ships ?? [],
|
||||
[gameProps.payload?.users, selfIndex],
|
||||
)
|
||||
const setShips = useCallback(
|
||||
(ships: ShipProps[]) => gameProps.setShips(ships, selfIndex),
|
||||
[gameProps, selfIndex],
|
||||
)
|
||||
const removeShip = useCallback(
|
||||
(ship: ShipProps) => gameProps.removeShip(ship, selfIndex),
|
||||
[gameProps, selfIndex],
|
||||
)
|
||||
|
||||
return { ships, setShips, removeShip }
|
||||
}
|
||||
|
||||
export default useShips
|
192
leaky-ships/hooks/useSocket.ts
Normal file
192
leaky-ships/hooks/useSocket.ts
Normal file
|
@ -0,0 +1,192 @@
|
|||
import { socket } from "@lib/socket"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import status from "http-status"
|
||||
import { useRouter } from "next/router"
|
||||
import { useEffect, useMemo, useState } from "react"
|
||||
import { toast } from "react-toastify"
|
||||
import { GameSettings, PlayerEvent } from "../interfaces/frontend"
|
||||
import { isAuthenticated } from "../pages/start"
|
||||
import { useGameProps } from "./useGameProps"
|
||||
import useIndex from "./useIndex"
|
||||
|
||||
/** This function should only be called once per page, otherwise there will be multiple socket connections and duplicate event listeners. */
|
||||
function useSocket() {
|
||||
const [isConnectedState, setIsConnectedState] = useState(false)
|
||||
const { selfIndex } = useIndex()
|
||||
const {
|
||||
payload,
|
||||
userStates,
|
||||
setPlayer,
|
||||
setSetting,
|
||||
full,
|
||||
setIsReady,
|
||||
gameState,
|
||||
setIsConnected,
|
||||
setActiveIndex,
|
||||
DispatchMove,
|
||||
setShips,
|
||||
} = useGameProps()
|
||||
const router = useRouter()
|
||||
|
||||
const isConnected = useMemo(
|
||||
() =>
|
||||
selfIndex >= 0 ? userStates[selfIndex].isConnected : isConnectedState,
|
||||
[selfIndex, isConnectedState, userStates],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (selfIndex < 0) return
|
||||
setIsConnected({
|
||||
i: selfIndex,
|
||||
isConnected: isConnectedState,
|
||||
})
|
||||
}, [selfIndex, isConnectedState, setIsConnected])
|
||||
|
||||
useEffect(() => {
|
||||
const connect = () => {
|
||||
console.log("connected")
|
||||
toast.dismiss("connect_error")
|
||||
setIsConnectedState(true)
|
||||
}
|
||||
|
||||
const connectError = (error: Error) => {
|
||||
console.log("Connection error:", error.message)
|
||||
if (error.message === status["403"]) router.push("/")
|
||||
if (error.message !== "xhr poll error") return
|
||||
const toastId = "connect_error"
|
||||
const isActive = toast.isActive(toastId)
|
||||
console.log(toastId, isActive)
|
||||
if (isActive)
|
||||
toast.update(toastId, {
|
||||
autoClose: 5000,
|
||||
})
|
||||
else
|
||||
toast.warn("Es gibt Probleme mit der Echtzeitverbindung.", { toastId })
|
||||
}
|
||||
|
||||
const playerEvent = (event: PlayerEvent) => {
|
||||
const { type, i } = event
|
||||
let message: string
|
||||
console.log("playerEvent", type)
|
||||
switch (type) {
|
||||
case "disconnect":
|
||||
setIsConnected({
|
||||
i,
|
||||
isConnected: false,
|
||||
})
|
||||
message = "Player is disconnected."
|
||||
break
|
||||
|
||||
case "leave":
|
||||
message = "Player has left the lobby."
|
||||
break
|
||||
|
||||
case "connect":
|
||||
setIsConnected({
|
||||
i,
|
||||
isConnected: true,
|
||||
})
|
||||
socket.emit("isReady", userStates[selfIndex].isReady)
|
||||
message = "Player has joined the lobby."
|
||||
break
|
||||
|
||||
default:
|
||||
message = "Not defined yet."
|
||||
break
|
||||
}
|
||||
toast.info(message, { toastId: message })
|
||||
if (type === "disconnect") return
|
||||
const { payload, hash } = event
|
||||
const newHash = setPlayer(payload)
|
||||
console.log(newHash, hash, !newHash, newHash === hash)
|
||||
if (!newHash || newHash === hash) return
|
||||
console.log("hash", hash, newHash)
|
||||
socket.emit("update", (body) => {
|
||||
console.log("update")
|
||||
full(body)
|
||||
})
|
||||
}
|
||||
|
||||
const gameSetting = (payload: GameSettings, hash: string) => {
|
||||
const newHash = setSetting(payload)
|
||||
if (!newHash || newHash === hash) return
|
||||
console.log("hash", hash, newHash)
|
||||
socket.emit("update", (body) => {
|
||||
console.log("update")
|
||||
full(body)
|
||||
})
|
||||
}
|
||||
|
||||
const activeIndex = (i: number) => setActiveIndex(i, selfIndex)
|
||||
|
||||
const disconnect = () => {
|
||||
console.log("disconnect")
|
||||
setIsConnectedState(false)
|
||||
}
|
||||
|
||||
socket.on("connect", connect)
|
||||
socket.on("connect_error", connectError)
|
||||
socket.on("gameSetting", gameSetting)
|
||||
socket.on("playerEvent", playerEvent)
|
||||
socket.on("isReady", setIsReady)
|
||||
socket.on("gameState", gameState)
|
||||
socket.on("dispatchMove", DispatchMove)
|
||||
socket.on("activeIndex", activeIndex)
|
||||
socket.on("ships", setShips)
|
||||
socket.on("disconnect", disconnect)
|
||||
|
||||
return () => {
|
||||
socket.off("connect", connect)
|
||||
socket.off("connect_error", connectError)
|
||||
socket.off("gameSetting", gameSetting)
|
||||
socket.off("playerEvent", playerEvent)
|
||||
socket.off("isReady", setIsReady)
|
||||
socket.off("gameState", gameState)
|
||||
socket.off("dispatchMove", DispatchMove)
|
||||
socket.off("activeIndex", activeIndex)
|
||||
socket.off("ships", setShips)
|
||||
socket.off("disconnect", disconnect)
|
||||
}
|
||||
}, [
|
||||
DispatchMove,
|
||||
full,
|
||||
gameState,
|
||||
router,
|
||||
selfIndex,
|
||||
setActiveIndex,
|
||||
setIsConnected,
|
||||
setIsReady,
|
||||
setPlayer,
|
||||
setSetting,
|
||||
setShips,
|
||||
userStates,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (!payload?.game?.id) {
|
||||
socket.disconnect()
|
||||
fetch("/api/game/running", {
|
||||
method: "GET",
|
||||
})
|
||||
.then(isAuthenticated)
|
||||
.then((game) => GamePropsSchema.parse(game))
|
||||
.then((res) => full(res))
|
||||
.catch((e) => console.log(e))
|
||||
return
|
||||
}
|
||||
if (isConnected) return
|
||||
socket.connect()
|
||||
const start = Date.now()
|
||||
socket.volatile.emit("ping", () => {
|
||||
const duration = Date.now() - start
|
||||
console.log("ping", duration)
|
||||
})
|
||||
}, [full, isConnected, payload?.game?.id])
|
||||
|
||||
return {
|
||||
isConnected:
|
||||
selfIndex >= 0 ? userStates[selfIndex].isConnected : isConnectedState,
|
||||
}
|
||||
}
|
||||
|
||||
export default useSocket
|
|
@ -1,14 +1,15 @@
|
|||
import { Session } from "@auth/core/types"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import { GameState } from "@prisma/client"
|
||||
import type { Server as HTTPServer } from "http"
|
||||
import type { Socket as NetSocket } from "net"
|
||||
import type { NextApiResponse } from "next"
|
||||
import { Session } from "next-auth"
|
||||
import type {
|
||||
Server as IOServer,
|
||||
Server,
|
||||
Socket as SocketforServer,
|
||||
} from "socket.io"
|
||||
import type { Socket as SocketforClient } from "socket.io-client"
|
||||
import { GamePropsSchema } from "~/lib/zodSchemas"
|
||||
import { GameState } from "../lib/zodSchemas"
|
||||
import {
|
||||
DrawLineProps,
|
||||
GameSettings,
|
||||
|
@ -21,22 +22,30 @@ interface SocketServer extends HTTPServer {
|
|||
io?: IOServer
|
||||
}
|
||||
|
||||
export interface SocketWithIO extends NetSocket {
|
||||
interface SocketWithIO extends NetSocket {
|
||||
server: SocketServer
|
||||
}
|
||||
|
||||
export interface NextApiResponseWithSocket extends NextApiResponse {
|
||||
socket: SocketWithIO
|
||||
}
|
||||
|
||||
export interface ServerToClientEvents {
|
||||
// noArg: () => void
|
||||
// basicEmit: (a: number, b: string, c: Buffer) => void
|
||||
// withAck: (d: string, ) => void
|
||||
gameSetting: (payload: GameSettings, hash: string) => void
|
||||
playerEvent: (event: PlayerEvent) => void
|
||||
isReady: (payload: { i: 0 | 1; isReady: boolean }) => void
|
||||
isConnected: (payload: { i: 0 | 1; isConnected: boolean }) => void
|
||||
isReady: (payload: { i: number; isReady: boolean }) => void
|
||||
isConnected: (payload: { i: number; isConnected: boolean }) => void
|
||||
"get-canvas-state": () => void
|
||||
"canvas-state-from-server": (state: DrawLineProps[], i: 0 | 1) => void
|
||||
"draw-line": (props: DrawLineProps, i: 0 | 1) => void
|
||||
"canvas-clear": (index: { i: 0 | 1 }) => void
|
||||
"canvas-state-from-server": (state: string, userIndex: number) => void
|
||||
"draw-line": (props: DrawLineProps, userIndex: number) => void
|
||||
"canvas-clear": () => void
|
||||
gameState: (newState: GameState) => void
|
||||
ships: (ships: ShipProps[], index: 0 | 1) => void
|
||||
dispatchMove: (props: MoveDispatchProps, i: 0 | 1) => void
|
||||
ships: (ships: ShipProps[], index: number) => void
|
||||
activeIndex: (index: number) => void
|
||||
dispatchMove: (props: MoveDispatchProps, i: number) => void
|
||||
}
|
||||
|
||||
export interface ClientToServerEvents {
|
||||
|
@ -47,7 +56,7 @@ export interface ClientToServerEvents {
|
|||
join: (withAck: (ack: boolean) => void) => void
|
||||
gameSetting: (payload: GameSettings, callback: (hash: string) => void) => void
|
||||
leave: (withAck: (ack: boolean) => void) => void
|
||||
"canvas-state": (state: DrawLineProps[]) => void
|
||||
"canvas-state": (state: string) => void
|
||||
"draw-line": (props: DrawLineProps) => void
|
||||
"canvas-clear": () => void
|
||||
gameState: (newState: GameState) => void
|
||||
|
@ -60,9 +69,14 @@ interface InterServerEvents {
|
|||
}
|
||||
|
||||
interface SocketData {
|
||||
props: {
|
||||
userId: string
|
||||
gameId: string
|
||||
index: number
|
||||
}
|
||||
user: Session["user"]
|
||||
gameId: string
|
||||
index: { i: 0 | 1 } | null
|
||||
gameId: string | null
|
||||
index: number
|
||||
}
|
||||
|
||||
export type sServer = Server<
|
|
@ -1,5 +1,6 @@
|
|||
import { IconDefinition } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { MoveType, Orientation, PlayerSchema } from "~/lib/zodSchemas"
|
||||
import { PlayerSchema } from "@lib/zodSchemas"
|
||||
import { MoveType, Orientation } from "@prisma/client"
|
||||
|
||||
export interface Position {
|
||||
x: number
|
||||
|
@ -22,7 +23,7 @@ export interface TargetList extends Position {
|
|||
edges: string[]
|
||||
}
|
||||
export interface Mode {
|
||||
pointerGrid: void[][]
|
||||
pointerGrid: any[][]
|
||||
type: MoveType
|
||||
}
|
||||
export interface ItemProps {
|
||||
|
@ -31,7 +32,6 @@ export interface ItemProps {
|
|||
amount?: number
|
||||
iconColor?: string
|
||||
disabled?: boolean
|
||||
showWhen?: () => boolean
|
||||
enabled?: boolean
|
||||
callback?: () => void
|
||||
}
|
||||
|
@ -55,6 +55,9 @@ export interface DrawLineProps {
|
|||
prevPoint: Point | null
|
||||
color: string
|
||||
}
|
||||
export interface Draw extends DrawLineProps {
|
||||
ctx: CanvasRenderingContext2D
|
||||
}
|
||||
export interface ShipProps extends Position {
|
||||
size: number
|
||||
variant: number
|
||||
|
@ -74,20 +77,15 @@ export type GameSettingKeys =
|
|||
| "allowChat"
|
||||
| "allowMarkDraw"
|
||||
|
||||
export type GameSettings = Partial<Record<GameSettingKeys, boolean>>
|
||||
export type GameSettings = { [key in GameSettingKeys]?: boolean }
|
||||
export type PlayerEvent =
|
||||
| {
|
||||
type: "connect" | "leave"
|
||||
i: 0 | 1
|
||||
users: Players
|
||||
i: number
|
||||
payload: { users: PlayerSchema[] }
|
||||
hash: string
|
||||
}
|
||||
| {
|
||||
type: "disconnect"
|
||||
i: 0 | 1
|
||||
i: number
|
||||
}
|
||||
export type Players = { 0: PlayerSchema; 1: PlayerSchema }
|
||||
export type User =
|
||||
| (PlayerSchema & { isReady: boolean; isConnected: boolean })
|
||||
| null
|
||||
export type Users = { 0: User; 1: User }
|
6
leaky-ships/jest-puppeteer.config.js
Normal file
6
leaky-ships/jest-puppeteer.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('jest-environment-puppeteer').JestPuppeteerConfig} */
|
||||
module.exports = {
|
||||
launch: {
|
||||
headless: "new",
|
||||
},
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import { APIEvent } from "@solidjs/start/server/types"
|
||||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import { z } from "zod"
|
||||
import sendError from "./sendError"
|
||||
|
||||
|
@ -6,13 +6,13 @@ const pinBodySchema = z.object({
|
|||
pin: z.string(),
|
||||
})
|
||||
|
||||
async function getPinFromBody(request: APIEvent["request"]) {
|
||||
async function getPinFromBody<T>(req: NextApiRequest, res: NextApiResponse<T>) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const body = JSON.parse(req.body)
|
||||
const { pin } = pinBodySchema.parse(body)
|
||||
return pin
|
||||
} catch {
|
||||
sendError(request, {
|
||||
} catch (err: any) {
|
||||
sendError(req, res, {
|
||||
message: "No pin in request body!",
|
||||
statusCode: 401,
|
||||
solved: true,
|
|
@ -1,7 +1,7 @@
|
|||
import { APIEvent } from "@solidjs/start/server/types"
|
||||
import colors, { Color } from "colors"
|
||||
import fs from "fs"
|
||||
import { IncomingMessage } from "http"
|
||||
import { NextApiRequest } from "next"
|
||||
|
||||
colors.enable()
|
||||
|
||||
|
@ -39,7 +39,7 @@ async function logStartup() {
|
|||
async function logging(
|
||||
message: string,
|
||||
types: Logging[],
|
||||
request?: APIEvent["request"] | IncomingMessage,
|
||||
req?: NextApiRequest | IncomingMessage,
|
||||
) {
|
||||
if (!started) await logStartup()
|
||||
const messages = { console: message, file: message }
|
||||
|
@ -55,14 +55,10 @@ async function logging(
|
|||
messages.console =
|
||||
`[${new Date().toString().slice(0, 33)}] ` + messages.console
|
||||
messages.file = `[${new Date().toString().slice(0, 33)}] ` + messages.file
|
||||
if (request) {
|
||||
const xForwardedFor =
|
||||
typeof request.headers.get === "function"
|
||||
? request.headers.get("x-forwarded-for")
|
||||
: // @ts-expect-error Bad IncomingHttpHeaders Type
|
||||
request.headers["x-forwarded-for"]
|
||||
const ip = (xForwardedFor || "127.0.0.1, 192.168.178.1").split(",")
|
||||
const route = request.url
|
||||
if (req) {
|
||||
const forwardedFor: any = req.headers["x-forwarded-for"]
|
||||
const ip = (forwardedFor || "127.0.0.1, 192.168.178.1").split(",")
|
||||
const route = req.url
|
||||
messages.console = [ip[0].yellow, route?.green, messages.console].join(
|
||||
" - ",
|
||||
)
|
|
@ -1,21 +1,20 @@
|
|||
import { APIEvent } from "@solidjs/start/server/types"
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
import { rejectionError } from "./errors"
|
||||
import logging from "./logging"
|
||||
|
||||
export default function sendError(
|
||||
request: APIEvent["request"],
|
||||
export default function sendError<T>(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<T>,
|
||||
err: rejectionError | Error,
|
||||
) {
|
||||
// If something went wrong, let the client know with status 500
|
||||
res.status("statusCode" in err ? err.statusCode : 500).end()
|
||||
logging(
|
||||
err.message,
|
||||
"type" in err && err.type
|
||||
? err.type
|
||||
: ["solved" in err && err.solved ? "debug" : "error"],
|
||||
request,
|
||||
req,
|
||||
)
|
||||
if ("name" in err) console.log("Sending Respons: " + err)
|
||||
// If something went wrong, let the client know with status 500
|
||||
return new Response(null, {
|
||||
status: "statusCode" in err ? err.statusCode : 500,
|
||||
})
|
||||
if ("name" in err) console.log(err)
|
||||
}
|
25
leaky-ships/lib/backend/sendResponse.ts
Normal file
25
leaky-ships/lib/backend/sendResponse.ts
Normal file
|
@ -0,0 +1,25 @@
|
|||
import { NextApiRequest, NextApiResponse } from "next"
|
||||
import logging, { Logging } from "./logging"
|
||||
|
||||
export interface Result<T> {
|
||||
message: string
|
||||
statusCode?: number
|
||||
body?: T
|
||||
type?: Logging[]
|
||||
redirectUrl?: string
|
||||
}
|
||||
|
||||
export default function sendResponse<T>(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<T>,
|
||||
result: Result<T>,
|
||||
) {
|
||||
if (result.redirectUrl) {
|
||||
res.redirect(result.statusCode ?? 307, result.redirectUrl)
|
||||
} else {
|
||||
res.status(result.statusCode ?? 200)
|
||||
result.body ? res.json(result.body) : res.end()
|
||||
logging(result.message, result.type ?? ["debug"], req)
|
||||
}
|
||||
return "done" as const
|
||||
}
|
10
leaky-ships/lib/getPayloadwithChecksum.ts
Normal file
10
leaky-ships/lib/getPayloadwithChecksum.ts
Normal file
|
@ -0,0 +1,10 @@
|
|||
import crypto from "crypto"
|
||||
import { GamePropsSchema } from "./zodSchemas"
|
||||
|
||||
export function getPayloadwithChecksum(
|
||||
payload: GamePropsSchema["payload"],
|
||||
): GamePropsSchema {
|
||||
const objString = JSON.stringify(payload)
|
||||
const hash = crypto.createHash("md5").update(objString).digest("hex")
|
||||
return { payload, hash }
|
||||
}
|
15
leaky-ships/lib/prisma.ts
Normal file
15
leaky-ships/lib/prisma.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
// lib/prisma.ts
|
||||
import { PrismaClient } from "@prisma/client"
|
||||
|
||||
let prisma: PrismaClient
|
||||
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
prisma = new PrismaClient()
|
||||
} else {
|
||||
if (!global.prismaClient) {
|
||||
global.prismaClient = new PrismaClient()
|
||||
}
|
||||
prisma = global.prismaClient
|
||||
}
|
||||
|
||||
export default prisma
|
|
@ -1,5 +1,5 @@
|
|||
import { io } from "socket.io-client"
|
||||
import { cSocket } from "../interfaces/ApiSocket"
|
||||
import { cSocket } from "../interfaces/NextApiSocket"
|
||||
|
||||
export const socket: cSocket = io({
|
||||
path: "/api/ws",
|
|
@ -1,5 +1,5 @@
|
|||
import { count } from "~/components/Gamefield/Gamefield"
|
||||
import { users } from "~/hooks/useGameProps"
|
||||
import { count } from "@components/Gamefield/Gamefield"
|
||||
import { Orientation } from "@prisma/client"
|
||||
import type {
|
||||
Hit,
|
||||
IndexedPosition,
|
||||
|
@ -9,9 +9,7 @@ import type {
|
|||
ShipProps,
|
||||
Target,
|
||||
TargetList,
|
||||
TargetPreview,
|
||||
} from "../../interfaces/frontend"
|
||||
import { MoveType, Orientation } from "../zodSchemas"
|
||||
|
||||
export function borderCN(count: number, x: number, y: number) {
|
||||
if (x === 0) return "left"
|
||||
|
@ -117,13 +115,13 @@ export const initlialTarget = {
|
|||
x: 2,
|
||||
y: 2,
|
||||
show: false,
|
||||
orientation: Orientation.Enum.h,
|
||||
orientation: Orientation.h,
|
||||
}
|
||||
export const initlialTargetPreview = {
|
||||
x: 2,
|
||||
y: 2,
|
||||
show: false,
|
||||
orientation: Orientation.Enum.h,
|
||||
orientation: Orientation.h,
|
||||
}
|
||||
export const initlialMouseCursor = {
|
||||
shouldShow: false,
|
||||
|
@ -134,7 +132,7 @@ export const initlialMouseCursor = {
|
|||
export const shipProps = (
|
||||
ships: ShipProps[],
|
||||
mode: number,
|
||||
targetPreview: Omit<TargetPreview, "show">,
|
||||
targetPreview: Position & { orientation: Orientation },
|
||||
) => ({
|
||||
size: mode + 2,
|
||||
variant:
|
||||
|
@ -155,8 +153,8 @@ export const shipProps = (
|
|||
orientation: targetPreview.orientation,
|
||||
})
|
||||
export function shipFields(ship: ShipProps, i?: number) {
|
||||
const fields: IndexedPosition[] = []
|
||||
const borders: IndexedPosition[] = []
|
||||
let fields: IndexedPosition[] = []
|
||||
let borders: IndexedPosition[] = []
|
||||
for (
|
||||
let x = ship.x;
|
||||
x <= (ship.orientation === "h" ? ship.x + ship.size - 1 : ship.x);
|
||||
|
@ -222,25 +220,3 @@ export function intersectingShip(
|
|||
borders,
|
||||
}
|
||||
}
|
||||
|
||||
export function compiledHits(i: 0 | 1) {
|
||||
return (
|
||||
users[i]?.moves.reduce((hits, { orientation, type, ...move }) => {
|
||||
const list = targetList(move, type)
|
||||
return type === MoveType.Enum.radar
|
||||
? hits
|
||||
: [
|
||||
...hits,
|
||||
...list.map((pos) => ({
|
||||
hit: !!intersectingShip(users[i === 0 ? 1 : 0]?.ships ?? [], {
|
||||
...pos,
|
||||
orientation,
|
||||
size: 1,
|
||||
variant: 0,
|
||||
}).fields.length,
|
||||
...pos,
|
||||
})),
|
||||
]
|
||||
}, [] as Hit[]) ?? []
|
||||
)
|
||||
}
|
73
leaky-ships/lib/zodSchemas.ts
Normal file
73
leaky-ships/lib/zodSchemas.ts
Normal file
|
@ -0,0 +1,73 @@
|
|||
import { GameState, MoveType, Orientation } from "@prisma/client"
|
||||
import { z } from "zod"
|
||||
|
||||
export const PlayerSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
name: z.string().nullable(),
|
||||
index: z.number(),
|
||||
chats: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
event: z.string().nullable(),
|
||||
message: z.string().nullable(),
|
||||
createdAt: z.coerce.date(),
|
||||
})
|
||||
.array(),
|
||||
moves: z
|
||||
.object({
|
||||
index: z.number(),
|
||||
type: z.nativeEnum(MoveType),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
orientation: z.nativeEnum(Orientation),
|
||||
})
|
||||
.array(),
|
||||
ships: z
|
||||
.object({
|
||||
size: z.number(),
|
||||
variant: z.number(),
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
orientation: z.nativeEnum(Orientation),
|
||||
})
|
||||
.array(),
|
||||
hits: z
|
||||
.object({
|
||||
x: z.number(),
|
||||
y: z.number(),
|
||||
hit: z.boolean(),
|
||||
})
|
||||
.array(),
|
||||
})
|
||||
.nullable()
|
||||
|
||||
export type PlayerSchema = z.infer<typeof PlayerSchema>
|
||||
|
||||
export const CreateSchema = z.object({
|
||||
game: z
|
||||
.object({
|
||||
id: z.string(),
|
||||
state: z.nativeEnum(GameState),
|
||||
allowSpectators: z.boolean(),
|
||||
allowSpecials: z.boolean(),
|
||||
allowChat: z.boolean(),
|
||||
allowMarkDraw: z.boolean(),
|
||||
})
|
||||
.nullable(),
|
||||
gamePin: z.string().nullable(),
|
||||
users: PlayerSchema.array(),
|
||||
activeIndex: z.number().optional(),
|
||||
})
|
||||
|
||||
export const GamePropsSchema = z.object({
|
||||
payload: CreateSchema,
|
||||
hash: z.string(),
|
||||
})
|
||||
export const optionalGamePropsSchema = z.object({
|
||||
payload: CreateSchema.nullable(),
|
||||
hash: z.string().nullable(),
|
||||
})
|
||||
|
||||
export type GamePropsSchema = z.infer<typeof GamePropsSchema>
|
||||
export type optionalGamePropsSchema = z.infer<typeof optionalGamePropsSchema>
|
6
leaky-ships/next.config.js
Normal file
6
leaky-ships/next.config.js
Normal file
|
@ -0,0 +1,6 @@
|
|||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
|
@ -1,80 +1,64 @@
|
|||
{
|
||||
"name": "leaky-ships",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vinxi dev",
|
||||
"build": "vinxi build",
|
||||
"start": "vinxi start",
|
||||
"lint": "eslint --fix \"**/*.{ts,tsx,js,jsx}\"",
|
||||
"push": "drizzle-kit push:pg",
|
||||
"test": "pnpm playwright test --ui",
|
||||
"typecheck": "tsc --noEmit --checkJs false --skipLibCheck"
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint",
|
||||
"test": "pnpm playwright test --ui"
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@auth/core": "^0.27.0",
|
||||
"@auth/drizzle-adapter": "^0.7.0",
|
||||
"@auth/solid-start": "^0.6.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.5.1",
|
||||
"@fortawesome/pro-duotone-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-light-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-regular-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.5.1",
|
||||
"@fortawesome/pro-thin-svg-icons": "^6.5.1",
|
||||
"@fortawesome/sharp-solid-svg-icons": "^6.5.1",
|
||||
"@paralleldrive/cuid2": "^2.2.2",
|
||||
"@solidjs/meta": "^0.29.3",
|
||||
"@solidjs/router": "^0.12.4",
|
||||
"@solidjs/start": "^0.5.9",
|
||||
"classnames": "^2.5.1",
|
||||
"@fortawesome/fontawesome-svg-core": "^6.4.2",
|
||||
"@fortawesome/pro-duotone-svg-icons": "^6.4.2",
|
||||
"@fortawesome/pro-light-svg-icons": "^6.4.2",
|
||||
"@fortawesome/pro-regular-svg-icons": "^6.4.2",
|
||||
"@fortawesome/pro-solid-svg-icons": "^6.4.2",
|
||||
"@fortawesome/pro-thin-svg-icons": "^6.4.2",
|
||||
"@fortawesome/react-fontawesome": "^0.2.0",
|
||||
"@fortawesome/sharp-solid-svg-icons": "^6.4.2",
|
||||
"@next-auth/prisma-adapter": "^1.0.7",
|
||||
"@next/font": "13.1.1",
|
||||
"@prisma/client": "^4.16.2",
|
||||
"classnames": "^2.3.2",
|
||||
"colors": "^1.4.0",
|
||||
"drizzle-orm": "^0.29.4",
|
||||
"drizzle-zod": "^0.5.1",
|
||||
"http-status": "^1.7.3",
|
||||
"json-stable-stringify": "^1.1.1",
|
||||
"lodash-es": "^4.17.21",
|
||||
"nodemailer": "^6.9.10",
|
||||
"object-hash": "^3.0.0",
|
||||
"postgres": "^3.4.3",
|
||||
"socket.io": "^4.7.4",
|
||||
"socket.io-client": "^4.7.4",
|
||||
"solid-color": "^0.0.4",
|
||||
"solid-js": "^1.8.15",
|
||||
"tinycolor2": "^1.6.0",
|
||||
"eslint": "8.31.0",
|
||||
"eslint-config-next": "13.1.1",
|
||||
"http-status": "^1.6.2",
|
||||
"immer": "^10.0.2",
|
||||
"next": "13.1.1",
|
||||
"next-auth": "^4.23.0",
|
||||
"nodemailer": "^6.9.4",
|
||||
"prisma": "^4.16.2",
|
||||
"react": "18.2.0",
|
||||
"react-colorful": "^5.6.1",
|
||||
"react-dom": "18.2.0",
|
||||
"react-otp-input": "^3.0.4",
|
||||
"react-toastify": "^9.1.3",
|
||||
"socket.io": "^4.7.2",
|
||||
"socket.io-client": "^4.7.2",
|
||||
"typescript": "4.9.4",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"vinxi": "^0.3.3",
|
||||
"zod": "3.22.4"
|
||||
"zod": "3.21.1",
|
||||
"zod-prisma-types": "^2.7.9",
|
||||
"zustand": "^4.4.1"
|
||||
},
|
||||
"packageManager": "pnpm@8.7.4",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.41.2",
|
||||
"@total-typescript/ts-reset": "^0.5.1",
|
||||
"@types/json-stable-stringify": "^1.0.36",
|
||||
"@types/node": "^20.11.19",
|
||||
"@types/nodemailer": "^6.4.14",
|
||||
"@types/object-hash": "^3.0.6",
|
||||
"@types/web-bluetooth": "^0.0.20",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.2",
|
||||
"autoprefixer": "^10.4.17",
|
||||
"dotenv": "^16.4.5",
|
||||
"drizzle-kit": "^0.20.14",
|
||||
"eslint": "^8.56.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-plugin-solid": "^0.13.1",
|
||||
"pg": "^8.11.3",
|
||||
"postcss": "^8.4.35",
|
||||
"prettier": "^3.2.5",
|
||||
"prettier-plugin-organize-imports": "^3.2.4",
|
||||
"prettier-plugin-tailwindcss": "^0.5.11",
|
||||
"sass": "^1.71.1",
|
||||
"solid-start-node": "^0.3.10",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"typescript": "^5.3.3",
|
||||
"vite": "^5.1.4"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@auth/core": "^0.13.0",
|
||||
"solid-start": "^0.3.5"
|
||||
}
|
||||
"@playwright/test": "^1.37.0",
|
||||
"@total-typescript/ts-reset": "^0.3.7",
|
||||
"@types/node": "^18.17.5",
|
||||
"@types/react": "^18.2.20",
|
||||
"@types/react-dom": "^18.2.7",
|
||||
"@types/web-bluetooth": "^0.0.16",
|
||||
"autoprefixer": "^10.4.15",
|
||||
"dotenv": "^16.3.1",
|
||||
"eslint-config-prettier": "^8.10.0",
|
||||
"postcss": "^8.4.27",
|
||||
"prettier": "^3.0.1",
|
||||
"prettier-plugin-organize-imports": "^3.2.3",
|
||||
"prettier-plugin-tailwindcss": "^0.4.1",
|
||||
"sass": "^1.65.1",
|
||||
"tailwindcss": "^3.3.3"
|
||||
}
|
||||
}
|
||||
|
|
21
leaky-ships/pages/_app.tsx
Normal file
21
leaky-ships/pages/_app.tsx
Normal file
|
@ -0,0 +1,21 @@
|
|||
import "@fortawesome/fontawesome-svg-core/styles.css"
|
||||
import { SessionProvider } from "next-auth/react"
|
||||
import type { AppProps } from "next/app"
|
||||
import { ToastContainer } from "react-toastify"
|
||||
import "react-toastify/dist/ReactToastify.css"
|
||||
import "../styles/App.scss"
|
||||
import "../styles/globals.scss"
|
||||
import "../styles/grid.scss"
|
||||
import "../styles/grid2.scss"
|
||||
|
||||
export default function App({
|
||||
Component,
|
||||
pageProps: { session, ...pageProps },
|
||||
}: AppProps) {
|
||||
return (
|
||||
<SessionProvider session={session}>
|
||||
<Component {...pageProps} />
|
||||
<ToastContainer />
|
||||
</SessionProvider>
|
||||
)
|
||||
}
|
13
leaky-ships/pages/_document.tsx
Normal file
13
leaky-ships/pages/_document.tsx
Normal file
|
@ -0,0 +1,13 @@
|
|||
import { Head, Html, Main, NextScript } from "next/document"
|
||||
|
||||
export default function Document() {
|
||||
return (
|
||||
<Html lang="en">
|
||||
<Head />
|
||||
<body>
|
||||
<Main />
|
||||
<NextScript />
|
||||
</body>
|
||||
</Html>
|
||||
)
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
import Discord from "@auth/core/providers/discord"
|
||||
import { DrizzleAdapter } from "@auth/drizzle-adapter"
|
||||
import { type SolidAuthConfig } from "@auth/solid-start"
|
||||
import prisma from "@lib/prisma"
|
||||
import { PrismaAdapter } from "@next-auth/prisma-adapter"
|
||||
import { NextApiHandler } from "next"
|
||||
import NextAuth, { NextAuthOptions } from "next-auth"
|
||||
import AzureADProvider from "next-auth/providers/azure-ad"
|
||||
import EmailProvider from "next-auth/providers/email"
|
||||
import {
|
||||
animals,
|
||||
Config,
|
||||
NumberDictionary,
|
||||
uniqueNamesGenerator,
|
||||
} from "unique-names-generator"
|
||||
import db from "~/drizzle"
|
||||
|
||||
const numberDictionary = NumberDictionary.generate({ min: 0, max: 9999 })
|
||||
const customConfig: Config = {
|
||||
|
@ -17,16 +19,20 @@ const customConfig: Config = {
|
|||
length: 2,
|
||||
}
|
||||
|
||||
export const authOptions: SolidAuthConfig = {
|
||||
const options: NextAuthOptions = {
|
||||
providers: [
|
||||
// @ts-ignore
|
||||
Discord({
|
||||
clientId: import.meta.env.VITE_DISCORD_CLIENT_ID,
|
||||
clientSecret: import.meta.env.VITE_DISCORD_CLIENT_SECRET,
|
||||
EmailProvider({
|
||||
server: process.env.EMAIL_SERVER,
|
||||
from: process.env.EMAIL_FROM,
|
||||
}),
|
||||
AzureADProvider({
|
||||
clientId: process.env.AZURE_AD_CLIENT_ID ?? "",
|
||||
clientSecret: process.env.AZURE_AD_CLIENT_SECRET ?? "",
|
||||
tenantId: process.env.AZURE_AD_TENANT_ID,
|
||||
}),
|
||||
],
|
||||
adapter: DrizzleAdapter(db),
|
||||
secret: import.meta.env.VITE_AUTH_SECRET,
|
||||
adapter: PrismaAdapter(prisma),
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
callbacks: {
|
||||
signIn: ({ user, account }) => {
|
||||
// Custom signIn callback to add username to email provider
|
||||
|
@ -37,7 +43,6 @@ export const authOptions: SolidAuthConfig = {
|
|||
},
|
||||
session: ({ session, user }) => {
|
||||
if (session?.user) {
|
||||
// @ts-ignore
|
||||
session.user.id = user.id
|
||||
}
|
||||
return session
|
||||
|
@ -51,3 +56,8 @@ export const authOptions: SolidAuthConfig = {
|
|||
// newUser: '/auth/new-user' // New users will be directed here on first sign in (leave the property out if not of interest)
|
||||
},
|
||||
}
|
||||
|
||||
export { options as authOptions }
|
||||
|
||||
const authHandler: NextApiHandler = (req, res) => NextAuth(req, res, options)
|
||||
export default authHandler
|
46
leaky-ships/pages/api/game/[id].ts
Normal file
46
leaky-ships/pages/api/game/[id].ts
Normal file
|
@ -0,0 +1,46 @@
|
|||
import { rejectionErrors } from "@lib/backend/errors"
|
||||
import sendResponse from "@lib/backend/sendResponse"
|
||||
import prisma from "@lib/prisma"
|
||||
import { Game } from "@prisma/client"
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "../auth/[...nextauth]"
|
||||
|
||||
interface Data {
|
||||
game: Game
|
||||
}
|
||||
|
||||
export default async function id(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<Data>,
|
||||
) {
|
||||
const gameId = req.query.id
|
||||
const session = await getServerSession(req, res, authOptions)
|
||||
|
||||
if (!session?.user || typeof gameId !== "string") {
|
||||
return sendResponse(req, res, rejectionErrors.unauthorized)
|
||||
}
|
||||
|
||||
let game: Game | null
|
||||
switch (req.method) {
|
||||
case "DELETE":
|
||||
game = await prisma.game.delete({
|
||||
where: { id: gameId },
|
||||
})
|
||||
break
|
||||
|
||||
default:
|
||||
game = await prisma.game.findFirst({
|
||||
where: { id: gameId },
|
||||
})
|
||||
}
|
||||
|
||||
if (!game) {
|
||||
return sendResponse(req, res, rejectionErrors.gameNotFound)
|
||||
}
|
||||
|
||||
sendResponse(req, res, {
|
||||
message: "Here is the game.",
|
||||
body: { game },
|
||||
})
|
||||
}
|
66
leaky-ships/pages/api/game/create.ts
Normal file
66
leaky-ships/pages/api/game/create.ts
Normal file
|
@ -0,0 +1,66 @@
|
|||
import sendResponse from "@backend/sendResponse"
|
||||
import { rejectionErrors } from "@lib/backend/errors"
|
||||
import prisma from "@lib/prisma"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "../auth/[...nextauth]"
|
||||
import { composeBody, gameSelects, getAnyRunningGame } from "./running"
|
||||
|
||||
export default async function create(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<GamePropsSchema>,
|
||||
) {
|
||||
const session = await getServerSession(req, res, authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
return sendResponse(req, res, rejectionErrors.unauthorized)
|
||||
}
|
||||
const { email, id } = session.user
|
||||
|
||||
// Generate a random 4-digit code
|
||||
const pin = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, "0")
|
||||
|
||||
let created = false
|
||||
|
||||
let game = await getAnyRunningGame(id)
|
||||
if (game) {
|
||||
return sendResponse(req, res, {
|
||||
redirectUrl: "/api/game/running",
|
||||
message: "Running game already exists.",
|
||||
})
|
||||
} else {
|
||||
game = await prisma.game.create({
|
||||
data: {
|
||||
gamePin: {
|
||||
create: {
|
||||
pin,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
create: {
|
||||
userId: id,
|
||||
index: 0,
|
||||
chats: {
|
||||
create: {
|
||||
event: "created",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
...gameSelects,
|
||||
})
|
||||
}
|
||||
|
||||
const body = composeBody(game)
|
||||
|
||||
return sendResponse(req, res, {
|
||||
message: `User <${email}> created game: ${game.id}`,
|
||||
statusCode: created ? 201 : 200,
|
||||
body,
|
||||
type: ["debug", "infoCyan"],
|
||||
})
|
||||
}
|
89
leaky-ships/pages/api/game/join.ts
Normal file
89
leaky-ships/pages/api/game/join.ts
Normal file
|
@ -0,0 +1,89 @@
|
|||
import sendError from "@backend/sendError"
|
||||
import sendResponse from "@backend/sendResponse"
|
||||
import { rejectionErrors } from "@lib/backend/errors"
|
||||
import getPinFromBody from "@lib/backend/getPinFromBody"
|
||||
import logging from "@lib/backend/logging"
|
||||
import prisma from "@lib/prisma"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "../auth/[...nextauth]"
|
||||
import { composeBody, gameSelects } from "./running"
|
||||
|
||||
export default async function join(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<GamePropsSchema>,
|
||||
) {
|
||||
const session = await getServerSession(req, res, authOptions)
|
||||
const pin = await getPinFromBody(req, res)
|
||||
|
||||
if (!session?.user) {
|
||||
return sendResponse(req, res, rejectionErrors.unauthorized)
|
||||
}
|
||||
|
||||
const { email, id } = session.user
|
||||
|
||||
try {
|
||||
const game = await prisma.game.findFirst({
|
||||
where: {
|
||||
gamePin: {
|
||||
pin,
|
||||
},
|
||||
},
|
||||
})
|
||||
if (!game) {
|
||||
return sendResponse(req, res, {
|
||||
message: "Spiel existiert nicht",
|
||||
statusCode: 404,
|
||||
type: ["infoCyan"],
|
||||
})
|
||||
}
|
||||
|
||||
const games = await prisma.game.findMany({
|
||||
where: {
|
||||
NOT: {
|
||||
state: "ended",
|
||||
},
|
||||
users: {
|
||||
some: {
|
||||
userId: id,
|
||||
},
|
||||
},
|
||||
},
|
||||
...gameSelects,
|
||||
})
|
||||
if (games.length) {
|
||||
return sendResponse(req, res, {
|
||||
message: "Spieler ist bereits in Spiel!",
|
||||
redirectUrl: "/api/game/running",
|
||||
type: ["infoCyan"],
|
||||
})
|
||||
}
|
||||
|
||||
const user_Game = await prisma.user_Game.create({
|
||||
data: {
|
||||
gameId: game.id,
|
||||
userId: id,
|
||||
index: 1,
|
||||
},
|
||||
select: {
|
||||
game: gameSelects,
|
||||
},
|
||||
})
|
||||
|
||||
const body = composeBody(user_Game.game)
|
||||
|
||||
return sendResponse(req, res, {
|
||||
message: `User <${email}> joined game: ${game.id}`,
|
||||
body,
|
||||
type: ["debug", "infoCyan"],
|
||||
})
|
||||
} catch (err: any) {
|
||||
await logging(
|
||||
"HERE".red + err.code + err.meta + err.message,
|
||||
["error"],
|
||||
req,
|
||||
)
|
||||
throw sendError(req, res, rejectionErrors.gameNotFound)
|
||||
}
|
||||
}
|
155
leaky-ships/pages/api/game/running.ts
Normal file
155
leaky-ships/pages/api/game/running.ts
Normal file
|
@ -0,0 +1,155 @@
|
|||
import sendResponse from "@backend/sendResponse"
|
||||
import { rejectionErrors } from "@lib/backend/errors"
|
||||
import { getPayloadwithChecksum } from "@lib/getPayloadwithChecksum"
|
||||
import prisma from "@lib/prisma"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import type { NextApiRequest, NextApiResponse } from "next"
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "../auth/[...nextauth]"
|
||||
|
||||
export const gameSelects = {
|
||||
select: {
|
||||
id: true,
|
||||
allowChat: true,
|
||||
allowMarkDraw: true,
|
||||
allowSpecials: true,
|
||||
allowSpectators: true,
|
||||
state: true,
|
||||
gamePin: {
|
||||
select: {
|
||||
pin: true,
|
||||
},
|
||||
},
|
||||
users: {
|
||||
select: {
|
||||
id: true,
|
||||
index: true,
|
||||
chats: {
|
||||
select: {
|
||||
id: true,
|
||||
event: true,
|
||||
message: true,
|
||||
createdAt: true,
|
||||
},
|
||||
},
|
||||
moves: {
|
||||
select: {
|
||||
index: true,
|
||||
type: true,
|
||||
x: true,
|
||||
y: true,
|
||||
orientation: true,
|
||||
},
|
||||
},
|
||||
ships: {
|
||||
select: {
|
||||
size: true,
|
||||
variant: true,
|
||||
x: true,
|
||||
y: true,
|
||||
orientation: true,
|
||||
},
|
||||
},
|
||||
hits: {
|
||||
select: {
|
||||
x: true,
|
||||
y: true,
|
||||
hit: true,
|
||||
},
|
||||
},
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export const getAnyGame = (gameId: string) => {
|
||||
const game = prisma.game.findFirst({
|
||||
where: {
|
||||
NOT: {
|
||||
state: "ended",
|
||||
},
|
||||
id: gameId,
|
||||
},
|
||||
...gameSelects,
|
||||
})
|
||||
return game
|
||||
}
|
||||
|
||||
export const getAnyRunningGame = (userId: string) => {
|
||||
const game = prisma.game.findFirst({
|
||||
where: {
|
||||
NOT: {
|
||||
state: "ended",
|
||||
},
|
||||
users: {
|
||||
some: {
|
||||
userId,
|
||||
},
|
||||
},
|
||||
},
|
||||
...gameSelects,
|
||||
})
|
||||
return game
|
||||
}
|
||||
|
||||
export function composeBody(
|
||||
gameDB: NonNullable<Awaited<ReturnType<typeof getAnyRunningGame>>>,
|
||||
): GamePropsSchema {
|
||||
const { gamePin, ...game } = gameDB
|
||||
const users = gameDB.users
|
||||
.map(({ user, ...props }) => ({
|
||||
...props,
|
||||
...user,
|
||||
}))
|
||||
.sort((user1, user2) => user1.index - user2.index)
|
||||
let activeIndex = undefined
|
||||
if (game.state === "running") {
|
||||
const l1 = game.users[0].moves.length
|
||||
const l2 = game.users[1].moves.length
|
||||
activeIndex = l1 > l2 ? 1 : 0
|
||||
}
|
||||
const payload = {
|
||||
game: game,
|
||||
gamePin: gamePin?.pin ?? null,
|
||||
users,
|
||||
activeIndex,
|
||||
}
|
||||
return getPayloadwithChecksum(payload)
|
||||
}
|
||||
|
||||
export default async function running(
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponse<GamePropsSchema>,
|
||||
) {
|
||||
const session = await getServerSession(req, res, authOptions)
|
||||
|
||||
if (!session?.user) {
|
||||
return sendResponse(req, res, rejectionErrors.unauthorized)
|
||||
}
|
||||
|
||||
const { email, id } = session.user
|
||||
|
||||
const game = await getAnyRunningGame(id)
|
||||
|
||||
if (!game)
|
||||
return sendResponse(req, res, {
|
||||
message: `User <${email}> is in no game.`,
|
||||
statusCode: 204,
|
||||
type: ["debug", "infoCyan"],
|
||||
})
|
||||
|
||||
const body = composeBody(game)
|
||||
|
||||
return sendResponse(req, res, {
|
||||
message: `User <${email}> asked for game: ${game.id}`,
|
||||
statusCode: 200,
|
||||
body,
|
||||
type: ["debug", "infoCyan"],
|
||||
})
|
||||
}
|
299
leaky-ships/pages/api/ws.ts
Normal file
299
leaky-ships/pages/api/ws.ts
Normal file
|
@ -0,0 +1,299 @@
|
|||
import logging from "@lib/backend/logging"
|
||||
import prisma from "@lib/prisma"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import colors from "colors"
|
||||
import status from "http-status"
|
||||
import { NextApiRequest } from "next"
|
||||
import { getSession } from "next-auth/react"
|
||||
import { Server } from "socket.io"
|
||||
import {
|
||||
NextApiResponseWithSocket,
|
||||
sServer,
|
||||
} from "../../interfaces/NextApiSocket"
|
||||
import {
|
||||
composeBody,
|
||||
gameSelects,
|
||||
getAnyGame,
|
||||
getAnyRunningGame,
|
||||
} from "./game/running"
|
||||
|
||||
colors.enable()
|
||||
|
||||
const SocketHandler = async (
|
||||
req: NextApiRequest,
|
||||
res: NextApiResponseWithSocket,
|
||||
) => {
|
||||
if (res.socket.server.io) {
|
||||
logging("Socket is already running " + req.url, ["infoCyan"], req)
|
||||
} else {
|
||||
logging("Socket is initializing " + req.url, ["infoCyan"], req)
|
||||
const io: sServer = new Server(res.socket.server, {
|
||||
path: "/api/ws",
|
||||
cors: {
|
||||
origin: "https://leaky-ships.mal-noh.de",
|
||||
},
|
||||
})
|
||||
|
||||
res.socket.server.io = io
|
||||
|
||||
// io.use(authenticate)
|
||||
io.use(async (socket, next) => {
|
||||
try {
|
||||
const session = await getSession({
|
||||
req: socket.request,
|
||||
})
|
||||
if (!session) return next(new Error(status["401"]))
|
||||
socket.data.user = session.user
|
||||
|
||||
const game = await getAnyRunningGame(socket.data.user?.id ?? "")
|
||||
if (!game) {
|
||||
logging(
|
||||
"Forbidden, no game found: " +
|
||||
JSON.stringify(Array.from(socket.rooms)),
|
||||
["debug"],
|
||||
socket.request,
|
||||
)
|
||||
return next(new Error(status["403"]))
|
||||
}
|
||||
|
||||
const { payload, hash } = composeBody(game)
|
||||
// let index: number | null = null
|
||||
const index = payload.users.findIndex(
|
||||
(user) => socket.data.user?.id === user?.id,
|
||||
)
|
||||
if (index < 0) return next(new Error(status["401"]))
|
||||
socket.data.index = index
|
||||
socket.data.gameId = game.id
|
||||
socket.join(game.id)
|
||||
socket.to(game.id).emit("playerEvent", {
|
||||
type: "connect",
|
||||
i: socket.data.index,
|
||||
payload: { users: payload.users },
|
||||
hash,
|
||||
})
|
||||
|
||||
next()
|
||||
} catch (err: any) {
|
||||
logging("Unkonwn error - " + status["401"], ["warn"], socket.request)
|
||||
next(new Error(status["401"]))
|
||||
}
|
||||
})
|
||||
|
||||
io.on("connection", async (socket) => {
|
||||
logging(
|
||||
`User connected <${socket.data.user?.email}>`.green +
|
||||
", " +
|
||||
socket.id.cyan,
|
||||
["infoGreen"],
|
||||
socket.request,
|
||||
)
|
||||
|
||||
socket.on("update", async (cb) => {
|
||||
const game = await getAnyGame(socket.data.gameId ?? "")
|
||||
if (!game) return
|
||||
const body = composeBody(game)
|
||||
cb(body)
|
||||
})
|
||||
|
||||
socket.on("gameSetting", async (payload, cb) => {
|
||||
const game = await prisma.game.update({
|
||||
where: { id: socket.data.gameId ?? "" },
|
||||
data: payload,
|
||||
...gameSelects,
|
||||
})
|
||||
const { hash } = composeBody(game)
|
||||
if (!hash) return
|
||||
cb(hash)
|
||||
socket.to(game.id).emit("gameSetting", payload, hash)
|
||||
})
|
||||
|
||||
socket.on("ping", (callback) => callback())
|
||||
|
||||
socket.on("leave", async (cb) => {
|
||||
if (!socket.data.gameId || !socket.data.user?.id) return cb(false)
|
||||
const user_Game = await prisma.user_Game.delete({
|
||||
where: {
|
||||
gameId_userId: {
|
||||
gameId: socket.data.gameId,
|
||||
userId: socket.data.user?.id,
|
||||
},
|
||||
},
|
||||
})
|
||||
const enemy = await prisma.user_Game.findFirst({
|
||||
where: {
|
||||
gameId: socket.data.gameId,
|
||||
},
|
||||
})
|
||||
let body: GamePropsSchema
|
||||
if (user_Game.index === 1 && enemy) {
|
||||
const { game } = await prisma.user_Game.update({
|
||||
where: {
|
||||
gameId_index: {
|
||||
gameId: socket.data.gameId,
|
||||
index: 2,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
index: 1,
|
||||
},
|
||||
select: {
|
||||
game: { ...gameSelects },
|
||||
},
|
||||
})
|
||||
body = composeBody(game)
|
||||
} else {
|
||||
const game = await prisma.game.findUnique({
|
||||
where: {
|
||||
id: socket.data.gameId,
|
||||
},
|
||||
...gameSelects,
|
||||
})
|
||||
if (!game) return cb(false)
|
||||
body = composeBody(game)
|
||||
}
|
||||
const { payload, hash } = body
|
||||
if (!payload || !hash || socket.data.index === undefined)
|
||||
return cb(false)
|
||||
socket.to(socket.data.gameId).emit("playerEvent", {
|
||||
type: "leave",
|
||||
i: socket.data.index,
|
||||
payload: { users: payload.users },
|
||||
hash,
|
||||
})
|
||||
cb(true)
|
||||
|
||||
if (!payload.users.length) {
|
||||
await prisma.game.delete({
|
||||
where: {
|
||||
id: socket.data.gameId,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
socket.on("isReady", async (isReady) => {
|
||||
if (socket.data.index === undefined || !socket.data.gameId) return
|
||||
socket
|
||||
.to(socket.data.gameId)
|
||||
.emit("isReady", { i: socket.data.index, isReady })
|
||||
socket
|
||||
.to(socket.data.gameId)
|
||||
.emit("isConnected", { i: socket.data.index, isConnected: true })
|
||||
})
|
||||
|
||||
socket.on("canvas-state", (state) => {
|
||||
if (!socket.data.gameId || !socket.data.index) return
|
||||
console.log("received canvas state")
|
||||
socket
|
||||
.to(socket.data.gameId)
|
||||
.emit("canvas-state-from-server", state, socket.data.index)
|
||||
})
|
||||
|
||||
socket.on("draw-line", ({ prevPoint, currentPoint, color }) => {
|
||||
if (!socket.data.gameId || !socket.data.index) return
|
||||
socket
|
||||
.to(socket.data.gameId)
|
||||
.emit(
|
||||
"draw-line",
|
||||
{ prevPoint, currentPoint, color },
|
||||
socket.data.index,
|
||||
)
|
||||
})
|
||||
|
||||
socket.on("canvas-clear", () => {
|
||||
if (!socket.data.gameId) return
|
||||
socket.to(socket.data.gameId).emit("canvas-clear")
|
||||
})
|
||||
|
||||
socket.on("gameState", async (newState) => {
|
||||
if (socket.data.index !== 0 || !socket.data.gameId) return
|
||||
await prisma.game.update({
|
||||
where: { id: socket.data.gameId },
|
||||
data: {
|
||||
state: newState,
|
||||
},
|
||||
})
|
||||
io.to(socket.data.gameId).emit("gameState", newState)
|
||||
if (newState === "running")
|
||||
io.to(socket.data.gameId).emit("activeIndex", 0)
|
||||
})
|
||||
|
||||
socket.on("ships", async (ships) => {
|
||||
if (
|
||||
!socket.data.gameId ||
|
||||
!socket.data.user?.id ||
|
||||
typeof socket.data.index === "undefined"
|
||||
)
|
||||
return
|
||||
await prisma.user_Game.update({
|
||||
where: {
|
||||
gameId_userId: {
|
||||
gameId: socket.data.gameId,
|
||||
userId: socket.data.user.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
ships: {
|
||||
deleteMany: {},
|
||||
createMany: {
|
||||
data: ships,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
socket.to(socket.data.gameId).emit("ships", ships, socket.data.index)
|
||||
})
|
||||
|
||||
socket.on("dispatchMove", async (props) => {
|
||||
if (
|
||||
!socket.data.gameId ||
|
||||
!socket.data.user?.id ||
|
||||
typeof socket.data.index === "undefined"
|
||||
)
|
||||
return
|
||||
const user_Game = await prisma.user_Game
|
||||
.update({
|
||||
where: {
|
||||
gameId_userId: {
|
||||
gameId: socket.data.gameId,
|
||||
userId: socket.data.user?.id,
|
||||
},
|
||||
},
|
||||
data: {
|
||||
moves: {
|
||||
create: props,
|
||||
},
|
||||
},
|
||||
select: { game: gameSelects },
|
||||
})
|
||||
.catch((e) => console.log(e, props))
|
||||
if (!user_Game?.game) return
|
||||
const game = user_Game.game
|
||||
const l1 = game.users[0].moves.length
|
||||
const l2 = game.users[1].moves.length
|
||||
io.to(socket.data.gameId).emit("dispatchMove", props, socket.data.index)
|
||||
io.to(socket.data.gameId).emit("activeIndex", l1 > l2 ? 1 : 0)
|
||||
})
|
||||
|
||||
socket.on("disconnecting", async () => {
|
||||
logging(
|
||||
"Disconnecting: " + JSON.stringify(Array.from(socket.rooms)),
|
||||
["debug"],
|
||||
socket.request,
|
||||
)
|
||||
if (socket.data.index === undefined || !socket.data.gameId) return
|
||||
socket.to(socket.data.gameId).emit("playerEvent", {
|
||||
type: "disconnect",
|
||||
i: socket.data.index,
|
||||
})
|
||||
})
|
||||
|
||||
socket.on("disconnect", () => {
|
||||
logging("Disconnect: " + socket.id, ["debug"], socket.request)
|
||||
})
|
||||
})
|
||||
}
|
||||
res.end()
|
||||
}
|
||||
|
||||
export default SocketHandler
|
44
leaky-ships/pages/game.tsx
Normal file
44
leaky-ships/pages/game.tsx
Normal file
|
@ -0,0 +1,44 @@
|
|||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useRouter } from "next/router"
|
||||
import { useEffect } from "react"
|
||||
import { toast } from "react-toastify"
|
||||
|
||||
export default function Game() {
|
||||
const { payload } = useGameProps()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
|
||||
useEffect(() => {
|
||||
const gameId = payload?.game?.id
|
||||
const path = gameId ? "/game" : "/start"
|
||||
toast.promise(router.push(path), {
|
||||
pending: {
|
||||
render: "Wird weitergeleitet...",
|
||||
toastId: "pageLoad",
|
||||
},
|
||||
success: {
|
||||
render: gameId
|
||||
? "Spiel gefunden!"
|
||||
: session?.user.id
|
||||
? "Kein laufendes Spiel."
|
||||
: "Kein laufendes Spiel. Bitte anmelden.",
|
||||
toastId: "pageLoad",
|
||||
theme: session?.user.id ? "dark" : undefined,
|
||||
type: gameId ? "success" : "info",
|
||||
},
|
||||
error: {
|
||||
render: "Es ist ein Fehler aufgetreten 🤯",
|
||||
type: "error",
|
||||
toastId: "pageLoad",
|
||||
theme: "colored",
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="h-full bg-theme">
|
||||
<div className="mx-auto flex h-full max-w-screen-md flex-col items-center justify-evenly" />
|
||||
</div>
|
||||
)
|
||||
}
|
22
leaky-ships/pages/gamefield.tsx
Normal file
22
leaky-ships/pages/gamefield.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Gamefield from "@components/Gamefield/Gamefield"
|
||||
import Head from "next/head"
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main>
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<Gamefield />
|
||||
</header>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
22
leaky-ships/pages/grid.tsx
Normal file
22
leaky-ships/pages/grid.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Grid from "@components/Grid"
|
||||
import Head from "next/head"
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main>
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<Grid />
|
||||
</header>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
22
leaky-ships/pages/grid2.tsx
Normal file
22
leaky-ships/pages/grid2.tsx
Normal file
|
@ -0,0 +1,22 @@
|
|||
import Grid2 from "@components/Grid2"
|
||||
import Head from "next/head"
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<>
|
||||
<Head>
|
||||
<title>Create Next App</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
</Head>
|
||||
<main>
|
||||
<div className="App">
|
||||
<header className="App-header">
|
||||
<Grid2 />
|
||||
</header>
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
)
|
||||
}
|
30
leaky-ships/pages/index.tsx
Normal file
30
leaky-ships/pages/index.tsx
Normal file
|
@ -0,0 +1,30 @@
|
|||
import BurgerMenu from "@components/BurgerMenu"
|
||||
import Logo from "@components/Logo"
|
||||
import { useRouter } from "next/router"
|
||||
|
||||
export default function Home() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="h-full bg-theme">
|
||||
<div className="mx-auto flex h-full max-w-screen-md flex-col items-center justify-evenly">
|
||||
<Logo />
|
||||
<BurgerMenu />
|
||||
<div className="flex h-36 w-64 items-center justify-center overflow-hidden rounded-xl border-8 border-black bg-[#2227] sm:h-48 sm:w-96 md:h-72 md:w-[32rem] md:border-[6px] xl:h-[26rem] xl:w-[48rem]">
|
||||
<video controls preload="metadata" src="/Regelwerk.mp4" />
|
||||
</div>
|
||||
<button
|
||||
id="start"
|
||||
className="font-farro rounded-lg border-b-4 border-orange-400 bg-warn px-12 pb-4 pt-5 text-2xl font-bold duration-100 active:border-b-0 active:border-t-4 sm:rounded-xl sm:border-b-[6px] sm:px-14 sm:pb-5 sm:pt-6 sm:text-3xl sm:active:border-t-[6px] md:rounded-2xl md:border-b-8 md:px-20 md:pb-6 md:pt-7 md:text-4xl md:active:border-t-8 xl:px-24 xl:pb-8 xl:pt-10 xl:text-5xl"
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
router.push("/start")
|
||||
}, 200)
|
||||
}
|
||||
>
|
||||
START
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
39
leaky-ships/pages/lobby.tsx
Normal file
39
leaky-ships/pages/lobby.tsx
Normal file
|
@ -0,0 +1,39 @@
|
|||
import BurgerMenu from "@components/BurgerMenu"
|
||||
import LobbyFrame from "@components/Lobby/LobbyFrame"
|
||||
import Settings from "@components/Lobby/SettingsFrame/Settings"
|
||||
import Logo from "@components/Logo"
|
||||
import classNames from "classnames"
|
||||
import Head from "next/head"
|
||||
import { useState } from "react"
|
||||
|
||||
export default function Lobby() {
|
||||
const [settings, setSettings] = useState(false)
|
||||
|
||||
return (
|
||||
<div className="h-full bg-theme">
|
||||
<Head>
|
||||
<title>Lobby</title>
|
||||
<meta name="description" content="Generated by create next app" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link
|
||||
rel="preload"
|
||||
href="/fonts/cpfont_ote/CP Font.otf"
|
||||
as="font"
|
||||
type="font/woff2"
|
||||
/>
|
||||
</Head>
|
||||
<div
|
||||
className={classNames(
|
||||
"mx-auto flex h-full max-w-screen-2xl flex-col items-center justify-evenly",
|
||||
{ "blur-sm": settings },
|
||||
)}
|
||||
>
|
||||
<Logo small={true} />
|
||||
<LobbyFrame openSettings={() => setSettings(true)} />
|
||||
</div>
|
||||
<BurgerMenu blur={settings} />
|
||||
{settings ? <Settings closeSettings={() => setSettings(false)} /> : null}
|
||||
</div>
|
||||
)
|
||||
}
|
152
leaky-ships/pages/signin.tsx
Normal file
152
leaky-ships/pages/signin.tsx
Normal file
|
@ -0,0 +1,152 @@
|
|||
import { faLeftLong } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { signIn, useSession } from "next-auth/react"
|
||||
import { useRouter } from "next/router"
|
||||
import { FormEvent, useEffect, useState } from "react"
|
||||
import { toast } from "react-toastify"
|
||||
|
||||
type SignInErrorTypes =
|
||||
| "Signin"
|
||||
| "OAuthSignin"
|
||||
| "OAuthCallback"
|
||||
| "OAuthCreateAccount"
|
||||
| "EmailCreateAccount"
|
||||
| "Callback"
|
||||
| "OAuthAccountNotLinked"
|
||||
| "EmailSignin"
|
||||
| "CredentialsSignin"
|
||||
| "SessionRequired"
|
||||
| "default"
|
||||
|
||||
const errors: Record<SignInErrorTypes, string> = {
|
||||
Signin: "Try signing in with a different account.",
|
||||
OAuthSignin: "Try signing in with a different account.",
|
||||
OAuthCallback: "Try signing in with a different account.",
|
||||
OAuthCreateAccount: "Try signing in with a different account.",
|
||||
EmailCreateAccount: "Try signing in with a different account.",
|
||||
Callback: "Try signing in with a different account.",
|
||||
OAuthAccountNotLinked:
|
||||
"To confirm your identity, sign in with the same account you used originally.",
|
||||
EmailSignin: "The e-mail could not be sent.",
|
||||
CredentialsSignin:
|
||||
"Sign in failed. Check the details you provided are correct.",
|
||||
SessionRequired: "Please sign in to access this page.",
|
||||
default: "Unable to sign in.",
|
||||
}
|
||||
|
||||
function Login() {
|
||||
const [email, setEmail] = useState("")
|
||||
const { status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
const errorType = router.query.error as SignInErrorTypes
|
||||
|
||||
useEffect(() => {
|
||||
if (!errorType) return
|
||||
toast.error(errors[errorType] ?? errors.default, { theme: "colored" })
|
||||
}, [errorType])
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "authenticated") router.push("/")
|
||||
}, [router, status])
|
||||
|
||||
function login(provider: "email" | "azure-ad") {
|
||||
return (e?: FormEvent) => {
|
||||
e?.preventDefault()
|
||||
signIn(provider, { email, callbackUrl: "/" })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-gray-900 bg-[url('/images/wallpaper.jpg')] bg-cover bg-center bg-no-repeat">
|
||||
<div className="rounded-xl bg-gray-800 bg-opacity-60 px-16 py-10 text-white shadow-lg backdrop-blur-md max-sm:px-8">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<img
|
||||
className="rounded-full shadow-lg"
|
||||
src="/logo512.png"
|
||||
width="150"
|
||||
alt="Avatar"
|
||||
/>
|
||||
<h1 className="mb-2 text-2xl">Leaky Ships</h1>
|
||||
<span className="text-gray-300">Choose Login Method</span>
|
||||
</div>
|
||||
{errorType && <hr className="mb-8 border-gray-400" />}
|
||||
<div className="flex flex-col">
|
||||
<form className="flex flex-col" onSubmit={login("email")}>
|
||||
<label htmlFor="email" className="mx-2 text-lg">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
className="my-1 rounded-lg border-2 border-gray-500 bg-slate-800 bg-opacity-60 px-6 py-2 text-center text-inherit placeholder-slate-400 shadow-lg outline-none backdrop-blur-md focus-within:border-blue-500"
|
||||
type="email"
|
||||
name="email"
|
||||
id="email"
|
||||
placeholder="user@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
id="email-submit"
|
||||
type="submit"
|
||||
className="my-1 rounded-lg bg-blue-500 bg-opacity-75 px-10 py-3 text-white shadow-inner drop-shadow-md backdrop-blur-md transition-colors duration-300 hover:bg-blue-600"
|
||||
>
|
||||
Sign in with Email
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div className="flex flex-row items-center">
|
||||
<hr className="w-full" />
|
||||
<span className="mx-4 my-2">or</span>
|
||||
<hr className="w-full" />
|
||||
</div>
|
||||
|
||||
<div className="my-2 flex flex-col rounded-lg bg-gradient-to-tr from-[#fff8] via-[#fffd] to-[#fff8] p-4 shadow-lg drop-shadow-md">
|
||||
<a
|
||||
href="https://gbs-grafschaft.de/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<img
|
||||
src="/images/logo-gbs.png"
|
||||
loading="lazy"
|
||||
alt="Gewerbliche Berufsbildende Schulen"
|
||||
className="m-4 mt-2 w-60 justify-center"
|
||||
/>
|
||||
</a>
|
||||
<button
|
||||
id="microsoft"
|
||||
onClick={login("azure-ad")}
|
||||
className="flex w-full justify-evenly rounded-lg border border-gray-400 bg-slate-100 px-5 py-3 text-black drop-shadow-md duration-300 hover:bg-slate-200"
|
||||
>
|
||||
<img
|
||||
src="/images/Microsoft_icon.svg"
|
||||
loading="lazy"
|
||||
height="24"
|
||||
width="24"
|
||||
alt="Microsoft_icon"
|
||||
/>
|
||||
<span>Sign in with Microsoft</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{errorType ? (
|
||||
<>
|
||||
<hr className="mt-8 border-gray-400" />
|
||||
<div className="flex flex-col items-center">
|
||||
<button
|
||||
id="back"
|
||||
onClick={() => router.push("/")}
|
||||
className="mt-10 rounded-lg border-2 border-gray-400 bg-gray-500 bg-opacity-75 px-16 py-2 text-white shadow-inner drop-shadow-md backdrop-blur-md transition-colors duration-300 hover:border-blue-600"
|
||||
>
|
||||
<FontAwesomeIcon icon={faLeftLong} />
|
||||
<span className="mx-4 font-bold">Return</span>
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Login
|
43
leaky-ships/pages/signout.tsx
Normal file
43
leaky-ships/pages/signout.tsx
Normal file
|
@ -0,0 +1,43 @@
|
|||
import { signOut, useSession } from "next-auth/react"
|
||||
import { useRouter } from "next/router"
|
||||
import { useEffect } from "react"
|
||||
|
||||
function Logout() {
|
||||
const { status } = useSession()
|
||||
const router = useRouter()
|
||||
|
||||
useEffect(() => {
|
||||
if (status === "unauthenticated") router.push("/signin")
|
||||
}, [router, status])
|
||||
|
||||
return (
|
||||
<div className="flex h-screen w-full items-center justify-center bg-gray-900 bg-[url('/images/wallpaper.jpg')] bg-cover bg-center bg-no-repeat">
|
||||
<div className="rounded-xl bg-gray-800 bg-opacity-50 px-16 py-10 shadow-lg backdrop-blur-md max-sm:px-8">
|
||||
<div className="text-white">
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<img
|
||||
className="rounded-full shadow-lg"
|
||||
src="/logo512.png"
|
||||
width="150"
|
||||
alt="Avatar"
|
||||
/>
|
||||
<h1 className="mb-2 text-2xl">Leaky Ships</h1>
|
||||
<span className="text-gray-300">Signout</span>
|
||||
</div>
|
||||
<div className="flex flex-col justify-start gap-4">
|
||||
<span>Are you sure you want to sign out?</span>
|
||||
<button
|
||||
id="signout"
|
||||
onClick={() => signOut({ callbackUrl: "/" })}
|
||||
className="rounded-lg bg-blue-500 bg-opacity-75 px-10 py-3 text-white shadow-inner drop-shadow-md backdrop-blur-md transition-colors duration-300 hover:bg-blue-600"
|
||||
>
|
||||
Sign out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logout
|
231
leaky-ships/pages/start.tsx
Normal file
231
leaky-ships/pages/start.tsx
Normal file
|
@ -0,0 +1,231 @@
|
|||
import BurgerMenu from "@components/BurgerMenu"
|
||||
import Logo from "@components/Logo"
|
||||
import OptionButton from "@components/OptionButton"
|
||||
import { faEye, faLeftLong } from "@fortawesome/pro-regular-svg-icons"
|
||||
import { faPlus, faUserPlus } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"
|
||||
import { useGameProps } from "@hooks/useGameProps"
|
||||
import { GamePropsSchema } from "@lib/zodSchemas"
|
||||
import status from "http-status"
|
||||
import { useSession } from "next-auth/react"
|
||||
import { useRouter } from "next/router"
|
||||
import { useCallback, useEffect, useMemo, useState } from "react"
|
||||
import OtpInput from "react-otp-input"
|
||||
import { Icons, toast } from "react-toastify"
|
||||
|
||||
export function isAuthenticated(res: Response) {
|
||||
switch (status[`${res.status}_CLASS`]) {
|
||||
case status.classes.SUCCESSFUL:
|
||||
case status.classes.REDIRECTION:
|
||||
return res.json()
|
||||
}
|
||||
|
||||
const resStatus = status[`${res.status}_CLASS`]
|
||||
if (typeof resStatus !== "string") return
|
||||
|
||||
toast(status[res.status], {
|
||||
position: "top-center",
|
||||
type: "info",
|
||||
theme: "colored",
|
||||
})
|
||||
}
|
||||
|
||||
const handleConfirmation = () => {
|
||||
const toastId = "confirm"
|
||||
toast.warn(
|
||||
<div id="toast-confirm">
|
||||
<h4>You are already in another round, do you want to:</h4>
|
||||
<button onClick={() => toast.dismiss(toastId)}>Join</button>
|
||||
or
|
||||
<button onClick={() => toast.dismiss(toastId)}>Leave</button>
|
||||
</div>,
|
||||
{ autoClose: false, toastId },
|
||||
)
|
||||
}
|
||||
|
||||
export default function Start() {
|
||||
const [otp, setOtp] = useState("")
|
||||
const { full } = useGameProps()
|
||||
const router = useRouter()
|
||||
const { data: session } = useSession()
|
||||
|
||||
const query = useMemo((): { join?: boolean; watch?: boolean } => {
|
||||
switch (router.query.q) {
|
||||
case "join":
|
||||
return { join: true }
|
||||
case "watch":
|
||||
return { watch: true }
|
||||
default:
|
||||
return {}
|
||||
}
|
||||
}, [router])
|
||||
|
||||
const gameFetch = useCallback(
|
||||
async (pin?: string) => {
|
||||
const gameRequestPromise = fetch(
|
||||
"/api/game/" + (!pin ? "create" : "join"),
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ pin }),
|
||||
},
|
||||
)
|
||||
.then(isAuthenticated)
|
||||
.then((game) => GamePropsSchema.parse(game))
|
||||
|
||||
const move = !pin ? "erstellt" : "angefragt"
|
||||
const toastId = "pageLoad"
|
||||
toast("Raum wird " + move, {
|
||||
icon: Icons.spinner(),
|
||||
toastId,
|
||||
autoClose: false,
|
||||
hideProgressBar: true,
|
||||
closeButton: false,
|
||||
})
|
||||
const res = await gameRequestPromise.catch(() =>
|
||||
toast.update(toastId, {
|
||||
render: "Es ist ein Fehler aufgetreten bei der Anfrage 🤯",
|
||||
type: "error",
|
||||
icon: Icons.error,
|
||||
theme: "colored",
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeButton: true,
|
||||
}),
|
||||
)
|
||||
if (!res) return
|
||||
full(res)
|
||||
|
||||
toast.update(toastId, {
|
||||
render: "Weiterleitung",
|
||||
})
|
||||
|
||||
router
|
||||
.push("/lobby")
|
||||
.then(() =>
|
||||
toast.update(toastId, {
|
||||
render: "Raum begetreten 👌",
|
||||
type: "info",
|
||||
icon: Icons.success,
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeButton: true,
|
||||
}),
|
||||
)
|
||||
.catch(() =>
|
||||
toast.update(toastId, {
|
||||
render: "Es ist ein Fehler aufgetreten beim Seiten wechsel 🤯",
|
||||
type: "error",
|
||||
icon: Icons.error,
|
||||
theme: "colored",
|
||||
autoClose: 5000,
|
||||
hideProgressBar: false,
|
||||
closeButton: true,
|
||||
}),
|
||||
)
|
||||
},
|
||||
[router, full],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (otp.length !== 4) return
|
||||
gameFetch(otp)
|
||||
}, [otp, gameFetch])
|
||||
|
||||
return (
|
||||
<div className="h-full bg-theme">
|
||||
<div className="mx-auto flex h-full max-w-screen-md flex-col items-center justify-evenly">
|
||||
<Logo />
|
||||
<BurgerMenu />
|
||||
<div className="flex flex-col items-center rounded-xl border-4 border-black bg-grayish px-4 py-6 shadow-lg sm:mx-8 sm:p-12 md:w-full">
|
||||
<div className="flex w-full justify-between">
|
||||
<button
|
||||
id="back"
|
||||
className="-mt-2 h-14 w-20 self-start rounded-xl border-b-4 border-shield-gray bg-voidDark text-2xl text-grayish duration-100 active:border-b-0 active:border-t-4 sm:-mt-6 sm:w-40 sm:px-2 sm:text-5xl"
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
router.push("/")
|
||||
}, 200)
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon icon={faLeftLong} />
|
||||
</button>
|
||||
{!session?.user.id && (
|
||||
<button
|
||||
id="login"
|
||||
className="-mt-2 h-14 w-20 self-start rounded-xl border-b-4 border-orange-500 bg-yellow-500 text-2xl active:border-b-0 active:border-t-4 sm:-mt-6 sm:w-40 sm:px-2 sm:text-4xl"
|
||||
onClick={() =>
|
||||
setTimeout(() => {
|
||||
router.push("/signin")
|
||||
}, 200)
|
||||
}
|
||||
>
|
||||
Login
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-center gap-6 sm:gap-12">
|
||||
<OptionButton
|
||||
id="Raum erstellen"
|
||||
callback={() => gameFetch()}
|
||||
icon={faPlus}
|
||||
disabled={!session}
|
||||
/>
|
||||
<OptionButton
|
||||
id="Raum beitreten"
|
||||
callback={() => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { q: "join" },
|
||||
})
|
||||
}}
|
||||
icon={faUserPlus}
|
||||
disabled={!session}
|
||||
node={
|
||||
query.join && session ? (
|
||||
<OtpInput
|
||||
shouldAutoFocus
|
||||
containerStyle={{ color: "initial" }}
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
numInputs={4}
|
||||
inputType="number"
|
||||
inputStyle="inputStyle"
|
||||
placeholder="0000"
|
||||
renderSeparator={<span>-</span>}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
<OptionButton
|
||||
id="Zuschauen"
|
||||
icon={faEye}
|
||||
callback={() => {
|
||||
router.push({
|
||||
pathname: router.pathname,
|
||||
query: { q: "watch" },
|
||||
})
|
||||
}}
|
||||
node={
|
||||
query.watch ? (
|
||||
<OtpInput
|
||||
shouldAutoFocus
|
||||
containerStyle={{ color: "initial" }}
|
||||
value={otp}
|
||||
onChange={setOtp}
|
||||
numInputs={4}
|
||||
inputType="number"
|
||||
inputStyle="inputStyle"
|
||||
placeholder="0000"
|
||||
renderSeparator={<span>-</span>}
|
||||
renderInput={(props) => <input {...props} />}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
|
@ -1,11 +1,10 @@
|
|||
import { defineConfig, devices } from "@playwright/test"
|
||||
import dotenv from "dotenv"
|
||||
|
||||
/**
|
||||
* Read environment variables from file.
|
||||
* https://github.com/motdotla/dotenv
|
||||
*/
|
||||
dotenv.config()
|
||||
require("dotenv").config()
|
||||
|
||||
/**
|
||||
* See https://playwright.dev/docs/test-configuration.
|
||||
|
@ -25,7 +24,7 @@ export default defineConfig({
|
|||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
baseURL: process.env.AUTH_URL!,
|
||||
// baseURL: 'http://127.0.0.1:3000',
|
||||
|
||||
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
|
||||
trace: "on-first-retry",
|
||||
|
@ -71,8 +70,8 @@ export default defineConfig({
|
|||
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
command: "pnpm start",
|
||||
url: process.env.AUTH_URL,
|
||||
command: "pnpm run start",
|
||||
url: process.env.NEXTAUTH_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
|
|
8261
leaky-ships/pnpm-lock.yaml
generated
8261
leaky-ships/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
5820
leaky-ships/prisma/generated/zod/index.ts
Normal file
5820
leaky-ships/prisma/generated/zod/index.ts
Normal file
File diff suppressed because it is too large
Load diff
|
@ -50,13 +50,13 @@ model User {
|
|||
email String? @unique
|
||||
emailVerified DateTime? @map("email_verified")
|
||||
image String?
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
createdAt DateTime @default(now()) @map(name: "created_at")
|
||||
updatedAt DateTime @updatedAt @map(name: "updated_at")
|
||||
games User_Game[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
|
||||
@@map("users")
|
||||
@@map(name: "users")
|
||||
}
|
||||
|
||||
model VerificationToken {
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
// @refresh reload
|
||||
import "@fortawesome/fontawesome-svg-core/styles.css"
|
||||
import { Link, Meta, MetaProvider, Title } from "@solidjs/meta"
|
||||
import { Router } from "@solidjs/router"
|
||||
import { FileRoutes } from "@solidjs/start"
|
||||
import { Suspense } from "solid-js"
|
||||
import "./styles/App.scss"
|
||||
import "./styles/globals.scss"
|
||||
import "./styles/grid.scss"
|
||||
import "./styles/grid2.scss"
|
||||
import "./styles/root.css"
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Router
|
||||
root={(props) => (
|
||||
<MetaProvider>
|
||||
<Title>Leaky Ships</Title>
|
||||
<Meta charset="utf-8" />
|
||||
<Meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
|
||||
<Link rel="manifest" href="/manifest.json" />
|
||||
<Link rel="icon" href="/favicon.ico" />
|
||||
<Meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<Meta name="theme-color" content="#000000" />
|
||||
<Meta
|
||||
name="description"
|
||||
content="Battleship web app with react frontend and ASP .NET backend"
|
||||
/>
|
||||
<Link rel="apple-touch-icon" href="/logo192.png" />
|
||||
|
||||
<Suspense>{props.children}</Suspense>
|
||||
</MetaProvider>
|
||||
)}
|
||||
>
|
||||
<FileRoutes />
|
||||
</Router>
|
||||
)
|
||||
}
|
|
@ -1,151 +0,0 @@
|
|||
import {
|
||||
FaSymbol,
|
||||
FlipProp,
|
||||
IconDefinition,
|
||||
IconProp,
|
||||
PullProp,
|
||||
RotateProp,
|
||||
SizeProp,
|
||||
Transform,
|
||||
} from "@fortawesome/fontawesome-svg-core"
|
||||
import { Show, type JSX } from "solid-js"
|
||||
|
||||
export interface FontAwesomeIconProps
|
||||
extends Omit<
|
||||
JSX.SvgSVGAttributes<SVGSVGElement>,
|
||||
"children" | "mask" | "transform"
|
||||
> {
|
||||
icon: IconDefinition
|
||||
mask?: IconProp
|
||||
maskId?: string
|
||||
color?: string
|
||||
spin?: boolean
|
||||
spinPulse?: boolean
|
||||
spinReverse?: boolean
|
||||
pulse?: boolean
|
||||
beat?: boolean
|
||||
fade?: boolean
|
||||
beatFade?: boolean
|
||||
bounce?: boolean
|
||||
shake?: boolean
|
||||
flash?: boolean
|
||||
border?: boolean
|
||||
fixedWidth?: boolean
|
||||
inverse?: boolean
|
||||
listItem?: boolean
|
||||
flip?: FlipProp
|
||||
size?: SizeProp
|
||||
pull?: PullProp
|
||||
rotation?: RotateProp
|
||||
transform?: string | Transform
|
||||
symbol?: FaSymbol
|
||||
style?: JSX.CSSProperties
|
||||
tabIndex?: number
|
||||
title?: string
|
||||
titleId?: string
|
||||
swapOpacity?: boolean
|
||||
}
|
||||
|
||||
const idPool = "0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
function nextUniqueId() {
|
||||
let size = 12
|
||||
let id = ""
|
||||
|
||||
while (size-- > 0) {
|
||||
id += idPool[(Math.random() * 62) | 0]
|
||||
}
|
||||
|
||||
return id
|
||||
}
|
||||
|
||||
function Path(props: { d: string | string[] }) {
|
||||
return (
|
||||
<>
|
||||
{typeof props.d === "string" ? (
|
||||
<path fill="currentColor" d={props.d} />
|
||||
) : (
|
||||
<>
|
||||
<path class="fa-secondary" fill="currentColor" d={props.d[0]} />
|
||||
<path class="fa-primary" fill="currentColor" d={props.d[1]} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function FontAwesomeIcon(props: FontAwesomeIconProps) {
|
||||
const titleId = () =>
|
||||
props.title
|
||||
? "svg-inline--fa-title-".concat(props.titleId || nextUniqueId())
|
||||
: undefined
|
||||
// Get CSS class list from the props object
|
||||
function attributes() {
|
||||
const defaultClasses = {
|
||||
"svg-inline--fa": true,
|
||||
[`fa-${props.icon.iconName}`]: true,
|
||||
[props.class ?? ""]:
|
||||
typeof props.class !== "undefined" && props.class !== null,
|
||||
...props.classList,
|
||||
}
|
||||
|
||||
// map of CSS class names to properties
|
||||
const faClasses = {
|
||||
"fa-beat": props.beat,
|
||||
"fa-fade": props.fade,
|
||||
"fa-beat-fade": props.beatFade,
|
||||
"fa-bounce": props.bounce,
|
||||
"fa-shake": props.shake,
|
||||
"fa-flash": props.flash,
|
||||
"fa-spin": props.spin,
|
||||
"fa-spin-reverse": props.spinReverse,
|
||||
"fa-spin-pulse": props.spinPulse,
|
||||
"fa-pulse": props.pulse,
|
||||
"fa-fw": props.fixedWidth,
|
||||
"fa-inverse": props.inverse,
|
||||
"fa-border": props.border,
|
||||
"fa-li": props.listItem,
|
||||
"fa-flip": typeof props.flip !== "undefined" && props.flip !== null,
|
||||
"fa-flip-horizontal":
|
||||
props.flip === "horizontal" || props.flip === "both",
|
||||
"fa-flip-vertical": props.flip === "vertical" || props.flip === "both",
|
||||
[`fa-${props.size}`]:
|
||||
typeof props.size !== "undefined" && props.size !== null,
|
||||
[`fa-rotate-${props.rotation}`]:
|
||||
typeof props.rotation !== "undefined" && props.size !== null,
|
||||
[`fa-pull-${props.pull}`]:
|
||||
typeof props.pull !== "undefined" && props.pull !== null,
|
||||
"fa-swap-opacity": props.swapOpacity,
|
||||
}
|
||||
|
||||
const attributes = {
|
||||
focusable: !!props.title,
|
||||
"aria-hidden": !props.title,
|
||||
role: "img",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"aria-labelledby": titleId(),
|
||||
"data-prefix": props.icon.prefix,
|
||||
"data-icon": props.icon.iconName,
|
||||
"data-fa-transform": props.transform,
|
||||
"data-fa-mask": props.mask,
|
||||
"data-fa-mask-id": props.maskId,
|
||||
"data-fa-symbol": props.symbol,
|
||||
tabIndex: props.tabIndex,
|
||||
classList: { ...defaultClasses, ...faClasses },
|
||||
color: props.color,
|
||||
style: props.style,
|
||||
viewBox: `0 0 ${props.icon.icon[0]} ${props.icon.icon[1]}`,
|
||||
} as const
|
||||
|
||||
// return the complete class list
|
||||
return attributes
|
||||
}
|
||||
|
||||
return (
|
||||
<svg {...attributes()}>
|
||||
<Show when={props.title}>
|
||||
<title id={titleId()}>{props.title}</title>
|
||||
</Show>
|
||||
<Path d={props.icon.icon[4]} />
|
||||
</svg>
|
||||
)
|
||||
}
|
|
@ -1,169 +0,0 @@
|
|||
import { For } from "solid-js"
|
||||
import {
|
||||
gameProps,
|
||||
mouseCursor,
|
||||
removeShip,
|
||||
setGameProps,
|
||||
setMouseCursor,
|
||||
setShips,
|
||||
setTarget,
|
||||
targetPreview,
|
||||
} from "~/hooks/useGameProps"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import {
|
||||
borderCN,
|
||||
compiledHits,
|
||||
cornerCN,
|
||||
fieldIndex,
|
||||
intersectingShip,
|
||||
isAlreadyHit,
|
||||
overlapsWithAnyBorder,
|
||||
shipProps,
|
||||
targetList,
|
||||
} from "~/lib/utils/helpers"
|
||||
import { count } from "./Gamefield"
|
||||
|
||||
type TilesType = {
|
||||
key: number
|
||||
isGameTile: boolean
|
||||
className: string
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
function settingTarget(
|
||||
isGameTile: boolean,
|
||||
x: number,
|
||||
y: number,
|
||||
index: 0 | 1,
|
||||
{
|
||||
activeIndex,
|
||||
ships,
|
||||
}: Pick<ReturnType<typeof useSession>, "activeIndex" | "ships">,
|
||||
) {
|
||||
if (gameProps.gameState === "running") {
|
||||
const list = targetList(targetPreview(), gameProps.mode)
|
||||
if (
|
||||
!isGameTile ||
|
||||
!list.filter(
|
||||
({ x, y }) => !isAlreadyHit(x, y, compiledHits(activeIndex())),
|
||||
).length
|
||||
)
|
||||
return
|
||||
if (!overlapsWithAnyBorder(targetPreview(), gameProps.mode))
|
||||
setTarget({
|
||||
show: true,
|
||||
x,
|
||||
y,
|
||||
orientation: targetPreview().orientation,
|
||||
})
|
||||
} else if (
|
||||
gameProps.gameState === "starting" &&
|
||||
targetPreview().show &&
|
||||
!intersectingShip(
|
||||
ships(),
|
||||
shipProps(ships(), gameProps.mode, targetPreview()),
|
||||
).score
|
||||
) {
|
||||
setMouseCursor((e) => ({ ...e, shouldShow: false }))
|
||||
setShips(
|
||||
[...ships(), shipProps(ships(), gameProps.mode, targetPreview())],
|
||||
index,
|
||||
)
|
||||
}
|
||||
}
|
||||
function onClick(
|
||||
props: TilesType,
|
||||
{ selfIndex, activeIndex, ships }: ReturnType<typeof useSession>,
|
||||
) {
|
||||
const sIndex = selfIndex()
|
||||
if (!sIndex) return
|
||||
if (gameProps.gameState === "running") {
|
||||
settingTarget(props.isGameTile, props.x, props.y, sIndex.i, {
|
||||
activeIndex,
|
||||
ships,
|
||||
})
|
||||
} else if (gameProps.gameState === "starting") {
|
||||
const { index } = intersectingShip(ships(), {
|
||||
...mouseCursor(),
|
||||
size: 1,
|
||||
variant: 0,
|
||||
orientation: "h",
|
||||
})
|
||||
if (typeof index === "undefined")
|
||||
settingTarget(props.isGameTile, props.x, props.y, sIndex.i, {
|
||||
activeIndex,
|
||||
ships,
|
||||
})
|
||||
else {
|
||||
const ship = ships()[index]
|
||||
setGameProps("mode", ship.size - 2)
|
||||
removeShip(ship, sIndex.i)
|
||||
setMouseCursor((e) => ({ ...e, shouldShow: true }))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function onMouseEnter(
|
||||
props: TilesType,
|
||||
{ ships }: ReturnType<typeof useSession>,
|
||||
) {
|
||||
setMouseCursor({
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
shouldShow:
|
||||
props.isGameTile &&
|
||||
(gameProps.gameState === "starting"
|
||||
? intersectingShip(
|
||||
ships(),
|
||||
shipProps(ships(), gameProps.mode, {
|
||||
x: props.x,
|
||||
y: props.y,
|
||||
orientation: targetPreview().orientation,
|
||||
}),
|
||||
true,
|
||||
).score < 2
|
||||
: true),
|
||||
})
|
||||
}
|
||||
|
||||
function BorderTiles() {
|
||||
const sessionProps = useSession()
|
||||
|
||||
const tilesProperties: TilesType[] = []
|
||||
|
||||
for (let y = 0; y < count + 2; y++) {
|
||||
for (let x = 0; x < count + 2; x++) {
|
||||
const key = fieldIndex(count, x, y)
|
||||
const cornerReslt = cornerCN(count, x, y)
|
||||
const borderType = cornerReslt ? cornerReslt : borderCN(count, x, y)
|
||||
const isGameTile = x > 0 && x < count + 1 && y > 0 && y < count + 1
|
||||
const classNames = ["border-tile"]
|
||||
if (borderType) classNames.push("edge", borderType)
|
||||
if (isGameTile) classNames.push("game-tile")
|
||||
const className = classNames.join(" ")
|
||||
tilesProperties.push({
|
||||
key,
|
||||
className,
|
||||
isGameTile,
|
||||
x: x + 1,
|
||||
y: y + 1,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<For each={tilesProperties}>
|
||||
{(props) => (
|
||||
<div
|
||||
class={props.className}
|
||||
style={{ "--x": props.x, "--y": props.y }}
|
||||
onClick={() => onClick(props, sessionProps)}
|
||||
onMouseEnter={() => onMouseEnter(props, sessionProps)}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
export default BorderTiles
|
|
@ -1,369 +0,0 @@
|
|||
import {
|
||||
faSquare2,
|
||||
faSquare3,
|
||||
faSquare4,
|
||||
} from "@fortawesome/pro-regular-svg-icons"
|
||||
import {
|
||||
faBroomWide,
|
||||
faCheck,
|
||||
faComments,
|
||||
faEye,
|
||||
faEyeSlash,
|
||||
faFlag,
|
||||
faGlasses,
|
||||
faLock,
|
||||
faPalette,
|
||||
faReply,
|
||||
faRotate,
|
||||
faScribble,
|
||||
faShip,
|
||||
faSparkles,
|
||||
faSwords,
|
||||
faXmark,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { socket } from "~/lib/socket"
|
||||
import { modes } from "~/lib/utils/helpers"
|
||||
// import { Icons, toast } from "react-toastify"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { For, Show, createEffect } from "solid-js"
|
||||
import { clearDrawing } from "~/hooks/useDraw"
|
||||
import {
|
||||
color,
|
||||
setEnable,
|
||||
setShouldHide,
|
||||
shouldHide,
|
||||
} from "~/hooks/useDrawProps"
|
||||
import {
|
||||
gameProps,
|
||||
reset,
|
||||
setGameProps,
|
||||
setGameSetting,
|
||||
setIsReadyFor,
|
||||
setTarget,
|
||||
setTargetPreview,
|
||||
target,
|
||||
users,
|
||||
} from "~/hooks/useGameProps"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import { EventBarModes } from "../../interfaces/frontend"
|
||||
import Item from "./Item"
|
||||
|
||||
function EventBar() {
|
||||
const { selfIndex, selfIsActiveIndex, selfUser, ships } = useSession()
|
||||
const navigator = useNavigate()
|
||||
|
||||
const items = (): EventBarModes => ({
|
||||
main: [
|
||||
{
|
||||
icon: "burger-menu",
|
||||
text: "Menu",
|
||||
callback: () => {
|
||||
setGameProps("menu", "menu")
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faSwords,
|
||||
text: "Attack",
|
||||
showWhen: () =>
|
||||
gameProps.gameState === "running" &&
|
||||
(selfIsActiveIndex() || gameProps.menu !== "main"),
|
||||
callback: () => {
|
||||
setGameProps("menu", "moves")
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faShip,
|
||||
text: "Ships",
|
||||
showWhen: () => gameProps.gameState !== "running",
|
||||
callback: () => {
|
||||
setGameProps("menu", "moves")
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "pen",
|
||||
text: "Draw",
|
||||
callback: () => {
|
||||
setGameProps("menu", "draw")
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "gear",
|
||||
text: "Settings",
|
||||
callback: () => {
|
||||
setGameProps("menu", "settings")
|
||||
},
|
||||
},
|
||||
],
|
||||
menu: [
|
||||
{
|
||||
icon: faFlag,
|
||||
text: "Surrender",
|
||||
iconColor: "darkred",
|
||||
callback: () => {
|
||||
setGameProps("menu", "surrender")
|
||||
},
|
||||
},
|
||||
],
|
||||
moves:
|
||||
gameProps.gameState === "running"
|
||||
? [
|
||||
{
|
||||
icon: "scope",
|
||||
text: "Fire missile",
|
||||
enabled: gameProps.mode === 0,
|
||||
callback: () => {
|
||||
setGameProps("mode", 0)
|
||||
setTarget((t) => ({ ...t, show: false }))
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "torpedo",
|
||||
text: "Fire torpedo",
|
||||
enabled: gameProps.mode === 1 || gameProps.mode === 2,
|
||||
amount:
|
||||
2 -
|
||||
(selfUser()?.moves.filter(
|
||||
(e) => e.type === "htorpedo" || e.type === "vtorpedo",
|
||||
).length ?? 0),
|
||||
callback: () => {
|
||||
setGameProps("mode", 1)
|
||||
setTarget((t) => ({ ...t, show: false }))
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: "radar",
|
||||
text: "Radar scan",
|
||||
enabled: gameProps.mode === 3,
|
||||
amount:
|
||||
1 -
|
||||
(selfUser()?.moves.filter((e) => e.type === "radar").length ??
|
||||
0),
|
||||
callback: () => {
|
||||
setGameProps("mode", 3)
|
||||
setTarget((t) => ({ ...t, show: false }))
|
||||
},
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
icon: faSquare2,
|
||||
text: "Minensucher",
|
||||
amount: 1 - ships().filter((e) => e.size === 2).length,
|
||||
callback: () => {
|
||||
if (1 - ships().filter((e) => e.size === 2).length === 0) return
|
||||
setGameProps("mode", 0)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faSquare3,
|
||||
text: "Kreuzer",
|
||||
amount: 3 - ships().filter((e) => e.size === 3).length,
|
||||
callback: () => {
|
||||
if (3 - ships().filter((e) => e.size === 3).length === 0) return
|
||||
setGameProps("mode", 1)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faSquare4,
|
||||
text: "Schlachtschiff",
|
||||
amount: 2 - ships().filter((e) => e.size === 4).length,
|
||||
callback: () => {
|
||||
if (2 - ships().filter((e) => e.size === 4).length === 0) return
|
||||
setGameProps("mode", 2)
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faRotate,
|
||||
text: "Rotate",
|
||||
callback: () => {
|
||||
setTargetPreview((t) => ({
|
||||
...t,
|
||||
orientation: t.orientation === "h" ? "v" : "h",
|
||||
}))
|
||||
},
|
||||
},
|
||||
],
|
||||
draw: [
|
||||
{
|
||||
icon: faBroomWide,
|
||||
text: "Clear",
|
||||
showWhen: selfIsActiveIndex,
|
||||
callback: () => clearDrawing(selfIndex()),
|
||||
},
|
||||
{
|
||||
icon: faPalette,
|
||||
text: "Color",
|
||||
showWhen: selfIsActiveIndex,
|
||||
iconColor: color(),
|
||||
},
|
||||
{
|
||||
icon: shouldHide() ? faEye : faEyeSlash,
|
||||
text: shouldHide() ? "Show" : "Hide",
|
||||
callback: () => {
|
||||
setShouldHide((e) => !e)
|
||||
},
|
||||
},
|
||||
],
|
||||
settings: [
|
||||
{
|
||||
icon: faGlasses,
|
||||
text: "Spectators",
|
||||
disabled: !gameProps.allowSpectators,
|
||||
callback: setGameSetting({
|
||||
allowSpectators: !gameProps.allowSpectators,
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: faSparkles,
|
||||
text: "Specials",
|
||||
disabled: !gameProps.allowSpecials,
|
||||
callback: setGameSetting({
|
||||
allowSpecials: !gameProps.allowSpecials,
|
||||
}),
|
||||
},
|
||||
{
|
||||
icon: faComments,
|
||||
text: "Chat",
|
||||
disabled: !gameProps.allowChat,
|
||||
callback: setGameSetting({ allowChat: !gameProps.allowChat }),
|
||||
},
|
||||
{
|
||||
icon: faScribble,
|
||||
text: "Mark/Draw",
|
||||
disabled: !gameProps.allowMarkDraw,
|
||||
callback: setGameSetting({
|
||||
allowMarkDraw: !gameProps.allowMarkDraw,
|
||||
}),
|
||||
},
|
||||
],
|
||||
surrender: [
|
||||
{
|
||||
icon: faCheck,
|
||||
text: "Yes",
|
||||
iconColor: "green",
|
||||
callback: async () => {
|
||||
socket.emit("gameState", "aborted")
|
||||
navigator("/")
|
||||
reset()
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: faXmark,
|
||||
text: "No",
|
||||
iconColor: "red",
|
||||
callback: () => {
|
||||
setGameProps("menu", "main")
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (
|
||||
gameProps.menu !== "moves" ||
|
||||
gameProps.gameState !== "starting" ||
|
||||
gameProps.mode < 0 ||
|
||||
items().moves[gameProps.mode].amount
|
||||
)
|
||||
return
|
||||
const index = items().moves.findIndex((e) => e.amount)
|
||||
setGameProps("mode", index)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
setEnable(gameProps.menu === "draw")
|
||||
})
|
||||
|
||||
// createEffect(() => {
|
||||
// if (gameProps.gameState !== "running") return
|
||||
|
||||
// const toastId = "otherPlayer"
|
||||
// if (selfIsActiveIndex) toast.dismiss(toastId)
|
||||
// else
|
||||
// toast.info("Waiting for other player...", {
|
||||
// toastId,
|
||||
// position: "top-right",
|
||||
// icon: Icons.spinner(),
|
||||
// autoClose: false,
|
||||
// hideProgressBar: true,
|
||||
// closeButton: false,
|
||||
// })
|
||||
|
||||
// toastId = "connect_error"
|
||||
// const isActive = toast.isActive(toastId)
|
||||
// console.log(toastId, isActive)
|
||||
// if (isActive)
|
||||
// toast.update(toastId, {
|
||||
// autoClose: 5000,
|
||||
// })
|
||||
// else
|
||||
// toast.warn("Spie", { toastId })
|
||||
// })
|
||||
|
||||
return (
|
||||
<div class="event-bar">
|
||||
<Show when={gameProps.menu !== "main"}>
|
||||
<Item
|
||||
{...{
|
||||
icon: faReply,
|
||||
text: "Return",
|
||||
iconColor: "#555",
|
||||
callback: () => {
|
||||
setGameProps("menu", "main")
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
<For each={items()[gameProps.menu]}>
|
||||
{(e) => (
|
||||
<Show when={!e?.showWhen || e?.showWhen()}>
|
||||
<Item {...e} />
|
||||
</Show>
|
||||
)}
|
||||
</For>
|
||||
<Show when={gameProps.menu === "moves"}>
|
||||
<Item
|
||||
{...{
|
||||
icon: selfUser()?.isReady ? faLock : faCheck,
|
||||
text: selfUser()?.isReady ? "unready" : "Done",
|
||||
disabled:
|
||||
gameProps.gameState === "starting"
|
||||
? gameProps.mode >= 0
|
||||
: undefined,
|
||||
enabled:
|
||||
gameProps.gameState === "running" &&
|
||||
gameProps.mode >= 0 &&
|
||||
target().show,
|
||||
callback: () => {
|
||||
const sIndex = selfIndex()
|
||||
if (!sIndex) return
|
||||
switch (gameProps.gameState) {
|
||||
case "starting":
|
||||
const isReady = !users[sIndex.i]?.isReady
|
||||
setIsReadyFor({ isReady, i: sIndex.i })
|
||||
socket.emit("isReady", isReady)
|
||||
break
|
||||
|
||||
case "running":
|
||||
const moves = selfUser()?.moves
|
||||
const length = moves?.length
|
||||
const props = {
|
||||
type: modes[gameProps.mode].type,
|
||||
x: target().x,
|
||||
y: target().y,
|
||||
orientation: target().orientation,
|
||||
index: length ?? 0,
|
||||
}
|
||||
socket.emit("dispatchMove", props)
|
||||
setTarget((t) => ({ ...t, show: false }))
|
||||
break
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Show>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default EventBar
|
|
@ -1,13 +0,0 @@
|
|||
function FogImages() {
|
||||
return (
|
||||
<>
|
||||
<img class="fog left" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img class="fog right" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img class="fog top" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img class="fog bottom" src={`/fog/fog2.png`} alt={`fog1.png`} />
|
||||
<img class="fog middle" src={`/fog/fog4.png`} alt={`fog4.png`} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default FogImages
|
|
@ -1,139 +0,0 @@
|
|||
// import Bluetooth from "./Bluetooth"
|
||||
// import FogImages from "./FogImages"
|
||||
// import { toast } from "react-toastify"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, onCleanup } from "solid-js"
|
||||
import BorderTiles from "~/components/Gamefield/BorderTiles"
|
||||
import EventBar from "~/components/Gamefield/EventBar"
|
||||
import HitElems from "~/components/Gamefield/HitElems"
|
||||
import Targets from "~/components/Gamefield/Targets"
|
||||
import { DrawingCanvas } from "~/hooks/useDraw"
|
||||
import { setFrameSize } from "~/hooks/useDrawProps"
|
||||
import {
|
||||
full,
|
||||
gameProps,
|
||||
mouseCursor,
|
||||
reset,
|
||||
setGameProps,
|
||||
setMouseCursor,
|
||||
setTargetPreview,
|
||||
target,
|
||||
users,
|
||||
} from "~/hooks/useGameProps"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import useSocket from "~/hooks/useSocket"
|
||||
import { socket } from "~/lib/socket"
|
||||
import { overlapsWithAnyBorder } from "~/lib/utils/helpers"
|
||||
import Labeling from "./Labeling"
|
||||
import Ships from "./Ships"
|
||||
|
||||
export const count = 12
|
||||
|
||||
function Gamefield() {
|
||||
let frameRef: HTMLDivElement
|
||||
const { ships } = useSession()
|
||||
const navigator = useNavigate()
|
||||
const { isConnected } = useSocket()
|
||||
|
||||
createEffect(() => {
|
||||
if (
|
||||
gameProps.gameState !== "starting" ||
|
||||
!users[0]?.isReady ||
|
||||
!users[1]?.isReady
|
||||
)
|
||||
return
|
||||
socket.emit("ships", ships())
|
||||
socket.emit("gameState", "running")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (gameProps.gameId || !isConnected) return
|
||||
socket.emit("update", full)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (gameProps.mode < 0) return
|
||||
const { x, y, show } = target()
|
||||
const { shouldShow, ...position } = mouseCursor()
|
||||
if (
|
||||
!shouldShow ||
|
||||
(gameProps.gameState === "running" &&
|
||||
overlapsWithAnyBorder(position, gameProps.mode))
|
||||
)
|
||||
setTargetPreview((t) => ({ ...t, show: false }))
|
||||
else {
|
||||
setTargetPreview((t) => ({
|
||||
...t,
|
||||
...position,
|
||||
show: !show || x !== position.x || y !== position.y,
|
||||
}))
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
if (event.key !== "r") return
|
||||
if (gameProps.gameState === "starting") {
|
||||
setTargetPreview((t) => ({
|
||||
...t,
|
||||
orientation: t.orientation === "h" ? "v" : "h",
|
||||
}))
|
||||
}
|
||||
if (
|
||||
gameProps.gameState === "running" &&
|
||||
(gameProps.mode === 1 || gameProps.mode === 2)
|
||||
)
|
||||
setGameProps("mode", gameProps.mode === 1 ? 2 : 1)
|
||||
}
|
||||
document.addEventListener("keydown", handleKeyPress)
|
||||
onCleanup(() => {
|
||||
document.removeEventListener("keydown", handleKeyPress)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (gameProps.gameState !== "aborted") return
|
||||
// toast.info("Enemy gave up!")
|
||||
navigator("/")
|
||||
reset()
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
function handleResize() {
|
||||
const rect = frameRef.getBoundingClientRect()
|
||||
setFrameSize({ x: rect.width, y: rect.height })
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
return (
|
||||
<div id="gamefield">
|
||||
{/* <Bluetooth /> */}
|
||||
<div
|
||||
id="game-frame"
|
||||
style={{ "--i": count }}
|
||||
onMouseLeave={() =>
|
||||
setMouseCursor((e) => ({ ...e, shouldShow: false }))
|
||||
}
|
||||
ref={frameRef!}
|
||||
>
|
||||
<BorderTiles />
|
||||
|
||||
{/* Collumn lettes and row numbers */}
|
||||
<Labeling />
|
||||
|
||||
<Ships />
|
||||
|
||||
<HitElems />
|
||||
|
||||
{/* <FogImages /> */}
|
||||
|
||||
<Targets />
|
||||
|
||||
<DrawingCanvas />
|
||||
</div>
|
||||
<EventBar />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Gamefield
|
|
@ -1,38 +0,0 @@
|
|||
import { faCrosshairs } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { faRadar } from "@fortawesome/pro-thin-svg-icons"
|
||||
import classNames from "classnames"
|
||||
import {} from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { PointerProps } from "../../interfaces/frontend"
|
||||
|
||||
function GamefieldPointer(
|
||||
props: PointerProps & {
|
||||
preview?: boolean
|
||||
},
|
||||
) {
|
||||
const isRadar = () => props.type === "radar"
|
||||
const style = () =>
|
||||
!(isRadar() && !props.edges.filter((s) => s).length)
|
||||
? { "--x": props.x, "--y": props.y }
|
||||
: {
|
||||
"--x1": props.x - 1,
|
||||
"--x2": props.x + 2,
|
||||
"--y1": props.y - 1,
|
||||
"--y2": props.y + 2,
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames("hit-svg", "target", props.type, ...props.edges, {
|
||||
preview: props.preview,
|
||||
show: props.show,
|
||||
imply: props.imply,
|
||||
})}
|
||||
style={style()}
|
||||
>
|
||||
<FontAwesomeIcon icon={!isRadar() ? faCrosshairs : faRadar} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default GamefieldPointer
|
|
@ -1,29 +0,0 @@
|
|||
import { faBurst, faXmark } from "@fortawesome/pro-solid-svg-icons"
|
||||
import { For } from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import { compiledHits } from "~/lib/utils/helpers"
|
||||
import { Hit } from "../../interfaces/frontend"
|
||||
|
||||
function HitElems(props: { hits?: Hit[]; colorOverride?: string }) {
|
||||
const { activeIndex } = useSession()
|
||||
const hits = () => props?.hits
|
||||
const colorOverride = () => props?.colorOverride
|
||||
|
||||
return (
|
||||
<For each={hits() ?? compiledHits(activeIndex())}>
|
||||
{(props) => (
|
||||
<div class="hit-svg" style={{ "--x": props.x, "--y": props.y }}>
|
||||
<FontAwesomeIcon
|
||||
icon={props.hit ? faBurst : faXmark}
|
||||
style={{
|
||||
color: colorOverride() || (props.hit ? "red" : undefined),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
)
|
||||
}
|
||||
|
||||
export default HitElems
|
|
@ -1,63 +0,0 @@
|
|||
import classNames from "classnames"
|
||||
import { BlockPicker } from "solid-color"
|
||||
import { Show, createSignal } from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { color, colors, setColor } from "~/hooks/useDrawProps"
|
||||
import { ItemProps } from "../../interfaces/frontend"
|
||||
|
||||
function Item(props: ItemProps) {
|
||||
const isColor = () => props.text === "Color"
|
||||
const [active, setActive] = createSignal(false)
|
||||
let cpRef: HTMLDivElement
|
||||
|
||||
return (
|
||||
<div class="item">
|
||||
<Show when={isColor()}>
|
||||
<div
|
||||
ref={cpRef!}
|
||||
class={classNames("color-picker-wrapper", { active: active() })}
|
||||
>
|
||||
<BlockPicker
|
||||
color={color()}
|
||||
onChange={(e) => setColor(e.hex)}
|
||||
colors={colors}
|
||||
triangle="hide"
|
||||
/>
|
||||
</div>
|
||||
</Show>
|
||||
<button
|
||||
class={classNames("container", {
|
||||
amount: typeof props.amount !== "undefined",
|
||||
disabled: props.disabled || props.amount === 0,
|
||||
enabled: props.disabled === false || props.enabled,
|
||||
})}
|
||||
style={
|
||||
typeof props.amount !== "undefined"
|
||||
? {
|
||||
"--amount": JSON.stringify(props.amount.toString()),
|
||||
}
|
||||
: {}
|
||||
}
|
||||
onClick={() => {
|
||||
isColor() ? setActive((e) => !e) : props.callback && props.callback()
|
||||
}}
|
||||
>
|
||||
{typeof props.icon === "string" ? (
|
||||
<img
|
||||
src={`/assets/${props.icon}.png`}
|
||||
alt={`${props.icon}.png`}
|
||||
class="pixelart"
|
||||
/>
|
||||
) : (
|
||||
<FontAwesomeIcon
|
||||
icon={props.icon}
|
||||
color={props.iconColor ?? "#444"}
|
||||
/>
|
||||
)}
|
||||
</button>
|
||||
<span>{props.text}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Item
|
|
@ -1,71 +0,0 @@
|
|||
import classNames from "classnames"
|
||||
import { createEffect } from "solid-js"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import { ShipProps } from "../../interfaces/frontend"
|
||||
|
||||
const sizes: { [n: number]: number } = {
|
||||
2: 96,
|
||||
3: 144,
|
||||
4: 196,
|
||||
}
|
||||
|
||||
function Ship(
|
||||
props: ShipProps & {
|
||||
preview?: boolean
|
||||
warn?: boolean
|
||||
color?: string
|
||||
},
|
||||
) {
|
||||
const { selfIndex } = useSession()
|
||||
const filename = () =>
|
||||
`ship_${selfIndex()?.i === 1 ? "red" : "blue"}_${props.size}x_${
|
||||
props.variant
|
||||
}.gif`
|
||||
let canvasRef: HTMLCanvasElement
|
||||
|
||||
createEffect(() => {
|
||||
const canvas = canvasRef
|
||||
const ctx = canvas?.getContext("2d")
|
||||
if (!canvas || !ctx) return
|
||||
const gif = new Image()
|
||||
gif.src = "/assets/" + filename()
|
||||
|
||||
// Load the GIF and start rendering
|
||||
gif.onload = function () {
|
||||
// Set the canvas size to match the GIF dimensions
|
||||
canvas.width = props.orientation === "h" ? sizes[props.size] : 48
|
||||
canvas.height = props.orientation === "v" ? sizes[props.size] : 48
|
||||
|
||||
if (props.orientation === "v")
|
||||
// Rotate the canvas by 90 degrees
|
||||
ctx.rotate((90 * Math.PI) / 180)
|
||||
|
||||
// Draw the rotated GIF
|
||||
ctx.drawImage(
|
||||
gif,
|
||||
0,
|
||||
props.orientation === "h" ? 0 : -48,
|
||||
sizes[props.size],
|
||||
48,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames("ship", "s" + props.size, props.orientation, {
|
||||
preview: props.preview,
|
||||
warn: props.warn,
|
||||
})}
|
||||
style={{
|
||||
"--x": props.x,
|
||||
"--y": props.y,
|
||||
"--color": props.color ?? "limegreen",
|
||||
}}
|
||||
>
|
||||
<canvas ref={canvasRef!} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ship
|
|
@ -1,16 +0,0 @@
|
|||
import { For, Show } from "solid-js"
|
||||
import { gameProps } from "~/hooks/useGameProps"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import Ship from "./Ship"
|
||||
|
||||
function Ships() {
|
||||
const { selfIsActiveIndex, selfUser } = useSession()
|
||||
|
||||
return (
|
||||
<Show when={gameProps.gameState !== "running" || !selfIsActiveIndex()}>
|
||||
<For each={selfUser()?.ships}>{(props) => <Ship {...props} />}</For>
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
export default Ships
|
|
@ -1,81 +0,0 @@
|
|||
import { For, Match, Switch } from "solid-js"
|
||||
import { gameProps, target, targetPreview } from "~/hooks/useGameProps"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import {
|
||||
compiledHits,
|
||||
composeTargetTiles,
|
||||
intersectingShip,
|
||||
shipProps,
|
||||
} from "~/lib/utils/helpers"
|
||||
import GamefieldPointer from "./GamefieldPointer"
|
||||
import HitElems from "./HitElems"
|
||||
import Ship from "./Ship"
|
||||
|
||||
function Targets() {
|
||||
const { activeIndex, ships } = useSession()
|
||||
|
||||
const ship = () => shipProps(ships(), gameProps.mode, targetPreview())
|
||||
const intersectionProps = () => intersectingShip(ships(), ship())
|
||||
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={gameProps.gameState === "running"}>
|
||||
<For
|
||||
each={composeTargetTiles(
|
||||
target(),
|
||||
gameProps.mode,
|
||||
compiledHits(activeIndex()),
|
||||
)}
|
||||
>
|
||||
{(props) => <GamefieldPointer {...props} />}
|
||||
</For>
|
||||
<For
|
||||
each={composeTargetTiles(
|
||||
targetPreview(),
|
||||
gameProps.mode,
|
||||
compiledHits(activeIndex()),
|
||||
)}
|
||||
>
|
||||
{(props) => <GamefieldPointer {...props} preview />}
|
||||
</For>
|
||||
</Match>
|
||||
<Match
|
||||
when={
|
||||
gameProps.gameState === "starting" &&
|
||||
gameProps.mode >= 0 &&
|
||||
targetPreview().show
|
||||
}
|
||||
>
|
||||
<Ship
|
||||
{...ship()}
|
||||
preview
|
||||
warn={intersectionProps().score > 0}
|
||||
color={
|
||||
intersectionProps().fields.length
|
||||
? "red"
|
||||
: intersectionProps().borders.length
|
||||
? "orange"
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
<HitElems
|
||||
hits={intersectionProps().fields.map((e, i) => ({
|
||||
...e,
|
||||
i,
|
||||
hit: true,
|
||||
}))}
|
||||
/>
|
||||
<HitElems
|
||||
hits={intersectionProps().borders.map((e, i) => ({
|
||||
...e,
|
||||
i,
|
||||
hit: true,
|
||||
}))}
|
||||
colorOverride={"orange"}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
export default Targets
|
|
@ -1,99 +0,0 @@
|
|||
import classNames from "classnames"
|
||||
import { For, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
function Grid() {
|
||||
function floorClient(number: number) {
|
||||
return Math.floor(number / 50)
|
||||
}
|
||||
|
||||
const [columns, setColumns] = createSignal(0)
|
||||
const [rows, setRows] = createSignal(0)
|
||||
const quantity = () => columns() * rows()
|
||||
const [position, setPosition] = createSignal([0, 0])
|
||||
const [active, setActve] = createSignal(false)
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
function handleResize() {
|
||||
setColumns(floorClient(document.body.clientWidth))
|
||||
setRows(floorClient(document.body.clientHeight))
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
function Tile(props: { index: number }) {
|
||||
const x = () => props.index % columns()
|
||||
const y = () => Math.floor(props.index / columns())
|
||||
const xDiff = () => (x() - position()[0]) / 20
|
||||
const yDiff = () => (y() - position()[1]) / 20
|
||||
const pos = () =>
|
||||
Math.sqrt(xDiff() * xDiff() + yDiff() * yDiff()).toFixed(2)
|
||||
|
||||
function doEffect(posX: number, posY: number) {
|
||||
if (active()) return
|
||||
setPosition([posX, posY])
|
||||
setActve(true)
|
||||
|
||||
function xDiff(x: number) {
|
||||
return (x - posX) / 20
|
||||
}
|
||||
function yDiff(y: number) {
|
||||
return (y - posY) / 20
|
||||
}
|
||||
function pos(x: number, y: number) {
|
||||
return Math.sqrt(xDiff(x) * xDiff(x) + yDiff(y) * yDiff(y))
|
||||
}
|
||||
const diagonals = [
|
||||
pos(0, 0),
|
||||
pos(columns(), 0),
|
||||
pos(0, rows()),
|
||||
pos(columns(), rows()),
|
||||
]
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
setActve(false)
|
||||
setCount((e) => e + 1)
|
||||
},
|
||||
Math.max(...diagonals) * 1000 + 300,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames({ tile: true, active: active() })}
|
||||
style={{ "--delay": pos() + "s" }}
|
||||
onClick={() => doEffect(x(), y())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const colors = [
|
||||
"rgb(229, 57, 53)",
|
||||
"rgb(253, 216, 53)",
|
||||
"rgb(244, 81, 30)",
|
||||
"rgb(76, 175, 80)",
|
||||
"rgb(33, 150, 243)",
|
||||
"rgb(156, 39, 176)",
|
||||
]
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tiles"
|
||||
style={{
|
||||
"--columns": columns(),
|
||||
"--rows": rows(),
|
||||
"--bg-color-1": colors[count() % colors.length],
|
||||
"--bg-color-2": colors[(count() + 1) % colors.length],
|
||||
}}
|
||||
>
|
||||
<For each={Array.from(Array(quantity()))}>
|
||||
{(_tile, i) => <Tile index={i()} />}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Grid
|
|
@ -1,103 +0,0 @@
|
|||
import classNames from "classnames"
|
||||
import { For, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
function Grid2() {
|
||||
function floorClient(number: number) {
|
||||
return Math.floor(number / 50)
|
||||
}
|
||||
|
||||
const [columns, setColumns] = createSignal(0)
|
||||
const [rows, setRows] = createSignal(0)
|
||||
const quantity = () => columns() * rows()
|
||||
const [position, setPosition] = createSignal([0, 0])
|
||||
const [active, setActve] = createSignal(false)
|
||||
const [action, setAction] = createSignal(false)
|
||||
const [count, setCount] = createSignal(0)
|
||||
|
||||
createEffect(() => {
|
||||
function handleResize() {
|
||||
setColumns(floorClient(document.body.clientWidth))
|
||||
setRows(floorClient(document.body.clientHeight))
|
||||
}
|
||||
handleResize()
|
||||
window.addEventListener("resize", handleResize)
|
||||
onCleanup(() => removeEventListener("resize", handleResize))
|
||||
})
|
||||
|
||||
const sentences = [
|
||||
"Ethem ...",
|
||||
"hat ...",
|
||||
"lange ...",
|
||||
"Hörner 🐂",
|
||||
"Grüße von Mallorca 🌊 🦦 ☀️",
|
||||
]
|
||||
|
||||
function Tile(props: { index: number }) {
|
||||
const x = () => props.index % columns()
|
||||
const y = () => Math.floor(props.index / columns())
|
||||
const xDiff = () => (x() - position()[0]) / 20
|
||||
const yDiff = () => (y() - position()[1]) / 20
|
||||
const pos = () =>
|
||||
Math.sqrt(xDiff() * xDiff() + yDiff() * yDiff()).toFixed(2)
|
||||
|
||||
function doEffect(posX: number, posY: number) {
|
||||
if (action()) return
|
||||
setPosition([posX, posY])
|
||||
setActve((e) => !e)
|
||||
setAction(true)
|
||||
|
||||
function xDiff(x: number) {
|
||||
return (x - posX) / 20
|
||||
}
|
||||
function yDiff(y: number) {
|
||||
return (y - posY) / 20
|
||||
}
|
||||
function pos(x: number, y: number) {
|
||||
return Math.sqrt(xDiff(x) * xDiff(x) + yDiff(y) * yDiff(y))
|
||||
}
|
||||
const diagonals = [
|
||||
pos(0, 0),
|
||||
pos(columns(), 0),
|
||||
pos(0, rows()),
|
||||
pos(columns(), rows()),
|
||||
]
|
||||
|
||||
setTimeout(
|
||||
() => {
|
||||
setAction(false)
|
||||
if (active()) setCount((e) => e + 1)
|
||||
},
|
||||
Math.max(...diagonals) * 1000 + 1000,
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
class={classNames("tile", active() ? "active" : "inactive")}
|
||||
style={{ "--delay": pos() + "s" }}
|
||||
onClick={() => doEffect(x(), y())}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
id="tiles"
|
||||
style={{
|
||||
"--columns": columns(),
|
||||
"--rows": rows(),
|
||||
}}
|
||||
>
|
||||
<div class="center-div">
|
||||
<h1 class={classNames("headline", !active ? "active" : "inactive")}>
|
||||
{sentences[count() % sentences.length]}
|
||||
</h1>
|
||||
</div>
|
||||
<For each={Array.from(Array(quantity()))}>
|
||||
{(_tile, i) => <Tile index={i()} />}
|
||||
</For>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Grid2
|
|
@ -1,23 +0,0 @@
|
|||
import { JSX } from "solid-js"
|
||||
|
||||
function Icon(props: {
|
||||
src: string
|
||||
children: JSX.Element
|
||||
onClick?: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
class="mx-4 mt-4 flex flex-col items-center border-none"
|
||||
onClick={() => props.onClick && props.onClick()}
|
||||
>
|
||||
<img
|
||||
class="pixelart mb-1 box-content w-16 rounded-xl bg-white p-1"
|
||||
src={"/assets/" + props.src}
|
||||
alt={props.src}
|
||||
/>
|
||||
<span class="font-semibold">{props.children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default Icon
|
|
@ -1,139 +0,0 @@
|
|||
import {
|
||||
faRightFromBracket,
|
||||
faSpinnerThird,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { JSX, Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { full, gameProps, leave, reset, users } from "~/hooks/useGameProps"
|
||||
import { useSession } from "~/hooks/useSession"
|
||||
import useSocket from "~/hooks/useSocket"
|
||||
import { socket } from "~/lib/socket"
|
||||
import Button from "./Button"
|
||||
import Icon from "./Icon"
|
||||
import Player from "./Player"
|
||||
|
||||
function WithDots(props: { children: JSX.Element }) {
|
||||
const [dots, setDots] = createSignal(1)
|
||||
|
||||
createEffect(() => {
|
||||
const interval = setInterval(() => setDots((e) => (e % 3) + 1), 1000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
{props.children + " "}
|
||||
{Array.from(Array(dots()), () => ".").join("")}
|
||||
{Array.from(Array(3 - dots()), () => (
|
||||
<> </>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LobbyFrame(props: { openSettings: () => void }) {
|
||||
const { isConnected } = useSocket()
|
||||
const navigator = useNavigate()
|
||||
const { session } = useSession()
|
||||
const [launchTime, setLaunchTime] = createSignal(3)
|
||||
const launching = () => users[0]?.isReady && users[1]?.isReady
|
||||
|
||||
createEffect(() => {
|
||||
if (!launching() || launchTime() > 0) return
|
||||
socket.emit("gameState", "starting")
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (!launching()) return setLaunchTime(3)
|
||||
if (launchTime() < 0) return
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
setLaunchTime((e) => e - 1)
|
||||
}, 1000)
|
||||
|
||||
onCleanup(() => clearTimeout(timeout))
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (gameProps.gameId || !isConnected) return
|
||||
socket.emit("update", full)
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
if (gameProps.gameState === "unknown" || gameProps.gameState === "lobby")
|
||||
return
|
||||
navigator("/gamefield")
|
||||
})
|
||||
|
||||
return (
|
||||
<div class="mx-32 flex flex-col self-stretch rounded-3xl bg-gray-400">
|
||||
<div class="flex items-center justify-between border-b-2 border-slate-900">
|
||||
<Icon src="speech_bubble.png">Chat</Icon>
|
||||
<h1 class="font-farro text-5xl font-medium">
|
||||
<Show
|
||||
when={!launching()}
|
||||
fallback={
|
||||
<WithDots>
|
||||
{launchTime() < 0
|
||||
? "Game starts"
|
||||
: "Game is starting in " + launchTime()}
|
||||
</WithDots>
|
||||
}
|
||||
>
|
||||
{"Game-PIN: "}
|
||||
<Show
|
||||
when={isConnected}
|
||||
fallback={<FontAwesomeIcon icon={faSpinnerThird} spin />}
|
||||
>
|
||||
<span class="underline">{gameProps.gamePin ?? "----"}</span>
|
||||
</Show>
|
||||
</Show>
|
||||
</h1>
|
||||
<Icon src="gear.png" onClick={props.openSettings}>
|
||||
Settings
|
||||
</Icon>
|
||||
</div>
|
||||
<div class="flex items-center justify-around">
|
||||
<Show
|
||||
when={isConnected}
|
||||
fallback={
|
||||
<p class="font-farro m-48 text-center text-6xl font-medium">
|
||||
Warte auf Verbindung
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Player src="player_blue.png" i={0} userId={session()?.user?.id} />
|
||||
<p class="font-farro m-4 text-6xl font-semibold">VS</p>
|
||||
<Show
|
||||
when={users[1]}
|
||||
fallback={
|
||||
<p class="font-farro w-96 text-center text-4xl font-medium">
|
||||
<WithDots>Warte auf Spieler 2</WithDots>
|
||||
</p>
|
||||
}
|
||||
>
|
||||
<Player src="player_red.png" i={1} userId={session()?.user?.id} />
|
||||
</Show>
|
||||
</Show>
|
||||
</div>
|
||||
<div class="flex items-center justify-around border-t-2 border-slate-900 p-4">
|
||||
<Button
|
||||
type={launching() ? "gray" : "red"}
|
||||
disabled={launching()}
|
||||
onClick={() => {
|
||||
leave(async () => {
|
||||
reset()
|
||||
navigator("/")
|
||||
})
|
||||
}}
|
||||
>
|
||||
<span>LEAVE</span>
|
||||
<FontAwesomeIcon icon={faRightFromBracket} class="ml-4 w-12" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LobbyFrame
|
|
@ -1,120 +0,0 @@
|
|||
import {
|
||||
faCheck,
|
||||
faHandPointer,
|
||||
faHourglass1,
|
||||
faHourglass2,
|
||||
faHourglass3,
|
||||
faHourglassClock,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import { faCaretDown } from "@fortawesome/sharp-solid-svg-icons"
|
||||
import classNames from "classnames"
|
||||
import { Show, createEffect, createSignal, onCleanup } from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { setIsReadyFor, users } from "~/hooks/useGameProps"
|
||||
import { socket } from "~/lib/socket"
|
||||
import Button from "./Button"
|
||||
|
||||
function HourGlass() {
|
||||
const [count, setCount] = createSignal(3)
|
||||
|
||||
createEffect(() => {
|
||||
const interval = setInterval(() => setCount((e) => (e + 1) % 4), 1000)
|
||||
onCleanup(() => clearInterval(interval))
|
||||
})
|
||||
|
||||
const icon = () => {
|
||||
switch (count()) {
|
||||
case 0:
|
||||
return faHourglass3
|
||||
case 1:
|
||||
return faHourglass1
|
||||
case 2:
|
||||
return faHourglass2
|
||||
case 3:
|
||||
return faHourglass3
|
||||
default:
|
||||
return faHourglassClock
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<FontAwesomeIcon icon={icon()} class="ml-4 w-12" spin={count() === 0} />
|
||||
)
|
||||
}
|
||||
|
||||
function Player(props: { src: string; i: 0 | 1; userId?: string }) {
|
||||
const player = () => users[props.i]
|
||||
const primary = () => props.userId && props.userId === player()?.id
|
||||
|
||||
return (
|
||||
<div class="flex w-96 flex-col items-center gap-4 p-4">
|
||||
<p
|
||||
class={classNames(
|
||||
"font-farro w-max text-5xl",
|
||||
primary() ? "font-semibold" : "font-normal",
|
||||
)}
|
||||
>
|
||||
{player()?.name ?? "Spieler " + (props.i === 1 ? "2" : "1")}
|
||||
</p>
|
||||
<div class="relative">
|
||||
<img
|
||||
class="pixelart w-64"
|
||||
src={"/assets/" + props.src}
|
||||
alt={props.src}
|
||||
/>
|
||||
<Show when={primary()}>
|
||||
<button class="absolute right-4 top-4 h-14 w-14 rounded-lg border-2 border-dashed border-warn bg-gray-800 bg-opacity-90">
|
||||
<FontAwesomeIcon
|
||||
class="h-full w-full text-warn"
|
||||
icon={faCaretDown}
|
||||
/>
|
||||
</button>
|
||||
</Show>
|
||||
</div>
|
||||
<Button
|
||||
type={
|
||||
player()?.isConnected
|
||||
? users[props.i]?.isReady
|
||||
? "green"
|
||||
: "orange"
|
||||
: "gray"
|
||||
}
|
||||
latching
|
||||
isLatched={users[props.i]?.isReady}
|
||||
onClick={() => {
|
||||
if (!player()) return
|
||||
socket.emit("isReady", !users[props.i]?.isReady)
|
||||
setIsReadyFor({
|
||||
i: props.i,
|
||||
isReady: !users[props.i]?.isReady,
|
||||
})
|
||||
}}
|
||||
disabled={!primary()}
|
||||
>
|
||||
Ready
|
||||
{users[props.i]?.isReady && player()?.isConnected ? (
|
||||
<FontAwesomeIcon icon={faCheck} class="ml-4 w-12" />
|
||||
) : primary() ? (
|
||||
<FontAwesomeIcon
|
||||
icon={faHandPointer}
|
||||
class="ml-4 w-12"
|
||||
style={{
|
||||
"--fa-bounce-start-scale-x": 1.05,
|
||||
"--fa-bounce-start-scale-y": 0.95,
|
||||
"--fa-bounce-jump-scale-x": 0.95,
|
||||
"--fa-bounce-jump-scale-y": 1.05,
|
||||
"--fa-bounce-land-scale-x": 1.025,
|
||||
"--fa-bounce-land-scale-y": 0.975,
|
||||
"--fa-bounce-height": "-0.125em",
|
||||
}}
|
||||
bounce
|
||||
/>
|
||||
) : (
|
||||
<HourGlass />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Player
|
|
@ -1,46 +0,0 @@
|
|||
import {
|
||||
faToggleLargeOff,
|
||||
faToggleLargeOn,
|
||||
} from "@fortawesome/pro-solid-svg-icons"
|
||||
import classNames from "classnames"
|
||||
import { JSX } from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { gameProps, setGameSetting } from "~/hooks/useGameProps"
|
||||
import { GameSettingKeys } from "../../../interfaces/frontend"
|
||||
|
||||
function Setting(props: { children: JSX.Element; key: GameSettingKeys }) {
|
||||
const state = () => gameProps[props.key]
|
||||
|
||||
return (
|
||||
<label class="flex items-center justify-between" for={props.key}>
|
||||
<span class="col-span-2 w-96 select-none text-5xl text-white drop-shadow-md">
|
||||
{props.children}
|
||||
</span>
|
||||
<FontAwesomeIcon
|
||||
class={classNames(
|
||||
"text-md mx-auto rounded-full px-4 drop-shadow-md transition-all",
|
||||
state() ? "text-blue-500" : "text-gray-800",
|
||||
{
|
||||
"bg-gray-300 ": state(),
|
||||
},
|
||||
)}
|
||||
size="3x"
|
||||
icon={state() ? faToggleLargeOn : faToggleLargeOff}
|
||||
/>
|
||||
<input
|
||||
class="bg-none"
|
||||
checked={state()}
|
||||
type="checkbox"
|
||||
id={props.key}
|
||||
onChange={() =>
|
||||
setGameSetting({
|
||||
[props.key]: !state(),
|
||||
})
|
||||
}
|
||||
hidden={true}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export default Setting
|
|
@ -1,72 +0,0 @@
|
|||
import { faRotateLeft } from "@fortawesome/pro-regular-svg-icons"
|
||||
import { faXmark } from "@fortawesome/pro-solid-svg-icons"
|
||||
import {} from "solid-js"
|
||||
import { FontAwesomeIcon } from "~/components/FontAwesomeIcon"
|
||||
import { full, setSetting } from "~/hooks/useGameProps"
|
||||
import { socket } from "~/lib/socket"
|
||||
import { GameSettings } from "../../../interfaces/frontend"
|
||||
import Setting from "./Setting"
|
||||
|
||||
function Settings(props: { closeSettings: () => void }) {
|
||||
const gameSetting = (newSettings: GameSettings) => {
|
||||
const hash = setSetting(newSettings)
|
||||
socket.emit("gameSetting", newSettings, (newHash) => {
|
||||
if (newHash === hash) return
|
||||
console.log("hash", hash, newHash)
|
||||
socket.emit("update", full)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div class="absolute inset-0 flex flex-col items-center justify-center bg-black/40">
|
||||
<div class="w-full max-w-screen-lg">
|
||||
<div class="mx-16 flex flex-col rounded-3xl border-4 border-slate-800 bg-zinc-500 p-8">
|
||||
<div class="flex items-center justify-center">
|
||||
<h1 class="font-farro ml-auto pl-14 text-center text-6xl font-semibold text-white shadow-black drop-shadow-lg">
|
||||
Settings
|
||||
</h1>
|
||||
<button
|
||||
class="right-6 top-6 ml-auto h-14 w-14"
|
||||
onClick={() => props.closeSettings()}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="h-full w-full text-gray-800 drop-shadow-md"
|
||||
size="3x"
|
||||
icon={faXmark}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-8 rounded-xl bg-zinc-600 p-8">
|
||||
<div class="flex items-center justify-end">
|
||||
<button
|
||||
class="right-12 top-8 h-14 w-14"
|
||||
onClick={() =>
|
||||
gameSetting({
|
||||
allowSpectators: true,
|
||||
allowSpecials: true,
|
||||
allowChat: true,
|
||||
allowMarkDraw: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<FontAwesomeIcon
|
||||
class="h-full w-full text-gray-800 drop-shadow-md"
|
||||
size="3x"
|
||||
icon={faRotateLeft}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-col gap-8">
|
||||
<Setting key="allowSpectators">Erlaube Zuschauer</Setting>
|
||||
<Setting key="allowSpecials">Erlaube spezial Items</Setting>
|
||||
<Setting key="allowChat">Erlaube den Chat</Setting>
|
||||
<Setting key="allowMarkDraw">Erlaube zeichen/makieren</Setting>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Settings
|
|
@ -1,81 +0,0 @@
|
|||
import classNames from "classnames"
|
||||
|
||||
function Logo(props: { small?: boolean }) {
|
||||
return (
|
||||
<a href="/">
|
||||
<div class="relative flex flex-col items-center rounded-sm border-x-4 border-y-2 border-shield-gray bg-shield-lightgray md:border-x-8 md:border-y-4">
|
||||
<h1
|
||||
class={classNames(
|
||||
"font-checkpoint mx-16 my-2 flex flex-col gap-2 border-y-2 border-slate-700 text-center text-2xl leading-tight tracking-widest sm:mx-24 sm:my-3 sm:gap-3 sm:border-y-[3px] sm:text-4xl md:mx-36 md:my-4 md:gap-4 md:border-y-4 md:text-5xl",
|
||||
{ "xl:gap-6 xl:py-2 xl:text-6xl": !props.small },
|
||||
)}
|
||||
>
|
||||
<span>Leaky</span>
|
||||
<span>Ships</span>
|
||||
</h1>
|
||||
<Screws small={props.small} />
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
function Screws(props: { small?: boolean }) {
|
||||
return (
|
||||
<>
|
||||
<Screw
|
||||
small={props.small}
|
||||
orientation={classNames("top-1 left-1 sm:top-2 sm:left-2", {
|
||||
"xl:top-4 xl:left-4": !props.small,
|
||||
})}
|
||||
rotation="rotate-[135deg]"
|
||||
/>
|
||||
<Screw
|
||||
small={props.small}
|
||||
orientation={classNames("top-1 right-1 sm:top-2 sm:right-2", {
|
||||
"xl:top-4 xl:right-4": !props.small,
|
||||
})}
|
||||
rotation="rotate-[5deg]"
|
||||
/>
|
||||
<Screw
|
||||
small={props.small}
|
||||
orientation={classNames("bottom-1 right-1 sm:bottom-2 sm:right-2", {
|
||||
"xl:bottom-4 xl:right-4": !props.small,
|
||||
})}
|
||||
rotation="rotate-[150deg]"
|
||||
/>
|
||||
<Screw
|
||||
small={props.small}
|
||||
orientation={classNames("bottom-1 left-1 sm:bottom-2 sm:left-2", {
|
||||
"xl:bottom-4 xl:left-4": !props.small,
|
||||
})}
|
||||
rotation="rotate-[20deg]"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Screw(props: {
|
||||
orientation: string
|
||||
rotation: string
|
||||
small?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
class={classNames(
|
||||
"absolute flex h-3 w-3 flex-col items-center justify-center rounded-full border-[1px] border-neutral-700 bg-neutral-400 sm:h-5 sm:w-5 sm:border-2 md:h-6 md:w-6",
|
||||
{ "xl:h-8 xl:w-8": !props.small },
|
||||
props.orientation,
|
||||
)}
|
||||
>
|
||||
<hr
|
||||
class={classNames(
|
||||
"color w-full border-neutral-500 sm:border-t-2",
|
||||
{ "xl:border-t-4": !props.small },
|
||||
props.rotation,
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default Logo
|
|
@ -1,68 +0,0 @@
|
|||
import { IconDefinition } from "@fortawesome/fontawesome-svg-core"
|
||||
import classNames from "classnames"
|
||||
import { JSX } from "solid-js"
|
||||
import { FontAwesomeIcon } from "./FontAwesomeIcon"
|
||||
|
||||
const styles = {
|
||||
wrapper:
|
||||
"flex w-full flex-row items-center justify-between rounded-lg py-2 pl-8 pr-4 text-lg text-grayish duration-100 first:mt-4 last:mt-4 sm:rounded-xl sm:py-4 sm:pl-16 sm:pr-8 sm:text-4xl sm:first:mt-8 sm:last:mt-8",
|
||||
enabled:
|
||||
"border-b-4 border-shield-gray bg-voidDark active:border-b-0 active:border-t-4",
|
||||
disabled: "border-4 border-dashed border-slate-600 bg-red-950",
|
||||
icon: "ml-2 w-10 text-xl sm:ml-12 sm:text-4xl",
|
||||
}
|
||||
|
||||
export function OptionAnchor(props: {
|
||||
text: string
|
||||
icon: IconDefinition
|
||||
href: string
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<a
|
||||
class={classNames(
|
||||
styles.wrapper,
|
||||
props.disabled ? styles.disabled : styles.enabled,
|
||||
)}
|
||||
href={props.href}
|
||||
title={!props.disabled ? "" : "Please login"}
|
||||
>
|
||||
<span class="mx-auto">{props.text}</span>
|
||||
<FontAwesomeIcon class={styles.icon} icon={props.icon} />
|
||||
</a>
|
||||
)
|
||||
}
|
||||
|
||||
export function OptionButton(props: {
|
||||
text: string
|
||||
icon: IconDefinition
|
||||
callback: () => void
|
||||
disabled?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
class={classNames(
|
||||
styles.wrapper,
|
||||
props.disabled ? styles.disabled : styles.enabled,
|
||||
)}
|
||||
onClick={() => setTimeout(props.callback, 200)}
|
||||
disabled={props.disabled}
|
||||
title={!props.disabled ? "" : "Please login"}
|
||||
>
|
||||
<span class="mx-auto">{props.text}</span>
|
||||
<FontAwesomeIcon class={styles.icon} icon={props.icon} />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OptionDiv(props: {
|
||||
icon: IconDefinition
|
||||
children: JSX.Element
|
||||
}) {
|
||||
return (
|
||||
<div class={classNames(styles.wrapper, styles.enabled)}>
|
||||
{props.children}
|
||||
<FontAwesomeIcon class={styles.icon} icon={props.icon} />
|
||||
</div>
|
||||
)
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue