Compare commits
	
		
			11 Commits
		
	
	
		
			d1200eea74
			...
			AddGames
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 9e72f0cc9a | |||
| 6eaf1d6b9f | |||
| ce19237fd2 | |||
| 06d75f9d29 | |||
| dc195aecc3 | |||
| a591fe4157 | |||
| 9d0017a1b6 | |||
| 3f62ec6251 | |||
| 28cf5edc6a | |||
| 1e975f56b6 | |||
| 3c1a277b37 | 
							
								
								
									
										38
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										38
									
								
								.dockerignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,38 @@ | |||||||
|  | # flyctl launch added from .gitignore | ||||||
|  | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. | ||||||
|  |  | ||||||
|  | # dependencies | ||||||
|  | node_modules | ||||||
|  | .pnp | ||||||
|  | **/.pnp.js | ||||||
|  | **/.yarn/install-state.gz | ||||||
|  |  | ||||||
|  | # testing | ||||||
|  | coverage | ||||||
|  |  | ||||||
|  | # next.js | ||||||
|  | .next | ||||||
|  | out | ||||||
|  |  | ||||||
|  | # production | ||||||
|  | build | ||||||
|  |  | ||||||
|  | # misc | ||||||
|  | **/.DS_Store | ||||||
|  | **/*.pem | ||||||
|  |  | ||||||
|  | # debug | ||||||
|  | **/npm-debug.log* | ||||||
|  | **/yarn-debug.log* | ||||||
|  | **/yarn-error.log* | ||||||
|  |  | ||||||
|  | # local env files | ||||||
|  | **/.env*.local | ||||||
|  |  | ||||||
|  | # vercel | ||||||
|  | **/.vercel | ||||||
|  |  | ||||||
|  | # typescript | ||||||
|  | **/*.tsbuildinfo | ||||||
|  | **/next-env.d.ts | ||||||
|  | fly.toml | ||||||
| @@ -2,35 +2,50 @@ name: Build and deploy | |||||||
| run-name: Build and deploy | run-name: Build and deploy | ||||||
| on: | on: | ||||||
|   push: |   push: | ||||||
|     # branches: |     branches: | ||||||
|     #   - main |       - main | ||||||
|  |  | ||||||
| jobs: | jobs: | ||||||
|   deploy: |   deploy: | ||||||
|     runs-on: ubuntu-latest |     runs-on: ubuntu-latest | ||||||
|     steps: |     steps: | ||||||
|       - |       - name: Checkout  | ||||||
|         name: Checkout  |  | ||||||
|         uses: actions/checkout@v4 |         uses: actions/checkout@v4 | ||||||
|         with: |         with: | ||||||
|           github-server-url: 'https://gitea.home.joemonk.co.uk' |           github-server-url: 'https://gitea.home.joemonk.co.uk' | ||||||
|       - |  | ||||||
|         name: Set up docker |       - name: Set up docker | ||||||
|         run: 'curl -fsSL https://get.docker.com | sh' |         run: 'curl -fsSL https://get.docker.com | sh' | ||||||
|       - |  | ||||||
|         name: Set up Docker Buildx |       - name: Set up Docker Buildx | ||||||
|         uses: docker/setup-buildx-action@v3 |         uses: docker/setup-buildx-action@v3 | ||||||
|       -  |  | ||||||
|         name: Login to private registry |       - name: Login to gitea registry | ||||||
|         uses: docker/login-action@v3 |         uses: docker/login-action@v3 | ||||||
|         with: |         with: | ||||||
|           registry: 'gitea.home.joemonk.co.uk/${{ github.repository }}' |           registry: 'gitea.home.joemonk.co.uk/${{ github.repository }}' | ||||||
|           username: ${{ secrets.REGISTRY_USERNAME }} |           username: ${{ secrets.REGISTRY_USERNAME }} | ||||||
|           password: ${{ secrets.REGISTRY_PASSWORD }} |           password: ${{ secrets.REGISTRY_PASSWORD }} | ||||||
|       - |  | ||||||
|         name: Build and push |       - name: Login to flyio registry | ||||||
|  |         uses: docker/login-action@v3 | ||||||
|  |         with: | ||||||
|  |           registry: 'registry.fly.io' | ||||||
|  |           username: x | ||||||
|  |           password: ${{ secrets.FLY_API_TOKEN }} | ||||||
|  |  | ||||||
|  |       - name: Build and push | ||||||
|         uses: docker/build-push-action@v6 |         uses: docker/build-push-action@v6 | ||||||
|         with: |         with: | ||||||
|           context: . |           context: . | ||||||
|           push: true |           push: true | ||||||
|           tags: 'gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest' |           tags: | | ||||||
|  |             gitea.home.joemonk.co.uk/${{ gitea.repository }}:latest | ||||||
|  |             gitea.home.joemonk.co.uk/${{ gitea.repository }}:${{ gitea.sha }} | ||||||
|  |             registry.fly.io/joemonk:${{ gitea.sha }} | ||||||
|  |  | ||||||
|  |       - uses: superfly/flyctl-actions/setup-flyctl@master | ||||||
|  |  | ||||||
|  |       - run: flyctl deploy --remote-only -i registry.fly.io/joemonk:${{ gitea.sha }} | ||||||
|  |         env: | ||||||
|  |           FLY_API_TOKEN: ${{ secrets.FLY_API_TOKEN }} | ||||||
| @@ -15,7 +15,7 @@ WORKDIR /app | |||||||
| COPY --from=deps /app/node_modules ./node_modules | COPY --from=deps /app/node_modules ./node_modules | ||||||
| COPY . . | COPY . . | ||||||
|  |  | ||||||
| ENV NEXT_TELEMETRY_DISABLED 1 | ENV NEXT_TELEMETRY_DISABLED=1 | ||||||
|  |  | ||||||
| RUN npm run build | RUN npm run build | ||||||
|  |  | ||||||
| @@ -23,8 +23,8 @@ RUN npm run build | |||||||
| FROM base AS runner | FROM base AS runner | ||||||
| WORKDIR /app | WORKDIR /app | ||||||
|  |  | ||||||
| ENV NODE_ENV production | ENV NODE_ENV=production | ||||||
| ENV NEXT_TELEMETRY_DISABLED 1 | ENV NEXT_TELEMETRY_DISABLED=1 | ||||||
|  |  | ||||||
| RUN addgroup --system --gid 1001 nodejs | RUN addgroup --system --gid 1001 nodejs | ||||||
| RUN adduser --system --uid 1001 nextjs | RUN adduser --system --uid 1001 nextjs | ||||||
| @@ -39,12 +39,13 @@ RUN chown nextjs:nodejs .next | |||||||
| # https://nextjs.org/docs/advanced-features/output-file-tracing | # https://nextjs.org/docs/advanced-features/output-file-tracing | ||||||
| COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ | COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ | ||||||
| COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static | COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static | ||||||
|  | COPY --from=builder --chown=nextjs:nodejs /app/db.sql ./db.sql | ||||||
|  |  | ||||||
| USER nextjs | USER nextjs | ||||||
|  |  | ||||||
| EXPOSE 3000 | EXPOSE 3000 | ||||||
|  |  | ||||||
| ENV PORT 3000 | ENV PORT=3000 | ||||||
|  |  | ||||||
| # server.js is created by next build from the standalone output | # server.js is created by next build from the standalone output | ||||||
| # https://nextjs.org/docs/pages/api-reference/next-config-js/output | # https://nextjs.org/docs/pages/api-reference/next-config-js/output | ||||||
|   | |||||||
							
								
								
									
										10
									
								
								drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								drizzle.config.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | import { defineConfig } from 'drizzle-kit'; | ||||||
