Compare commits

...
Sign in to create a new pull request.

28 commits

Author SHA1 Message Date
6fb057a102
Migrated to latest solid-start version 2024-02-22 17:14:09 +01:00
777b807225
Fix grid resize 2023-12-15 08:57:31 +01:00
44bf98e88e
FontAwesomeIcon react-free 2023-10-19 18:35:35 +02:00
8b179b5e65
Fix hit boolean for torpedos 2023-09-10 19:46:21 +02:00
2991dc19f2
Add missing cleanup 2023-09-10 19:44:18 +02:00
c8a5c47b98
Working player wise drawing 2023-09-10 19:33:21 +02:00
d245009c37
Fixid color picker and drawing functionality 2023-09-10 01:42:09 +02:00
efcb61b1ed
user states improvements and fixes 2023-09-08 19:55:33 +02:00
252f6f6028
Replace gameProp signals with store 2023-09-08 18:55:59 +02:00
4390269ed1
Use of client-side <A> instead of navigator 2023-09-08 10:17:45 +02:00
1e7b46ff69
Little changes 2023-09-07 18:10:46 +02:00
a3eab18535
Update packages 2023-09-07 18:10:45 +02:00
129e36a6f2
Fix tests 2023-09-07 17:38:07 +02:00
8e4c11570a
Further fixes for solidJS 2023-09-04 09:51:28 +02:00
26ee9652e6
Rework 'hits' storage 2023-09-03 18:48:39 +02:00
b067747d48
Fix some game logic 2023-09-03 18:48:06 +02:00
b4fd992611
Update README.md 2023-08-31 11:40:16 +02:00
89b79fa245
Further fixes for SolidJS 2023-08-31 09:20:33 +02:00
db7fb9213e
Fixed drizzle config 2023-08-20 15:49:37 +02:00
1cc34744a8
Partial start on types for prizma query 2023-08-19 23:19:38 +02:00
4c2c578311
Fix tests 2023-08-19 23:17:14 +02:00
2479dd518d
Migration from Prisma to Drizzle-ORM 2023-08-19 23:16:49 +02:00
ed472e40ed
Update Packages 2023-08-16 05:26:52 +02:00
9d8bb8e20b
Fixed all eslint errors 2023-08-16 05:19:31 +02:00
fc7bf96b04
Fully working FontAwesomeIcon 2023-08-16 04:31:28 +02:00
16a3279e5a
Fix signin and little changes 2023-08-16 01:37:34 +02:00
3adddef8cc
Fix eslint and add to config 2023-08-16 01:33:04 +02:00
afe1e0426c
SolidJS implementation 2023-08-15 03:27:55 +02:00
145 changed files with 11296 additions and 11666 deletions

View file

@ -1,8 +0,0 @@
# 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/!*/

View file

@ -18,12 +18,12 @@ jobs:
- name: Install Node.js
uses: actions/setup-node@v3
with:
node-version: 18
node-version: 20
- uses: pnpm/action-setup@v2
name: Install pnpm
with:
version: 8.6.10
version: 8.7.4
run_install: false
- name: Get pnpm store directory
@ -41,8 +41,8 @@ jobs:
- name: Add FA token
run: |
echo "@fortawesome:registry=https://npm.fontawesome.com/" > .npmrc
npm config set '//npm.fontawesome.com/:_authToken' "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}"
npm config set "@fortawesome:registry" "https://npm.fontawesome.com/"
npm config set "//npm.fontawesome.com/:_authToken" "${{ secrets.FONTAWESOME_NPM_AUTH_TOKEN }}"
- name: Install dependencies
run: pnpm install --frozen-lockfile
@ -53,8 +53,7 @@ jobs:
- name: 'Compiling page'
run: |
echo "${{ secrets.ENV_FILE }}" > .env
pnpm prisma generate
pnpm run build
pnpm build
- name: Run Playwright tests
run: pnpm playwright test

View file

@ -1,5 +1,6 @@
# leaky-ships
Battleship web app with react frontend and ASP.NET Core backend
Battleship web app made with SolidJS using solid-start.
## Bluetooth

View file

@ -1,6 +1,11 @@
{
"extends": ["next/core-web-vitals", "prettier"],
"rules": {
"@next/next/no-img-element": "off"
}
"parser": "@typescript-eslint/parser",
"env": {
"node": true
},
"plugins": ["solid"],
"extends": [
"plugin:@typescript-eslint/recommended",
"plugin:solid/typescript"
]
}

View file

@ -1,48 +1,35 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# logs
/log
src/drizzle/migrations
# prisma
/prisma/migrations
dist
.vinxi
.output
.vercel
.netlify
netlify
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# local env files
# Environment
.env
.env*.local
# vercel
.vercel
# dependencies
/node_modules
# typescript
*.tsbuildinfo
next-env.d.ts
# IDEs and editors
/.idea
.project
.classpath
*.launch
.settings/
# Temp
gitignore
# System Files
.DS_Store
Thumbs.db
# playwright
/test-results/
/playwright-report/
/playwright/.cache/
/playwright/.cache/

View file

@ -1,151 +0,0 @@
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

View file