|  |  | ||||||
|  | export default defineConfig({ | ||||||
|  |   out: './drizzle', | ||||||
|  |   schema: './src/db/schema', | ||||||
|  |   dialect: 'sqlite', | ||||||
|  |   dbCredentials: { | ||||||
|  |     url: `${process.cwd()}/db.sql`, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
							
								
								
									
										28
									
								
								fly.toml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								fly.toml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,28 @@ | |||||||
|  | # fly.toml app configuration file generated for joemonk on 2024-11-13T18:49:43Z | ||||||
|  | # | ||||||
|  | # See https://fly.io/docs/reference/configuration/ for information about how to use this file. | ||||||
|  | # | ||||||
|  |  | ||||||
|  | app = 'joemonk' | ||||||
|  | primary_region = 'lhr' | ||||||
|  |  | ||||||
|  | [http_service] | ||||||
|  |   internal_port = 3000 | ||||||
|  |   force_https = true | ||||||
|  |   auto_stop_machines = 'stop' | ||||||
|  |   auto_start_machines = true | ||||||
|  |   min_machines_running = 1 | ||||||
|  |   processes = ["app"] | ||||||
|  |   [[http_service.checks]] | ||||||
|  |     grace_period = "30s" | ||||||
|  |     interval = "120s" | ||||||
|  |     method = "GET" | ||||||
|  |     timeout = "5s" | ||||||
|  |     path = "/api/status" | ||||||
|  |     protocol = "http" | ||||||
|  |  | ||||||
|  |  | ||||||
|  | [[vm]] | ||||||
|  |   memory = '1gb' | ||||||
|  |   cpu_kind = 'shared' | ||||||
|  |   cpus = 1 | ||||||
| @@ -6,16 +6,19 @@ const nextConfig = { | |||||||
|   pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], |   pageExtensions: ["js", "jsx", "md", "mdx", "ts", "tsx"], | ||||||
|   experimental: { |   experimental: { | ||||||
|     reactCompiler: true, |     reactCompiler: true, | ||||||
|     ppr: true, |     ppr: "incremental", | ||||||
|  |     turbo: { | ||||||
|  |        | ||||||
|  |     } | ||||||
|   }, |   }, | ||||||
|   serverExternalPackages: ["typeorm"], |   serverExternalPackages: ["typeorm", "better-sqlite3"], | ||||||
|   reactStrictMode: true, |   reactStrictMode: true, | ||||||
|   output: "standalone", |   output: "standalone", | ||||||
|   images: { |   images: { | ||||||
|     remotePatterns: [ |     remotePatterns: [ | ||||||
|       { |       { | ||||||
|         protocol: "https", |         protocol: "https", | ||||||
|         hostname: "fly.storage.tigris.dev" |         hostname: "fly.storage.tigris.dev", | ||||||
|       }, |       }, | ||||||
|     ], |     ], | ||||||
|   }, |   }, | ||||||
|   | |||||||
							
								
								
									
										9479
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										9479
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										80
									
								
								package.json
									
									
									
									
									
								
							
							
						
						
									
										80
									
								
								package.json
									
									
									
									
									
								
							| @@ -3,49 +3,67 @@ | |||||||
|   "version": "0.1.0", |   "version": "0.1.0", | ||||||
|   "private": true, |   "private": true, | ||||||
|   "scripts": { |   "scripts": { | ||||||
|     "dev": "next dev", |     "dev": "next dev --turbopack", | ||||||
|     "build": "next build", |     "build": "next build", | ||||||
|     "build:analyse": "ANALYZE=true npm run build", |     "build:analyse": "ANALYZE=true npm run build", | ||||||
|     "start": "next start", |     "start": "next start", | ||||||
|     "lint": "next lint", |     "lint": "next lint", | ||||||
|     "lint:fix": "next lint -- --fix" |     "lint:fix": "next lint --fix" | ||||||
|   }, |   }, | ||||||
|   "dependencies": { |   "dependencies": { | ||||||
|     "@aws-sdk/client-s3": "^3.663.0", |     "@aws-sdk/client-s3": "^3.750.0", | ||||||
|     "@heroicons/react": "^2.1.5", |     "@dimforge/rapier2d-compat": "^0.14.0", | ||||||
|     "@mdx-js/loader": "^3.0.1", |     "@heroicons/react": "^2.2.0", | ||||||
|     "@mdx-js/react": "^3.0.1", |     "@mdx-js/loader": "^3.1.0", | ||||||
|     "@next/bundle-analyzer": "^14.2.13", |     "@mdx-js/react": "^3.1.0", | ||||||
|     "@next/mdx": "^14.2.13", |     "@next/bundle-analyzer": "^15.1.7", | ||||||
|     "@tailwindcss/typography": "^0.5.15", |     "@next/mdx": "^15.1.7", | ||||||
|  |     "@pixi-essentials/object-pool": "^1.0.1", | ||||||
|  |     "@pixi/events": "^7.4.2", | ||||||
|  |     "@tailwindcss/postcss": "^4.0.8", | ||||||
|  |     "@tailwindcss/typography": "^0.5.16", | ||||||
|  |     "@tanstack/react-query": "^5.66.9", | ||||||
|  |     "@tanstack/react-virtual": "^3.13.0", | ||||||
|  |     "@trpc/client": "^11.0.0-rc.802", | ||||||
|  |     "@trpc/react-query": "^11.0.0-rc.802", | ||||||
|  |     "@trpc/server": "^11.0.0-rc.802", | ||||||
|  |     "@types/better-sqlite3": "^7.6.12", | ||||||
|     "@types/mdx": "^2.0.13", |     "@types/mdx": "^2.0.13", | ||||||
|     "@types/node": "^22.6.1", |     "@types/node": "^22.13.5", | ||||||
|     "@types/react": "^18.3.9", |     "@types/react": "^19.0.10", | ||||||
|     "@types/react-dom": "^18.3.0", |     "@types/react-dom": "^19.0.4", | ||||||
|     "@typescript-eslint/eslint-plugin": "^8.7.0", |     "@typescript-eslint/eslint-plugin": "^8.14.1", | ||||||
|     "autoprefixer": "^10.4.20", |     "autoprefixer": "^10.4.20", | ||||||
|     "babel-plugin-react-compiler": "^0.0.0-experimental-6067d4e-20240924", |     "babel-plugin-react-compiler": "beta", | ||||||
|     "better-sqlite3": "^9.6.0", |     "better-sqlite3": "^11.8.1", | ||||||
|     "eslint": "^9.11.1", |     "client-only": "^0.0.1", | ||||||
|     "eslint-config-next": "^15.0.0-rc.0", |     "drizzle-kit": "^0.30.4", | ||||||
|     "exif-reader": "^2.0.1", |     "drizzle-orm": "^0.39.3", | ||||||
|     "framer-motion": "^11.5.6", |     "eslint": "^9.21.0", | ||||||
|     "glob": "^11.0.0", |     "eslint-config-next": "^15.1.7", | ||||||
|  |     "eventemitter3": "^5.0.1", | ||||||
|  |     "exif-reader": "^2.0.2", | ||||||
|  |     "framer-motion": "^12.4.7", | ||||||
|  |     "glob": "^11.0.1", | ||||||
|     "million": "^3.1.11", |     "million": "^3.1.11", | ||||||
|     "next": "^15.0.0-rc.0", |     "next": "15.2.0-canary.69", | ||||||
|     "next-auth": "^5.0.0-beta", |     "next-auth": "5.0.0-beta.25", | ||||||
|     "postcss": "^8.4.47", |     "pixi-filters": "^6.1.0", | ||||||
|  |     "pixi-viewport": "^6.0.3", | ||||||
|  |     "pixi.js": "^8.8.0", | ||||||
|  |     "postcss": "^8.5.3", | ||||||
|     "radash": "^12.1.0", |     "radash": "^12.1.0", | ||||||
|     "react": "^19.0.0-rc-04bd67a4-20240924", |     "react": "19.0.0", | ||||||
|     "react-dom": "^19.0.0-rc-04bd67a4-20240924", |     "react-dom": "19.0.0", | ||||||
|     "react-zoom-pan-pinch": "^3.6.1", |     "react-zoom-pan-pinch": "^3.7.0", | ||||||
|     "reflect-metadata": "^0.2.2", |     "reflect-metadata": "^0.2.2", | ||||||
|     "server-only": "^0.0.1", |     "server-only": "^0.0.1", | ||||||
|     "sharp": "^0.33.5", |     "sharp": "^0.33.5", | ||||||
|     "tailwind-scrollbar": "^3.1.0", |     "superjson": "^2.2.2", | ||||||
|     "tailwindcss": "^3.4.13", |     "tailwind-scrollbar": "^4.0.0", | ||||||
|     "typeorm": "^0.3.20", |     "tailwindcss": "^4.0.8", | ||||||
|     "typescript": "^5.6.2", |     "typescript": "^5.7.3", | ||||||
|     "yet-another-react-lightbox": "^3.21.6" |     "yet-another-react-lightbox": "^3.21.7", | ||||||
|  |     "zod": "^3.24.2" | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,6 +1,3 @@ | |||||||
| module.exports = { | module.exports = { | ||||||
|   plugins: { |   plugins: ["@tailwindcss/postcss"], | ||||||
|     tailwindcss: {}, |  | ||||||
|     autoprefixer: {}, |  | ||||||
|   }, |  | ||||||
| }; | }; | ||||||
|   | |||||||
| @@ -1,16 +1,17 @@ | |||||||
| import { signIn } from "@/lib/auth" | import { signIn } from "@/lib/auth"; | ||||||
|  | import type React from "react"; | ||||||
|   |   | ||||||
| export default function Auth(props: { | export default function Auth(props: { | ||||||
|   searchParams: { callbackUrl: string | undefined } |   searchParams: Promise<{ callbackUrl: string | undefined }> | ||||||
| }) { | }): React.JSX.Element { | ||||||
|   return ( |   return ( | ||||||
|     <form |     <form | ||||||
|       className="w-40 mx-auto" |       className="w-40 mx-auto" | ||||||
|       action={async () => { |       action={async () => { | ||||||
|         "use server" |         "use server"; | ||||||
|         await signIn("authelia", { |         await signIn("authelia", { | ||||||
|           redirectTo: props.searchParams?.callbackUrl ?? "", |           redirectTo: (await props.searchParams)?.callbackUrl ?? "", | ||||||
|         }) |         }); | ||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|       <button type="submit" |       <button type="submit" | ||||||
| @@ -19,5 +20,5 @@ export default function Auth(props: { | |||||||
|         <span>Sign in with Authelia</span> |         <span>Sign in with Authelia</span> | ||||||
|       </button> |       </button> | ||||||
|     </form> |     </form> | ||||||
|   ) |   ); | ||||||
| } | } | ||||||
							
								
								
									
										9
									
								
								src/app/(root)/games/map/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/app/(root)/games/map/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | import Wrapper from "./wrapper"; | ||||||
|  |  | ||||||
|  | export default function Game(): React.JSX.Element { | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Wrapper/> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								src/app/(root)/games/map/wrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/app/(root)/games/map/wrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | "use client"; | ||||||
|  |  | ||||||
|  | import dynamic from "next/dynamic"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
|  | const MapGen = dynamic<Record<string, never>>(() => import('../../../../games/games/MapGen/MapGenWrapper').then((module) => module.default), { | ||||||
|  |   ssr: false, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default function Wrapper(): React.ReactNode { | ||||||
|  |   return ( | ||||||
|  |     <div className="flex flex-1"> | ||||||
|  |       <MapGen/> | ||||||
|  |     </div> | ||||||
|  |   ) | ||||||
|  | } | ||||||
							
								
								
									
										9
									
								
								src/app/(root)/games/player/page.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/app/(root)/games/player/page.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | import Wrapper from "./wrapper"; | ||||||
|  |  | ||||||
|  | export default function Game(): React.JSX.Element { | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <Wrapper/> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										16
									
								
								src/app/(root)/games/player/wrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								src/app/(root)/games/player/wrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,16 @@ | |||||||
|  | "use client"; | ||||||
|  |  | ||||||
|  | import dynamic from "next/dynamic"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
|  | const TestGame = dynamic<Record<string, never>>(() => import('../../../../games/games/TestGame/TestGameWrapper').then((module) => module.default), { | ||||||
|  |   ssr: false, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export default function Wrapper(): React.ReactNode { | ||||||
|  |   return ( | ||||||
|  |     <div className="flex flex-1"> | ||||||
|  |       <TestGame/> | ||||||
|  |     </div> | ||||||
|  |   ) | ||||||
|  | } | ||||||
| @@ -1,8 +1,5 @@ | |||||||
| import { SessionProvider } from "next-auth/react"; |  | ||||||
|  |  | ||||||
| import NavBar from '@/components/navbar'; | import NavBar from '@/components/navbar'; | ||||||
| import Footer from '@/components/footer'; | import Footer from '@/components/footer'; | ||||||
| import LogIn from "@/components/auth/login"; |  | ||||||
|  |  | ||||||
| import "../globals.css"; | import "../globals.css"; | ||||||
|  |  | ||||||
|   | |||||||
| @@ -1,18 +1,16 @@ | |||||||
| import Image from "next/image"; | import Image from "next/image"; | ||||||
| import Lightbox from "@/components/lightbox"; | import FilteredLightbox from "@/components/lightbox"; | ||||||
| import { type GetPhotos } from "@/app/api/photos/route"; | import { trpc } from "@/trpc/server"; | ||||||
|  | import { TRPCProvider } from "@/trpc/client"; | ||||||
| async function getImageData(): Promise<GetPhotos> { |  | ||||||
|   const res = await fetch(`http://localhost:3000/api/photos`, { next: { revalidate: false, tags: ['photos'] } }); |  | ||||||
|   return res.json() as Promise<GetPhotos>; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export default async function Photos(): Promise<React.JSX.Element> { | export default async function Photos(): Promise<React.JSX.Element> { | ||||||
|   const {data: imageData} = await getImageData(); |   const { data: images } = await trpc.photos.list(); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <Lightbox imageData={imageData.images}> |     <div className="mx-auto"> | ||||||
|       {imageData.images.map((image) => ( |       <TRPCProvider> | ||||||
|  |         <FilteredLightbox imageData={images}> | ||||||
|  |           {images.map((image) => ( | ||||||
|             <Image |             <Image | ||||||
|               key={image.src} |               key={image.src} | ||||||
|               alt={image.src} |               alt={image.src} | ||||||
| @@ -26,6 +24,8 @@ export default async function Photos(): Promise<React.JSX.Element> { | |||||||
|               placeholder="blur" |               placeholder="blur" | ||||||
|             /> |             /> | ||||||
|           ))} |           ))} | ||||||
|     </Lightbox> |         </FilteredLightbox> | ||||||
|  |       </TRPCProvider> | ||||||
|  |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,24 +1,32 @@ | |||||||
| import { glob } from "glob"; | import { glob } from "glob"; | ||||||
| import dynamic from "next/dynamic"; | import dynamic, { LoaderComponent } from "next/dynamic"; | ||||||
|  | import React from "react"; | ||||||
|  |  | ||||||
| export const dynamicParams = false; | export const dynamicParams = false; | ||||||
|  |  | ||||||
| export async function generateStaticParams(): Promise<{ slug: string[] }[]> { | export async function generateStaticParams(): Promise<{ slug: string[] }[]> { | ||||||
|   const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, { |   const posts = await glob( | ||||||
|  |     `${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, | ||||||
|  |     { | ||||||
|       nodir: true, |       nodir: true, | ||||||
|   }); |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const slugs = posts.map((post) => ({ |   const slugs = posts.map((post) => ({ | ||||||
|     slug: [post.split('/').at(-1)!.slice(0, -4)] |     slug: [post.split("/").at(-1)!.slice(0, -4)], | ||||||
|   })); |   })); | ||||||
|  |  | ||||||
|   return slugs; |   return slugs; | ||||||
| } | } | ||||||
|  |  | ||||||
| export default async function Post({params}: {params: { slug: string[] }}): Promise<React.JSX.Element> { | export default async function Post({ | ||||||
|   const mdxFile = await import(`../../../../markdown/posts/[...slug]/${params.slug.join('/')}.mdx`) |   params, | ||||||
|   const Post = dynamic(async () => mdxFile); | }: { | ||||||
|   return ( |   params: Promise<{ slug: string[] }>; | ||||||
|     <Post/> | }): Promise<React.JSX.Element> { | ||||||
|   ); |   const mdxFile = await import( | ||||||
|  |     `../../../../markdown/posts/[...slug]/${(await params).slug.join("/")}.mdx` | ||||||
|  |   ) as LoaderComponent<unknown>; | ||||||
|  |   const Post = dynamic(() => mdxFile); | ||||||
|  |   return <Post />; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -4,42 +4,43 @@ import { unstable_cache } from "next/cache"; | |||||||
| import Link from "next/link"; | import Link from "next/link"; | ||||||
|  |  | ||||||
| type postDetails = { | type postDetails = { | ||||||
|   link: string, |   link: string; | ||||||
|   metadata: { |   metadata: { | ||||||
|     title: string, |     title: string; | ||||||
|     date: string, |     date: string; | ||||||
|     coverImage: string, |     coverImage: string; | ||||||
|     blurb: string, |     blurb: string; | ||||||
|     shortBlurb: string, |     shortBlurb: string; | ||||||
|     tags: string[] |     tags: string[]; | ||||||
|   } |   }; | ||||||
| } | }; | ||||||
|  |  | ||||||
| async function loadPostDetails(): Promise<postDetails[]> { | async function loadPostDetails(): Promise<postDetails[]> { | ||||||
|   const posts = await glob(`${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, { |   const posts = await glob( | ||||||
|  |     `${process.cwd()}/src/markdown/posts/[[]...slug[]]/**/*.mdx`, | ||||||
|  |     { | ||||||
|       nodir: true, |       nodir: true, | ||||||
|   }); |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|   const loadPostData = posts.map(async (post) => { |   const loadPostData = posts.map(async (post) => { | ||||||
|     const slug = [post.split('/').at(-1)!.slice(0, -4)] |     const slug = [post.split("/").at(-1)!.slice(0, -4)]; | ||||||
|     const mdxFile = await import(`../../../../src/markdown/posts/[...slug]/${slug.join('/')}.mdx`) |     const mdxFile = await import( | ||||||
|  |       `../../../../src/markdown/posts/[...slug]/${slug.join("/")}.mdx` | ||||||
|  |     ) as postDetails; | ||||||
|     return { |     return { | ||||||
|  |       link: getCurrentUrl() + "/posts/" + slug.join("/"), | ||||||
|       metadata: mdxFile.metadata, |       metadata: mdxFile.metadata, | ||||||
|       link: getCurrentUrl() + '/posts/' + slug.join('/') |     }; | ||||||
|     } |  | ||||||
|   }); |   }); | ||||||
|  |  | ||||||
|   const postData = await Promise.all(loadPostData); |   const postData = await Promise.all(loadPostData); | ||||||
|   return postData; |   return postData; | ||||||
| } | } | ||||||
|  |  | ||||||
| const getPosts = unstable_cache( | const getPosts = unstable_cache(loadPostDetails, ["posts"], { | ||||||
|   loadPostDetails, |   revalidate: false, | ||||||
|   ['posts'], | }); | ||||||
|   { |  | ||||||
|     revalidate: false |  | ||||||
|   } |  | ||||||
| ) |  | ||||||
|  |  | ||||||
| export default async function Posts(): Promise<React.JSX.Element> { | export default async function Posts(): Promise<React.JSX.Element> { | ||||||
|   const postDetails = await getPosts(); |   const postDetails = await getPosts(); | ||||||
| @@ -56,17 +57,17 @@ export default async function Posts(): Promise<React.JSX.Element> { | |||||||
|                 {post.metadata.tags.map((tag) => { |                 {post.metadata.tags.map((tag) => { | ||||||
|                   return ( |                   return ( | ||||||
|                     <div key={`${post.link}_${tag}`}> |                     <div key={`${post.link}_${tag}`}> | ||||||
|                       <span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span> |                       <span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink"> | ||||||
|  |                         {tag} | ||||||
|  |                       </span> | ||||||
|                     </div> |                     </div> | ||||||
|                   ) |                   ); | ||||||
|                 })} |                 })} | ||||||
|               </div> |               </div> | ||||||
|               <p> |               <p>{post.metadata.blurb}</p> | ||||||
|                 {post.metadata.blurb} |  | ||||||
|               </p> |  | ||||||
|             </div> |             </div> | ||||||
|           </div> |           </div> | ||||||
|         ) |         ); | ||||||
|       })} |       })} | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -2,21 +2,21 @@ import { NextRequest } from "next/server"; | |||||||
| import { handlers } from "@/lib/auth"; | import { handlers } from "@/lib/auth"; | ||||||
|  |  | ||||||
| const reqWithTrustedOrigin = (req: NextRequest): NextRequest => { | const reqWithTrustedOrigin = (req: NextRequest): NextRequest => { | ||||||
|   const proto = req.headers.get('x-forwarded-proto') |   const proto = req.headers.get('x-forwarded-proto'); | ||||||
|   const host = req.headers.get('x-forwarded-host') |   const host = req.headers.get('x-forwarded-host'); | ||||||
|   if (!proto || !host) { |   if (!proto || !host) { | ||||||
|     console.warn("Missing x-forwarded-proto or x-forwarded-host headers.") |     console.warn("Missing x-forwarded-proto or x-forwarded-host headers."); | ||||||
|     return req |     return req; | ||||||
|   } |  | ||||||
|   const envOrigin = `${proto}://${host}` |  | ||||||
|   const { href, origin } = req.nextUrl |  | ||||||
|   return new NextRequest(href.replace(origin, envOrigin), req) |  | ||||||
|   } |   } | ||||||
|  |   const envOrigin = `${proto}://${host}`; | ||||||
|  |   const { href, origin } = req.nextUrl; | ||||||
|  |   return new NextRequest(href.replace(origin, envOrigin), req); | ||||||
|  | }; | ||||||
|  |  | ||||||
| export const GET = (req: NextRequest) => { | export const GET = (req: NextRequest): Promise<Response> => { | ||||||
|   return handlers.GET(reqWithTrustedOrigin(req)) |   return handlers.GET(reqWithTrustedOrigin(req)); | ||||||
| } | }; | ||||||
|  |  | ||||||
| export const POST = (req: NextRequest) => { | export const POST = (req: NextRequest): Promise<Response> => { | ||||||
|   return handlers.POST(reqWithTrustedOrigin(req)) |   return handlers.POST(reqWithTrustedOrigin(req)); | ||||||
| } | }; | ||||||
| @@ -1,86 +0,0 @@ | |||||||
| import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3"; |  | ||||||
| import exifReader from "exif-reader"; |  | ||||||
| import { NextResponse } from "next/server"; |  | ||||||
| import { diff, sift } from "radash"; |  | ||||||
| import sharp from "sharp"; |  | ||||||
|  |  | ||||||
| import PhotoDataSource from "@/data-source"; |  | ||||||
| import { Photo } from "@/entity/photo"; |  | ||||||
| import { auth } from "@/lib/auth"; |  | ||||||
|  |  | ||||||
| export type GetPhotosUpdate = { |  | ||||||
|   status: number, |  | ||||||
|   s3Photos: string[] |  | ||||||
| } |  | ||||||
|  |  | ||||||
| export const GET = auth(async function GET(req): Promise<Response> { |  | ||||||
|   if (!req.auth) { |  | ||||||
|     return NextResponse.json({ message: "Not authenticated" }, { status: 401 }) |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   const dataSource = await PhotoDataSource.dataSource; |  | ||||||
|   const photoRepository = dataSource.getRepository(Photo); |  | ||||||
|   const currentSources = (await photoRepository.find({ |  | ||||||
|     select: { |  | ||||||
|       src: true |  | ||||||
|     } |  | ||||||
|   })).map((photo) => photo.src); |  | ||||||
|  |  | ||||||
|   const s3Client = new S3Client(); |  | ||||||
|  |  | ||||||
|   const listObjCmd = new ListObjectsV2Command({ |  | ||||||
|     Bucket: "joemonk-photos" |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   const s3Res = await s3Client.send(listObjCmd); |  | ||||||
|  |  | ||||||
|   if (!s3Res.Contents) { |  | ||||||
|     return NextResponse.json({ status: 500 }) |  | ||||||
|   } |  | ||||||
|   const s3Photos = sift(s3Res.Contents.map((obj) => { |  | ||||||
|     if (!obj.Key?.endsWith('/')) { |  | ||||||
|       return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`; |  | ||||||
|     } else { |  | ||||||
|       return null; |  | ||||||
|     } |  | ||||||
|   })); |  | ||||||
|  |  | ||||||
|   const newPhotos = diff(s3Photos, currentSources); |  | ||||||
|  |  | ||||||
|   const imageData = newPhotos.map(async (fileName: string) => { |  | ||||||
|     const getImageCmd = new GetObjectCommand({ |  | ||||||
|       Bucket: "joemonk-photos", |  | ||||||
|       Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", "") |  | ||||||
|     }) |  | ||||||
|     const imgRes = await s3Client.send(getImageCmd); |  | ||||||
|     const image = await imgRes.Body?.transformToByteArray(); |  | ||||||
|  |  | ||||||
|     const { width, height, exif } = await sharp(image).metadata(); |  | ||||||
|     const blur = await sharp(image) |  | ||||||
|       .resize({ width: 12, height: 12, fit: 'inside' }) |  | ||||||
|       .toBuffer(); |  | ||||||
|     const exifData = exif ? exifReader(exif) : undefined; |  | ||||||
|  |  | ||||||
|     const photo = new Photo(); |  | ||||||
|     photo.src = fileName; |  | ||||||
|     photo.width = width ?? 10; |  | ||||||
|     photo.height = height ?? 10; |  | ||||||
|     photo.blur = `data:image/jpeg;base64,${blur.toString('base64')}` as `data:image/${string}`; |  | ||||||
|     photo.camera = exifData?.Image?.Model ?? null; |  | ||||||
|  |  | ||||||
|     photo.exposureBiasValue = exifData?.Photo?.ExposureBiasValue ?? null; |  | ||||||
|     photo.fNumber = exifData?.Photo?.FNumber ?? null; |  | ||||||
|     photo.isoSpeedRatings = exifData?.Photo?.ISOSpeedRatings ?? null; |  | ||||||
|     photo.focalLength = exifData?.Photo?.FocalLength ?? null; |  | ||||||
|     photo.dateTimeOriginal = exifData?.Photo?.DateTimeOriginal ?? null; |  | ||||||
|     photo.lensModel = exifData?.Photo?.LensModel ?? null; |  | ||||||
|  |  | ||||||
|     return photo; |  | ||||||
|   }); |  | ||||||
|  |  | ||||||
|   const images = await Promise.all(imageData); |  | ||||||
|  |  | ||||||
|   await photoRepository.save(images); |  | ||||||
|  |  | ||||||
|   return NextResponse.json<GetPhotosUpdate>({ status: 200, s3Photos: newPhotos }); |  | ||||||
| }); |  | ||||||
							
								
								
									
										11
									
								
								src/app/api/trpc/[trpc]/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/app/api/trpc/[trpc]/route.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | import { fetchRequestHandler } from '@trpc/server/adapters/fetch'; | ||||||
|  | import { createTRPCContext } from '@/trpc/init'; | ||||||
|  | import { appRouter } from '@/trpc/routers/_app'; | ||||||
|  | const handler = (req: Request): Promise<Response> => | ||||||
|  |   fetchRequestHandler({ | ||||||
|  |     endpoint: '/api/trpc', | ||||||
|  |     req, | ||||||
|  |     router: appRouter, | ||||||
|  |     createContext: createTRPCContext, | ||||||
|  |   }); | ||||||
|  | export { handler as GET, handler as POST }; | ||||||
| @@ -1,6 +1,4 @@ | |||||||
| @tailwind base; | @import "tailwindcss"; | ||||||
| @tailwind components; |  | ||||||
| @tailwind utilities; |  | ||||||
|  |  | ||||||
| @layer utilities { | @layer utilities { | ||||||
|   .text-balance { |   .text-balance { | ||||||
|   | |||||||
| @@ -8,13 +8,13 @@ export default async function LogIn(): Promise<React.JSX.Element | undefined> { | |||||||
|   return ( |   return ( | ||||||
|     <form |     <form | ||||||
|       action={async () => { |       action={async () => { | ||||||
|         "use server" |         "use server"; | ||||||
|         if (session?.user) { |         if (session?.user) { | ||||||
|           await signOut({ |           await signOut({ | ||||||
|             redirectTo: `${getCurrentUrl()}/` |             redirectTo: `${getCurrentUrl()}/` | ||||||
|           }) |           }); | ||||||
|         } else { |         } else { | ||||||
|           await signIn("authelia") |           await signIn("authelia"); | ||||||
|         } |         } | ||||||
|       }} |       }} | ||||||
|     > |     > | ||||||
|   | |||||||
| @@ -15,9 +15,22 @@ import "yet-another-react-lightbox/styles.css"; | |||||||
| import "yet-another-react-lightbox/plugins/thumbnails.css"; | import "yet-another-react-lightbox/plugins/thumbnails.css"; | ||||||
| import "yet-another-react-lightbox/plugins/captions.css"; | import "yet-another-react-lightbox/plugins/captions.css"; | ||||||
|  |  | ||||||
| import { type ImageData } from "@/app/api/photos/route"; | import type { RouterOutput } from "@/trpc/routers/_app"; | ||||||
|  | import { trpc } from "@/trpc/client"; | ||||||
|  |  | ||||||
| function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: ImageData, offset: number, rect: {width: number, height: number}, unoptimized: boolean}): React.JSX.Element { | type ImageData = RouterOutput["photos"]["list"]["data"][number]; | ||||||
|  |  | ||||||
|  | function NextJsImage({ | ||||||
|  |   slide, | ||||||
|  |   offset, | ||||||
|  |   rect, | ||||||
|  |   unoptimized = false, | ||||||
|  | }: { | ||||||
|  |   slide: ImageData; | ||||||
|  |   offset: number; | ||||||
|  |   rect: { width: number; height: number }; | ||||||
|  |   unoptimized: boolean; | ||||||
|  | }): React.JSX.Element { | ||||||
|   const { |   const { | ||||||
|     on: { click }, |     on: { click }, | ||||||
|     carousel: { imageFit }, |     carousel: { imageFit }, | ||||||
| @@ -29,13 +42,13 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image | |||||||
|  |  | ||||||
|   const width = !cover |   const width = !cover | ||||||
|     ? Math.round( |     ? Math.round( | ||||||
|       Math.min(rect.width, (rect.height / slide.height) * slide.width), |       Math.min(rect.width, (rect.height / slide.height) * slide.width) | ||||||
|     ) |     ) | ||||||
|     : rect.width; |     : rect.width; | ||||||
|  |  | ||||||
|   const height = !cover |   const height = !cover | ||||||
|     ? Math.round( |     ? Math.round( | ||||||
|       Math.min(rect.height, (rect.width / slide.width) * slide.height), |       Math.min(rect.height, (rect.width / slide.width) * slide.height) | ||||||
|     ) |     ) | ||||||
|     : rect.height; |     : rect.height; | ||||||
|  |  | ||||||
| @@ -56,38 +69,141 @@ function NextJsImage({ slide, offset, rect, unoptimized = false }: {slide: Image | |||||||
|         }} |         }} | ||||||
|         sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`} |         sizes={`${Math.ceil((width / window.innerWidth) * 100)}vw`} | ||||||
|         onClick={ |         onClick={ | ||||||
|           offset === 0 ? (): void => click?.({ index: currentIndex }) : undefined |           offset === 0 | ||||||
|  |             ? (): void => click?.({ index: currentIndex }) | ||||||
|  |             : undefined | ||||||
|         } |         } | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
| export default function MyLightbox({imageData, children}: {imageData: ImageData[], children: React.JSX.Element[]}): React.JSX.Element { | export function Lightbox({ | ||||||
|  |   imageData, | ||||||
|  |   children, | ||||||
|  | }: { | ||||||
|  |   imageData: ImageData[]; | ||||||
|  |   children: React.JSX.Element[]; | ||||||
|  | }): React.JSX.Element { | ||||||
|   const [active, setActive] = useState<number | null>(null); |   const [active, setActive] = useState<number | null>(null); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <div className="mx-auto"> |     <div className="mx-auto"> | ||||||
|       <div className="flex flex-row flex-wrap justify-center"> |       <div className="flex flex-row flex-wrap justify-center"> | ||||||
|         {children.map((image, index) => ( |         {children.map((image, index) => { | ||||||
|           <button key={`lightbox_img_${index}`} onClick={(() => { |           return ( | ||||||
|  |             <button | ||||||
|  |               key={`lightbox_img_${index}`} | ||||||
|  |               onClick={() => { | ||||||
|                 setActive(index); |                 setActive(index); | ||||||
|           })}> |               }} | ||||||
|             <div className="relative"> |             > | ||||||
|               {image} |               <div className="relative">{image}</div> | ||||||
|             </div> |  | ||||||
|             </button> |             </button> | ||||||
|         ))} |           ); | ||||||
|  |         })} | ||||||
|       </div> |       </div> | ||||||
|       <YARL |       <YARL | ||||||
|         open={typeof active === 'number'} |         open={typeof active === "number"} | ||||||
|         close={() => setActive(null)} |         close={() => setActive(null)} | ||||||
|         index={active ?? undefined} |         index={active ?? undefined} | ||||||
|         slides={imageData} |         slides={imageData} | ||||||
|  |         render={{ | ||||||
|  |           // @ts-expect-error - Todo | ||||||
|  |           slide: (args) => NextJsImage({ ...args, unoptimized: true }), | ||||||
|           // @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type |           // @ts-expect-error - Todo - This just passes the slide through, but it doesn't know the type | ||||||
|         render={{ slide: (args) => NextJsImage({...args, unoptimized: true }), thumbnail: NextJsImage }} |           thumbnail: NextJsImage, | ||||||
|  |         }} | ||||||
|         plugins={[Thumbnails, Zoom, Captions]} |         plugins={[Thumbnails, Zoom, Captions]} | ||||||
|       /> |       /> | ||||||
|     </div> |     </div> | ||||||
|   ); |   ); | ||||||
| } | } | ||||||
|  |  | ||||||
|  | interface FormElements extends HTMLFormControlsCollection { | ||||||
|  |   src: HTMLInputElement; | ||||||
|  | } | ||||||
|  | interface UsernameFormElement extends HTMLFormElement { | ||||||
|  |   readonly elements: FormElements; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default function FilteredLightbox(props: { | ||||||
|  |   imageData: ImageData[]; | ||||||
|  |   children: React.JSX.Element[]; | ||||||
|  | }): React.JSX.Element { | ||||||
|  |   //const [imageData, setImageData] = useState(props.imageData); | ||||||
|  |   const [imageData] = useState(props.imageData); | ||||||
|  |   const photoQuery = trpc.photos.list.useInfiniteQuery( | ||||||
|  |     { | ||||||
|  |       limit: 1, | ||||||
|  |     }, | ||||||
|  |     { | ||||||
|  |       initialData: { | ||||||
|  |         pages: [ | ||||||
|  |           { | ||||||
|  |             data: props.imageData, | ||||||
|  |             next: props.imageData.length, | ||||||
|  |           }, | ||||||
|  |         ], | ||||||
|  |         pageParams: [0], | ||||||
|  |       }, | ||||||
|  |       getNextPageParam: (lastPage) => lastPage.next, | ||||||
|  |     } | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   const refreshQuery = trpc.photos.update.useQuery(undefined, { | ||||||
|  |     enabled: false, | ||||||
|  |     retry: false, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   function handleSubmit(event: React.FormEvent<UsernameFormElement>): void { | ||||||
|  |     event.preventDefault(); | ||||||
|  |     // const imageData = props.imageData; | ||||||
|  |     // setImageData( | ||||||
|  |     //   imageData.filter( | ||||||
|  |     //     (data) => data.src === event.currentTarget.elements.src.value | ||||||
|  |     //   ) | ||||||
|  |     // ); | ||||||
|  |     void photoQuery.fetchNextPage(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const children = photoQuery.data.pages | ||||||
|  |     .flatMap((data) => data.data) | ||||||
|  |     .map((data) => ( | ||||||
|  |       <Image | ||||||
|  |         key={data.src} | ||||||
|  |         alt={data.src} | ||||||
|  |         src={data.src} | ||||||
|  |         className="object-contain h-60 w-80" | ||||||
|  |         sizes="100vw" | ||||||
|  |         loading="lazy" | ||||||
|  |         width={data.width} | ||||||
|  |         height={data.height} | ||||||
|  |         blurDataURL={data.blur} | ||||||
|  |         placeholder="blur" | ||||||
|  |       /> | ||||||
|  |     )) | ||||||
|  |     .filter((data) => !!data); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <> | ||||||
|  |       <form onSubmit={handleSubmit}> | ||||||
|  |         <div> | ||||||
|  |           <label htmlFor="src">Src:</label> | ||||||
|  |           <input id="src" type="text" /> | ||||||
|  |         </div> | ||||||
|  |         <button type="submit">Submit</button> | ||||||
|  |       </form> | ||||||
|  |       <button | ||||||
|  |         onClick={() => { | ||||||
|  |           void refreshQuery.refetch(); | ||||||
|  |         }} | ||||||
|  |       > | ||||||
|  |         Refresh | ||||||
|  |       </button> | ||||||
|  |       {refreshQuery.data ? JSON.stringify(refreshQuery.data) : "\nNot"} | ||||||
|  |       {refreshQuery.error ? JSON.stringify(refreshQuery.error) : "\nNo Error"} | ||||||
|  |       <Lightbox imageData={imageData}>{...children}</Lightbox> | ||||||
|  |     </> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -1,21 +1,33 @@ | |||||||
| 'use client'; | "use client"; | ||||||
| import { useMemo, useState } from 'react'; | import { useMemo, useState } from "react"; | ||||||
| import Link from 'next/link'; | import Link from "next/link"; | ||||||
| import { HomeModernIcon, Bars3Icon, XMarkIcon } from '@heroicons/react/24/outline'; | import { | ||||||
| import { AnimatePresence, m, LazyMotion, domAnimation } from "framer-motion"; |   HomeModernIcon, | ||||||
| import { usePathname } from 'next/navigation'; |   Bars3Icon, | ||||||
| import ThemeSwitcher from './theme-switcher'; |   XMarkIcon, | ||||||
|  | } from "@heroicons/react/24/outline"; | ||||||
|  | import { | ||||||
|  |   AnimatePresence, | ||||||
|  |   motion, | ||||||
|  |   LazyMotion, | ||||||
|  |   domAnimation, | ||||||
|  | } from "framer-motion"; | ||||||
|  | import { usePathname } from "next/navigation"; | ||||||
|  | import ThemeSwitcher from "./theme-switcher"; | ||||||
|  |  | ||||||
| type NavBarClientProps = { | type NavBarClientProps = { | ||||||
|   LogIn: React.JSX.Element, |   LogIn: React.JSX.Element; | ||||||
|   navigation: { |   navigation: { | ||||||
|     name: string; |     name: string; | ||||||
|     href: string; |     href: string; | ||||||
|     current: boolean; |     current: boolean; | ||||||
|   }[] |   }[]; | ||||||
| } | }; | ||||||
|  |  | ||||||
| export default function NavBarClient({LogIn, navigation}: NavBarClientProps): React.JSX.Element { | export default function NavBarClient({ | ||||||
|  |   LogIn, | ||||||
|  |   navigation, | ||||||
|  | }: NavBarClientProps): React.JSX.Element { | ||||||
|   const [open, setOpen] = useState(false); |   const [open, setOpen] = useState(false); | ||||||
|   const pathname = usePathname(); |   const pathname = usePathname(); | ||||||
|  |  | ||||||
| @@ -27,7 +39,7 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re | |||||||
|       current.current = true; |       current.current = true; | ||||||
|     } |     } | ||||||
|     return nav; |     return nav; | ||||||
|   }, [pathname]); |   }, [pathname, navigation]); | ||||||
|  |  | ||||||
|   return ( |   return ( | ||||||
|     <nav className="dark:bg-dracula-bg-darker border-b-2 dark:border-dracula-purple"> |     <nav className="dark:bg-dracula-bg-darker border-b-2 dark:border-dracula-purple"> | ||||||
| @@ -35,32 +47,38 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re | |||||||
|         <div className="mx-auto max-w-7xl px-4"> |         <div className="mx-auto max-w-7xl px-4"> | ||||||
|           <div className="relative flex h-16 items-center justify-between"> |           <div className="relative flex h-16 items-center justify-between"> | ||||||
|             <div className="flex"> |             <div className="flex"> | ||||||
|               <button className='sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1' onClick={() => setOpen(!open)}> |               <button | ||||||
|  |                 className="sm:hidden dark:hover:bg-dracula-bg-light transition-colors duration-100 rounded-sm p-1" | ||||||
|  |                 onClick={() => setOpen(!open)} | ||||||
|  |               > | ||||||
|                 {open ? ( |                 {open ? ( | ||||||
|                   <XMarkIcon className='rounded-sm dark:stroke-dracula-cyan h-8 w-auto'/> |                   <XMarkIcon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" /> | ||||||
|                 ) : ( |                 ) : ( | ||||||
|                   <Bars3Icon className='rounded-sm dark:stroke-dracula-cyan h-8 w-auto'/> |                   <Bars3Icon className="rounded-sm dark:stroke-dracula-cyan h-8 w-auto" /> | ||||||
|                 )} |                 )} | ||||||
|               </button> |               </button> | ||||||
|               <Link className='hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors' href='/'> |               <Link | ||||||
|                 <HomeModernIcon className='dark:stroke-dracula-cyan rounded-sm h-8 w-auto'/> |                 className="hidden sm:flex items-center p-1 dark:hover:bg-dracula-bg-light transition-colors" | ||||||
|  |                 href="/" | ||||||
|  |               > | ||||||
|  |                 <HomeModernIcon className="dark:stroke-dracula-cyan rounded-sm h-8 w-auto" /> | ||||||
|               </Link> |               </Link> | ||||||
|               <div className='space-x-5 hidden sm:flex ml-10'> |               <div className="space-x-5 hidden sm:flex ml-10"> | ||||||
|                 {activeNavigation.map((item) => ( |                 {activeNavigation.map((item) => ( | ||||||
|                   <Link |                   <Link | ||||||
|                     key={item.name} |                     key={item.name} | ||||||
|                     href={item.href} |                     href={item.href} | ||||||
|                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${ |                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white rounded-sm px-3 pt-2 pb-1.5 font-normal border-b-2 border-transparent ${ | ||||||
|                       item.current ? 'dark:border-b-dracula-pink' : '' |                       item.current ? "dark:border-b-dracula-pink" : "" | ||||||
|                     }`} |                     }`} | ||||||
|                     aria-current={item.current ? 'page' : undefined} |                     aria-current={item.current ? "page" : undefined} | ||||||
|                   > |                   > | ||||||
|                     {item.name} |                     {item.name} | ||||||
|                   </Link> |                   </Link> | ||||||
|                 ))} |                 ))} | ||||||
|               </div> |               </div> | ||||||
|             </div> |             </div> | ||||||
|             <div className='space-x-4 flex'> |             <div className="space-x-4 flex"> | ||||||
|               <ThemeSwitcher /> |               <ThemeSwitcher /> | ||||||
|               {LogIn} |               {LogIn} | ||||||
|             </div> |             </div> | ||||||
| @@ -68,28 +86,28 @@ export default function NavBarClient({LogIn, navigation}: NavBarClientProps): Re | |||||||
|         </div> |         </div> | ||||||
|         <AnimatePresence> |         <AnimatePresence> | ||||||
|           {open ? ( |           {open ? ( | ||||||
|             <m.div |             <motion.div | ||||||
|               initial={{ height: 0 }} |               initial={{ height: 0 }} | ||||||
|               animate={{ height: "auto" }} |               animate={{ height: "auto" }} | ||||||
|               transition={{ duration: 0.15, ease: 'linear' }} |               transition={{ duration: 0.15, ease: "linear" }} | ||||||
|               exit={{ height: 0 }} |               exit={{ height: 0 }} | ||||||
|               className='sm:hidden overflow-hidden' |               className="sm:hidden overflow-hidden" | ||||||
|             > |             > | ||||||
|               <div className='flex flex-col space-y-1 py-1'> |               <div className="flex flex-col space-y-1 py-1"> | ||||||
|                 {activeNavigation.map((item) => ( |                 {activeNavigation.map((item) => ( | ||||||
|                   <Link |                   <Link | ||||||
|                     key={item.name} |                     key={item.name} | ||||||
|                     href={item.href} |                     href={item.href} | ||||||
|                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${ |                     className={`dark:hover:bg-dracula-bg-light transition-colors duration-100 dark:text-white px-2 py-2 font-normal border-l-4 border-transparent ${ | ||||||
|                       item.current ? 'dark:border-l-dracula-pink' : '' |                       item.current ? "dark:border-l-dracula-pink" : "" | ||||||
|                     }`} |                     }`} | ||||||
|                     aria-current={item.current ? 'page' : undefined} |                     aria-current={item.current ? "page" : undefined} | ||||||
|                   > |                   > | ||||||
|                     {item.name} |                     {item.name} | ||||||
|                   </Link> |                   </Link> | ||||||
|                 ))} |                 ))} | ||||||
|               </div> |               </div> | ||||||
|             </m.div> |             </motion.div> | ||||||
|           ) : null} |           ) : null} | ||||||
|         </AnimatePresence> |         </AnimatePresence> | ||||||
|       </LazyMotion> |       </LazyMotion> | ||||||
|   | |||||||
| @@ -12,7 +12,7 @@ const defaultNavigation = [ | |||||||
|  |  | ||||||
| const authedNavigation = [ | const authedNavigation = [ | ||||||
|   { name: 'Manage', href: '/manage', current: false }, |   { name: 'Manage', href: '/manage', current: false }, | ||||||
| ] | ]; | ||||||
|  |  | ||||||
| export default async function NavBar(): Promise<React.JSX.Element> { | export default async function NavBar(): Promise<React.JSX.Element> { | ||||||
|   const session = await auth(); |   const session = await auth(); | ||||||
|   | |||||||
| @@ -22,7 +22,7 @@ export default function PostHeader({metadata}: PostHeaderProps): React.JSX.Eleme | |||||||
|             <> |             <> | ||||||
|               <span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span> |               <span className="select-none text-sm me-2 px-2.5 py-1 rounded border border-dracula-pink dark:bg-dracula-bg-darker dark:text-dracula-pink">{tag}</span> | ||||||
|             </> |             </> | ||||||
|           ) |           ); | ||||||
|         })} |         })} | ||||||
|       </div> |       </div> | ||||||
|     </> |     </> | ||||||
|   | |||||||
| @@ -1,32 +0,0 @@ | |||||||
| import { DataSource } from "typeorm"; |  | ||||||
| import { Photo } from "./entity/photo"; |  | ||||||
|  |  | ||||||
| const dataSource = new DataSource({ |  | ||||||
|   type: "better-sqlite3", |  | ||||||
|   database: "db.sql", |  | ||||||
|   entities: [Photo], |  | ||||||
|   migrations: ["./migrations"], |  | ||||||
| }) |  | ||||||
|  |  | ||||||
| export default class PhotoDataSource { |  | ||||||
|   private static _dataSource: DataSource | null = null; |  | ||||||
|  |  | ||||||
|   static get dataSource(): Promise<DataSource> { |  | ||||||
|     if (PhotoDataSource._dataSource === null) { |  | ||||||
|       return PhotoDataSource.initDataSource(); |  | ||||||
|     } else { |  | ||||||
|       return Promise.resolve(PhotoDataSource._dataSource); |  | ||||||
|     } |  | ||||||
|   } |  | ||||||
|  |  | ||||||
|   static async initDataSource(): Promise<DataSource> { |  | ||||||
|     if (!PhotoDataSource._dataSource || !PhotoDataSource._dataSource.isInitialized) { |  | ||||||
|       const ds = await dataSource.initialize(); |  | ||||||
|       console.log('Photo data source initialized') |  | ||||||
|       PhotoDataSource._dataSource = ds; |  | ||||||
|     } |  | ||||||
|     return PhotoDataSource._dataSource; |  | ||||||
|   } |  | ||||||
| } |  | ||||||
|  |  | ||||||
| PhotoDataSource.initDataSource(); |  | ||||||
							
								
								
									
										3
									
								
								src/db/db.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								src/db/db.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,3 @@ | |||||||
|  | import { drizzle } from 'drizzle-orm/better-sqlite3'; | ||||||
|  |  | ||||||
|  | export default drizzle(`${process.cwd()}/db.sql`); | ||||||
							
								
								
									
										22
									
								
								src/db/schema/photo.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								src/db/schema/photo.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,22 @@ | |||||||
|  | import { int, sqliteTable, text, blob, real } from "drizzle-orm/sqlite-core"; | ||||||
|  |  | ||||||
|  | export const photosTable = sqliteTable( | ||||||
|  |   "photo", | ||||||
|  |   { | ||||||
|  |     id: int().primaryKey({ autoIncrement: true }), | ||||||
|  |     src: text().notNull().unique(), | ||||||
|  |     width: int().notNull(), | ||||||
|  |     height: int().notNull(), | ||||||
|  |     blur: blob().notNull(), | ||||||
|  |  | ||||||
|  |     camera: text(), | ||||||
|  |     title: text(), | ||||||
|  |     description: text(), | ||||||
|  |     exposureBiasValue: int(), | ||||||
|  |     fNumber: real(), | ||||||
|  |     isoSpeedRatings: int(), | ||||||
|  |     focalLength: int(), | ||||||
|  |     dateTimeOriginal: int({ mode: 'timestamp' }), | ||||||
|  |     lensModel: text(), | ||||||
|  |   } | ||||||
|  | ); | ||||||
| @@ -1,48 +0,0 @@ | |||||||
| import { Column, Entity, PrimaryGeneratedColumn } from "typeorm" |  | ||||||
|  |  | ||||||
| @Entity() |  | ||||||
| export class Photo { |  | ||||||
|   @PrimaryGeneratedColumn() |  | ||||||
|   id!: number |  | ||||||
|  |  | ||||||
|   @Column("text", { unique: true }) |  | ||||||
|   src!: string; |  | ||||||
|  |  | ||||||
|   @Column() |  | ||||||
|   width!: number |  | ||||||
|  |  | ||||||
|   @Column() |  | ||||||
|   height!: number |  | ||||||
|  |  | ||||||
|   @Column("blob") |  | ||||||
|   blur!: string |  | ||||||
|  |  | ||||||
|   @Column("text", { nullable: true }) |  | ||||||
|   camera: string | null = null; |  | ||||||
|  |  | ||||||
|   // Manually input data |  | ||||||
|   @Column("text", { nullable: true }) |  | ||||||
|   title: string | null = null; |  | ||||||
|  |  | ||||||
|   @Column("text", { nullable: true }) |  | ||||||
|   description: string | null = null; |  | ||||||
|  |  | ||||||
|   // Exif data |  | ||||||
|   @Column("int", { nullable: true }) |  | ||||||
|   exposureBiasValue: number | null = null |  | ||||||
|  |  | ||||||
|   @Column("float", { nullable: true }) |  | ||||||
|   fNumber: number | null = null |  | ||||||
|  |  | ||||||
|   @Column("int", { nullable: true }) |  | ||||||
|   isoSpeedRatings: number | null = null |  | ||||||
|  |  | ||||||
|   @Column("int", { nullable: true }) |  | ||||||
|   focalLength: number | null = null |  | ||||||
|  |  | ||||||
|   @Column("date", { nullable: true }) |  | ||||||
|   dateTimeOriginal: Date | null = null |  | ||||||
|  |  | ||||||
|   @Column("text", { nullable: true }) |  | ||||||
|   lensModel: string | null = null |  | ||||||
| } |  | ||||||
							
								
								
									
										82
									
								
								src/games/games/MapGen/MapGenApp.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/games/games/MapGen/MapGenApp.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | import BaseGameApp from '@/core/BaseGameApp/BaseGameApp'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import Control from '@/control/Control'; | ||||||
|  | import Tile, { TileDir } from './Tile'; | ||||||
|  | import Vector from '@/games/lib/utils/Vector'; | ||||||
|  | import { min, shuffle } from 'radash'; | ||||||
|  |  | ||||||
|  | const grid = new Vector(64, 256); | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * A test "game" to start generating useful examples, building up classes as needed etc | ||||||
|  |  */ | ||||||
|  | class TestGameApp extends BaseGameApp { | ||||||
|  |   private _control: Control; | ||||||
|  |   private _tileGrid: Tile[][] = Array.from({ length: grid.x }).map(() => Array.from({ length: grid.y })); | ||||||
|  |   private _allTiles: Tile[] = Array.from({ length: grid.x * grid.y }); | ||||||
|  |  | ||||||
|  |   constructor(canvas: HTMLCanvasElement | null) { | ||||||
|  |     super(canvas); | ||||||
|  |     this._control = new Control(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   protected async _start(canvas: HTMLCanvasElement): Promise<void> { | ||||||
|  |     await super._start(canvas); | ||||||
|  |     Registry.register('Control', this._control); | ||||||
|  |  | ||||||
|  |     for (let x = 0; x < grid.x; x++) { | ||||||
|  |       for (let y = 0; y < grid.y; y++) { | ||||||
|  |         this._tileGrid[x][y] = new Tile({ x, y }); | ||||||
|  |         this._allTiles[x + y * grid.x] = this._tileGrid[x][y]; | ||||||
|  |         this._viewport.addChild(this._tileGrid[x][y]); | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     for (let x = 0; x < grid.x; x++) { | ||||||
|  |       for (let y = 0; y < grid.y; y++) { | ||||||
|  |         if (x > 0) { | ||||||
|  |           this._tileGrid[x][y].link(this._tileGrid[x - 1][y], TileDir.west); | ||||||
|  |         } | ||||||
|  |         if (x < grid.x - 1) { | ||||||
|  |           this._tileGrid[x][y].link(this._tileGrid[x + 1][y], TileDir.east); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (y > 0) { | ||||||
|  |           this._tileGrid[x][y].link(this._tileGrid[x][y - 1], TileDir.north); | ||||||
|  |         } | ||||||
|  |         if (y < grid.y - 1) { | ||||||
|  |           this._tileGrid[x][y].link(this._tileGrid[x][y + 1], TileDir.south); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     for (let x = 0; x < grid.x; x++) { | ||||||
|  |       for (let y = 0; y < grid.y; y++) { | ||||||
|  |         this._tileGrid[x][y].rescore() | ||||||
|  |       } | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Update the logic of the game | ||||||
|  |    * | ||||||
|  |    * @param timeDelta - The time difference since the last update (in ms) | ||||||
|  |    */ | ||||||
|  |   protected async _update(timeDelta: number): Promise<void> { | ||||||
|  |     await super._update(timeDelta); | ||||||
|  |  | ||||||
|  |     while (true) { | ||||||
|  |  | ||||||
|  |       const activeTiles = this._allTiles.filter((tile) => !tile.selected && tile.score > 0); | ||||||
|  |       if (activeTiles.length === 0) { | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       const lowestScore = min(activeTiles, t => t.score)!.score; | ||||||
|  |       const lowestTiles = activeTiles.filter((tile) => tile.score === lowestScore); | ||||||
|  |       const selectedTile = shuffle(lowestTiles)[0]; | ||||||
|  |       selectedTile.select(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default TestGameApp; | ||||||
							
								
								
									
										24
									
								
								src/games/games/MapGen/MapGenWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/games/games/MapGen/MapGenWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | import React, { useEffect, useRef } from 'react'; | ||||||
|  | import MapGenApp from './MapGenApp'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The wrapper for Test Game so it can be dynamically loaded with the WebGL hook | ||||||
|  |  */ | ||||||
|  | function TestGameWrapper(): React.ReactElement { | ||||||
|  |   const canvas = useRef<HTMLCanvasElement>(null); | ||||||
|  |   const gameCreated = useRef<boolean>(false); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!gameCreated.current) { | ||||||
|  |       console.log('Creating game'); | ||||||
|  |       gameCreated.current = true; | ||||||
|  |       new MapGenApp(canvas.current); | ||||||
|  |     } | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <canvas ref={canvas} /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default TestGameWrapper; | ||||||
							
								
								
									
										212
									
								
								src/games/games/MapGen/Tile.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										212
									
								
								src/games/games/MapGen/Tile.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,212 @@ | |||||||
|  | import Container from "@/games/lib/visual/Container"; | ||||||
|  | import Graphics from "@/games/lib/visual/Graphics"; | ||||||
|  | import Text from "@/games/lib/visual/Text"; | ||||||
|  |  | ||||||
|  | const TileTypes = { | ||||||
|  |   grass: "grass", | ||||||
|  |   dirt: "dirt", | ||||||
|  |   rock: "rock", | ||||||
|  |   iron: "iron", | ||||||
|  |   copper: "copper", | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export enum TileDir { | ||||||
|  |   north, | ||||||
|  |   east, | ||||||
|  |   south, | ||||||
|  |   west | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | type TileData = { | ||||||
|  |   available: boolean, | ||||||
|  |   colour: number | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const tileData: { [key in keyof typeof TileTypes]: TileData } = { | ||||||
|  |   grass: { | ||||||
|  |     available: false, | ||||||
|  |     colour: 0x117c13 | ||||||
|  |   }, | ||||||
|  |   rock: { | ||||||
|  |     available: false, | ||||||
|  |     colour: 0xc6bfb8 | ||||||
|  |   }, | ||||||
|  |   dirt: { | ||||||
|  |     available: false, | ||||||
|  |     colour: 0x402905 | ||||||
|  |   }, | ||||||
|  |   copper: { | ||||||
|  |     available: false, | ||||||
|  |     colour: 0xb87333 | ||||||
|  |   }, | ||||||
|  |   iron: { | ||||||
|  |     available: false, | ||||||
|  |     colour: 0xa19d94 | ||||||
|  |   }, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default class Tile extends Container { | ||||||
|  |   private static readonly size = { | ||||||
|  |     x: 30, | ||||||
|  |     y: 30 | ||||||
|  |   } | ||||||
|  |   private _graphic: Graphics = new Graphics(); | ||||||
|  |   private _point: { x: number, y: number }; | ||||||
|  |   private _text: Text = new Text({ | ||||||
|  |     position: { | ||||||
|  |       x: Tile.size.x / 2, | ||||||
|  |       y: Tile.size.y / 2, | ||||||
|  |     } | ||||||
|  |   }); | ||||||
|  |   private _choices = structuredClone(tileData); | ||||||
|  |   private _selected: null | keyof typeof tileData = null; | ||||||
|  |   public score = 0; | ||||||
|  |   public neighbors: Tile[] = []; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   constructor(point: { x: number, y: number }) { | ||||||
|  |     super({ | ||||||
|  |       position: { | ||||||
|  |         x: point.x * Tile.size.x, | ||||||
|  |         y: point.y * Tile.size.y, | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     this._point = point; | ||||||
|  |     this.addChild(this._graphic, this._text); | ||||||
|  |  | ||||||
|  |     this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke(); | ||||||
|  |     this._text.text = "-1"; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   link(tile: Tile, dir: TileDir) { | ||||||
|  |     this.neighbors[dir] = tile; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   reset() { | ||||||
|  |     this._graphic.clear(); | ||||||
|  |     this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: 0xcccccc }).stroke(); | ||||||
|  |     Object.values(this._choices).forEach((choice) => choice.available = false); | ||||||
|  |     this._text.text = "-1"; | ||||||
|  |     this._point = { | ||||||
|  |       x: -1, | ||||||
|  |       y: -1 | ||||||
|  |     } | ||||||
|  |     this._selected = null; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   _calculateAvailable() { | ||||||
|  |     if (!this.neighbors[TileDir.north]) { | ||||||
|  |       this._choices.grass.available = true; | ||||||
|  |       this._choices.dirt.available = true; | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this.neighbors.forEach((neighbor) => { | ||||||
|  |       if (neighbor.selected === 'grass') { | ||||||
|  |         this._choices.dirt.available = true; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (neighbor.selected === 'dirt') { | ||||||
|  |         this._choices.dirt.available = true; | ||||||
|  |         this._choices.rock.available = true; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (neighbor.selected === 'rock') { | ||||||
|  |         this._choices.rock.available = true; | ||||||
|  |         this._choices.iron.available = true; | ||||||
|  |         this._choices.copper.available = true; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (neighbor.selected === 'iron') { | ||||||
|  |         this._choices.rock.available = true; | ||||||
|  |         this._choices.iron.available = true; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       if (neighbor.selected === 'copper') { | ||||||
|  |         this._choices.rock.available = true; | ||||||
|  |         this._choices.copper.available = true; | ||||||
|  |       } | ||||||
|  |     }) | ||||||
|  |  | ||||||
|  |     if (this._point.y < 32) { | ||||||
|  |       this._choices.iron.available = false; | ||||||
|  |     } | ||||||
|  |     if (this._point.y < 64) { | ||||||
|  |       this._choices.copper.available = false; | ||||||
|  |       this._choices.dirt.available = true; | ||||||
|  |       this._choices.rock.available = true; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   rescore(): number { | ||||||
|  |     if (this._selected) { | ||||||
|  |       return 0; | ||||||
|  |     } | ||||||
|  |     this._calculateAvailable(); | ||||||
|  |  | ||||||
|  |     this.score = Object.values(this._choices).reduce((count, choice) => { | ||||||
|  |       return choice.available ? count + 1 : count; | ||||||
|  |     }, 0); | ||||||
|  |  | ||||||
|  |     this._text.text = this.score; | ||||||
|  |     return this.score; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   select(): void { | ||||||
|  |  | ||||||
|  |     //const available = Object.entries(this._choices).filter((choice) => choice[1].available); | ||||||
|  |     //this._selected = shuffle(available)[0][0] as keyof typeof tileData; | ||||||
|  |     const available = Object.entries(this._choices).filter((choice) => choice[1].available) as [keyof typeof TileTypes, TileData][]; | ||||||
|  |  | ||||||
|  |     const weightedAvailable = available.map(([tileType, tileData]) => { | ||||||
|  |       const weightedEntry = { | ||||||
|  |         type: tileType, | ||||||
|  |         weight: 4 | ||||||
|  |       }; | ||||||
|  |  | ||||||
|  |       if (tileType === 'grass') { | ||||||
|  |         weightedEntry.weight = 8; | ||||||
|  |       } | ||||||
|  |       else if (tileType === 'dirt') { | ||||||
|  |         if (this._point.y === 0) { | ||||||
|  |           weightedEntry.weight = 2; | ||||||
|  |         } else { | ||||||
|  |           weightedEntry.weight += 20 - Math.floor(((Math.min(this._point.y, 63) / 64) * 20) + 3); | ||||||
|  |         } | ||||||
|  |       } | ||||||
|  |       else if (tileType === 'iron') { | ||||||
|  |         weightedEntry.weight = 3; | ||||||
|  |       } | ||||||
|  |       else if (tileType === 'copper') { | ||||||
|  |         weightedEntry.weight = 1; | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return weightedEntry; | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     const totalWeight = weightedAvailable.reduce((weight, avail) => weight + avail.weight, 0); | ||||||
|  |     let pick = Math.floor(Math.random() * totalWeight) | ||||||
|  |  | ||||||
|  |     for (let i = 0; i < weightedAvailable.length; i++) { | ||||||
|  |       if (pick < weightedAvailable[i].weight) { | ||||||
|  |         this._selected = weightedAvailable[i].type; | ||||||
|  |         break; | ||||||
|  |       } | ||||||
|  |       pick -= weightedAvailable[i].weight; | ||||||
|  |     } | ||||||
|  |     if (this._selected === null) { | ||||||
|  |       throw new Error('Selection not done'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._graphic.clear(); | ||||||
|  |     this._graphic.rect(0, 0, Tile.size.x, Tile.size.y).fill({ color: this._choices[this._selected].colour }); | ||||||
|  |     this._text.text = ""; | ||||||
|  |  | ||||||
|  |     this.neighbors.forEach((neighbor) => neighbor.rescore()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get selected() { | ||||||
|  |     return this._selected; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										41
									
								
								src/games/games/TestGame/TestGameApp.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								src/games/games/TestGame/TestGameApp.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,41 @@ | |||||||
|  | import BaseGameApp from '@/core/BaseGameApp/BaseGameApp'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import Control from '@/control/Control'; | ||||||
|  |  | ||||||
|  | import Player from './components/Player/Player'; | ||||||
|  | import Trees from './components/Trees'; | ||||||
|  | import Walls from './components/Walls/Walls'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * A test "game" to start generating useful examples, building up classes as needed etc | ||||||
|  |  */ | ||||||
|  | class TestGameApp extends BaseGameApp { | ||||||
|  |   private _player!: Player; | ||||||
|  |   private _control: Control; | ||||||
|  |  | ||||||
|  |  | ||||||
|  |   constructor(canvas: HTMLCanvasElement | null) { | ||||||
|  |     super(canvas); | ||||||
|  |     this._control = new Control(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   protected async _start(canvas: HTMLCanvasElement): Promise<void> { | ||||||
|  |     await super._start(canvas); | ||||||
|  |     Registry.register('Control', this._control); | ||||||
|  |     this._viewport.addChild(new Trees({ numberOfTrees: 200 })); | ||||||
|  |     this._player = this._viewport.addChild(new Player()); | ||||||
|  |     this._viewport.addChild(new Walls()); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Update the logic of the game | ||||||
|  |    * | ||||||
|  |    * @param timeDelta - The time difference since the last update (in ms) | ||||||
|  |    */ | ||||||
|  |   protected async _update(timeDelta: number): Promise<void> { | ||||||
|  |     this._player.update(timeDelta); | ||||||
|  |     await super._update(timeDelta); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default TestGameApp; | ||||||
							
								
								
									
										24
									
								
								src/games/games/TestGame/TestGameWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/games/games/TestGame/TestGameWrapper.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | |||||||
|  | import React, { useEffect, useRef } from 'react'; | ||||||
|  | import TestGameApp from './TestGameApp'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * The wrapper for Test Game so it can be dynamically loaded with the WebGL hook | ||||||
|  |  */ | ||||||
|  | function TestGameWrapper(): React.ReactElement { | ||||||
|  |   const canvas = useRef<HTMLCanvasElement>(null); | ||||||
|  |   const gameCreated = useRef<boolean>(false); | ||||||
|  |  | ||||||
|  |   useEffect(() => { | ||||||
|  |     if (!gameCreated.current) { | ||||||
|  |       console.log('Creating game'); | ||||||
|  |       gameCreated.current = true; | ||||||
|  |       new TestGameApp(canvas.current); | ||||||
|  |     } | ||||||
|  |   }, []); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <canvas ref={canvas} /> | ||||||
|  |   ); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default TestGameWrapper; | ||||||
							
								
								
									
										132
									
								
								src/games/games/TestGame/components/Player/Player.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										132
									
								
								src/games/games/TestGame/components/Player/Player.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,132 @@ | |||||||
|  | import Collidable from '@/control/Collidable'; | ||||||
|  | import Rapier from '@/utils/Rapier'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import Vector from '@/utils/Vector'; | ||||||
|  | import Container from '@/visual/Container'; | ||||||
|  | import Graphics from '@/visual/Graphics'; | ||||||
|  |  | ||||||
|  | import BaseAttack from './attack/BaseAttack'; | ||||||
|  | import Forward from './attack/Forward'; | ||||||
|  |  | ||||||
|  | import type { Viewport } from '@/visual/pixi'; | ||||||
|  | import type Control from '@/control/Control'; | ||||||
|  | import { assign } from 'radash'; | ||||||
|  |  | ||||||
|  | type PlayerSettings = ConstructorParameters<typeof Collidable>[0] | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class Player extends Collidable { | ||||||
|  |   private _control: Control; | ||||||
|  |   private _characterGraphics: Graphics; | ||||||
|  |  | ||||||
|  |   private _attacks: BaseAttack[] = []; | ||||||
|  |   private _rapier: Rapier; | ||||||
|  |   private _collider: InstanceType<Rapier['rapier']['Collider']>; | ||||||
|  |   private _characterController: InstanceType<Rapier['rapier']['KinematicCharacterController']>; | ||||||
|  |  | ||||||
|  |   constructor(settings?: PlayerSettings) { | ||||||
|  |     super(assign(structuredClone(settings ?? {}), { | ||||||
|  |       position: { | ||||||
|  |         x: 960, | ||||||
|  |         y: 540 | ||||||
|  |       } | ||||||
|  |     })); | ||||||
|  |     const viewport = Registry.fetch<Viewport>('Viewport'); | ||||||
|  |     this._rapier = Registry.fetch<Rapier>('Rapier'); | ||||||
|  |     this._control = Registry.fetch<Control>('Control'); | ||||||
|  |  | ||||||
|  |     this._characterGraphics = viewport.addChild(new Graphics()); | ||||||
|  |     this._characterGraphics.setStrokeStyle({ | ||||||
|  |       color: 0x00DD00, | ||||||
|  |       width: 1 | ||||||
|  |     }).regularPoly(-10, -10, 20, 3).fill(0x00AA00).stroke(); | ||||||
|  |     this._characterGraphics.pivot.set(-10, -10); | ||||||
|  |     this.addChild(this._characterGraphics); | ||||||
|  |  | ||||||
|  |     viewport.follow(this, { radius: 250 }); | ||||||
|  |     viewport.addChild(this); | ||||||
|  |  | ||||||
|  |     const projectileContainer = new Container(); | ||||||
|  |     viewport.addChild(projectileContainer); | ||||||
|  |     const forwardAttack = new Forward(projectileContainer); | ||||||
|  |     this._attacks.push(forwardAttack); | ||||||
|  |     this.addChild(forwardAttack); | ||||||
|  |  | ||||||
|  |     const trianglePoints = { | ||||||
|  |       a: new Vector(0, -20), | ||||||
|  |       b: new Vector(10 * Math.sqrt(3), 10), | ||||||
|  |       c: new Vector(-10 * Math.sqrt(3), 10) | ||||||
|  |     }; | ||||||
|  |     this._collider = this._rapier.createCollider(this, 'triangle', trianglePoints); | ||||||
|  |     this._collider.setRestitution(0); | ||||||
|  |     this._collider.setTranslation(this.position); | ||||||
|  |  | ||||||
|  |     this._rapier.addColliderMemberGroups(this._collider, 'player'); | ||||||
|  |     this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'enemyProjectile', 'wall'); | ||||||
|  |  | ||||||
|  |     this._characterController = this._rapier.world.createCharacterController(0.01); | ||||||
|  |  | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   update(timeDelta: number): void { | ||||||
|  |     let impulse = new Vector(0, 0); | ||||||
|  |     let speed = 250; | ||||||
|  |  | ||||||
|  |     if (this._control.isDown('up')) { | ||||||
|  |       impulse.y -= 1; | ||||||
|  |     } | ||||||
|  |     if (this._control.isDown('down')) { | ||||||
|  |       impulse.y += 1; | ||||||
|  |     } | ||||||
|  |     if (this._control.isDown('left')) { | ||||||
|  |       impulse.x -= 1; | ||||||
|  |     } | ||||||
|  |     if (this._control.isDown('right')) { | ||||||
|  |       impulse.x += 1; | ||||||
|  |     } | ||||||
|  |     if (this._control.isDown('shift')) { | ||||||
|  |       speed *= 4; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (impulse.magnitude() > 0) { | ||||||
|  |       const normImpulse = impulse.normalize(); | ||||||
|  |       this.rotation = Math.atan2(normImpulse.y, normImpulse.x) - Math.atan2(1, 0) + Math.PI; | ||||||
|  |       impulse = impulse.normalize().multiplyScalar(speed); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._characterController.computeColliderMovement( | ||||||
|  |       this._collider, | ||||||
|  |       impulse.multiplyScalar(timeDelta) | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     const correctedMovement = this._characterController.computedMovement(); | ||||||
|  |     this.position.add(correctedMovement); | ||||||
|  |     // console.log('impulse', impulse); | ||||||
|  |     // console.log('corrected', correctedMovement) | ||||||
|  |     //this._collider.setTranslation(this.position); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     if (this._control.isDown(' ')) { | ||||||
|  |       const modifiedRotation = this.rotation - (Math.PI / 2); | ||||||
|  |       const currentVector = new Vector(Math.cos(modifiedRotation), Math.sin(modifiedRotation)); | ||||||
|  |       this._attacks[0].fire({ | ||||||
|  |         playerVector: currentVector | ||||||
|  |       }); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._attacks.forEach((attack) => { | ||||||
|  |       attack.update(timeDelta); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   collide(collided: Collidable, colliding: boolean): void { | ||||||
|  |     if (!colliding) { | ||||||
|  |       console.log('player no longer colliding'); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Player; | ||||||
| @@ -0,0 +1,20 @@ | |||||||
|  | import Container from '../../../../../lib/visual/Container'; | ||||||
|  | import type Vector from '../../../../../lib/utils/Vector'; | ||||||
|  |  | ||||||
|  | type fireState = { | ||||||
|  |   playerVector: Vector | ||||||
|  | } | ||||||
|  |  | ||||||
|  | abstract class BaseAttack extends Container { | ||||||
|  |   protected _projectileContainer: Container; | ||||||
|  |  | ||||||
|  |   constructor(projectileContainer: Container) { | ||||||
|  |     super(); | ||||||
|  |     this._projectileContainer = projectileContainer; | ||||||
|  |   } | ||||||
|  |   abstract fire(state: fireState): void; | ||||||
|  |   abstract update(timeDelta: number): void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | export default BaseAttack; | ||||||
							
								
								
									
										159
									
								
								src/games/games/TestGame/components/Player/attack/Forward.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										159
									
								
								src/games/games/TestGame/components/Player/attack/Forward.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,159 @@ | |||||||
|  | import Collidable from '@/control/Collidable'; | ||||||
|  | import Pool from '@/utils/Pool'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import Vector from '@/utils/Vector'; | ||||||
|  | import Graphics from '@/visual/Graphics'; | ||||||
|  |  | ||||||
|  | import BaseAttack from './BaseAttack'; | ||||||
|  |  | ||||||
|  | import type Rapier from '@/utils/Rapier'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class ForwardProjectile extends Collidable { | ||||||
|  |   private _speed: number = 600; | ||||||
|  |   private _size: number = 5; | ||||||
|  |   private _graphic: Graphics; | ||||||
|  |  | ||||||
|  |   private _lifeTime: { | ||||||
|  |     lifeTime: number, | ||||||
|  |     lifeTimeRemaining: number | ||||||
|  |   } = { | ||||||
|  |       lifeTime: 2500, | ||||||
|  |       lifeTimeRemaining: 0 | ||||||
|  |     }; | ||||||
|  |   private _collider!: InstanceType<Rapier['rapier']['Collider']>; | ||||||
|  |   private _rigidBody!: InstanceType<Rapier['rapier']['RigidBody']>; | ||||||
|  |   private _rapier: Rapier; | ||||||
|  |   private _alive: boolean = true; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     super(); | ||||||
|  |     this._rapier = Registry.fetch<Rapier>('Rapier'); | ||||||
|  |     this._graphic = new Graphics(); | ||||||
|  |     this._graphic.circle(this._size / 2, this._size / 2, this._size).fill(0xAA0000); | ||||||
|  |     this.addChild(this._graphic); | ||||||
|  |     this.pivot.set(this._size / 2, this._size / 2); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   alive(): boolean { | ||||||
|  |     return this._lifeTime.lifeTimeRemaining > 0 && this._alive; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   fire(vector: Vector): void { | ||||||
|  |     this._alive = true; | ||||||
|  |     this._addPhysics(); | ||||||
|  |     this._rigidBody.setTranslation(this.position, true); | ||||||
|  |     this._rigidBody.setLinvel(vector.multiplyScalar(this._speed), true); | ||||||
|  |     this._lifeTime.lifeTimeRemaining = this._lifeTime.lifeTime; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    * | ||||||
|  |    * @param timeDelta - TODO | ||||||
|  |    */ | ||||||
|  |   update(timeDelta: number): void { | ||||||
|  |     this.position.copyFrom(this._rigidBody.translation()); | ||||||
|  |  | ||||||
|  |     if (this._lifeTime.lifeTimeRemaining > 0) { | ||||||
|  |       this._lifeTime.lifeTimeRemaining = Math.max(0, this._lifeTime.lifeTimeRemaining - timeDelta); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Gets called when we've collided with something.   | ||||||
|  |    * Use to kill the projectile. | ||||||
|  |    * | ||||||
|  |    * @param collided - The Collidable this projectile has hit  | ||||||
|  |    * @param colliding - Are we currently colliding? | ||||||
|  |    */ | ||||||
|  |   collide(collided: Collidable, colliding: boolean): void { | ||||||
|  |     if (colliding) { | ||||||
|  |       this._alive = false; | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   private _addPhysics(): void { | ||||||
|  |     this._rigidBody = this._rapier.createRigidBody(this, 'Dynamic', false, true); | ||||||
|  |     this._collider = this._rapier.createCollider(this, 'ball', { radius: this._size }, this._rigidBody); | ||||||
|  |     this._collider.setRestitution(0); | ||||||
|  |     this._rapier.addColliderMemberGroups(this._collider, 'playerProjectile'); | ||||||
|  |     this._rapier.addColliderFilterGroups(this._collider, 'enemy', 'wall'); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   removePhysics(): void { | ||||||
|  |     this._rapier.removeRigidBody(this._rigidBody); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class Forward extends BaseAttack { | ||||||
|  |   private _projectilePool: Pool<ForwardProjectile> = new Pool(ForwardProjectile, 50); | ||||||
|  |   private _activeProjectiles: ForwardProjectile[] = []; | ||||||
|  |   private _coolDown: { | ||||||
|  |     coolDown: number, | ||||||
|  |     coolDownRemaining: number | ||||||
|  |   } = { | ||||||
|  |       coolDown: 250, | ||||||
|  |       coolDownRemaining: 0 | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   fire(state: Parameters<BaseAttack['fire']>[0]): void { | ||||||
|  |     if (this._coolDown.coolDownRemaining > 0) { | ||||||
|  |       return; | ||||||
|  |     } | ||||||
|  |     const newBullet = this._projectilePool.allocate(); | ||||||
|  |  | ||||||
|  |     const newPoint = this._projectileContainer.toLocal(new Vector(0, 0), this); | ||||||
|  |     newBullet.position.copyFrom(newPoint); | ||||||
|  |     this._projectileContainer.addChild(newBullet); | ||||||
|  |     this._activeProjectiles.push(newBullet); | ||||||
|  |  | ||||||
|  |     newBullet.fire(state.playerVector.clone()); | ||||||
|  |  | ||||||
|  |     this._coolDown.coolDownRemaining = this._coolDown.coolDown; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   update(timeDelta: number): void { | ||||||
|  |     if (this._coolDown.coolDownRemaining > 0) { | ||||||
|  |       this._coolDown.coolDownRemaining = Math.max(0, this._coolDown.coolDownRemaining - timeDelta); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._activeProjectiles = this._activeProjectiles.filter((projectile) => { | ||||||
|  |       projectile.update(timeDelta); | ||||||
|  |       const alive = projectile.alive(); | ||||||
|  |       if (!alive) { | ||||||
|  |         projectile.removePhysics(); | ||||||
|  |         this._projectileContainer.removeChild(projectile); | ||||||
|  |         this._projectilePool.release(projectile); | ||||||
|  |       } | ||||||
|  |       return alive; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Forward; | ||||||
|  |  | ||||||
							
								
								
									
										31
									
								
								src/games/games/TestGame/components/Trees.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/games/games/TestGame/components/Trees.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | import Pool from '@/utils/Pool'; | ||||||
|  | import Container from '@/visual/Container'; | ||||||
|  | import Graphics from '@/visual/Graphics'; | ||||||
|  | import { PIXI } from '@/visual/pixi'; | ||||||
|  |  | ||||||
|  | type TreesSettings = ConstructorParameters<typeof Container>[0] & { | ||||||
|  |   numberOfTrees: number | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Trees extends Container { | ||||||
|  |   private readonly _allTrees: Graphics[] = new Array<Graphics>(200); | ||||||
|  |   private readonly _treePool: Pool<Graphics>; | ||||||
|  |   constructor(settings: TreesSettings) { | ||||||
|  |     super(settings); | ||||||
|  |     this._treePool = new Pool(Graphics, settings.numberOfTrees); | ||||||
|  |  | ||||||
|  |     for (let i = 0; i < settings.numberOfTrees; i++) { | ||||||
|  |       const tree = this.addChild(this._treePool.allocate()); | ||||||
|  |       const treeColour = ((Math.random() * 80) + 70); | ||||||
|  |       tree.regularPoly(-10, 10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour - 10) / 255, 0]) }); | ||||||
|  |       tree.regularPoly(-10, 0, 20, 3).fill({ color: new PIXI.Color([0, treeColour / 255, 0]) }); | ||||||
|  |       tree.regularPoly(-10, -10, 20, 3).fill({ color: new PIXI.Color([0, (treeColour + 10) / 255, 0]) }); | ||||||
|  |       tree.position.set(Math.random() * 1920, Math.random() * 1080); | ||||||
|  |       tree.cullable = true; | ||||||
|  |       this._allTrees.push(tree); | ||||||
|  |     } | ||||||
|  |     this.cullable = true; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Trees; | ||||||
							
								
								
									
										76
									
								
								src/games/games/TestGame/components/Walls/Walls.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										76
									
								
								src/games/games/TestGame/components/Walls/Walls.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,76 @@ | |||||||
|  | import Collidable from '@/control/Collidable'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import Container from '@/visual/Container'; | ||||||
|  | import Graphics from '@/visual/Graphics'; | ||||||
|  | import { PIXI } from '@/visual/pixi'; | ||||||
|  |  | ||||||
|  | import type Rapier from '@/utils/Rapier'; | ||||||
|  |  | ||||||
|  | type WallsSettings = ConstructorParameters<typeof Container>[0] & { | ||||||
|  |   numberOfTrees: number | ||||||
|  | } | ||||||
|  |  | ||||||
|  | type wallData = { | ||||||
|  |   graphic: Graphics, | ||||||
|  |   collider: InstanceType<Rapier['rapier']['Collider']>, | ||||||
|  |   rigidBody: InstanceType<Rapier['rapier']['RigidBody']>, | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class Walls extends Collidable { | ||||||
|  |   private _rapier: Rapier; | ||||||
|  |   private _walls: wallData[] = []; | ||||||
|  |  | ||||||
|  |   constructor(settings?: WallsSettings) { | ||||||
|  |     super(settings); | ||||||
|  |     this._rapier = Registry.fetch<Rapier>('Rapier'); | ||||||
|  |     this._walls.push(this._createWall(10, 1110, -10, 540)); | ||||||
|  |     this._walls.push(this._createWall(10, 1110, 1930, 540)); | ||||||
|  |     this._walls.push(this._createWall(1950, 10, 960, -10)); | ||||||
|  |     this._walls.push(this._createWall(1950, 10, 960, 1090)); | ||||||
|  |  | ||||||
|  |     this._walls.forEach((wall) => { | ||||||
|  |       this.addChild(wall.graphic); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    * | ||||||
|  |    * @param width - TODO | ||||||
|  |    * @param height  | ||||||
|  |    * @param x  | ||||||
|  |    * @param y  | ||||||
|  |    */ | ||||||
|  |   private _createWall(width: number, height: number, x: number, y: number): wallData { | ||||||
|  |     const wall = new Graphics(); | ||||||
|  |     wall.rect(-width / 2, -height / 2, width, height).fill({ color: new PIXI.Color([0, 0, 0]) }); | ||||||
|  |     wall.position.set(x, y); | ||||||
|  |  | ||||||
|  |  | ||||||
|  |     const rigidBody = this._rapier.createRigidBody(this, 'Fixed'); | ||||||
|  |     const collider = this._rapier.createCollider(this, 'cuboid', { halfHeight: height / 2, halfWidth: width / 2 }, rigidBody); | ||||||
|  |     collider.setRestitution(0); | ||||||
|  |  | ||||||
|  |     rigidBody.setTranslation(this.position.add({ x, y }), true); | ||||||
|  |  | ||||||
|  |     this._rapier.addColliderMemberGroups(collider, 'wall'); | ||||||
|  |     this._rapier.addColliderFilterGroups(collider, 'player', 'enemy', 'enemyProjectile', 'playerProjectile'); | ||||||
|  |  | ||||||
|  |     return { | ||||||
|  |       graphic: wall, | ||||||
|  |       collider, | ||||||
|  |       rigidBody | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   collide(collided: Collidable, colliding: boolean): void { | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Walls; | ||||||
							
								
								
									
										7
									
								
								src/games/lib/control/Collidable.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/games/lib/control/Collidable.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | import Container from '@/visual/Container'; | ||||||
|  |  | ||||||
|  | abstract class Collidable extends Container { | ||||||
|  |   abstract collide(collided: Collidable, colliding: boolean): void; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Collidable; | ||||||
							
								
								
									
										87
									
								
								src/games/lib/control/Control.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										87
									
								
								src/games/lib/control/Control.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,87 @@ | |||||||
|  | type state = 'down' | 'up'; | ||||||
|  |  | ||||||
|  | // - TODO - Pull this from the actual game creating the controls | ||||||
|  | const GameControls = { | ||||||
|  |   down: 'down', | ||||||
|  |   up: 'up', | ||||||
|  |   left: 'left', | ||||||
|  |   right: 'right', | ||||||
|  |   shift: 'shift', | ||||||
|  |   ' ': ' ', | ||||||
|  | } as const; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * Abstraction for game controls, the game should say what controls it expects and then we can remap the actual controls separately.   | ||||||
|  |  * i.e. Both "ArrowDown" and "Down" from the keyboard listener should be the games "down" event.   | ||||||
|  |  * Also handles when the keys are pressed or not. | ||||||
|  |  */ | ||||||
|  | class Control { | ||||||
|  |   readonly gameControls = GameControls; | ||||||
|  |   private _controlMap: { [key: string]: keyof typeof GameControls } = { | ||||||
|  |     ArrowDown: GameControls.down, | ||||||
|  |     Down: GameControls.down, | ||||||
|  |     ArrowUp: GameControls.up, | ||||||
|  |     Up: GameControls.up, | ||||||
|  |     ArrowLeft: GameControls.left, | ||||||
|  |     Left: GameControls.left, | ||||||
|  |     ArrowRight: GameControls.right, | ||||||
|  |     Right: GameControls.right, | ||||||
|  |     Shift: GameControls.shift, | ||||||
|  |     ' ': GameControls[' '], | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   private _controlState: Map<keyof typeof GameControls, { | ||||||
|  |     state: state, | ||||||
|  |  | ||||||
|  |     consumed: boolean | ||||||
|  |   }> = new Map(); | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     this._setKeys(); | ||||||
|  |     window.addEventListener('keydown', (event) => { | ||||||
|  |       if (this._controlState.get(this._controlMap[event.key])?.state !== 'down') { | ||||||
|  |         this._controlState.set(this._controlMap[event.key], { state: 'down', consumed: false }); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     window.addEventListener('keyup', (event) => { | ||||||
|  |       if (this._controlState.get(this._controlMap[event.key])?.state !== 'up') { | ||||||
|  |         this._controlState.set(this._controlMap[event.key], { state: 'up', consumed: false }); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   private _setKeys(): void { | ||||||
|  |     Object.keys(GameControls).forEach((key) => { | ||||||
|  |       this._controlState.set(key as keyof typeof GameControls, { state: 'up', consumed: false }); | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    * | ||||||
|  |    * @param key - TODO | ||||||
|  |    */ | ||||||
|  |   isDown(key: keyof typeof GameControls): boolean { | ||||||
|  |     return this._controlState.get(key)?.state === 'down'; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Consume a down key event once | ||||||
|  |    *  | ||||||
|  |    * @param key  - TODO | ||||||
|  |    */ | ||||||
|  |   onceDown(key: keyof typeof GameControls): boolean { | ||||||
|  |     const keyState = this._controlState.get(key); | ||||||
|  |     if (keyState && keyState.state === 'down' && !keyState.consumed) { | ||||||
|  |       keyState.consumed = true; | ||||||
|  |       return true; | ||||||
|  |     } | ||||||
|  |     return false; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Control; | ||||||
							
								
								
									
										96
									
								
								src/games/lib/core/BaseGameApp/BaseGameApp.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								src/games/lib/core/BaseGameApp/BaseGameApp.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | |||||||
|  | import { PIXI, Viewport } from '@/visual/pixi'; | ||||||
|  | import Text from '@/visual/Text'; | ||||||
|  | import Rapier from '@/utils/Rapier'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import Ticker from '@/utils/Ticker'; | ||||||
|  | import Vector from '@/utils/Vector'; | ||||||
|  | import config from './BaseGameAppConfig'; | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class BaseGameApp { | ||||||
|  |   protected readonly _app: PIXI.Application; | ||||||
|  |   protected readonly _ticker: Ticker = new Ticker(); | ||||||
|  |   protected _fps: Text = new Text(config.fps); | ||||||
|  |   protected _viewport!: Viewport; | ||||||
|  |   protected _rapier!: Rapier; | ||||||
|  |  | ||||||
|  |   constructor(canvas: HTMLCanvasElement | null) { | ||||||
|  |     if (canvas === null) { | ||||||
|  |       throw new Error('No canvas'); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     this._app = new PIXI.Application(); | ||||||
|  |  | ||||||
|  |     void this._start(canvas); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   protected async _start(canvas: HTMLCanvasElement): Promise<void> { | ||||||
|  |     await this._app.init({ | ||||||
|  |       backgroundColor: 0x999999, | ||||||
|  |       canvas: canvas, | ||||||
|  |       antialias: true, | ||||||
|  |       resizeTo: canvas.parentElement as HTMLElement | ||||||
|  |     }); | ||||||
|  |  | ||||||
|  |     this._app.ticker.autoStart = false; | ||||||
|  |     this._app.ticker.stop(); | ||||||
|  |  | ||||||
|  |     this._viewport = new Viewport({ | ||||||
|  |       screenWidth: this._app.canvas.width, | ||||||
|  |       screenHeight: this._app.canvas.height, | ||||||
|  |       worldWidth: 1920, | ||||||
|  |       worldHeight: 1080, | ||||||
|  |       passiveWheel: false, | ||||||
|  |       noTicker: true, | ||||||
|  |       events: this._app.renderer.events | ||||||
|  |     }); | ||||||
|  |     Registry.register('Viewport', this._viewport); | ||||||
|  |     this._rapier = new Rapier(new Vector(0, 0)); | ||||||
|  |     Registry.register('Rapier', this._rapier); | ||||||
|  |  | ||||||
|  |     this._viewport.ensureVisible(0, 0, 1920, 1080, true); | ||||||
|  |     this._viewport.fitWorld(true); | ||||||
|  |     this._viewport.drag(); | ||||||
|  |  | ||||||
|  |     this._app.stage.addChild(this._viewport); | ||||||
|  |     this._app.stage.addChild(this._fps); | ||||||
|  |  | ||||||
|  |     await this._rapier.setup(); | ||||||
|  |  | ||||||
|  |     this._ticker.ticker(1000 / 60, this._update.bind(this), this._updateComplete.bind(this)); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Update the logic of the game   | ||||||
|  |    * - TODO - Figure out if this should be called before or after game updates | ||||||
|  |    * | ||||||
|  |    * @param timeDelta - The time difference since the last update (in ms) | ||||||
|  |    */ | ||||||
|  |   protected async _update(timeDelta: number): Promise<void> { | ||||||
|  |     this._rapier.debugRender(); | ||||||
|  |     this._viewport.update(timeDelta); | ||||||
|  |     this._rapier.update(); | ||||||
|  |     this._fps.text = this._ticker.currentTicksPerSecond; | ||||||
|  |     return Promise.resolve(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * When the update is complete, kick off a render | ||||||
|  |    * | ||||||
|  |    * @param updated - Was there actually an update in the last raf? | ||||||
|  |    */ | ||||||
|  |   protected async _updateComplete(updated: boolean): Promise<void> { | ||||||
|  |     this._viewport.resize(this._app.canvas.width, this._app.canvas.height); | ||||||
|  |     this._viewport.fitWorld(true); | ||||||
|  |     this._app.renderer.render(this._app.stage); | ||||||
|  |     return Promise.resolve(); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default BaseGameApp; | ||||||
							
								
								
									
										10
									
								
								src/games/lib/core/BaseGameApp/BaseGameAppConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								src/games/lib/core/BaseGameApp/BaseGameAppConfig.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | const BaseGameAppConfig = { | ||||||
|  |   fps: { | ||||||
|  |     position: { | ||||||
|  |       x: 20, | ||||||
|  |       y: 20 | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } as const; | ||||||
|  |  | ||||||
|  | export default BaseGameAppConfig; | ||||||
							
								
								
									
										31
									
								
								src/games/lib/utils/Pool.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/games/lib/utils/Pool.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | import { ObjectPoolFactory } from '@pixi-essentials/object-pool'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class Pool<T> { | ||||||
|  |   private _pool: ObjectPoolFactory<T>; | ||||||
|  |   // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||
|  |   constructor(ctor: new (...args: any[]) => T, reserve: number) { | ||||||
|  |     // Unsure why this needs the cast, probably because of the generic | ||||||
|  |     this._pool = ObjectPoolFactory.build(ctor) as ObjectPoolFactory<T>; | ||||||
|  |     this._pool.reserve(reserve); | ||||||
|  |     this._pool.startGC(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   allocate(): T { | ||||||
|  |     return this._pool.allocate(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   release(object: T): void { | ||||||
|  |     this._pool.release(object); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Pool; | ||||||
							
								
								
									
										223
									
								
								src/games/lib/utils/Rapier.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										223
									
								
								src/games/lib/utils/Rapier.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,223 @@ | |||||||
|  | import StoredPromise from './StoredPromise'; | ||||||
|  | import Vector from './Vector'; | ||||||
|  | import Collidable from '../control/Collidable'; | ||||||
|  | import _rapier from '@dimforge/rapier2d-compat'; | ||||||
|  | import Graphics from '@/visual/Graphics'; | ||||||
|  | import Registry from '@/utils/Registry'; | ||||||
|  | import { PIXI, type Viewport } from '@/visual/pixi'; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  * https://rapier.rs/docs/user_guides/javascript/colliders#overview | ||||||
|  |  */ | ||||||
|  | type colliderTypes = { | ||||||
|  |   ball: { | ||||||
|  |     radius: number | ||||||
|  |   }, | ||||||
|  |   cuboid: { | ||||||
|  |     halfHeight: number, | ||||||
|  |     halfWidth: number | ||||||
|  |   }, | ||||||
|  |   capsule: { | ||||||
|  |     halfHeight: number, | ||||||
|  |     radius: number | ||||||
|  |   }, | ||||||
|  |   triangle: { | ||||||
|  |     a: Vector, | ||||||
|  |     b: Vector, | ||||||
|  |     c: Vector | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | const colliderGroups = { | ||||||
|  |   'wall': 0b0000_0000_0000_0001, | ||||||
|  |   'player': 0b0000_0000_0001_0000, | ||||||
|  |   'enemy': 0b0000_0000_0010_0000, | ||||||
|  |   'playerProjectile': 0b0000_0001_0000_0000, | ||||||
|  |   'enemyProjectile': 0b0000_0010_0000_0000, | ||||||
|  | } as const; | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class Rapier { | ||||||
|  |   private _settingUp!: StoredPromise<void>; | ||||||
|  |   private _world!: _rapier.World; | ||||||
|  |   private _eventQueue!: _rapier.EventQueue; | ||||||
|  |   private _rigidBodyMap: Map<number, Collidable> = new Map(); | ||||||
|  |   private _colliderMap: Map<number, Collidable> = new Map(); | ||||||
|  |   private _debugLines: Graphics; | ||||||
|  |  | ||||||
|  |   constructor(gravity: Vector) { | ||||||
|  |     void this._setup(gravity); | ||||||
|  |     this._debugLines = new Graphics(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get rapier(): typeof _rapier { | ||||||
|  |     return _rapier; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get world(): _rapier.World { | ||||||
|  |     return this._world; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Needed as rapier2d is a webasm module that needs to be async imported directly | ||||||
|  |    */ | ||||||
|  |   private async _setup(gravity: Vector): Promise<void> { | ||||||
|  |     this._settingUp = new StoredPromise(); | ||||||
|  |     await this.rapier.init() | ||||||
|  |     // - TODO - Change integration params dt to ticker speed | ||||||
|  |     this._world = new this.rapier.World(gravity); | ||||||
|  |     this._eventQueue = new this.rapier.EventQueue(true); | ||||||
|  |  | ||||||
|  |     const viewport = Registry.fetch<Viewport>('Viewport'); | ||||||
|  |     viewport.addChild(this._debugLines); | ||||||
|  |  | ||||||
|  |     this._settingUp.resolve(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   async setup(): Promise<void> { | ||||||
|  |     await this._settingUp.promise; | ||||||
|  |  | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   createRigidBody(container: Collidable, type: keyof typeof _rapier.RigidBodyType, sleep: boolean = true, CCD: boolean = false): _rapier.RigidBody { | ||||||
|  |     const rigidBodyDesc = new this.rapier.RigidBodyDesc(this.rapier.RigidBodyType[type]) | ||||||
|  |       .setCanSleep(sleep) | ||||||
|  |       .setCcdEnabled(CCD); | ||||||
|  |  | ||||||
|  |     const rigidBody = this._world.createRigidBody(rigidBodyDesc); | ||||||
|  |     this._rigidBodyMap.set(rigidBody.handle, container); | ||||||
|  |     return rigidBody; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   createCollider<T extends keyof colliderTypes, U extends colliderTypes[T]>(container: Collidable, type: T, settings: U, rigidBody?: _rapier.RigidBody): _rapier.Collider { | ||||||
|  |     let colliderDesc: _rapier.ColliderDesc; | ||||||
|  |     if (type === 'ball') { | ||||||
|  |       const castSettings = (settings as colliderTypes['ball']); | ||||||
|  |       colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Ball(castSettings.radius)); | ||||||
|  |     } else if (type === 'capsule') { | ||||||
|  |       const castSettings = (settings as colliderTypes['capsule']); | ||||||
|  |       colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Capsule(castSettings.halfHeight, castSettings.radius)); | ||||||
|  |     } else if (type === 'cuboid') { | ||||||
|  |       const castSettings = (settings as colliderTypes['cuboid']); | ||||||
|  |       colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Cuboid(castSettings.halfWidth, castSettings.halfHeight)); | ||||||
|  |     } else if (type === 'triangle') { | ||||||
|  |       const castSettings = (settings as colliderTypes['triangle']); | ||||||
|  |       colliderDesc = new this.rapier.ColliderDesc(new this.rapier.Triangle(castSettings.a, castSettings.b, castSettings.c)); | ||||||
|  |     } else { | ||||||
|  |       throw new Error(`Cannot create collider of type ${type}`); | ||||||
|  |     } | ||||||
|  |     colliderDesc.setActiveEvents(this.rapier.ActiveEvents.COLLISION_EVENTS); | ||||||
|  |     colliderDesc.setActiveCollisionTypes(this.rapier.ActiveCollisionTypes.DEFAULT); | ||||||
|  |  | ||||||
|  |     const collider = this._world.createCollider(colliderDesc, rigidBody); | ||||||
|  |     collider.setCollisionGroups(0); | ||||||
|  |     collider.setFriction(0); | ||||||
|  |  | ||||||
|  |     this._colliderMap.set(collider.handle, container); | ||||||
|  |  | ||||||
|  |     return collider; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   removeRigidBody(rigidBody: _rapier.RigidBody): void { | ||||||
|  |     if (rigidBody) { | ||||||
|  |       this._world.removeRigidBody(rigidBody); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   removeCollider(collider: _rapier.Collider): void { | ||||||
|  |     if (collider) { | ||||||
|  |       this._world.removeCollider(collider, true); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Add the collider to member groups.   | ||||||
|  |    * A member group is what groups we're in (i.e. so other groups can collide with us). | ||||||
|  |    * | ||||||
|  |    * @param collider - The collider to add the groups to | ||||||
|  |    * @param groups - The groups to add to | ||||||
|  |    */ | ||||||
|  |   addColliderMemberGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void { | ||||||
|  |     const bitmask = this._getColliderGroupsBitmask(groups) << 16; | ||||||
|  |     collider.setCollisionGroups(collider.collisionGroups() | bitmask); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Add the collider to filter groups.   | ||||||
|  |    * A filter group is what groups we can collide with. | ||||||
|  |    * | ||||||
|  |    * @param collider - The collider to add the groups to | ||||||
|  |    * @param groups - The groups to add to | ||||||
|  |    */ | ||||||
|  |   addColliderFilterGroups(collider: _rapier.Collider, ...groups: (keyof typeof colliderGroups)[]): void { | ||||||
|  |     const bitmask = this._getColliderGroupsBitmask(groups); | ||||||
|  |     collider.setCollisionGroups(collider.collisionGroups() | bitmask); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   private _getColliderGroupsBitmask(groups: (keyof typeof colliderGroups)[]): number { | ||||||
|  |     return groups.reduce((bitmask, group) => { | ||||||
|  |       return bitmask | colliderGroups[group]; | ||||||
|  |     }, 0); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    */ | ||||||
|  |   update(): void { | ||||||
|  |     this._world.step(this._eventQueue); | ||||||
|  |     this._eventQueue.drainCollisionEvents((handle1, handle2, colliding) => { | ||||||
|  |       const container1 = this._colliderMap.get(handle1); | ||||||
|  |       const container2 = this._colliderMap.get(handle2); | ||||||
|  |       if (container1 && container2) { | ||||||
|  |         container1.collide(container2, colliding); | ||||||
|  |         container2.collide(container1, colliding); | ||||||
|  |       } | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   debugRender(): void { | ||||||
|  |     const { vertices, colors } = this._world.debugRender(); | ||||||
|  |  | ||||||
|  |     this._debugLines.clear(); | ||||||
|  |  | ||||||
|  |     for (let i = 0; i < vertices.length / 4; i += 1) { | ||||||
|  |       let color = new PIXI.Color([ | ||||||
|  |         colors[i * 8], | ||||||
|  |         colors[i * 8 + 1], | ||||||
|  |         colors[i * 8 + 2], | ||||||
|  |       ]); | ||||||
|  |       this._debugLines.setStrokeStyle({ | ||||||
|  |         color: color, | ||||||
|  |         width: 2, | ||||||
|  |         alignment: 0.5, | ||||||
|  |       }) | ||||||
|  |       this._debugLines.moveTo(vertices[i * 4], vertices[i * 4 + 1]); | ||||||
|  |       this._debugLines.lineTo(vertices[i * 4 + 2], vertices[i * 4 + 3]); | ||||||
|  |       this._debugLines.stroke(); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Rapier; | ||||||
							
								
								
									
										32
									
								
								src/games/lib/utils/Registry.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										32
									
								
								src/games/lib/utils/Registry.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,32 @@ | |||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class Registry { | ||||||
|  |   static _objects: Map<string, unknown> = new Map(); | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    * | ||||||
|  |    * @param name - TODO | ||||||
|  |    * @param obj - TODO | ||||||
|  |    */ | ||||||
|  |   static register<T>(name: string, obj: T): void { | ||||||
|  |     this._objects.set(name, obj); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * - TODO | ||||||
|  |    * | ||||||
|  |    * @param name - TODO | ||||||
|  |    */ | ||||||
|  |   static fetch<T>(name: string): T { | ||||||
|  |     const obj = this._objects.get(name) as T; | ||||||
|  |     if (obj) { | ||||||
|  |       return obj; | ||||||
|  |     } else { | ||||||
|  |       throw new Error(`Cannot find ${name}`); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Registry; | ||||||
							
								
								
									
										31
									
								
								src/games/lib/utils/StoredPromise.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								src/games/lib/utils/StoredPromise.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,31 @@ | |||||||
|  | class StoredPromise<T = void> { | ||||||
|  |   promise: null | Promise<T>; | ||||||
|  |   private _resolve: null | (( value: T | PromiseLike<T> ) => void); | ||||||
|  |   // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||
|  |   private _reject: null | (( reason?: any ) => void); | ||||||
|  |   constructor() { | ||||||
|  |     this.promise = new Promise<T>((resolve, reject) => { | ||||||
|  |       this._resolve = resolve; | ||||||
|  |       this._reject = reject; | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   resolve(value: T | PromiseLike<T>): void { | ||||||
|  |     this._resolve?.(value); | ||||||
|  |     this.clear(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||
|  |   reject( reason?: any ): void { | ||||||
|  |     this._reject?.(reason); | ||||||
|  |     this.clear(); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   clear(): void { | ||||||
|  |     this.promise = null; | ||||||
|  |     this._resolve = null; | ||||||
|  |     this._reject = null; | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default StoredPromise; | ||||||
							
								
								
									
										75
									
								
								src/games/lib/utils/Ticker.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										75
									
								
								src/games/lib/utils/Ticker.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,75 @@ | |||||||
|  | /** | ||||||
|  |  * A ticker class to call a callback on a set time\ | ||||||
|  |  * Is catch-up tick based so will call update multiple times | ||||||
|  |  */ | ||||||
|  | class Ticker { | ||||||
|  |   private _tickUpdateCb: (timeDelta: number) => Promise<void> = async () => {}; | ||||||
|  |   private _tickCompleteCb: (updated: boolean) => Promise<void> = async () => {}; | ||||||
|  |   private _voidUpdate: ( frameTime: number ) => void; | ||||||
|  |   private _tickCounter: number[] = []; | ||||||
|  |   private _tickTime = 0; | ||||||
|  |   private _lastFrameTime = 0; | ||||||
|  |   private _currentTotalDelta = 0; | ||||||
|  |   private _ticksPerSecond = 0; | ||||||
|  |  | ||||||
|  |   constructor() { | ||||||
|  |     this._voidUpdate = (frameTime: number): void => { | ||||||
|  |       void this._update(frameTime); | ||||||
|  |     }; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   get currentTicksPerSecond(): number { | ||||||
|  |     return this._ticksPerSecond; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Start the ticker to update every tickTime\ | ||||||
|  |    * Will use requestAnimationFrame and catch up if needed\ | ||||||
|  |    * Use the tick callback for logic updates and the complete callback for visual representations of that logic | ||||||
|  |    */ | ||||||
|  |   ticker(tickTime: number, tickCb: (timeDelta: number) => Promise<void>, completeCb: (updated: boolean) => Promise<void>): void { | ||||||
|  |     this._tickTime = tickTime; | ||||||
|  |     this._lastFrameTime = performance.now(); | ||||||
|  |     this._tickUpdateCb = tickCb; | ||||||
|  |     this._tickCompleteCb = completeCb; | ||||||
|  |     requestAnimationFrame(this._voidUpdate); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * The update loop on raf, call the update multiple times to catch up, then call raf again | ||||||
|  |    */ | ||||||
|  |   private async _update(currentFrameTime: number): Promise<void> { | ||||||
|  |     const delta = currentFrameTime - this._lastFrameTime; | ||||||
|  |     this._lastFrameTime = currentFrameTime; | ||||||
|  |     this._currentTotalDelta += delta; | ||||||
|  |  | ||||||
|  |     let updated = false; | ||||||
|  |     /** | ||||||
|  |      * - TODO - This min number probably needs to change, and reset the time differently | ||||||
|  |      * This way will reset on the next frame, do we want to allow a min frame for update/render purposes but run all the ticks? | ||||||
|  |      * Probably depends on the game. | ||||||
|  |      */ | ||||||
|  |     let ticks = Math.min(Math.floor(this._currentTotalDelta / this._tickTime), 100); | ||||||
|  |     this._currentTotalDelta = this._currentTotalDelta % this._tickTime; | ||||||
|  |     while (ticks > 0) { | ||||||
|  |       updated = true; | ||||||
|  |       ticks -= 1; | ||||||
|  |       await this._tickUpdateCb(this._tickTime); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (updated) { | ||||||
|  |       this._tickCounter.push(currentFrameTime); | ||||||
|  |       const minusOneSecond = currentFrameTime - 1000; | ||||||
|  |       while (this._tickCounter[0] < minusOneSecond) { | ||||||
|  |         this._tickCounter.shift(); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       this._ticksPerSecond = this._tickCounter.length; | ||||||
|  |     } | ||||||
|  |     await this._tickCompleteCb(updated); | ||||||
|  |  | ||||||
|  |     requestAnimationFrame(this._voidUpdate); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Ticker; | ||||||
							
								
								
									
										7
									
								
								src/games/lib/utils/Vector.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								src/games/lib/utils/Vector.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,7 @@ | |||||||
|  | import { PIXI } from '@/visual/pixi'; | ||||||
|  |  | ||||||
|  | class Vector extends PIXI.Point { | ||||||
|  |  | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Vector; | ||||||
							
								
								
									
										9
									
								
								src/games/lib/utils/pixi-object-pool.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								src/games/lib/utils/pixi-object-pool.d.ts
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | declare module '@pixi-essentials/object-pool' { | ||||||
|  |   class ObjectPoolFactory<T> { | ||||||
|  |     static build: (ctor: new (args: unknown[]) => T) => ObjectPoolFactory<T>; | ||||||
|  |     reserve: (reserve: number) => void; | ||||||
|  |     startGC: () => void; | ||||||
|  |     allocate: () => T; | ||||||
|  |     release: (obj: T) => void; | ||||||
|  |   } | ||||||
|  | } | ||||||
							
								
								
									
										23
									
								
								src/games/lib/visual/Container.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								src/games/lib/visual/Container.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,23 @@ | |||||||
|  | import Vector from '@/utils/Vector'; | ||||||
|  | import { PIXI } from './pixi'; | ||||||
|  | import { VisualBase, VisualBaseSettings } from './VisualBase'; | ||||||
|  |  | ||||||
|  | type ContainerSettings = VisualBaseSettings; | ||||||
|  |  | ||||||
|  | class Container extends PIXI.Container { | ||||||
|  |   constructor(settings?: ContainerSettings) { | ||||||
|  |     super(); | ||||||
|  |     VisualBase.applySettings(this, settings); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   /** | ||||||
|  |    * Switch this container's parent to a new parent, keeping it's positioning | ||||||
|  |    */ | ||||||
|  |   addToNewParent(newParent: Container): void { | ||||||
|  |     const localPoint = newParent.toLocal(new Vector(0, 0), this); | ||||||
|  |     this.position.set(localPoint.x, localPoint.y); | ||||||
|  |     newParent.addChild(this); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Container; | ||||||
							
								
								
									
										13
									
								
								src/games/lib/visual/Graphics.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								src/games/lib/visual/Graphics.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | import { PIXI } from './pixi'; | ||||||
|  | import { VisualBase, VisualBaseSettings } from './VisualBase'; | ||||||
|  |  | ||||||
|  | type GraphicsSettings = VisualBaseSettings; | ||||||
|  |  | ||||||
|  | class Graphics extends PIXI.Graphics { | ||||||
|  |   constructor(settings?: GraphicsSettings ) { | ||||||
|  |     super(); | ||||||
|  |     VisualBase.applySettings(this, settings); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Graphics; | ||||||
							
								
								
									
										29
									
								
								src/games/lib/visual/Text.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								src/games/lib/visual/Text.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,29 @@ | |||||||
|  | import { assign } from 'radash'; | ||||||
|  | import { PIXI } from './pixi'; | ||||||
|  | import { VisualBase, VisualBaseSettings } from './VisualBase'; | ||||||
|  |  | ||||||
|  | type TextSettings = VisualBaseSettings & { | ||||||
|  |   text?: string | number, | ||||||
|  |   style?: string | ||||||
|  | } | ||||||
|  |  | ||||||
|  | class Text extends PIXI.Text { | ||||||
|  |   static styles: { [key: string]: (Partial<PIXI.TextStyle> | PIXI.TextStyle) } = { | ||||||
|  |   default: { | ||||||
|  |       fontSize: 16, | ||||||
|  |       fill: 0x000000, | ||||||
|  |       align: 'center', | ||||||
|  |        | ||||||
|  |     } | ||||||
|  |   }; | ||||||
|  |  | ||||||
|  |   constructor(settings?: TextSettings, style?: Partial<PIXI.TextStyle> | PIXI.TextStyle) { | ||||||
|  |     super({ | ||||||
|  |       text: settings?.text, | ||||||
|  |       style: assign(Text.styles[settings?.style ?? 'default'], style ?? {}) | ||||||
|  |     }) | ||||||
|  |     VisualBase.applySettings(this, settings); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default Text; | ||||||
							
								
								
									
										93
									
								
								src/games/lib/visual/VisualBase.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										93
									
								
								src/games/lib/visual/VisualBase.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,93 @@ | |||||||
|  | import { PIXI } from './pixi'; | ||||||
|  |  | ||||||
|  | type pointOrNumber = { | ||||||
|  |   x: number, | ||||||
|  |   y: number | ||||||
|  | } | number | undefined | PIXI.Point; | ||||||
|  |  | ||||||
|  | type VisualBaseSettings = { | ||||||
|  |   angle?: number, | ||||||
|  |   pivot?: pointOrNumber | ||||||
|  |   position?: { | ||||||
|  |     x: number, | ||||||
|  |     y: number | ||||||
|  |   }, | ||||||
|  |   rotation?: number, | ||||||
|  |   scale?: pointOrNumber | ||||||
|  |   skew?: pointOrNumber, | ||||||
|  |   width?: number, | ||||||
|  |   height?: number, | ||||||
|  |   alpha?: number, | ||||||
|  |   visible?: boolean, | ||||||
|  |   interactive?: boolean, | ||||||
|  |   zIndex?: number | ||||||
|  | } | ||||||
|  |  | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  */ | ||||||
|  | class VisualBase { | ||||||
|  |   static applySettings<T extends PIXI.Container>(applyTo: T, settings?: VisualBaseSettings): void { | ||||||
|  |     if (settings?.angle) { | ||||||
|  |       applyTo.angle = settings.angle; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.position) { | ||||||
|  |       applyTo?.position.set(settings.position.x, settings.position.y); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.rotation) { | ||||||
|  |       applyTo.rotation = settings.rotation; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.interactive) { | ||||||
|  |       applyTo.interactive = settings.interactive; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.zIndex) { | ||||||
|  |       applyTo.zIndex = settings.zIndex; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.alpha) { | ||||||
|  |       applyTo.alpha = settings.alpha; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.visible) { | ||||||
|  |       applyTo.visible = settings.visible; | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     if (settings?.position) { | ||||||
|  |       applyTo?.position.set(settings.position.x, settings.position.y); | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     applyPointPrimitiveOrObject('pivot', applyTo, settings?.pivot); | ||||||
|  |     applyPointPrimitiveOrObject('scale', applyTo, settings?.scale); | ||||||
|  |     applyPointPrimitiveOrObject('skew', applyTo, settings?.skew); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * - TODO | ||||||
|  |  * | ||||||
|  |  * @param prop - TODO | ||||||
|  |  * @param applyTo - TODO | ||||||
|  |  * @param setting  - TODO | ||||||
|  |  */ | ||||||
|  | function applyPointPrimitiveOrObject<T extends PIXI.Container>(prop: 'skew' | 'pivot' | 'scale', applyTo: T, setting: pointOrNumber): void { | ||||||
|  |   if (setting) { | ||||||
|  |     if (typeof setting === 'object') { | ||||||
|  |       applyTo[prop].set(setting.x, setting.y); | ||||||
|  |     } else { | ||||||
|  |       applyTo[prop].set(setting); | ||||||
|  |     } | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export { | ||||||
|  |   VisualBase | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | export type { | ||||||
|  |   VisualBaseSettings | ||||||
|  | }; | ||||||
							
								
								
									
										11
									
								
								src/games/lib/visual/pixi.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/games/lib/visual/pixi.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | // - TODO - Clean this up | ||||||
|  | import * as PIXI from 'pixi.js'; | ||||||
|  | import * as Events from '@pixi/events'; | ||||||
|  | import { Viewport } from 'pixi-viewport'; | ||||||
|  |  | ||||||
|  | import 'pixi.js/math-extras'; | ||||||
|  |  | ||||||
|  | // Filters | ||||||
|  | import * as Filters from 'pixi-filters'; | ||||||
|  |  | ||||||
|  | export { PIXI, Events, Viewport, Filters }; | ||||||
| @@ -13,4 +13,4 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ | |||||||
|   }], |   }], | ||||||
|   trustHost: true, |   trustHost: true, | ||||||
|   redirectProxyUrl: `${getCurrentUrl()}/api/auth`, |   redirectProxyUrl: `${getCurrentUrl()}/api/auth`, | ||||||
| }) | }); | ||||||
							
								
								
									
										60
									
								
								src/trpc/client.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/trpc/client.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | |||||||
|  | "use client"; | ||||||
|  |  | ||||||
|  | import React, { useState } from "react"; | ||||||
|  | import superjson from "superjson"; | ||||||
|  | import { httpBatchLink } from "@trpc/client"; | ||||||
|  | import { createTRPCReact } from "@trpc/react-query"; | ||||||
|  | import { getCurrentUrl } from "@/lib/current-url"; | ||||||
|  | import { makeQueryClient } from "./query-client"; | ||||||
|  |  | ||||||
|  | import { QueryClientProvider, type QueryClient } from "@tanstack/react-query"; | ||||||
|  | import type { appRouter } from "./routers/_app"; | ||||||
|  |  | ||||||
|  | export const trpc = createTRPCReact<typeof appRouter>(); | ||||||
|  |  | ||||||
|  | let clientQueryClientSingleton: QueryClient; | ||||||
|  |  | ||||||
|  | function getQueryClient(): QueryClient { | ||||||
|  |   if (typeof window === "undefined") { | ||||||
|  |     // Server: always make a new query client | ||||||
|  |     return makeQueryClient(); | ||||||
|  |   } | ||||||
|  |   // Browser: use singleton pattern to keep the same query client | ||||||
|  |   return (clientQueryClientSingleton ??= makeQueryClient()); | ||||||
|  | } | ||||||
|  | function getUrl(): string { | ||||||
|  |   const base = ((): string => { | ||||||
|  |     if (typeof window !== "undefined") return ""; | ||||||
|  |     return getCurrentUrl(); | ||||||
|  |   })(); | ||||||
|  |   return `${base}/api/trpc`; | ||||||
|  | } | ||||||
|  | export function TRPCProvider( | ||||||
|  |   props: Readonly<{ | ||||||
|  |     children: React.ReactNode; | ||||||
|  |   }> | ||||||
|  | ): React.JSX.Element { | ||||||
|  |   // NOTE: Avoid useState when initializing the query client if you don't | ||||||
|  |   //       have a suspense boundary between this and the code that may | ||||||
|  |   //       suspend because React will throw away the client on the initial | ||||||
|  |   //       render if it suspends and there is no boundary | ||||||
|  |   const queryClient = getQueryClient(); | ||||||
|  |   const [trpcClient] = useState(() => | ||||||
|  |     trpc.createClient({ | ||||||
|  |       links: [ | ||||||
|  |         httpBatchLink({ | ||||||
|  |           transformer: superjson, | ||||||
|  |           url: getUrl(), | ||||||
|  |         }), | ||||||
|  |       ] | ||||||
|  |     }) | ||||||
|  |   ); | ||||||
|  |  | ||||||
|  |   return ( | ||||||
|  |     <trpc.Provider client={trpcClient} queryClient={queryClient}> | ||||||
|  |       <QueryClientProvider client={queryClient}> | ||||||
|  |         {props.children} | ||||||
|  |       </QueryClientProvider> | ||||||
|  |     </trpc.Provider> | ||||||
|  |   ); | ||||||
|  | } | ||||||
							
								
								
									
										49
									
								
								src/trpc/init.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								src/trpc/init.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,49 @@ | |||||||
|  | import { cache } from 'react'; | ||||||
|  | import superjson from 'superjson'; | ||||||
|  | import { initTRPC, TRPCError } from '@trpc/server'; | ||||||
|  | import { auth } from '@/lib/auth'; | ||||||
|  |  | ||||||
|  | interface Context { | ||||||
|  |   user?: { | ||||||
|  |     id?: string | ||||||
|  |     name?: string | null | ||||||
|  |     email?: string | null | ||||||
|  |     image?: string | null | ||||||
|  |   }; | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export const createTRPCContext = cache(async (): Promise<Context> => { | ||||||
|  |   /** | ||||||
|  |    * @see: https://trpc.io/docs/server/context | ||||||
|  |    */ | ||||||
|  |   const session = await auth(); | ||||||
|  |   return { user: session?.user }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Avoid exporting the entire t-object | ||||||
|  | // since it's not very descriptive. | ||||||
|  | // For instance, the use of a t variable | ||||||
|  | // is common in i18n libraries. | ||||||
|  | const t = initTRPC.context<Context>().create({ | ||||||
|  |   /** | ||||||
|  |    * @see https://trpc.io/docs/server/data-transformers | ||||||
|  |    */ | ||||||
|  |   transformer: superjson, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const authMiddleware = t.middleware(({ ctx, next }) => { | ||||||
|  |   if (ctx.user?.name !== 'Joe') { | ||||||
|  |     throw new TRPCError({ code: 'UNAUTHORIZED' }); | ||||||
|  |   } | ||||||
|  |   return next({ | ||||||
|  |     ctx: { | ||||||
|  |       user: ctx.user, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | // Base router and procedure helpers | ||||||
|  | export const createTRPCRouter = t.router; | ||||||
|  | export const createCallerFactory = t.createCallerFactory; | ||||||
|  | export const publicProcedure = t.procedure; | ||||||
|  | export const privateProcedure = t.procedure.use(authMiddleware); | ||||||
							
								
								
									
										27
									
								
								src/trpc/query-client.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								src/trpc/query-client.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,27 @@ | |||||||
|  | import { | ||||||
|  |   defaultShouldDehydrateQuery, | ||||||
|  |   QueryClient, | ||||||
|  | } from '@tanstack/react-query'; | ||||||
|  | import { serialize, deserialize } from 'superjson'; | ||||||
|  |  | ||||||
|  | export function makeQueryClient(): QueryClient { | ||||||
|  |   return new QueryClient({ | ||||||
|  |     defaultOptions: { | ||||||
|  |       queries: { | ||||||
|  |         refetchOnMount: false, | ||||||
|  |         refetchOnWindowFocus: false, | ||||||
|  |         refetchOnReconnect: false, | ||||||
|  |         staleTime: 30 * 1000, | ||||||
|  |       }, | ||||||
|  |       dehydrate: { | ||||||
|  |         serializeData: serialize, | ||||||
|  |         shouldDehydrateQuery: (query) => | ||||||
|  |           defaultShouldDehydrateQuery(query) || | ||||||
|  |           query.state.status === 'pending', | ||||||
|  |       }, | ||||||
|  |       hydrate: { | ||||||
|  |         deserializeData: deserialize, | ||||||
|  |       }, | ||||||
|  |     }, | ||||||
|  |   }); | ||||||
|  | } | ||||||
							
								
								
									
										11
									
								
								src/trpc/routers/_app.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								src/trpc/routers/_app.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | |||||||
|  | // eslint-disable-next-line import/named | ||||||
|  | import { inferRouterInputs, inferRouterOutputs } from '@trpc/server'; | ||||||
|  | import { createTRPCRouter } from '../init'; | ||||||
|  | import { photosRouter } from './photos'; | ||||||
|  |  | ||||||
|  | export const appRouter = createTRPCRouter({ | ||||||
|  |   photos: photosRouter, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | export type RouterInput = inferRouterInputs<typeof appRouter>; | ||||||
|  | export type RouterOutput = inferRouterOutputs<typeof appRouter>; | ||||||
							
								
								
									
										35
									
								
								src/trpc/routers/photos.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								src/trpc/routers/photos.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | |||||||
|  | import { z } from 'zod'; | ||||||
|  | import { createTRPCRouter, privateProcedure, publicProcedure } from '../init'; | ||||||
|  |  | ||||||
|  | import { list } from './photos/list'; | ||||||
|  | import { update } from './photos/update'; | ||||||
|  |  | ||||||
|  | export const photosRouter = createTRPCRouter({ | ||||||
|  |   list: publicProcedure | ||||||
|  |     .input( | ||||||
|  |       z.object({ | ||||||
|  |         limit: z.number().nonnegative().default(1), | ||||||
|  |         cursor: z.number().nonnegative().default(0), | ||||||
|  |       }) | ||||||
|  |         .optional() | ||||||
|  |         .default({}), | ||||||
|  |     ) | ||||||
|  |     .query(async ({ input }) => { | ||||||
|  |       const ret = await list({ | ||||||
|  |         limit: input.limit + 1, | ||||||
|  |         cursor: input.cursor, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       let next; | ||||||
|  |       if (ret.length > input.limit) { | ||||||
|  |         next = input.limit; | ||||||
|  |         ret.pop(); | ||||||
|  |       } | ||||||
|  |  | ||||||
|  |       return { | ||||||
|  |         data: ret, | ||||||
|  |         next | ||||||
|  |       }; | ||||||
|  |     }), | ||||||
|  |   update: privateProcedure.query(update) | ||||||
|  | }); | ||||||
| @@ -1,7 +1,6 @@ | |||||||
| import { NextResponse } from "next/server"; |  | ||||||
| import { shake } from "radash"; | import { shake } from "radash"; | ||||||
| import PhotoDataSource from "@/data-source"; | import db from "@/db/db"; | ||||||
| import { Photo } from "@/entity/photo"; | import { photosTable } from "@/db/schema/photo"; | ||||||
| 
 | 
 | ||||||
| export type ImageData = { | export type ImageData = { | ||||||
|   width: number, |   width: number, | ||||||
| @@ -21,17 +20,16 @@ export type ImageData = { | |||||||
|   description?: string |   description?: string | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export type GetPhotos = { | export type ListOptions = { | ||||||
|   status: number, |   cursor: number, | ||||||
|   data: { |   limit: number | ||||||
|     images: ImageData[] |  | ||||||
|   } |  | ||||||
| } | } | ||||||
| 
 | 
 | ||||||
| export async function GET(): Promise<Response> { | export async function list(options: ListOptions): Promise<ImageData[]> { | ||||||
|   const dataSource = await PhotoDataSource.dataSource; |   const currentSources = await db.select().from(photosTable) | ||||||
|   const photoRepository = dataSource.getRepository(Photo); |     .limit(options.limit) | ||||||
|   const currentSources = await photoRepository.find(); |     .offset(options.cursor); | ||||||
|  | 
 | ||||||
|   const images = currentSources.map((photo) => { |   const images = currentSources.map((photo) => { | ||||||
|     return { |     return { | ||||||
|       width: photo.width, |       width: photo.width, | ||||||
| @@ -49,8 +47,8 @@ export async function GET(): Promise<Response> { | |||||||
|       }), |       }), | ||||||
|       title: photo.title ?? undefined, |       title: photo.title ?? undefined, | ||||||
|       description: photo.description ?? undefined |       description: photo.description ?? undefined | ||||||
|     } |     }; | ||||||
|   }) |   }); | ||||||
| 
 | 
 | ||||||
|   return NextResponse.json<GetPhotos>({ status: 200, data: { images } }); |   return images; | ||||||
| } | } | ||||||
							
								
								
									
										82
									
								
								src/trpc/routers/photos/update/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/trpc/routers/photos/update/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | |||||||
|  | import { S3Client, ListObjectsV2Command, GetObjectCommand } from "@aws-sdk/client-s3"; | ||||||
|  | import exifReader from "exif-reader"; | ||||||
|  | import { diff, sift } from "radash"; | ||||||
|  | import sharp from "sharp"; | ||||||
|  |  | ||||||
|  | import db from "@/db/db"; | ||||||
|  | import { photosTable } from "@/db/schema/photo"; | ||||||
|  | import { TRPCError } from "@trpc/server"; | ||||||
|  |  | ||||||
|  | export async function update(): Promise<string[]> { | ||||||
|  |   const photos = await db.select().from(photosTable); | ||||||
|  |   const currentSources = photos.map((photo) => photo.src); | ||||||
|  |  | ||||||
|  |   const s3Client = new S3Client({ | ||||||
|  |     region: "auto", | ||||||
|  |     endpoint: `https://fly.storage.tigris.dev`, | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const listObjCmd = new ListObjectsV2Command({ | ||||||
|  |     Bucket: "joemonk-photos" | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const s3Res = await s3Client.send(listObjCmd); | ||||||
|  |  | ||||||
|  |   if (!s3Res.Contents) { | ||||||
|  |     throw new TRPCError({ | ||||||
|  |       code: "GATEWAY_TIMEOUT", | ||||||
|  |       message: "Could not get contents from Tigris" | ||||||
|  |     }); | ||||||
|  |   } | ||||||
|  |   const s3Photos = sift(s3Res.Contents.map((obj) => { | ||||||
|  |     if (!obj.Key?.endsWith('/')) { | ||||||
|  |       return `https://fly.storage.tigris.dev/joemonk-photos/${obj.Key}`; | ||||||
|  |     } else { | ||||||
|  |       return null; | ||||||
|  |     } | ||||||
|  |   })); | ||||||
|  |  | ||||||
|  |   const newPhotos = diff(s3Photos, currentSources); | ||||||
|  |  | ||||||
|  |   if (newPhotos.length === 0) { | ||||||
|  |     return []; | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   const imageData = newPhotos.map(async (fileName: string) => { | ||||||
|  |     const getImageCmd = new GetObjectCommand({ | ||||||
|  |       Bucket: "joemonk-photos", | ||||||
|  |       Key: fileName.replace("https://fly.storage.tigris.dev/joemonk-photos/", "") | ||||||
|  |     }); | ||||||
|  |     const imgRes = await s3Client.send(getImageCmd); | ||||||
|  |     const image = await imgRes.Body?.transformToByteArray(); | ||||||
|  |  | ||||||
|  |     const { width, height, exif } = await sharp(image).metadata(); | ||||||
|  |     const blur = await sharp(image) | ||||||
|  |       .resize({ width: 12, height: 12, fit: 'inside' }) | ||||||
|  |       .toBuffer(); | ||||||
|  |     const exifData = exif ? exifReader(exif) : undefined; | ||||||
|  |  | ||||||
|  |     const photo: typeof photosTable.$inferInsert = { | ||||||
|  |       src: fileName, | ||||||
|  |       width: width ?? 10, | ||||||
|  |       height: height ?? 10, | ||||||
|  |       blur: `data:image/jpeg;base64,${blur.toString('base64')}` as `data:image/${string}`, | ||||||
|  |       camera: exifData?.Image?.Model ?? null, | ||||||
|  |  | ||||||
|  |       exposureBiasValue: exifData?.Photo?.ExposureBiasValue ?? null, | ||||||
|  |       fNumber: exifData?.Photo?.FNumber ?? null, | ||||||
|  |       isoSpeedRatings: exifData?.Photo?.ISOSpeedRatings ?? null, | ||||||
|  |       focalLength: exifData?.Photo?.FocalLength ?? null, | ||||||
|  |       dateTimeOriginal: exifData?.Photo?.DateTimeOriginal ?? null, | ||||||
|  |       lensModel: exifData?.Photo?.LensModel ?? null, | ||||||
|  |     }; | ||||||
|  |  | ||||||
|  |     return photo; | ||||||
|  |   }); | ||||||
|  |  | ||||||
|  |   const images = await Promise.all(imageData); | ||||||
|  |  | ||||||
|  |   await db.insert(photosTable).values(images); | ||||||
|  |  | ||||||
|  |   return newPhotos; | ||||||
|  | }; | ||||||
							
								
								
									
										14
									
								
								src/trpc/server.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								src/trpc/server.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | import 'server-only'; // <-- ensure this file cannot be imported from the client | ||||||
|  | import { createHydrationHelpers } from '@trpc/react-query/rsc'; | ||||||
|  | import { cache } from 'react'; | ||||||
|  | import { createCallerFactory, createTRPCContext } from './init'; | ||||||
|  | import { makeQueryClient } from './query-client'; | ||||||
|  | import { appRouter } from './routers/_app'; | ||||||
|  | // IMPORTANT: Create a stable getter for the query client that | ||||||
|  | //            will return the same client during the same request. | ||||||
|  | export const getQueryClient = cache(makeQueryClient); | ||||||
|  | const caller = createCallerFactory(appRouter)(createTRPCContext); | ||||||
|  | export const { trpc, HydrateClient } = createHydrationHelpers<typeof appRouter>( | ||||||
|  |   caller, | ||||||
|  |   getQueryClient, | ||||||
|  | ); | ||||||
| @@ -27,7 +27,19 @@ | |||||||
|     "paths": { |     "paths": { | ||||||
|       "@/*": [ |       "@/*": [ | ||||||
|         "./src/*" |         "./src/*" | ||||||
|       ] |       ], | ||||||
|  |       "@/control/*": [ | ||||||
|  |         "./src/games/lib/control/*" | ||||||
|  |       ], | ||||||
|  |       "@/core/*": [ | ||||||
|  |         "./src/games/lib/core/*" | ||||||
|  |       ], | ||||||
|  |       "@/utils/*": [ | ||||||
|  |         "./src/games/lib/utils/*" | ||||||
|  |       ], | ||||||
|  |       "@/visual/*": [ | ||||||
|  |         "./src/games/lib/visual/*" | ||||||
|  |       ], | ||||||
|     } |     } | ||||||
|   }, |   }, | ||||||
|   "include": [ |   "include": [ | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user