@ -1,395 +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 { 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

View file

@ -1,13 +0,0 @@
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

View file

@ -1,145 +0,0 @@
// 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

View file

@ -1,33 +0,0 @@
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

View file

@ -1,36 +0,0 @@
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

View file

@ -1,73 +0,0 @@
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

View file

@ -1,62 +0,0 @@
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

View file

@ -1,16 +0,0 @@
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

View file

@ -1,71 +0,0 @@
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

View file

@ -1,115 +0,0 @@
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

View file

@ -1,121 +0,0 @@
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

View file

@ -1,27 +0,0 @@
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

View file

@ -1,143 +0,0 @@
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}>&nbsp;</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

View file

@ -1,125 +0,0 @@
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

View file

@ -1,58 +0,0 @@
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

View file

@ -1,77 +0,0 @@
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

View file

@ -1,83 +0,0 @@
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

View file

@ -1,41 +0,0 @@
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

View file

@ -0,0 +1,11 @@
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

View file

@ -1,6 +1,5 @@
import { expect, test, type BrowserContext, type Page } from "@playwright/test"
const callbackUrl = process.env.NEXTAUTH_URL + "/"
let context: BrowserContext
let page: Page
@ -16,36 +15,36 @@ test.describe.serial("Check Azure AD auth", () => {
})
test("Login process...", async () => {
await page.goto(callbackUrl + "signin")
await page.goto("/signin")
await page.waitForLoadState("domcontentloaded")
await page.click("button#microsoft")
await page.locator("button#microsoft").click()
// Indicates email can be filled in
await page.waitForSelector("a#cantAccessAccount")
// Fill email input
await page.fill("input#i0116", process.env.AUTH_EMAIL ?? "")
await page.locator("input#i0116").fill(process.env.AUTH_EMAIL!)
// Click the "Next" button
await page.click("input#idSIButton9")
await page.locator("input#idSIButton9").click()
// Indicates password can be filled in
await page.waitForSelector("a#idA_PWD_ForgotPassword")
// Fill password input
await page.fill("input#i0118", process.env.AUTH_PW ?? "")
await page.locator("input#i0118").fill(process.env.AUTH_PW!)
// Click the "Sign in" button
await page.click("input#idSIButton9")
await page.locator("input#idSIButton9").click()
// Click the "No" button
await page.click("input#idBtn_Back")
await page.locator("input#idBtn_Back").click()
await page.waitForSelector("#start")
})
test("Is logged in", async () => {
await page.goto(callbackUrl + "signin")
await page.goto("/signin")
await page.waitForSelector("button#signout")
await page.goto("/")
await page.waitForSelector("#start")
await page.evaluate(() => document.fonts.ready)
@ -55,11 +54,9 @@ test.describe.serial("Check Azure AD auth", () => {
})
test("Is logged out", async () => {
await page.goto(callbackUrl + "signout")
await page.goto("/signout")
await page.waitForLoadState("domcontentloaded")
await page.click("button#signout")
await page.locator("button#signout").click()
await page.waitForSelector("#start")

View file

@ -5,9 +5,10 @@ import {
type Page,
} from "@playwright/test"
import { createHash, randomBytes } from "crypto"
import prisma from "../lib/prisma"
import { and, desc, eq } from "drizzle-orm"
import db from "~/drizzle"
import { verificationTokens } from "~/drizzle/schemas/Tables"
const callbackUrl = process.env.NEXTAUTH_URL + "/"
const player1Email = (browser: Browser) =>
browser.browserType().name() + "-player-1@example.com"
@ -25,51 +26,51 @@ test.describe.serial("Check Email auth", () => {
})
test("Email login process...", async ({ browser }) => {
await page.goto(callbackUrl + "signin")
await page.goto("/signin")
await page.waitForSelector("input#email")
await page.fill("input#email", player1Email(browser))
await page.click("button#email-submit")
await page.locator("input#email").fill(player1Email(browser))
await page.locator("button#email-submit").click()
await page.waitForURL(
callbackUrl + "api/auth/verify-request?provider=email&type=email",
)
await page.waitForURL("/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.NEXTAUTH_SECRET}`)
.update(`${token}${process.env.AUTH_SECRET}`)
.digest("hex")
// 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 },
// 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)],
})
await db
.update(verificationTokens)
.set({ token: hash })
.where(
and(
eq(verificationTokens.identifier, player1Email(browser)),
eq(verificationTokens.token, latestToken?.token ?? ""),
),
)
const params = new URLSearchParams({
callbackUrl,
callbackUrl: process.env.AUTH_URL!,
token,
email: player1Email(browser),
})
const url = callbackUrl + "api/auth/callback/email?" + params
await page.goto(url)
await page.goto("/api/auth/callback/email?" + params)
})
await page.waitForLoadState("domcontentloaded")
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")
})
})

View file

@ -1,5 +0,0 @@
import "@total-typescript/ts-reset"
declare global {
var prismaClient: PrismaClient
}

View file

@ -1,126 +0,0 @@
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 }
}

View file

@ -1,40 +0,0 @@
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",
},
),
)

View file

@ -1,274 +0,0 @@
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",
},
),
)

View file

@ -1,24 +0,0 @@
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

View file

@ -1,27 +0,0 @@
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

View file

@ -1,192 +0,0 @@
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

View file

@ -1,6 +0,0 @@
/** @type {import('jest-environment-puppeteer').JestPuppeteerConfig} */
module.exports = {
launch: {
headless: "new",
},
}

View file

@ -1,25 +0,0 @@
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
}

View file

@ -1,10 +0,0 @@
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 }
}

View file

@ -1,15 +0,0 @@
// 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

View file

@ -1,73 +0,0 @@
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>

View file

@ -1,6 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
}
module.exports = nextConfig

View file

@ -1,64 +1,80 @@
{
"name": "leaky-ships",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint",
"test": "pnpm playwright test --ui"
"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"
},
"type": "module",
"dependencies": {
"@fortawesome/fontawesome-svg-core": "^6.4.0",
"@fortawesome/pro-duotone-svg-icons": "^6.4.0",
"@fortawesome/pro-light-svg-icons": "^6.4.0",
"@fortawesome/pro-regular-svg-icons": "^6.4.0",
"@fortawesome/pro-solid-svg-icons": "^6.4.0",
"@fortawesome/pro-thin-svg-icons": "^6.4.0",
"@fortawesome/react-fontawesome": "^0.2.0",
"@fortawesome/sharp-solid-svg-icons": "^6.4.0",
"@next-auth/prisma-adapter": "^1.0.7",
"@next/font": "13.1.1",
"@prisma/client": "^4.16.2",
"classnames": "^2.3.2",
"@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",
"colors": "^1.4.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.22.3",
"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.1",
"socket.io-client": "^4.7.1",
"typescript": "4.9.4",
"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",
"unique-names-generator": "^4.7.1",
"zod": "3.21.1",
"zod-prisma-types": "^2.7.4",
"zustand": "^4.3.9"
"vinxi": "^0.3.3",
"zod": "3.22.4"
},
"packageManager": "pnpm@8.7.4",
"devDependencies": {
"@playwright/test": "^1.36.2",
"@total-typescript/ts-reset": "^0.3.7",
"@types/node": "^18.17.0",
"@types/react": "^18.2.15",
"@types/react-dom": "^18.2.7",
"@types/web-bluetooth": "^0.0.16",
"autoprefixer": "^10.4.14",
"dotenv": "^16.3.1",
"eslint-config-prettier": "^8.8.0",
"postcss": "^8.4.27",
"prettier": "^3.0.0",
"prettier-plugin-organize-imports": "^3.2.3",
"prettier-plugin-tailwindcss": "^0.4.1",
"sass": "^1.64.1",
"tailwindcss": "^3.3.3"
"@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"
}
}
}

View file

@ -1,21 +0,0 @@
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>
)
}

View file

@ -1,13 +0,0 @@
import { Head, Html, Main, NextScript } from "next/document"
export default function Document() {
return (
<Html lang="en">
<Head />
<body>
<Main />
<NextScript />
</body>
</Html>
)
}

View file

@ -1,46 +0,0 @@
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 },
})
}

View file

@ -1,66 +0,0 @@
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"],
})
}

View file

@ -1,89 +0,0 @@
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)
}
}

View file

@ -1,155 +0,0 @@
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"],
})
}

View file

@ -1,299 +0,0 @@
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

View file

@ -1,44 +0,0 @@
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>
)
}

View file

@ -1,22 +0,0 @@
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>
</>
)
}

View file

@ -1,22 +0,0 @@
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>
</>
)
}

View file

@ -1,22 +0,0 @@
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>
</>
)
}

View file

@ -1,30 +0,0 @@
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>
)
}

View file

@ -1,39 +0,0 @@
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>
)
}

View file

@ -1,152 +0,0 @@
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

View file

@ -1,43 +0,0 @@
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

View file

@ -1,231 +0,0 @@
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>
)
}

View file

@ -1,10 +1,11 @@
import { defineConfig, devices } from "@playwright/test"
import dotenv from "dotenv"
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
require("dotenv").config()
dotenv.config()
/**
* See https://playwright.dev/docs/test-configuration.
@ -24,7 +25,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: 'http://127.0.0.1:3000',
baseURL: process.env.AUTH_URL!,
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: "on-first-retry",
@ -70,8 +71,8 @@ export default defineConfig({
/* Run your local dev server before starting the tests */
webServer: {
command: "pnpm run start",
url: process.env.NEXTAUTH_URL,
command: "pnpm start",
url: process.env.AUTH_URL,
reuseExistingServer: !process.env.CI,
},
})

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -50,13 +50,13 @@ model User {
email String? @unique
emailVerified DateTime? @map("email_verified")
image String?
createdAt DateTime @default(now()) @map(name: "created_at")
updatedAt DateTime @updatedAt @map(name: "updated_at")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
games User_Game[]
accounts Account[]
sessions Session[]
@@map(name: "users")
@@map("users")
}
model VerificationToken {

39
leaky-ships/src/app.tsx Normal file
View file

@ -0,0 +1,39 @@
// @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>
)
}

View file

@ -1,8 +1,8 @@
import { useState } from "react"
import { createSignal } from "solid-js"
function Bluetooth() {
const [startDisabled, setStartDisabled] = useState(true)
const [stopDisabled, setStopDisabled] = useState(true)
const [startDisabled, setStartDisabled] = createSignal(true)
const [stopDisabled, setStopDisabled] = createSignal(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" className="bluetooth" onClick={read}>
<button id="read" class="bluetooth" onClick={read}>
Connect with BLE device
</button>
<button
id="start"
className="bluetooth"
disabled={startDisabled}
class="bluetooth"
disabled={startDisabled()}
onClick={start}
>
Start
</button>
<button
id="stop"
className="bluetooth"
disabled={stopDisabled}
class="bluetooth"
disabled={stopDisabled()}
onClick={stop}
>
Stop
</button>
<p>
<span
className="App-link"
class="App-link"
onClick={() => {
navigator.clipboard.writeText(
"chrome://flags/#enable-experimental-web-platform-features",
@ -169,7 +169,7 @@ function Bluetooth() {
Step 1
</span>{" "}
<span
className="App-link"
class="App-link"
onClick={() => {
navigator.clipboard.writeText(
"chrome://flags/#enable-web-bluetooth-new-permissions-backend",

View file

@ -1,23 +1,17 @@
import classNames from "classnames"
function BurgerMenu({
onClick,
blur,
}: {
onClick?: () => void
blur?: boolean
}) {
function BurgerMenu(props: { onClick?: () => void; blur?: boolean }) {
return (
<button
id="menu"
className={classNames(
class={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": blur },
{ "blur-sm": props.blur },
)}
onClick={() => onClick && setTimeout(onClick, 200)}
onClick={() => props.onClick && setTimeout(props.onClick, 200)}
>
<img
className="pixelart h-12 w-12 md:h-16 md:w-16 xl:h-20 xl:w-20"
class="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"
/>

View file

@ -0,0 +1,151 @@
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>
)
}

View file

@ -0,0 +1,169 @@
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

View file

@ -0,0 +1,369 @@
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

View file

@ -0,0 +1,13 @@
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

View file

@ -0,0 +1,139 @@
// 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

View file

@ -0,0 +1,38 @@
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

View file

@ -0,0 +1,29 @@
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

View file

@ -0,0 +1,63 @@
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

View file

@ -1,6 +1,6 @@
import { fieldIndex } from "@lib/utils/helpers"
import classNames from "classnames"
import { CSSProperties } from "react"
import { For } from "solid-js"
import { fieldIndex } from "~/lib/utils/helpers"
import { Field } from "../../interfaces/frontend"
import { count } from "./Gamefield"
@ -34,17 +34,16 @@ function Labeling() {
(a, b) => fieldIndex(count, a.x, a.y) - fieldIndex(count, b.x, b.y),
)
return (
<>
{elems.map(({ field, x, y, orientation }, i) => (
<For each={elems}>
{(props) => (
<span
key={i}
className={classNames("label", orientation, field)}
style={{ "--x": x, "--y": y } as CSSProperties}
class={classNames("label", props.orientation, props.field)}
style={{ "--x": props.x, "--y": props.y }}
>
{field}
{props.field}
</span>
))}
</>
)}
</For>
)
}

View file

@ -0,0 +1,71 @@
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

View file

@ -0,0 +1,16 @@
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

View file

@ -0,0 +1,81 @@
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

View file

@ -0,0 +1,99 @@
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

View file

@ -0,0 +1,103 @@
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

View file

@ -1,43 +1,36 @@
import classNames from "classnames"
import { ReactNode } from "react"
import { JSX } from "solid-js"
function Button({
type,
disabled,
onClick,
children,
latching,
isLatched,
}: {
function Button(props: {
type: "red" | "orange" | "green" | "gray"
disabled?: boolean
onClick: () => void
children: ReactNode
children: JSX.Element
latching?: boolean
isLatched?: boolean
}) {
return (
<button
disabled={disabled}
className={classNames(
disabled={props.disabled}
class={classNames(
"font-farro rounded-xl px-8 py-4 text-5xl font-medium duration-100",
disabled
props.disabled
? "border-4 border-dashed"
: latching
? isLatched
: props.latching
? props.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": 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",
"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",
},
)}
onClick={onClick}
onClick={() => props.onClick()}
>
{children}
{props.children}
</button>
)
}

View file

@ -0,0 +1,23 @@
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

View file

@ -0,0 +1,139 @@
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()), () => (
<>&nbsp;</>
))}
</>
)
}
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

View file

@ -0,0 +1,120 @@
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

View file

@ -0,0 +1,46 @@
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

View file

@ -0,0 +1,72 @@
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

View file

@ -0,0 +1,81 @@
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

View file

@ -0,0 +1,68 @@
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>
)
}

View file

@ -1,7 +1,7 @@
function profileImg(src: string) {
return (
<img
style={{ transform: "scale(1.5)", borderRadius: "100%" }}
style={{ transform: "scale(1.5)", "border-radius": "100%" }}
src={src}
alt="profile picture"
/>

View file

@ -0,0 +1,10 @@
import { drizzle } from "drizzle-orm/postgres-js"
import postgres from "postgres"
import * as schema from "./schemas/Tables"
const queryClient = postgres(import.meta.env.VITE_DATABASE_URL ?? "")
const db = drizzle(queryClient, {
schema,
})
export default db

View file

@ -0,0 +1,194 @@
import type { AdapterAccount } from "@auth/core/adapters"
import { relations } from "drizzle-orm"
import {
boolean,
integer,
pgTable,
primaryKey,
text,
timestamp,
unique,
} from "drizzle-orm/pg-core"
import { gameState, moveType, orientation } from "./Types"
export const users = pgTable("user", {
id: text("id").notNull().primaryKey(),
name: text("name").notNull(),
email: text("email").notNull().unique(),
emailVerified: timestamp("emailVerified", { mode: "date" }),
image: text("image"),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
})
export const accounts = pgTable(
"account",
{
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
type: text("type").$type<AdapterAccount["type"]>().notNull(),
provider: text("provider").notNull(),
providerAccountId: text("providerAccountId").notNull(),
refresh_token: text("refresh_token"),
access_token: text("access_token"),
expires_at: integer("expires_at"),
extExpiresIn: integer("ext_expires_in"),
token_type: text("token_type"),
scope: text("scope"),
id_token: text("id_token"),
session_state: text("session_state"),
oauthTokenSecret: text("oauth_token_secret"),
oauthToken: text("oauth_token"),
},
(account) => ({
compoundKey: primaryKey(account.provider, account.providerAccountId),
}),
)
export const sessions = pgTable("session", {
sessionToken: text("sessionToken").notNull().primaryKey(),
userId: text("userId")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
expires: timestamp("expires", { mode: "date" }).notNull(),
})
export const verificationTokens = pgTable(
"verificationToken",
{
identifier: text("identifier").notNull(),
token: text("token").notNull(),
expires: timestamp("expires", { mode: "date" }).notNull(),
},
(vt) => ({
compoundKey: primaryKey(vt.identifier, vt.token),
}),
)
export const games = pgTable("game", {
id: text("id").notNull().primaryKey(),
createdAt: timestamp("created_at").defaultNow(),
updatedAt: timestamp("updated_at").notNull().defaultNow(),
state: gameState("state").notNull().default("lobby"),
allowSpectators: boolean("allow_spectators").notNull().default(true),
allowSpecials: boolean("allow_specials").notNull().default(true),
allowChat: boolean("allow_chat").notNull().default(true),
allowMarkDraw: boolean("allow_mark_draw").notNull().default(true),
})
export const gamepins = pgTable("gamepin", {
id: text("id").notNull().primaryKey(),
createdAt: timestamp("created_at").defaultNow(),
pin: text("pin").notNull().unique(),
gameId: text("game_id")
.notNull()
.unique()
.references(() => games.id, { onDelete: "cascade" }),
})
export const ships = pgTable("ship", {
id: text("id").notNull().primaryKey(),
size: integer("size").notNull(),
variant: integer("variant").notNull(),
x: integer("x").notNull(),
y: integer("y").notNull(),
orientation: orientation("orientation").notNull(),
user_game_id: text("user_game_id")
.notNull()
.references(() => user_games.id, { onDelete: "cascade" }),
})
export const moves = pgTable("move", {
id: text("id").notNull().primaryKey(),
createdAt: timestamp("created_at").defaultNow(),
index: integer("index").notNull(),
type: moveType("type").notNull(),
x: integer("x").notNull(),
y: integer("y").notNull(),
orientation: orientation("orientation").notNull(),
user_game_id: text("user_game_id")
.notNull()
.references(() => user_games.id, { onDelete: "cascade" }),
})
export const chats = pgTable("chat", {
id: text("id").notNull().primaryKey(),
createdAt: timestamp("created_at").notNull().defaultNow(),
message: text("message"),
event: text("event"),
user_game_id: text("user_game_id")
.notNull()
.references(() => user_games.id, { onDelete: "cascade" }),
})
export const user_games = pgTable(
"user_game",
{
id: text("id").notNull().primaryKey(),
createdAt: timestamp("created_at").defaultNow(),
gameId: text("game_id")
.notNull()
.references(() => games.id, { onDelete: "cascade" }),
userId: text("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
index: integer("index").notNull(),
},
(t) => ({
gameIndex: unique().on(t.gameId, t.index),
}),
)
export const usersRelations = relations(users, ({ many }) => ({
user_games: many(user_games),
sessions: many(sessions),
}))
export const sessionsRelations = relations(sessions, ({ one }) => ({
user: one(users, {
fields: [sessions.userId],
references: [users.id],
}),
}))
export const gamesRelations = relations(games, ({ one, many }) => ({
gamePin: one(gamepins, {
fields: [games.id],
references: [gamepins.gameId],
}),
users: many(user_games),
}))
export const gamepinsRelations = relations(gamepins, ({ one }) => ({
game: one(games, { fields: [gamepins.gameId], references: [games.id] }),
}))
export const shipsRelations = relations(ships, ({ one }) => ({
userGame: one(user_games, {
fields: [ships.user_game_id],
references: [user_games.id],
}),
}))
export const userGamesRelations = relations(user_games, ({ one, many }) => ({
user: one(users, { fields: [user_games.userId], references: [users.id] }),
game: one(games, { fields: [user_games.gameId], references: [games.id] }),
moves: many(moves),
ships: many(ships),
chats: many(chats),
}))
export const movesRelations = relations(moves, ({ one }) => ({
userGame: one(user_games, {
fields: [moves.user_game_id],
references: [user_games.id],
}),
}))
export const chatsRelations = relations(chats, ({ one }) => ({
userGame: one(user_games, {
fields: [chats.user_game_id],
references: [user_games.id],
}),
}))

View file

@ -0,0 +1,17 @@
import { pgEnum } from "drizzle-orm/pg-core"
export const gameState = pgEnum("game_state", [
"unknown",
"lobby",
"starting",
"running",
"ended",
"aborted",
])
export const moveType = pgEnum("move_type", [
"missile",
"vtorpedo",
"htorpedo",
"radar",
])
export const orientation = pgEnum("orientation", ["h", "v"])

View file

@ -0,0 +1,3 @@
import { mount, StartClient } from "@solidjs/start/client"
mount(() => <StartClient />, document.getElementById("app")!)

View file

@ -0,0 +1,20 @@
import { StartServer, createHandler } from "@solidjs/start/server"
export default createHandler(() => (
<StartServer
document={({ assets, children, scripts }) => (
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="icon" href="/favicon.ico" />
{assets}
</head>
<body id="app">
{children}
{scripts}
</body>
</html>
)}
/>
))

View file

@ -0,0 +1,179 @@
import { createEffect, createSignal, onCleanup } from "solid-js"
import { socket } from "~/lib/socket"
import { DrawLineProps, PlayerEvent, Point } from "../interfaces/frontend"
import { color, enable, frameSize, shouldHide } from "./useDrawProps"
import { useSession } from "./useSession"
let canvasRef: HTMLCanvasElement
const strokes: Record<0 | 1, DrawLineProps[]> = { 0: [], 1: [] }
function drawLine(
{ prevPoint, currentPoint, color }: DrawLineProps,
ctx: CanvasRenderingContext2D,
i: 0 | 1,
) {
strokes[i].push({ prevPoint, currentPoint, color })
const currX = currentPoint.x * frameSize().x
const currY = currentPoint.y * frameSize().y
const startPoint = prevPoint ?? currentPoint
const startX = startPoint.x * frameSize().x
const startY = startPoint.y * frameSize().y
const lineColor = color
const lineWidth = 5
ctx.beginPath()
ctx.lineWidth = lineWidth
ctx.strokeStyle = lineColor
ctx.moveTo(startX, startY)
ctx.lineTo(currX, currY)
ctx.stroke()
ctx.fillStyle = lineColor
ctx.beginPath()
ctx.arc(startPoint.x, startPoint.y, 2, 0, 2 * Math.PI)
ctx.fill()
}
function clear(sIndex?: { i: 0 | 1 }) {
const canvas = canvasRef
if (!canvas) return
if (sIndex) strokes[sIndex.i] = []
const ctx = canvas.getContext("2d")
if (!ctx) return
ctx.clearRect(0, 0, canvas.width, canvas.height)
}
export function DrawingCanvas() {
const [mouseDown, setMouseDown] = createSignal(false)
const [stateIndex, setStateIndex] = createSignal<{ i: 0 | 1 } | null>(null)
const { selfIndex, selfIsActiveIndex, activeIndex } = useSession()
createEffect(() => {
const i = activeIndex()
const canvas = canvasRef
if (!canvas || i === stateIndex()?.i) return
const ctx = canvasRef?.getContext("2d")
if (!ctx) return
clear()
strokes[i].forEach((props) => drawLine(props, ctx, i))
setStateIndex({ i })
})
createEffect(() => {
let prevPoint: null | Point
const canvas = canvasRef
if (!canvas) return
const handler = (e: MouseEvent) => {
const sIndex = selfIndex()
if (!mouseDown() || !selfIsActiveIndex() || !sIndex) return
const rect = canvas.getBoundingClientRect()
const x = (e.clientX - rect.left) / frameSize().x
const y = (e.clientY - rect.top) / frameSize().y
const currentPoint = { x, y }
const ctx = canvasRef?.getContext("2d")
if (!ctx) return
const props = {
currentPoint,
prevPoint,
color: color(),
}
socket.emit("draw-line", props)
drawLine(props, ctx, sIndex.i)
prevPoint = currentPoint
}
const mouseUpHandler = () => {
setMouseDown(false)
prevPoint = null
}
// Add event listeners
canvas.addEventListener("mousemove", handler)
window.addEventListener("mouseup", mouseUpHandler)
// Remove event listeners
onCleanup(() => {
canvas.removeEventListener("mousemove", handler)
window.removeEventListener("mouseup", mouseUpHandler)
})
})
createEffect(() => {
const sIndex = selfIndex()
const canvas = canvasRef
if (!canvas || !sIndex) return
const ctx = canvas.getContext("2d")
if (!ctx) return
const playerEvent = (event: PlayerEvent) => {
if (
!strokes[sIndex.i].length ||
!selfIsActiveIndex() ||
event.type !== "connect"
)
return
console.log("Sending canvas state.")
socket.emit("canvas-state", strokes[sIndex.i])
}
const canvasStateFromServer = (state: DrawLineProps[], i: 0 | 1) => {
console.log("Canvas state received.")
clear({ i })
state.forEach((props) => drawLine(props, ctx, i))
}
const socketDrawLine = (props: DrawLineProps, i: 0 | 1) => {
drawLine(props, ctx, i)
}
socket.on("playerEvent", playerEvent)
socket.on("canvas-state-from-server", canvasStateFromServer)
socket.on("draw-line", socketDrawLine)
socket.on("canvas-clear", clear)
onCleanup(() => {
socket.off("playerEvent", playerEvent)
socket.off("canvas-state-from-server", canvasStateFromServer)
socket.off("draw-line", socketDrawLine)
socket.off("canvas-clear", clear)
})
})
return (
<canvas
style={{
opacity: shouldHide() ? 0 : 1,
"box-shadow":
enable() && selfIsActiveIndex()
? "inset 0 0 0 2px " + color()
: "none",
"pointer-events": enable() && !shouldHide() ? "auto" : "none",
}}
ref={canvasRef}
onMouseDown={() => setMouseDown(true)}
width={frameSize().x}
height={frameSize().y}
/>
)
}
export function clearDrawing(sIndex: { i: 0 | 1 } | null) {
if (!sIndex) return
clear(sIndex)
socket.emit("canvas-clear")
}

View file

@ -0,0 +1,35 @@
import { createSignal } from "solid-js"
import { Position } from "~/interfaces/frontend"
export const colors = [
"#ff4400",
"#fea800",
"#ffd635",
"#00a367",
"#7eed55",
"#2351a6",
"#3590ea",
"#52e9f3",
"#811e9f",
"#b34abf",
"#fe99a9",
"#9c6926",
// "#ffffff",
"#d3d7d8",
"#898d90",
"#000000",
]
export const [enable, setEnable] = createSignal(false)
export const [shouldHide, setShouldHide] = createSignal(false)
export const [color, setColor] = createSignal("#b32aa9")
export const [frameSize, setFrameSize] = createSignal<Position>({
x: 648,
y: 648,
})
export function reset() {
setEnable(false)
setShouldHide(false)
setColor("#b32aa9")
}

View file

@ -0,0 +1,235 @@
/* eslint-disable solid/reactivity */
import { socket } from "~/lib/socket"
import { GamePropsSchema, GameState } from "~/lib/zodSchemas"
// import { toast } from "react-toastify"
import { createSignal } from "solid-js"
import { createStore, produce } from "solid-js/store"
import { getPayloadFromProps } from "~/lib/getPayloadFromProps"
import { getPayloadwithChecksum } from "~/lib/getPayloadwithChecksum"
import {
initlialMouseCursor,
initlialTarget,
initlialTargetPreview,
} from "~/lib/utils/helpers"
import {
EventBarModes,
GameSettings,
MouseCursor,
MoveDispatchProps,
Players,
ShipProps,
Target,
TargetPreview,
User,
Users,
} from "../interfaces/frontend"
export interface GameProps {
hash: string | null
gamePin: string | null
gameId: string
gameState: GameState
allowChat: boolean
allowMarkDraw: boolean
allowSpecials: boolean
allowSpectators: boolean
menu: keyof EventBarModes
mode: number
}
const initialGameProps = {
hash: null,
gamePin: null,
gameId: "",
gameState: "unknown",
allowChat: false,
allowMarkDraw: false,
allowSpecials: false,
allowSpectators: false,
menu: "moves",
mode: 0,
} satisfies GameProps
export const [gameProps, setGameProps] =
createStore<GameProps>(initialGameProps)
const initialUsers = { 0: null, 1: null }
export const [users, setUsersStore] = createStore<Users>(initialUsers)
export function setUsers(
i: 0 | 1,
userInput: Players | Partial<NonNullable<User>>,
): void {
setUsersStore(i, (state) => {
if (0 in userInput) {
const user = userInput[i]
if (!user && state) return null
else if (!state)
return {
...user,
isReady: false,
isConnected: false,
}
else
return produce<NonNullable<typeof state>>((u) => {
Object.assign(u, user)
})(state)
} else {
const user = userInput
if (!state) {
console.error(
"Everything is fine! Still, this was unexpected and should probably be fixed. 😁",
userInput,
state,
)
console.trace(userInput)
return null
}
return produce<NonNullable<typeof state>>((u) => {
Object.assign(u, user)
})(state)
}
})
}
export const [target, setTarget] = createSignal<Target>(initlialTarget)
export const [targetPreview, setTargetPreview] = createSignal<TargetPreview>(
initlialTargetPreview,
)
export const [mouseCursor, setMouseCursor] =
createSignal<MouseCursor>(initlialMouseCursor)
export function DispatchMove(move: MoveDispatchProps, index: 0 | 1) {
setUsers(index, {
moves: [...(users[index]?.moves ?? []), move],
isReady: false,
})
}
export function setShips(ships: ShipProps[], index: 0 | 1) {
setUsers(index, { ships })
}
export function removeShip({ size, variant, x, y }: ShipProps, index: 0 | 1) {
const ships = users[index]?.ships ?? []
const indexToRemove = users[index]?.ships.findIndex(
(ship) =>
ship.size === size &&
ship.variant === variant &&
ship.x === x &&
ship.y === y,
)
setUsers(index, {
ships: ships.filter((_, i) => i !== indexToRemove),
})
}
export function setPlayer(newUsers: Players): string | null {
let hash: string | null = null
setUsers(0, newUsers)
setUsers(1, newUsers)
const body = getPayloadwithChecksum(getPayloadFromProps())
hash = body.hash
setGameProps("hash", hash)
return hash
}
export function setSetting(newSettings: GameSettings): string | null {
let hash: string | null = null
setGameProps("allowChat", (e) => newSettings.allowChat ?? e)
setGameProps("allowMarkDraw", (e) => newSettings.allowMarkDraw ?? e)
setGameProps("allowSpecials", (e) => newSettings.allowSpecials ?? e)
setGameProps("allowSpectators", (e) => newSettings.allowSpectators ?? e)
const body = getPayloadwithChecksum(getPayloadFromProps())
if (!body.hash) {
console.log("Something is wrong... ")
// toast.warn("Something is wrong... ", {
// toastId: "st_wrong",
// theme: "colored",
// })
return null
}
hash = body.hash
setGameProps("hash", hash)
return hash
}
export function setGameSetting(newSettings: GameSettings) {
return () => {
const hash = setSetting(newSettings)
socket.emit("gameSetting", newSettings, (newHash) => {
if (newHash === hash) return
console.log("hash", hash, newHash)
socket.emit("update", full)
})
}
}
export function full(newProps: GamePropsSchema) {
if (gameProps.hash === newProps.hash) {
console.log("Everything up to date.")
} else {
console.log("Update was needed.", gameProps.hash, newProps.hash)
if (gameProps.gameId !== newProps.payload?.game?.id)
console.warn(
"Different gameId detected on update: ",
gameProps.gameId,
newProps.payload?.game?.id,
)
setGameProps({
hash: newProps.hash,
gamePin: newProps.payload.gamePin,
gameId: newProps.payload.game?.id ?? "",
gameState: newProps.payload.game?.state ?? "unknown",
allowChat: newProps.payload.game?.allowChat ?? false,
allowMarkDraw: newProps.payload.game?.allowMarkDraw ?? false,
allowSpecials: newProps.payload.game?.allowSpecials ?? false,
allowSpectators: newProps.payload.game?.allowSpectators ?? false,
})
setUsers(0, newProps.payload.users)
setUsers(1, newProps.payload.users)
}
}
export function leave(cb: () => void) {
socket.emit("leave", (ack) => {
if (!ack) {
console.log("Something is wrong... ")
// toast.error("Something is wrong...")
}
cb()
})
}
export function setIsReadyFor({ i, isReady }: { i: 0 | 1; isReady: boolean }) {
setUsers(i, {
isReady: isReady,
isConnected: true,
})
}
export function newGameState(newState: GameState) {
setGameProps("gameState", newState)
setUsers(0, { isReady: false })
setUsers(1, { isReady: false })
}
export function setIsConnectedFor(props: { i: 0 | 1; isConnected: boolean }) {
setUsers(props.i, {
isConnected: props.isConnected,
})
if (props.isConnected) return
setUsers(props.i, {
isReady: false,
})
}
export function reset() {
setGameProps(initialGameProps)
setTarget(initlialTarget)
setTargetPreview(initlialTargetPreview)
setMouseCursor(initlialMouseCursor)
setUsersStore(initialUsers)
}

View file

@ -0,0 +1,126 @@
import { Session } from "@auth/core/types"
import { getSession } from "@auth/solid-start"
import { useIsRouting } from "@solidjs/router"
import status from "http-status"
import stringify from "json-stable-stringify"
import {
JSX,
createContext,
createEffect,
createResource,
createSignal,
useContext,
} from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { authOptions } from "~/server/auth"
import { gameProps, setGameProps, users } from "./useGameProps"
const [state, setState] = createSignal<Session | null | undefined>(undefined)
function selfIndex(): { i: 0 | 1 } | null {
switch (state()?.user?.id) {
case users[0]?.id:
return { i: 0 }
case users[1]?.id:
return { i: 1 }
default:
return null
}
}
const activeIndex = () => {
if (gameProps.gameState !== "running") return 0
const l1 = users[0]?.moves.length ?? 0
const l2 = users[1]?.moves.length ?? 0
return l1 > l2 ? 1 : 0
}
const selfIsActiveIndex = () => {
const sIndex = selfIndex()
return !!sIndex && activeIndex() === sIndex.i
}
const selfUser = () => {
const sIndex = selfIndex()
return sIndex ? users[sIndex.i] : null
}
/**
* It should be the opposite of `activeIndex`.
*
* This is because `activeIndex` is attacking the `enemyUser`.
*/
const enemyUser = () => users[activeIndex() === 0 ? 1 : 0]
const ships = () => selfUser()?.ships ?? []
const contextValue = {
session: state,
selfIndex,
activeIndex,
selfIsActiveIndex,
selfUser,
activeUser: enemyUser,
ships,
}
export const SessionCtx = createContext(contextValue)
export async function getSessionFromServer() {
"use server"
const event = getRequestEvent()
if (!event) return
const session = await getSession(event.request, authOptions)
if (session) return session
else return null
}
export function SessionProvider(props: { children: JSX.Element }) {
const [data, { refetch }] = createResource(() => getSessionFromServer())
const isRouting = useIsRouting()
createEffect(() => {
if (!isRouting()) return
refetch()
})
createEffect(() => {
const session = data()
const hashDiff = stringify(session) !== stringify(state())
if (!session || !hashDiff) return
console.log("Session updated.")
// @ts-ignore
setState(session)
})
createEffect(() => {
if (gameProps.gameState !== "running") return
const sIndex = selfIndex()
if (activeIndex() === sIndex?.i) {
setGameProps("menu", "moves")
setGameProps("mode", 0)
} else {
setGameProps("menu", "main")
setGameProps("mode", -1)
}
})
return (
<SessionCtx.Provider value={contextValue}>
{props.children}
</SessionCtx.Provider>
)
}
export const useSession = () => useContext(SessionCtx)
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
}

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